From 6be2bb0e352a9ea83eb9def0f880734462d97920 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 22:25:49 +0000 Subject: [PATCH 01/28] feat(client): add configure for SDK defaults Add a process-wide configure(options=SDKOptions) API on the lifecycle singleton. Handlers read capture_content when they run, and an explicit True or False overrides the configured default. --- AGENTS.md | 14 +- TELEMETRY-CONTRACT.md | 14 +- .../launchdarkly_ai_claude_agents/handler.py | 22 ++- .../handler.py | 22 ++- .../claude-messages/tests/test_handler.py | 7 +- packages/client/README.md | 19 ++- packages/client/agents.md | 1 + .../src/launchdarkly_ai_server/__init__.py | 4 + .../src/launchdarkly_ai_server/content.py | 9 +- .../src/launchdarkly_ai_server/lifecycle.py | 66 +++++++- .../src/launchdarkly_ai_server/types.py | 44 ++++- .../src/launchdarkly_ai_server/utils.py | 8 +- packages/client/tests/test_configure.py | 150 ++++++++++++++++++ packages/client/tests/test_public_surface.py | 2 + .../handler.py | 20 ++- .../langchain-agents/tests/test_handler.py | 7 +- .../handler.py | 20 ++- .../langchain-messages/tests/test_handler.py | 7 +- .../launchdarkly_ai_openai_agents/handler.py | 24 ++- packages/openai-agents/tests/test_handler.py | 83 +++++++++- .../handler.py | 22 ++- .../openai-messages/tests/test_handler.py | 7 +- 22 files changed, 495 insertions(+), 77 deletions(-) create mode 100644 packages/client/tests/test_configure.py diff --git a/AGENTS.md b/AGENTS.md index 7cd59fbf..dac8348d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,10 +140,11 @@ The client manages a singleton connection to LaunchDarkly and the associated tel | Export | Description | |---|---| -| `init_client(options?)` | Auto-discovers and initializes `launchdarkly-server-sdk` (optional dep, loaded via `importlib`). Optional — the first AI API call triggers lazy init when `LD_SDK_KEY` is set. Accepts optional overrides for SDK key, base URIs, service name, environment, and OTLP endpoint. Returns `Awaitable[LDClientInterface]`. On every successful path, including the already-initialized path, flushes `$ld:ai:sdk:info` for any LaunchDarkly AI packages that have not yet reported. | +| `init_client(options?)` | Auto-discovers and initializes `launchdarkly-server-sdk` (optional dep, loaded via `importlib`). Optional — the first AI API call triggers lazy init when `LD_SDK_KEY` is set. Accepts optional overrides for SDK key, base URIs, service name, environment, and OTLP endpoint. Returns `Awaitable[LDClientInterface]`. On every successful path, including the already-initialized path, flushes `$ld:ai:sdk:info` for any LaunchDarkly AI packages that have not yet reported. Does not take behavior defaults. | | `init_client(client=...)` | **BYOC overload** — accepts a pre-initialized `LDClientInterface`. Stores it directly without calling the SDK. Flushes pending `$ld:ai:sdk:info` events. | +| `configure(*, options)` | Sets process-wide behavior defaults on the lifecycle singleton. `options` is a frozen `SDKOptions` (`capture_content: bool = False`). Keyword-only. Handlers read it at invocation. An explicit factory or wrapper `capture_content` overrides it. Replaceable until the first AI invocation; an identical value is allowed after. Not read from request data. | | `get_client()` | Returns the initialized `LDClientInterface`. Throws if initialization has not completed. | -| `shutdown()` | Flushes all pending events and telemetry, then closes the client. Must be awaited before the process exits. Clears sdk-info reporting so a later client reports again. | +| `shutdown()` | Flushes all pending events and telemetry, then closes the client. Must be awaited before the process exits. Clears sdk-info reporting so a later client reports again. Restores the built-in SDK defaults. | | `register_ai_sdk_package(name, version)` | Records a LaunchDarkly AI package identity. Handler and convenience packages call this at import time. | ### Core Data Types @@ -554,9 +555,12 @@ providers out of three. `SpanUsage` is the type that means the folding is alread #### Content is off by default -Every factory takes `capture_content: bool = False`. Guard at the -call site as well as inside the helper: the helper's guard makes a forgotten call site harmless, and -the call site's guard avoids serialising JSON that would then be discarded, once per turn, in a loop. +Every factory takes `capture_content: bool | None = None`. `None` reads `configure()` at +invocation, which is off until a process sets `SDKOptions(capture_content=True)`. An explicit +`True` or `False` overrides that default. Guard at the call site as well as inside the helper: the +helper's guard makes a forgotten call site harmless, and the call site's guard avoids serialising +JSON that would then be discarded, once per turn, in a loop. Resolve the bool when the handler +runs, not when it is constructed. #### Finish reasons have three mechanisms, not one diff --git a/TELEMETRY-CONTRACT.md b/TELEMETRY-CONTRACT.md index ef183049..2b5157d0 100644 --- a/TELEMETRY-CONTRACT.md +++ b/TELEMETRY-CONTRACT.md @@ -394,11 +394,15 @@ error for an abandoned stream, so two dashboards would describe the same run dif ## 7. Content capture -Every handler factory takes `capture_content: bool = False`. - -The default is off. Conversation content is personal data. A run emits only metadata, meaning -models, token counts, timings and tool names, until a caller asks for more. Turning it on sends the -text of every request and response to whatever collector the SDK points at. +Every handler factory takes `capture_content: bool | None = None`. + +`None` reads the process-wide default at invocation (`configure(options=SDKOptions(...))`), which +is off. `True` or `False` overrides that default for the handler, and an explicit `False` wins +over a configured `True`. A convenience wrapper's `capture_content` is the per-call override: when +the caller passes it, that value is what the handler is built with; when they omit it, the wrapper +passes `None` and the process default applies. Conversation content is personal data. A run emits +only metadata, meaning models, token counts, timings and tool names, until capture is turned on. +Turning it on sends the text of every request and response to whatever collector the SDK points at. Guard twice, on purpose. The helper returns without writing when capture is off, which makes a forgotten call site harmless. The call site also checks before building the argument, which avoids diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py index eb7665e1..5256e5ea 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -48,6 +48,7 @@ set_output_content_attributes, set_tool_call_content_attributes, ) +from launchdarkly_ai_server.lifecycle import effective_capture_content from ._version import PACKAGE_NAME, __version__ from .spans import ( @@ -504,18 +505,25 @@ def _build_query_options( # --------------------------------------------------------------------------- -def create_claude_agents_handler(*, capture_content: bool = False) -> ProviderHandler: +def create_claude_agents_handler( + *, capture_content: bool | None = None +) -> ProviderHandler: """Creates a ``ProviderHandler`` for Anthropic's Claude via the claude-agent-sdk. Set *capture_content* to put prompts, model output, tool arguments and tool results on the - emitted spans. It defaults to off. See TELEMETRY-CONTRACT.md section 7. + emitted spans. ``None`` (the default) reads the process-wide default at invocation, + which is off until ``configure`` sets it. ``True`` or ``False`` overrides that default + for this handler. See TELEMETRY-CONTRACT.md section 7. """ report_usage("claude-agents.createClaudeAgentsHandler", PACKAGE_NAME, __version__) return _create_claude_agents_handler(capture_content=capture_content) -def _create_claude_agents_handler(*, capture_content: bool = False) -> ProviderHandler: +def _create_claude_agents_handler( + *, capture_content: bool | None = None +) -> ProviderHandler: """Non-reporting :func:`create_claude_agents_handler`, used by this package's wrappers.""" + content_override = capture_content async def _call_impl( config: AiConfigRep, @@ -524,6 +532,8 @@ async def _call_impl( variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: + # None follows configure(); an explicit bool overrides it. Read when the call runs. + capture_content = effective_capture_content(content_override) th = tool_handlers or {} vs = variables or {} @@ -699,14 +709,14 @@ def _stream_impl( tool_handlers or {}, variables or {}, history, - capture_content=capture_content, + capture_content=effective_capture_content(content_override), ) return _create_handler( ("Anthropic", "agent"), _call_impl, # type: ignore[arg-type] _stream_impl, # type: ignore[arg-type] - capture_content=capture_content, + capture_content=content_override, ) @@ -896,7 +906,7 @@ def claude_agents( """Convenience wrapper: creates a handler and calls config(...).invoke().""" report_usage("claude-agents.claudeAgents", PACKAGE_NAME, __version__) variables = kwargs.pop("variables", None) - capture_content = kwargs.pop("capture_content", False) + capture_content = kwargs.pop("capture_content", None) return _config( key=config_key, handler=_create_claude_agents_handler(capture_content=capture_content), diff --git a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py index 9d01c14f..391ca5df 100644 --- a/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py +++ b/packages/claude-messages/src/launchdarkly_ai_claude_messages/handler.py @@ -25,6 +25,7 @@ set_tool_call_content_attributes, to_semconv_finish_reason, ) +from launchdarkly_ai_server.lifecycle import effective_capture_content from ._version import PACKAGE_NAME, __version__ from .spans import ( @@ -351,14 +352,18 @@ def _is_coroutine(fn: Any) -> bool: _MAX_STEPS = 10 -def create_claude_messages_handler(*, capture_content: bool = False) -> ProviderHandler: +def create_claude_messages_handler( + *, capture_content: bool | None = None +) -> ProviderHandler: """Creates a ``ProviderHandler`` for Anthropic Claude (messages API). Requires ``anthropic`` to be installed as a peer dependency. Set *capture_content* to put prompts, model output, tool arguments and tool results on the - emitted spans. It defaults to off. Conversation content is PII, so a run emits only metadata, - meaning models, token counts, timings and tool names, until a caller asks for more. Turning this + emitted spans. ``None`` (the default) reads the process-wide default at invocation, + which is off until ``configure`` sets it. ``True`` or ``False`` overrides that default + for this handler. Conversation content is PII, so a run emits only metadata, meaning + models, token counts, timings and tool names, until capture is turned on. Turning this on sends the text of every request and response to whatever collector the SDK is pointed at. """ report_usage( @@ -368,9 +373,10 @@ def create_claude_messages_handler(*, capture_content: bool = False) -> Provider def _create_claude_messages_handler( - *, capture_content: bool = False + *, capture_content: bool | None = None ) -> ProviderHandler: """Non-reporting :func:`create_claude_messages_handler`, used by this package's wrappers.""" + content_override = capture_content import importlib anthropic_mod = importlib.import_module("anthropic") @@ -383,6 +389,8 @@ async def _call_impl( variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: + # None follows configure(); an explicit bool overrides it. Read when the call runs. + capture_content = effective_capture_content(content_override) th = tool_handlers or {} vs = variables or {} @@ -466,14 +474,14 @@ def _stream_impl( tool_handlers or {}, variables or {}, history, - capture_content=capture_content, + capture_content=effective_capture_content(content_override), ) return _create_handler( ("Anthropic", "messages"), _call_impl, # type: ignore[arg-type] _stream_impl, # type: ignore[arg-type] - capture_content=capture_content, + capture_content=content_override, ) @@ -722,7 +730,7 @@ def claude_messages( # asking for content on spans got a TypeError instead of content. report_usage("claude-messages.claudeMessages", PACKAGE_NAME, __version__) variables = kwargs.pop("variables", None) - capture_content = kwargs.pop("capture_content", False) + capture_content = kwargs.pop("capture_content", None) return _config( key=config_key, handler=_create_claude_messages_handler(capture_content=capture_content), diff --git a/packages/claude-messages/tests/test_handler.py b/packages/claude-messages/tests/test_handler.py index 0a0353d9..a37aa8ab 100644 --- a/packages/claude-messages/tests/test_handler.py +++ b/packages/claude-messages/tests/test_handler.py @@ -1824,8 +1824,11 @@ def test_capture_content_reaches_the_factory(self) -> None: # And it must not have been forwarded to config(), which does not accept it. assert "capture_content" not in seen["config_kwargs"] - def test_defaults_to_off(self) -> None: - assert self._run()["capture_content"] is False + def test_omitted_capture_content_defers_to_the_process_default(self) -> None: + assert self._run()["capture_content"] is None + + def test_explicit_false_reaches_the_factory(self) -> None: + assert self._run(capture_content=False)["capture_content"] is False class TestToolSpanNeverLeaks: diff --git a/packages/client/README.md b/packages/client/README.md index 73ba687d..e117c518 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -211,10 +211,11 @@ asyncio.run(main()) | Export | Description | |---|---| +| `configure(options=...)` | Set process-wide behavior defaults. Keyword-only. Separate from `init_client`. | | `init_client(options?)` | Auto-discover and initialize `launchdarkly-server-sdk`. Optional — the first AI API call triggers lazy init when `LD_SDK_KEY` is set. Returns `Awaitable[LDClientInterface]`. | | `init_client(client=...)` | **BYOC overload** — accept a pre-initialized `LDClientInterface`. Skips SDK auto-discovery. | | `get_client()` | Return the initialized `LDClientInterface`. Raises if `init_client` has not completed. | -| `shutdown()` | Flush all events and telemetry, then close the client. Await before process exit. | +| `shutdown()` | Flush all events and telemetry, then close the client. Await before process exit. Restores the built-in SDK defaults. | | `inspect_config(key, context)` | Read an AI Config variation without invoking the model. Never raises. Returns `{"enabled", "config", "meta"}`. | `init_client` reads these option keys. Each overrides the environment variable of the same @@ -231,6 +232,22 @@ warning and ignored. | `serviceName` | `LD_SERVICE_NAME` | OTel `service.name` resource attribute | | `environment` | `LD_ENVIRONMENT` | `deployment.environment` resource attribute | +### Process-wide defaults + +`configure` sets behavior for the process. It does not initialize the LaunchDarkly client; call `init_client` for that. Handlers built without an explicit `capture_content` read this value when they run, so one startup call covers every handler. An explicit `True` or `False` on a handler factory or convenience wrapper overrides it. Call `configure` from process startup, not from request data. It can be replaced until the first AI invocation; after that only the same options are accepted. `shutdown()` restores the built-in default, which is off. + +```python +from launchdarkly_ai_server import SDKOptions, configure, init_client, shutdown + +async def main(): + configure(options=SDKOptions(capture_content=True)) + await init_client({"sdkKey": "sdk-...", "serviceName": "my-service"}) + # create_*_handler() with no capture_content argument follows the default above. + await shutdown() +``` + +`SDKOptions` is the extension point for further process-wide defaults. + ### Give the evaluation tools Pass `tools` to `run()` as a list of `EvalTool`. Construct one to define a tool in code. Await `evals.tools.get()` to use a tool that already exists in your project's AI library, which reads the tool and pins its version at that point. One list can hold both kinds. diff --git a/packages/client/agents.md b/packages/client/agents.md index 1ca5ae3a..c5190059 100644 --- a/packages/client/agents.md +++ b/packages/client/agents.md @@ -867,6 +867,7 @@ When `enabled` is `False`, `config` is always `None`. When `enabled` is `True` b ## Lifecycle Invariants +- **Process defaults.** `configure(options=SDKOptions(capture_content=True))` sets behavior defaults for the process. Call it once at startup, not from request data, and not via `init_client`. Handlers omit `capture_content` to follow it. `shutdown()` restores the built-in defaults. - **Lazy initialization.** Importing the package does not initialize the LD client. The first API call that needs LaunchDarkly calls `init_client()` internally when `LD_SDK_KEY` is set. - **Explicit initialization — SDK path.** `await init_client(options?)` dynamically imports `launchdarkly-server-sdk` at runtime (optional peer dep). If the package is not installed it raises with a clear message. - **Explicit initialization — BYOC path.** `await init_client(client)` accepts any pre-initialized object that satisfies `LDClientInterface` — this is the path for custom or edge environments whose SDK has different init semantics. diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index c0117b80..8726e950 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -52,6 +52,7 @@ ) from .judges import build_judge_tasks, run_judge, run_judges from .lifecycle import ( + configure, extract_variation, get_client, init_client, @@ -97,6 +98,7 @@ ProviderGraphResponse, ProviderHandler, ProviderResponse, + SDKOptions, StreamChunkEvent, StreamDoneEvent, StreamEvent, @@ -148,6 +150,7 @@ "HandlerResult", "HandlerStreamEvent", "InitClientOptions", + "SDKOptions", "JudgeResult", "JudgeRunResult", "JudgeTask", @@ -237,6 +240,7 @@ "execute_and_track", "execute_and_stream", # lifecycle + "configure", "init_client", "get_client", "shutdown", diff --git a/packages/client/src/launchdarkly_ai_server/content.py b/packages/client/src/launchdarkly_ai_server/content.py index 99eaf1c9..1c51bf34 100644 --- a/packages/client/src/launchdarkly_ai_server/content.py +++ b/packages/client/src/launchdarkly_ai_server/content.py @@ -7,10 +7,11 @@ canonical shape, so a ``gen_ai.content.prompt`` event, which is what these handlers emitted before, is read by nothing on the LaunchDarkly side. -**Off by default.** Everything here is conversation content, which is PII. A handler opts in with -``capture_content=True``; every function below takes that decision as its ``capture`` argument and -returns without writing when it is false. Passing the flag rather than reading ambient state keeps -the gate visible at each call site. +**Off by default.** Everything here is conversation content, which is PII. Handlers resolve +``capture_content`` to a bool before calling in: an explicit ``True`` or ``False``, otherwise the +process default from ``configure``, otherwise off. Every function below takes that decision as its +``capture`` argument and returns without writing when it is false. Passing the flag rather than +reading ambient state keeps the gate visible at each call site. Handlers also test the same flag themselves before building the argument, so the check appears twice on purpose: the guard here is the safety net that makes a forgotten call site harmless, and diff --git a/packages/client/src/launchdarkly_ai_server/lifecycle.py b/packages/client/src/launchdarkly_ai_server/lifecycle.py index 0cb5e6a3..5d4bbadc 100644 --- a/packages/client/src/launchdarkly_ai_server/lifecycle.py +++ b/packages/client/src/launchdarkly_ai_server/lifecycle.py @@ -9,7 +9,7 @@ from . import skills from .sdk_info import flush_ai_sdk_info, reset_ai_sdk_info from .sdk_usage import flush_sdk_usage, report_usage, reset_sdk_usage -from .types import InitClientOptions +from .types import InitClientOptions, SDKOptions from .utils import model_stamps_from_meta logger = logging.getLogger(__name__) @@ -43,6 +43,56 @@ def _env(name: str) -> str | None: # is once-guarded, so when another library registered first ours is refused and # the global stays theirs. Only the owner may release it on shutdown. _owns_otel_globals: bool = False +# Process-wide behavior defaults. Separate from ``_client``: ``init_client`` only +# builds the LaunchDarkly client and telemetry, and ``configure`` can run before +# that exists. ``_ai_invoked`` latches on the first handler call so a later +# ``configure`` cannot change what an in-flight process already started using. +_sdk_options: SDKOptions = SDKOptions() +_ai_invoked: bool = False + + +def configure(*, options: SDKOptions) -> None: + """Sets process-wide SDK behavior defaults. + + Call once from process startup, before the first AI invocation, with an + :class:`SDKOptions` value the process owns. Do not build it from request + data. Handlers constructed without an explicit ``capture_content`` read the + configured value when they run, including handlers built before this call. + + A later call may replace the options until the first AI invocation. After + that, only an identical ``options`` value is accepted. ``shutdown()`` + restores the built-in defaults and allows configuration again. + + ``init_client`` is unchanged: it only initializes the LaunchDarkly client + and telemetry. + """ + global _sdk_options + if not isinstance(options, SDKOptions): + raise TypeError("configure() options must be an SDKOptions") + if _ai_invoked and options != _sdk_options: + raise RuntimeError( + "SDK options cannot change after the first AI invocation. " + "shutdown() restores the built-in defaults." + ) + _sdk_options = options + + +def effective_capture_content(override: bool | None) -> bool: + """Resolves content capture for one invocation. + + An explicit ``True`` or ``False`` — from a convenience wrapper, then from + the handler factory — wins. ``None`` reads the configured default, which + itself defaults to ``False``. + """ + if override is not None: + return override + return _sdk_options.capture_content + + +def note_ai_invocation() -> None: + """Latches the process so ``configure`` can no longer change options.""" + global _ai_invoked + _ai_invoked = True def get_client() -> Any: @@ -318,13 +368,13 @@ async def shutdown() -> None: even if the client was never initialized or already shut down. Also clears any experimental feature state, such as a configured skill - store. + store, and restores process-wide SDK defaults from ``configure``. When telemetry was running, this also releases the process-global tracer provider so a later ``init_client`` can install its own — see ``_release_otel_globals``. """ - global _client, _tracer_provider, _owns_otel_globals + global _client, _tracer_provider, _owns_otel_globals, _sdk_options, _ai_invoked local_client = _client local_provider = _tracer_provider @@ -332,10 +382,14 @@ async def shutdown() -> None: _clear_experimental_state() - # Null the singleton before any awaits so a second call is a no-op + # Null the singleton before any awaits so a second call is a no-op. + # Options reset even when no client was initialized: configure() does not + # require one, and shutdown is what puts the process back to built-in defaults. _client = None _tracer_provider = None _owns_otel_globals = False + _sdk_options = SDKOptions() + _ai_invoked = False reset_ai_sdk_info() reset_sdk_usage() @@ -373,11 +427,13 @@ def _set_client_for_testing(c: Any) -> None: def _reset_for_testing() -> None: """Test helper — clear all singleton state.""" - global _client, _tracer_provider, _owns_otel_globals + global _client, _tracer_provider, _owns_otel_globals, _sdk_options, _ai_invoked owned_globals = _owns_otel_globals _client = None _tracer_provider = None _owns_otel_globals = False + _sdk_options = SDKOptions() + _ai_invoked = False # Directly, not through _clear_experimental_state: a failed reset should # fail the test rather than leak state into the next one. skills._clear_state() diff --git a/packages/client/src/launchdarkly_ai_server/types.py b/packages/client/src/launchdarkly_ai_server/types.py index 44c876fc..b72c7bf2 100644 --- a/packages/client/src/launchdarkly_ai_server/types.py +++ b/packages/client/src/launchdarkly_ai_server/types.py @@ -151,27 +151,35 @@ class ProviderHandler: - ``__call__`` — blocking invocation - ``stream`` — optional async-generator streaming (may be ``None``) - ``provides_for`` — ``(provider_name, mode)`` tuple or ``None`` - - ``capture_content`` — whether this handler was built with content capture on + - ``capture_content`` — the effective content-capture decision for this handler ``capture_content`` is declared here so the client core can apply the handler's own content decision to content it writes on the handler's behalf — notably the judge's reasoning — - without reaching into the factory's closure. + without reaching into the factory's closure. ``None`` at construction reads the process + default from ``configure`` when the value is used. An explicit ``True`` or ``False`` is an + override. The property returns that effective bool, so judge and span paths share one gate. """ provides_for: tuple[str, Literal["agent", "messages"]] | None - capture_content: bool def __init__( self, fn: _HandlerFn, provides_for: tuple[str, Literal["agent", "messages"]] | None = None, stream_fn: _StreamFn | None = None, - capture_content: bool = False, + capture_content: bool | None = None, ) -> None: self._fn = fn self.provides_for = provides_for self._stream_fn = stream_fn - self.capture_content = capture_content + self._capture_content_override = capture_content + + @property + def capture_content(self) -> bool: + """Effective content capture: an explicit override, else the process default.""" + from .lifecycle import effective_capture_content + + return effective_capture_content(self._capture_content_override) async def __call__( self, @@ -181,6 +189,9 @@ async def __call__( variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> HandlerResult: + from .lifecycle import note_ai_invocation + + note_ai_invocation() return await self._fn(config, user_input, tool_handlers, variables, history) async def stream( @@ -193,6 +204,9 @@ async def stream( ) -> AsyncGenerator[dict[str, Any], None]: if self._stream_fn is None: raise NotImplementedError("This handler does not support streaming") + from .lifecycle import note_ai_invocation + + note_ai_invocation() return self._stream_fn(config, user_input, tool_handlers, variables, history) @property @@ -577,6 +591,26 @@ def errors(self) -> list[ReconcileAction]: RoutedModelArgs = dict[str, Any] GraphOptions = dict[str, Any] GraphArgs = dict[str, Any] + + +@dataclass(frozen=True) +class SDKOptions: + """Process-wide behavior defaults, applied by :func:`configure`. + + ``capture_content`` is the default for writing prompts, model output, tool + arguments, tool results, and judge explanations. It is a default, not a + ceiling: an explicit ``True`` or ``False`` on a handler factory or a + convenience wrapper overrides it. Further process-wide defaults are added + as fields on this object. + """ + + capture_content: bool = False + + def __post_init__(self) -> None: + if not isinstance(self.capture_content, bool): + raise TypeError("SDKOptions.capture_content must be a bool") + + InitClientOptions = dict[str, Any] # --------------------------------------------------------------------------- diff --git a/packages/client/src/launchdarkly_ai_server/utils.py b/packages/client/src/launchdarkly_ai_server/utils.py index 7c656abc..9ae5ca57 100644 --- a/packages/client/src/launchdarkly_ai_server/utils.py +++ b/packages/client/src/launchdarkly_ai_server/utils.py @@ -23,11 +23,15 @@ def create_handler( provides_for: tuple[str, Literal["agent", "messages"]], fn: _HandlerFn, stream_fn: _StreamFn | None = None, - capture_content: bool = False, + capture_content: bool | None = None, ) -> ProviderHandler: """ Wraps a plain async callable in a :class:`ProviderHandler` with the given ``provides_for`` metadata and optional streaming implementation. + + ``capture_content`` is this handler's override. ``None`` reads the process + default from :func:`configure` when the value is used. ``True`` or ``False`` + overrides that default. """ report_usage("client.createHandler") return _create_handler(provides_for, fn, stream_fn, capture_content) @@ -37,7 +41,7 @@ def _create_handler( provides_for: tuple[str, Literal["agent", "messages"]], fn: _HandlerFn, stream_fn: _StreamFn | None = None, - capture_content: bool = False, + capture_content: bool | None = None, ) -> ProviderHandler: """Non-reporting :func:`create_handler`, used by the package factories.""" return ProviderHandler( diff --git a/packages/client/tests/test_configure.py b/packages/client/tests/test_configure.py new file mode 100644 index 00000000..3e704f4c --- /dev/null +++ b/packages/client/tests/test_configure.py @@ -0,0 +1,150 @@ +"""Process-wide ``configure`` defaults. + +``init_client`` stays the LaunchDarkly client and telemetry initializer. +``SDKOptions.capture_content`` is a default, not a ceiling: an explicit handler +override wins, including an explicit ``False`` over a configured ``True``. +""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +import launchdarkly_ai_server.lifecycle as lifecycle_module +from launchdarkly_ai_server import ( + SDKOptions, + configure, + init_client, + shutdown, +) +from launchdarkly_ai_server.lifecycle import _reset_for_testing +from launchdarkly_ai_server.types import ProviderHandler + + +@pytest.fixture(autouse=True) +def reset_singleton() -> None: + _reset_for_testing() + yield + _reset_for_testing() + + +async def _fn( + config: Any, + user_input: str | None = None, + tool_handlers: dict[str, Any] | None = None, + variables: dict[str, Any] | None = None, + history: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + return {"output": "ok", "usage": {}} + + +def _handler(capture_content: bool | None = None) -> ProviderHandler: + return ProviderHandler(_fn, capture_content=capture_content) + + +def test_builtin_default_is_off() -> None: + assert _handler().capture_content is False + + +def test_configure_is_read_after_the_handler_is_built() -> None: + handler = _handler() + configure(options=SDKOptions(capture_content=True)) + assert handler.capture_content is True + + +def test_configure_can_be_replaced_before_an_invocation() -> None: + configure(options=SDKOptions(capture_content=True)) + configure(options=SDKOptions(capture_content=False)) + assert _handler().capture_content is False + + +def test_explicit_false_overrides_a_configured_true() -> None: + configure(options=SDKOptions(capture_content=True)) + assert _handler(False).capture_content is False + assert _handler(True).capture_content is True + assert _handler(None).capture_content is True + + +def test_judge_gate_uses_the_effective_bool() -> None: + # The same expression judges.py uses. No second content gate. + configure(options=SDKOptions(capture_content=True)) + reasoning = "because" + assert (reasoning if _handler().capture_content else None) == "because" + assert (reasoning if _handler(False).capture_content else None) is None + + +def test_configure_is_keyword_only() -> None: + with pytest.raises(TypeError): + configure(SDKOptions()) # type: ignore[misc] + + +def test_configure_rejects_a_non_options_value() -> None: + with pytest.raises(TypeError, match="SDKOptions"): + configure(options={"capture_content": True}) # type: ignore[arg-type] + + +def test_sdk_options_rejects_a_non_bool() -> None: + with pytest.raises(TypeError, match="bool"): + SDKOptions(capture_content=1) # type: ignore[arg-type] + + +def test_sdk_options_is_frozen() -> None: + options = SDKOptions() + with pytest.raises(FrozenInstanceError): + options.capture_content = True # type: ignore[misc] + + +async def test_a_changed_configuration_is_rejected_after_invocation() -> None: + await _handler()({}) + with pytest.raises(RuntimeError, match="cannot change"): + configure(options=SDKOptions(capture_content=True)) + + +async def test_an_identical_configuration_is_allowed_after_invocation() -> None: + configure(options=SDKOptions(capture_content=True)) + await _handler()({}) + configure(options=SDKOptions(capture_content=True)) + assert _handler().capture_content is True + + +async def test_streaming_latches_invocation_before_the_body_runs() -> None: + async def _stream(*_args: Any, **_kwargs: Any) -> Any: + yield {"type": "done", "output": "", "usage": {}} + + handler = ProviderHandler(_fn, stream_fn=_stream) + gen = await handler.stream({}) + await gen.aclose() + with pytest.raises(RuntimeError, match="cannot change"): + configure(options=SDKOptions(capture_content=True)) + + +async def test_a_missing_stream_does_not_latch() -> None: + with pytest.raises(NotImplementedError): + await _handler().stream({}) + configure(options=SDKOptions(capture_content=True)) + assert _handler().capture_content is True + + +async def test_shutdown_restores_the_builtin_default() -> None: + configure(options=SDKOptions(capture_content=True)) + await _handler()({}) + await shutdown() + assert _handler().capture_content is False + configure(options=SDKOptions(capture_content=True)) + assert _handler().capture_content is True + + +async def test_init_client_does_not_carry_behavior_defaults( + caplog: pytest.LogCaptureFixture, +) -> None: + stub = MagicMock() + with ( + patch.object(lifecycle_module, "_setup_telemetry", return_value=None), + caplog.at_level("WARNING", logger="launchdarkly_ai_server.lifecycle"), + ): + await init_client({"capture_content": True}, stub) + assert _handler().capture_content is False + assert any("capture_content" in record.getMessage() for record in caplog.records) diff --git a/packages/client/tests/test_public_surface.py b/packages/client/tests/test_public_surface.py index 043e0f3e..06bcbf4e 100644 --- a/packages/client/tests/test_public_surface.py +++ b/packages/client/tests/test_public_surface.py @@ -35,6 +35,7 @@ "HandlerResult", "HandlerStreamEvent", "InitClientOptions", + "SDKOptions", "InputTokenDetails", "Judge", "JudgeResult", @@ -71,6 +72,7 @@ "any_multimodal", "build_judge_tasks", "compose", + "configure", "compose_history", "config", "content_to_text", diff --git a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py index fde27d23..9bdab685 100644 --- a/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py +++ b/packages/langchain-agents/src/launchdarkly_ai_langchain_agents/handler.py @@ -31,6 +31,7 @@ set_input_content_attributes, set_output_content_attributes, ) +from launchdarkly_ai_server.lifecycle import effective_capture_content from ._version import PACKAGE_NAME, __version__ from .messages import to_lang_chain_messages @@ -255,7 +256,7 @@ def _run_usage_from_messages(messages: list[Any]) -> Any: def create_langchain_agents_handler( - llm: Any = None, *, capture_content: bool = False + llm: Any = None, *, capture_content: bool | None = None ) -> ProviderHandler: """Creates a ``ProviderHandler`` for LangChain via ``create_react_agent``. @@ -263,8 +264,10 @@ def create_langchain_agents_handler( called after flag evaluation so ``model.parameters`` can be applied unchanged. Set *capture_content* to put prompts, model output, tool arguments and tool results on the - emitted spans. It defaults to off. Conversation content is PII, so a run emits only metadata, - meaning models, token counts, timings and tool names, until a caller asks for more. + emitted spans. ``None`` (the default) reads the process-wide default at invocation, + which is off until ``configure`` sets it. ``True`` or ``False`` overrides that default + for this handler. Conversation content is PII, so a run emits only metadata, meaning + models, token counts, timings and tool names, until capture is turned on. """ report_usage( "langchain-agents.createLangChainAgentsHandler", PACKAGE_NAME, __version__ @@ -273,9 +276,10 @@ def create_langchain_agents_handler( def _create_langchain_agents_handler( - llm: Any = None, *, capture_content: bool = False + llm: Any = None, *, capture_content: bool | None = None ) -> ProviderHandler: """Non-reporting :func:`create_langchain_agents_handler`, used by this package's wrappers.""" + content_override = capture_content async def _call_impl( config: AiConfigRep, @@ -284,6 +288,8 @@ async def _call_impl( variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: + # None follows configure(); an explicit bool overrides it. Read when the call runs. + capture_content = effective_capture_content(content_override) import importlib th = tool_handlers or {} @@ -420,14 +426,14 @@ def _stream_impl( tool_handlers or {}, variables or {}, history, - capture_content=capture_content, + capture_content=effective_capture_content(content_override), ) return _create_handler( ("*", "agent"), _call_impl, # type: ignore[arg-type] _stream_impl, # type: ignore[arg-type] - capture_content=capture_content, + capture_content=content_override, ) @@ -583,7 +589,7 @@ def langchain_agents( # asking for content on spans got a TypeError instead of content. report_usage("langchain-agents.langchainAgents", PACKAGE_NAME, __version__) variables = kwargs.pop("variables", None) - capture_content = kwargs.pop("capture_content", False) + capture_content = kwargs.pop("capture_content", None) return _config( key=config_key, handler=_create_langchain_agents_handler(capture_content=capture_content), diff --git a/packages/langchain-agents/tests/test_handler.py b/packages/langchain-agents/tests/test_handler.py index 4571a79b..70268265 100644 --- a/packages/langchain-agents/tests/test_handler.py +++ b/packages/langchain-agents/tests/test_handler.py @@ -1849,8 +1849,11 @@ def test_capture_content_reaches_the_factory(self) -> None: # And it must not have been forwarded to config(), which does not accept it. assert "capture_content" not in seen["config_kwargs"] - def test_defaults_to_off(self) -> None: - assert self._run()["capture_content"] is False + def test_omitted_capture_content_defers_to_the_process_default(self) -> None: + assert self._run()["capture_content"] is None + + def test_explicit_false_reaches_the_factory(self) -> None: + assert self._run(capture_content=False)["capture_content"] is False class TestCallbackSpansNeverLeak: diff --git a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py index d41876ba..95390dfa 100644 --- a/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py +++ b/packages/langchain-messages/src/launchdarkly_ai_langchain_messages/handler.py @@ -32,6 +32,7 @@ set_output_content_attributes, set_tool_call_content_attributes, ) +from launchdarkly_ai_server.lifecycle import effective_capture_content from ._version import PACKAGE_NAME, __version__ from .spans import ( @@ -371,7 +372,7 @@ async def _run_structured_turn( def create_langchain_messages_handler( - llm: Any = None, *, capture_content: bool = False + llm: Any = None, *, capture_content: bool | None = None ) -> ProviderHandler: """ Creates a ``ProviderHandler`` for LangChain (chat models). @@ -382,17 +383,20 @@ def create_langchain_messages_handler( config's model name and parameters. Set *capture_content* to put prompts, model output, tool arguments and tool results on the - emitted spans. It defaults to off. Conversation content is PII, so a run emits only metadata, - meaning models, token counts, timings and tool names, until a caller asks for more. + emitted spans. ``None`` (the default) reads the process-wide default at invocation, + which is off until ``configure`` sets it. ``True`` or ``False`` overrides that default + for this handler. Conversation content is PII, so a run emits only metadata, meaning + models, token counts, timings and tool names, until capture is turned on. """ report_usage("langchain-messages.createLangChainHandler", PACKAGE_NAME, __version__) return _create_langchain_messages_handler(llm, capture_content=capture_content) def _create_langchain_messages_handler( - llm: Any = None, *, capture_content: bool = False + llm: Any = None, *, capture_content: bool | None = None ) -> ProviderHandler: """Non-reporting :func:`create_langchain_messages_handler`, used by this package's wrappers.""" + content_override = capture_content async def _call_impl( config: AiConfigRep, @@ -401,6 +405,8 @@ async def _call_impl( variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: + # None follows configure(); an explicit bool overrides it. Read when the call runs. + capture_content = effective_capture_content(content_override) import importlib th = tool_handlers or {} @@ -700,14 +706,14 @@ def _stream_impl( tool_handlers or {}, variables or {}, history, - capture_content=capture_content, + capture_content=effective_capture_content(content_override), ) return _create_handler( ("*", "messages"), _call_impl, # type: ignore[arg-type] _stream_impl, # type: ignore[arg-type] - capture_content=capture_content, + capture_content=content_override, ) @@ -1024,7 +1030,7 @@ def langchain_messages( # asking for content on spans got a TypeError instead of content. report_usage("langchain-messages.langchainMessages", PACKAGE_NAME, __version__) variables = kwargs.pop("variables", None) - capture_content = kwargs.pop("capture_content", False) + capture_content = kwargs.pop("capture_content", None) return _config( key=config_key, handler=_create_langchain_messages_handler( diff --git a/packages/langchain-messages/tests/test_handler.py b/packages/langchain-messages/tests/test_handler.py index 0b337c71..101b7f22 100644 --- a/packages/langchain-messages/tests/test_handler.py +++ b/packages/langchain-messages/tests/test_handler.py @@ -1655,8 +1655,11 @@ def test_capture_content_reaches_the_factory(self) -> None: # And it must not have been forwarded to config(), which does not accept it. assert "capture_content" not in seen["config_kwargs"] - def test_defaults_to_off(self) -> None: - assert self._run()["capture_content"] is False + def test_omitted_capture_content_defers_to_the_process_default(self) -> None: + assert self._run()["capture_content"] is None + + def test_explicit_false_reaches_the_factory(self) -> None: + assert self._run(capture_content=False)["capture_content"] is False class TestChatSpanAndTeardownNeverLeak: diff --git a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py index 355aaab9..d840e019 100644 --- a/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py +++ b/packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py @@ -45,6 +45,7 @@ set_tool_call_content_attributes, text_message, ) +from launchdarkly_ai_server.lifecycle import effective_capture_content from ._version import PACKAGE_NAME, __version__ from .spans import ( @@ -459,19 +460,26 @@ def _usage_from_error(error: BaseException) -> SpanUsage | None: return to_span_usage(usage) -def create_openai_agent_handler(*, capture_content: bool = False) -> ProviderHandler: +def create_openai_agent_handler( + *, capture_content: bool | None = None +) -> ProviderHandler: """Creates a ``ProviderHandler`` for OpenAI via the openai-agents SDK. Set *capture_content* to put prompts, model output, tool arguments and tool results on the - emitted spans. It defaults to off. Conversation content is PII, so a run emits only metadata, - meaning models, token counts, timings and tool names, until a caller asks for more. + emitted spans. ``None`` (the default) reads the process-wide default at invocation, + which is off until ``configure`` sets it. ``True`` or ``False`` overrides that default + for this handler. Conversation content is PII, so a run emits only metadata, meaning + models, token counts, timings and tool names, until capture is turned on. """ report_usage("openai-agents.createOpenAIAgentHandler", PACKAGE_NAME, __version__) return _create_openai_agent_handler(capture_content=capture_content) -def _create_openai_agent_handler(*, capture_content: bool = False) -> ProviderHandler: +def _create_openai_agent_handler( + *, capture_content: bool | None = None +) -> ProviderHandler: """Non-reporting :func:`create_openai_agent_handler`, used by this package's wrappers.""" + content_override = capture_content async def _call_impl( config: AiConfigRep, @@ -480,6 +488,8 @@ async def _call_impl( variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: + # None follows configure(); an explicit bool overrides it. Read when the call runs. + capture_content = effective_capture_content(content_override) import importlib agents_mod = importlib.import_module("agents") @@ -565,14 +575,14 @@ def _stream_impl( tool_handlers or {}, variables or {}, history, - capture_content=capture_content, + capture_content=effective_capture_content(content_override), ) return _create_handler( ("OpenAI", "agent"), _call_impl, # type: ignore[arg-type] _stream_impl, # type: ignore[arg-type] - capture_content=capture_content, + capture_content=content_override, ) @@ -722,7 +732,7 @@ def openai_agents( # asking for content on spans got a TypeError instead of content. report_usage("openai-agents.openaiAgents", PACKAGE_NAME, __version__) variables = kwargs.pop("variables", None) - capture_content = kwargs.pop("capture_content", False) + capture_content = kwargs.pop("capture_content", None) return _config( key=config_key, handler=_create_openai_agent_handler(capture_content=capture_content), diff --git a/packages/openai-agents/tests/test_handler.py b/packages/openai-agents/tests/test_handler.py index fe0d4af2..e9eb7f40 100644 --- a/packages/openai-agents/tests/test_handler.py +++ b/packages/openai-agents/tests/test_handler.py @@ -998,6 +998,68 @@ async def test_puts_prompt_and_completion_on_spans_when_enabled(self) -> None: prompt_written = json.dumps(rec.root.attributes) assert "my question" in prompt_written + async def test_process_default_is_read_when_the_handler_runs(self) -> None: + from launchdarkly_ai_server import SDKOptions, configure + from launchdarkly_ai_server.lifecycle import _reset_for_testing + + _reset_for_testing() + try: + # Built before configure, with no capture flag. Vega's startup call + # has to reach this handler when it later runs. + handler = create_openai_agent_handler() + configure(options=SDKOptions(capture_content=True)) + ctx, rec = _recording() + agents_mod = _fake_agents_module( + run=_make_run([{"output": _text_output("answer")}]) + ) + with ctx, _patched_agents(agents_mod): + await handler(CONFIG, "my question", {}, {}) + assert "my question" in json.dumps(rec.root.attributes) + finally: + _reset_for_testing() + + async def test_explicit_false_overrides_a_process_default_of_on(self) -> None: + from launchdarkly_ai_server import SDKOptions, configure + from launchdarkly_ai_server.lifecycle import _reset_for_testing + + _reset_for_testing() + try: + configure(options=SDKOptions(capture_content=True)) + ctx, rec = _recording() + agents_mod = _fake_agents_module( + run=_make_run([{"output": _text_output("answer")}]) + ) + with ctx, _patched_agents(agents_mod): + await create_openai_agent_handler(capture_content=False)( + CONFIG, "my question", {}, {} + ) + for span in rec.spans: + assert "gen_ai.input.messages" not in span.attributes + assert "my question" not in json.dumps(span.attributes) + finally: + _reset_for_testing() + + async def test_process_default_is_read_on_the_streaming_path(self) -> None: + from launchdarkly_ai_server import SDKOptions, configure + from launchdarkly_ai_server.lifecycle import _reset_for_testing + + _reset_for_testing() + try: + configure(options=SDKOptions(capture_content=True)) + turns = [{"deltas": ["hi"], "output": _text_output("hi")}] + ctx, rec = _recording() + agents_mod = _fake_agents_module( + run_streamed=_make_run_streamed(turns, "hi") + ) + with ctx, _patched_agents(agents_mod): + async for _ in await create_openai_agent_handler().stream( + CONFIG, "stream question", {}, {} + ): + pass + assert "stream question" in json.dumps(rec.root.attributes) + finally: + _reset_for_testing() + async def test_tool_call_arguments_and_result_gated(self) -> None: turns = [ { @@ -1700,7 +1762,7 @@ def _factory(*, capture_content: bool = False) -> Any: # And it must not have been forwarded to config(), which does not accept it. assert "capture_content" not in fake_config.call_args.kwargs - async def test_defaults_to_off(self) -> None: + async def test_omitted_capture_content_defers_to_the_process_default(self) -> None: import launchdarkly_ai_openai_agents.handler as handler_mod seen: dict[str, Any] = {} @@ -1717,6 +1779,25 @@ def _factory(*, capture_content: bool = False) -> Any: ): handler_mod.openai_agents("k", "q", {}) + assert seen["capture_content"] is None + + async def test_explicit_false_reaches_the_factory(self) -> None: + import launchdarkly_ai_openai_agents.handler as handler_mod + + seen: dict[str, Any] = {} + + def _factory(*, capture_content: bool | None = None) -> Any: + seen["capture_content"] = capture_content + return MagicMock() + + fake_config = MagicMock() + fake_config.return_value.invoke = MagicMock(return_value="ok") + with ( + patch.object(handler_mod, "_create_openai_agent_handler", _factory), + patch.object(handler_mod, "_config", fake_config), + ): + handler_mod.openai_agents("k", "q", {}, capture_content=False) + assert seen["capture_content"] is False diff --git a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py index 6afc2b49..9d59a2d6 100644 --- a/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py +++ b/packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py @@ -27,6 +27,7 @@ set_output_content_attributes, set_tool_call_content_attributes, ) +from launchdarkly_ai_server.lifecycle import effective_capture_content from ._version import PACKAGE_NAME, __version__ from .spans import ( @@ -212,23 +213,28 @@ async def _run_model_turn( return response -def create_openai_messages_handler(*, capture_content: bool = False) -> ProviderHandler: +def create_openai_messages_handler( + *, capture_content: bool | None = None +) -> ProviderHandler: """ Creates a ``ProviderHandler`` for OpenAI (responses API). Requires ``openai`` to be installed as a peer dependency. Set *capture_content* to put prompts, model output, tool arguments and tool results on the - emitted spans. It defaults to off. Conversation content is PII, so a run emits only metadata, - meaning models, token counts, timings and tool names, until a caller asks for more. + emitted spans. ``None`` (the default) reads the process-wide default at invocation, + which is off until ``configure`` sets it. ``True`` or ``False`` overrides that default + for this handler. Conversation content is PII, so a run emits only metadata, meaning + models, token counts, timings and tool names, until capture is turned on. """ report_usage("openai-messages.createOpenAIHandler", PACKAGE_NAME, __version__) return _create_openai_messages_handler(capture_content=capture_content) def _create_openai_messages_handler( - *, capture_content: bool = False + *, capture_content: bool | None = None ) -> ProviderHandler: """Non-reporting :func:`create_openai_messages_handler`, used by this package's wrappers.""" + content_override = capture_content import importlib openai_mod = importlib.import_module("openai") @@ -241,6 +247,8 @@ async def _call_impl( variables: dict[str, Any] | None = None, history: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: + # None follows configure(); an explicit bool overrides it. Read when the call runs. + capture_content = effective_capture_content(content_override) th = tool_handlers or {} vs = variables or {} @@ -414,14 +422,14 @@ def _stream_impl( tool_handlers or {}, variables or {}, history, - capture_content=capture_content, + capture_content=effective_capture_content(content_override), ) return _create_handler( ("OpenAI", "messages"), _call_impl, # type: ignore[arg-type] _stream_impl, # type: ignore[arg-type] - capture_content=capture_content, + capture_content=content_override, ) @@ -653,7 +661,7 @@ def openai_messages( # asking for content on spans got a TypeError instead of content. report_usage("openai-messages.openaiMessages", PACKAGE_NAME, __version__) variables = kwargs.pop("variables", None) - capture_content = kwargs.pop("capture_content", False) + capture_content = kwargs.pop("capture_content", None) return _config( key=config_key, handler=_create_openai_messages_handler(capture_content=capture_content), diff --git a/packages/openai-messages/tests/test_handler.py b/packages/openai-messages/tests/test_handler.py index 35da2f9d..cb80e5a5 100644 --- a/packages/openai-messages/tests/test_handler.py +++ b/packages/openai-messages/tests/test_handler.py @@ -1879,8 +1879,11 @@ def test_capture_content_reaches_the_factory(self) -> None: # And it must not have been forwarded to config(), which does not accept it. assert "capture_content" not in seen["config_kwargs"] - def test_defaults_to_off(self) -> None: - assert self._run()["capture_content"] is False + def test_omitted_capture_content_defers_to_the_process_default(self) -> None: + assert self._run()["capture_content"] is None + + def test_explicit_false_reaches_the_factory(self) -> None: + assert self._run(capture_content=False)["capture_content"] is False class TestChatSpanNeverLeaks: From 60c14bd2a92ec50b8df8d1c9a8474afc88f36b9e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 23:11:14 +0000 Subject: [PATCH 02/28] feat(opencode): add an experimental OpenCode handler Reduce OpenCode session events into text and tool events. config().stream() stays chunk then done, so a tool event does not close it. The experimental rich_stream reader is how a caller opts into those events. --- AGENTS.md | 2 +- .../experimental/__init__.py | 4 +- .../experimental/_opencode.py | 373 +++++++++++ .../experimental/_rich_stream.py | 165 +++++ .../experimental/opencode.py | 12 + .../experimental/rich_stream.py | 10 + .../src/launchdarkly_ai_server/tracking.py | 108 +++- packages/client/tests/test_opencode.py | 588 ++++++++++++++++++ release-please-config.json | 6 + 9 files changed, 1253 insertions(+), 15 deletions(-) create mode 100644 packages/client/src/launchdarkly_ai_server/experimental/_opencode.py create mode 100644 packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py create mode 100644 packages/client/src/launchdarkly_ai_server/experimental/opencode.py create mode 100644 packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py create mode 100644 packages/client/tests/test_opencode.py diff --git a/AGENTS.md b/AGENTS.md index dac8348d..2548208c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ The `UP046` rule (PEP 695 `type` statement syntax for generic classes) is **igno PRs are squash-merged with the PR title as the commit message, and release-please builds each package's changelog from those messages. -- **A change to an experimental feature uses that feature's scope:** `skills` (Agent Skills) and `evaluations` (Evaluations from Code). `feat`, `fix` and `perf` commits with one of these scopes are listed under **Experimental** (`changelog-sections` in `release-please-config.json`), for example `feat(skills): watch skills for revocations`. Any other scope, such as `client`, lists the change as a core feature or fix. +- **A change to an experimental feature uses that feature's scope:** `skills` (Agent Skills), `evaluations` (Evaluations from Code), `opencode` (OpenCode handler), and `rich-stream` (experimental AG-UI reader). `feat`, `fix` and `perf` commits with one of these scopes are listed under **Experimental** (`changelog-sections` in `release-please-config.json`), for example `feat(skills): watch skills for revocations`. Any other scope, such as `client`, lists the change as a core feature or fix. - **Use exactly one scope.** `feat(skills,client):` matches no Experimental row, so it lands under Features. - **A PR that changes both core and experimental behaviour** should be split. If it can't be, add a `BEGIN_COMMIT_OVERRIDE` block to its description with one conventional-commit line per change. An override also corrects a merged PR's title, as long as the release PR hasn't merged yet. - **A breaking experimental change** uses `!` while the package is 0.x: `feat(skills)!:` bumps the minor version, which is what an experimental break is allowed. After 1.0, `!` would force a major release. Use `feat(skills):` without `!` instead, and say the change is breaking in the title. diff --git a/packages/client/src/launchdarkly_ai_server/experimental/__init__.py b/packages/client/src/launchdarkly_ai_server/experimental/__init__.py index 80ecfd7d..11472de7 100644 --- a/packages/client/src/launchdarkly_ai_server/experimental/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/experimental/__init__.py @@ -1,7 +1,9 @@ """ Experimental features of the LaunchDarkly AI SDK. -Each feature is a submodule, for example ``launchdarkly_ai_server.experimental.skills``. +Each feature is a submodule, for example ``launchdarkly_ai_server.experimental.skills``, +``launchdarkly_ai_server.experimental.opencode``, and +``launchdarkly_ai_server.experimental.rich_stream``. Names imported from here may change in a minor release, so review the changelog when you upgrade. Names from the package root change only in a major release. """ diff --git a/packages/client/src/launchdarkly_ai_server/experimental/_opencode.py b/packages/client/src/launchdarkly_ai_server/experimental/_opencode.py new file mode 100644 index 00000000..a0c2bc26 --- /dev/null +++ b/packages/client/src/launchdarkly_ai_server/experimental/_opencode.py @@ -0,0 +1,373 @@ +"""Reduce OpenCode session events into handler stream events. + +The session is a caller-supplied async iterable of OpenCode bus events. This +module does not start a process. Tool execution stays inside OpenCode: the +events record a call that already ran. +""" + +from __future__ import annotations + +import json +import uuid +from collections.abc import AsyncGenerator, AsyncIterable, Callable, Mapping +from typing import Any + +from ..types import AiConfigRep, ProviderHandler +from ..utils import _create_handler, number_or_zero + +_TOOL_FAILURE = "An error occurred." + + +class OpenCodeSessionError(Exception): + """The OpenCode session failed. + + The provider error body is not included. A failed session is not a tool + result and does not finish the run. + """ + + +class _Reducer: + """One pass over a session. Repeated snapshots do not emit a second event.""" + + def __init__(self, capture_content: bool) -> None: + self._capture = capture_content + self._open_message: str | None = None + self._deltas: list[str] = [] + self._delta_parts: set[str] = set() + self._snapshot_parts: set[str] = set() + self._tools: dict[str, str] = {} + self._usage_ids: set[str] = set() + self._input = 0 + self._output = 0 + self._cache_read = 0 + self._cache_write = 0 + self._saw_usage = False + self._saw_cache = False + + @property + def deltas(self) -> list[str]: + return self._deltas + + def usage_payload(self) -> dict[str, int] | None: + """Raw usage for ``parse_usage``, or ``None`` when no assistant message completed.""" + if not self._saw_usage: + return None + payload: dict[str, int] = { + "input_tokens": self._input, + "output_tokens": self._output, + } + if self._saw_cache: + payload["cache_read_input_tokens"] = self._cache_read + payload["cache_creation_input_tokens"] = self._cache_write + return payload + + async def reduce( + self, events: AsyncIterable[Mapping[str, Any]] + ) -> AsyncGenerator[dict[str, Any], None]: + async for raw in events: + if isinstance(raw, Mapping) and raw.get("type") == "session.error": + raise OpenCodeSessionError("The OpenCode session failed.") + for event in self._event(raw): + yield event + for event in self._end_open(): + yield event + + def _event(self, raw: Mapping[str, Any] | object) -> list[dict[str, Any]]: + if not isinstance(raw, Mapping): + return [] + kind = raw.get("type") + properties = raw.get("properties") + if not isinstance(properties, Mapping): + return [] + if kind == "message.part.delta": + return self._delta(properties) + if kind == "message.part.updated": + part = properties.get("part") + if not isinstance(part, Mapping): + return [] + if part.get("type") == "tool": + return self._tool(part) + if part.get("type") == "text": + return self._text_snapshot(part) + return [] + if kind == "message.updated": + info = properties.get("info") + if isinstance(info, Mapping): + return self._message(info) + return [] + + def _delta(self, properties: Mapping[str, Any]) -> list[dict[str, Any]]: + if properties.get("field") != "text": + return [] + delta = properties.get("delta") + message_id = properties.get("messageID") + if not isinstance(delta, str) or not delta or not isinstance(message_id, str): + return [] + part_id = properties.get("partID") + if isinstance(part_id, str): + if part_id in self._snapshot_parts: + return [] + self._delta_parts.add(part_id) + return self._text(message_id, delta) + + def _text_snapshot(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: + message_id = part.get("messageID") + text = part.get("text") + part_id = part.get("id") + if ( + not isinstance(message_id, str) + or not isinstance(text, str) + or not text + or not isinstance(part_id, str) + ): + return [] + if part_id in self._delta_parts or part_id in self._snapshot_parts: + return [] + self._snapshot_parts.add(part_id) + return self._text(message_id, text) + + def _text(self, message_id: str, delta: str) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + if self._open_message != message_id: + events.extend(self._end_open()) + self._open_message = message_id + events.append({"type": "TEXT_MESSAGE_START", "messageId": message_id}) + events.append( + { + "type": "TEXT_MESSAGE_CONTENT", + "messageId": message_id, + "delta": delta, + } + ) + self._deltas.append(delta) + return events + + def _end_open(self) -> list[dict[str, Any]]: + if self._open_message is None: + return [] + message_id = self._open_message + self._open_message = None + return [{"type": "TEXT_MESSAGE_END", "messageId": message_id}] + + def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: + if info.get("role") != "assistant": + return [] + completed = info.get("time") + if not isinstance(completed, Mapping) or "completed" not in completed: + return [] + message_id = info.get("id") + events: list[dict[str, Any]] = [] + if isinstance(message_id, str) and message_id == self._open_message: + events.extend(self._end_open()) + if isinstance(message_id, str) and message_id not in self._usage_ids: + tokens = info.get("tokens") + if isinstance(tokens, Mapping): + self._add_usage(tokens) + self._usage_ids.add(message_id) + return events + + def _add_usage(self, tokens: Mapping[str, Any]) -> None: + self._saw_usage = True + self._input += number_or_zero(tokens.get("input")) + self._output += number_or_zero(tokens.get("output")) + cache = tokens.get("cache") + if isinstance(cache, Mapping): + self._saw_cache = True + self._cache_read += number_or_zero(cache.get("read")) + self._cache_write += number_or_zero(cache.get("write")) + + def _tool(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: + state = part.get("state") + if not isinstance(state, Mapping): + return [] + call_id = part.get("callID") or part.get("id") + name = part.get("tool") + if ( + not isinstance(call_id, str) + or not call_id + or not isinstance(name, str) + or not name + ): + return [] + status = state.get("status") + if status not in ("running", "completed", "error"): + return [] + events: list[dict[str, Any]] = [] + if self._tools.get(call_id) is None and ( + "input" in state or status != "running" + ): + events.extend(self._open_tool(call_id, name, state.get("input"))) + self._tools[call_id] = "open" + if status == "completed" and self._tools.get(call_id) == "open": + events.append(self._result(call_id, state.get("output", ""), failed=False)) + self._tools[call_id] = "done" + elif status == "error" and self._tools.get(call_id) == "open": + events.append(self._result(call_id, None, failed=True)) + self._tools[call_id] = "done" + return events + + def _open_tool( + self, call_id: str, name: str, raw_input: object + ) -> list[dict[str, Any]]: + start: dict[str, Any] = { + "type": "TOOL_CALL_START", + "toolCallId": call_id, + "toolCallName": name, + } + if self._open_message is not None: + start["parentMessageId"] = self._open_message + events = [start] + if self._capture: + text = _argument_text(raw_input) + if text: + events.append( + {"type": "TOOL_CALL_ARGS", "toolCallId": call_id, "delta": text} + ) + events.append({"type": "TOOL_CALL_END", "toolCallId": call_id}) + return events + + def _result(self, call_id: str, output: object, *, failed: bool) -> dict[str, Any]: + content = "" + metadata: dict[str, Any] | None = None + if failed: + metadata = {"launchdarkly": {"toolFailed": True}} + if self._capture: + content = _TOOL_FAILURE + else: + rendered = _result_text(output) + if rendered is None: + metadata = {"launchdarkly": {"toolFailed": True}} + if self._capture: + content = _TOOL_FAILURE + elif self._capture: + content = rendered + event: dict[str, Any] = { + "type": "TOOL_CALL_RESULT", + "messageId": str(uuid.uuid4()), + "toolCallId": call_id, + "content": content, + } + if metadata is not None: + event["metadata"] = metadata + return event + + +def _argument_text(value: object) -> str: + """Argument text for one ``TOOL_CALL_ARGS`` delta. Empty when there is nothing to send.""" + if value is None: + return "" + if isinstance(value, str): + return value + rendered = _json_text(value) + return rendered or "" + + +def _result_text(value: object) -> str | None: + """Success content, or ``None`` when the value cannot be placed on the event.""" + if isinstance(value, str): + return value + return _json_text(value) + + +def _json_text(value: object) -> str | None: + if isinstance(value, (bytes, bytearray, memoryview)): + return None + try: + return json.dumps( + value, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ) + except (TypeError, ValueError): + return None + + +def _open_events( + events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None, +) -> AsyncIterable[Mapping[str, Any]]: + if events is None: + raise OpenCodeSessionError("The OpenCode handler has no session event source.") + produced = events() + if not hasattr(produced, "__aiter__"): + raise TypeError("OpenCode event source must return an async iterable.") + return produced + + +def create_opencode_handler( + *, + capture_content: bool | None = None, + events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None = None, +) -> ProviderHandler: + """Return a handler that runs an OpenCode session supplied by *events*. + + ``provides_for`` is ``('*', 'agent')``, so the handler matches an agent-mode + variation for any provider. *events* is called on every invocation and must + return a fresh async iterable of OpenCode session events. This function does + not start OpenCode. + + *capture_content* follows the other handlers: ``None`` reads the process + default from ``configure`` when the call runs, and ``True`` or ``False`` + overrides it. Tool argument text and tool results are omitted from the + stream unless capture is on. ``config().stream()`` still yields assistant + text as ``chunk`` and one ``done``. Tool events stay on the experimental + reader. + """ + from ..lifecycle import effective_capture_content + from ..sdk_usage import report_usage + + report_usage("client.experimental.opencode.createHandler") + return _create_opencode_handler( + capture_content=capture_content, + events=events, + resolve_capture=effective_capture_content, + ) + + +def _create_opencode_handler( + *, + capture_content: bool | None, + events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None, + resolve_capture: Callable[[bool | None], bool], +) -> ProviderHandler: + content_override = capture_content + + async def _stream_impl( + config: AiConfigRep, + user_input: str | None, + tool_handlers: dict[str, Any] | None, + variables: dict[str, Any] | None, + history: list[dict[str, Any]] | None = None, + ) -> AsyncGenerator[dict[str, Any], None]: + capture = resolve_capture(content_override) + reducer = _Reducer(capture) + async for event in reducer.reduce(_open_events(events)): + yield event + done: dict[str, Any] = {"type": "done", "output": "".join(reducer.deltas)} + usage = reducer.usage_payload() + if usage is not None: + done["usage"] = usage + yield done + + async def _call_impl( + config: AiConfigRep, + user_input: str | None, + tool_handlers: dict[str, Any] | None, + variables: dict[str, Any] | None, + history: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + output = "" + usage: dict[str, Any] = {} + async for event in _stream_impl( + config, user_input, tool_handlers, variables, history + ): + if event.get("type") == "done": + raw_output = event.get("output") + output = raw_output if isinstance(raw_output, str) else "" + raw_usage = event.get("usage") + usage = raw_usage if isinstance(raw_usage, dict) else {} + return {"output": output, "usage": usage} + + return _create_handler( + ("*", "agent"), + _call_impl, + _stream_impl, + capture_content=content_override, + ) diff --git a/packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py b/packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py new file mode 100644 index 00000000..3df97c0a --- /dev/null +++ b/packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py @@ -0,0 +1,165 @@ +"""AG-UI reader over the same handler drive as ``config().stream()``. + +The stable reader keeps yielding ``chunk`` then ``done``. This one forwards +text and tool events and brackets them with ``RUN_STARTED`` and ``RUN_FINISHED``. +A raised turn ends the generator with neither ``RUN_FINISHED`` nor ``RUN_ERROR``. +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncGenerator +from typing import Any + +from ..client import ConfigInstance +from ..conversation import bind_conversation_id +from ..judges import run_judges +from ..lifecycle import extract_variation +from ..registry import resolve_handlers, resolve_tools +from ..sdk_usage import report_usage +from ..tracking import _SETTLED_EVENT, _drive_handler_stream +from ..types import LDContext +from ..utils import select_handler + +_TEXT = frozenset({"TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END"}) +_TOOLS = frozenset( + {"TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT"} +) + + +def stream( + config_instance: ConfigInstance, + user_input: str | None, + context: LDContext, + *, + variables: dict[str, Any] | None = None, + history: list[dict[str, Any]] | None = None, +) -> AsyncGenerator[dict[str, Any], None]: + """Yield AG-UI events for one ``config()`` call. + + Takes the same arguments as ``ConfigInstance.stream``. ``config().stream()`` + is unchanged. This function is the opt-in reader; it is not a default, and + it does not read or set a protocol field. + """ + report_usage("client.experimental.richStream") + return bind_conversation_id( + _events(config_instance, user_input, context, variables, history) + ) + + +async def _events( + config_instance: ConfigInstance, + user_input: str | None, + context: LDContext, + variables: dict[str, Any] | None, + history: list[dict[str, Any]] | None, +) -> AsyncGenerator[dict[str, Any], None]: + resolved_handler_list = resolve_handlers( + config_instance._registry, config_instance._normalize_handlers() + ) + resolved_tools = resolve_tools( + config_instance._registry, config_instance._tool_handlers + ) + variation = await extract_variation(config_instance._key, context) + config = variation["config"] + meta = variation["meta"] + if not resolved_handler_list: + raise ValueError("No handlers provided to config()") + handler = select_handler(config, meta, resolved_handler_list) + + thread_id = str(uuid.uuid4()) + run_id = str(uuid.uuid4()) + yield { + "type": "RUN_STARTED", + "threadId": thread_id, + "runId": run_id, + "protocolVersion": "1.0", + } + + settled: dict[str, Any] | None = None + synthetic_id: str | None = None + saw_canonical_text = False + async for event in _drive_handler_stream( + config_key=config_instance._key, + config=config, + meta=meta, + user_context=context, + handler=handler, + user_input=user_input, + tool_handlers=resolved_tools, + variables=variables, + history=history, + ): + if event.get("type") == _SETTLED_EVENT: + settled = event + continue + kind = event.get("type") + if kind in _TEXT: + if synthetic_id is not None: + yield {"type": "TEXT_MESSAGE_END", "messageId": synthetic_id} + synthetic_id = None + saw_canonical_text = True + yield event + elif kind == "chunk" and not saw_canonical_text: + text = event.get("text") + if not isinstance(text, str) or not text: + continue + if synthetic_id is None: + synthetic_id = str(uuid.uuid4()) + yield {"type": "TEXT_MESSAGE_START", "messageId": synthetic_id} + yield { + "type": "TEXT_MESSAGE_CONTENT", + "messageId": synthetic_id, + "delta": text, + } + elif kind in _TOOLS: + yield event + + if synthetic_id is not None: + yield {"type": "TEXT_MESSAGE_END", "messageId": synthetic_id} + + if settled is None: + return + + judge_results: dict[str, Any] = {} + if not config_instance._skip_judges: + judge_results = await run_judges( + config=config, + user_context=context, + handler=handler, + handlers=resolved_handler_list, + user_input=user_input, + trajectory=settled.get("trajectory", ""), + llm_response=settled.get("response", ""), + base_track_data=settled.get("track_data", {}), + tool_handlers=resolved_tools, + ) + + finished: dict[str, Any] = { + "type": "RUN_FINISHED", + "threadId": thread_id, + "runId": run_id, + } + response = settled.get("response") + if isinstance(response, str) and response: + finished["result"] = response + if settled.get("had_usage"): + usage = settled.get("usage") + if isinstance(usage, dict): + finished["usage"] = _token_usage(usage) + if judge_results: + finished["metadata"] = {"launchdarkly": {"judgeResults": judge_results}} + yield finished + + +def _token_usage(parsed: dict[str, Any]) -> list[dict[str, Any]]: + entry: dict[str, Any] = { + "inputTokens": parsed.get("input", 0), + "outputTokens": parsed.get("output", 0), + "totalTokens": parsed.get("total", 0), + } + details = parsed.get("input_details") + if isinstance(details, dict): + entry["cachedInputTokens"] = details.get("cache_read", 0) + entry["cacheWriteInputTokens"] = details.get("cache_creation", 0) + return [entry] diff --git a/packages/client/src/launchdarkly_ai_server/experimental/opencode.py b/packages/client/src/launchdarkly_ai_server/experimental/opencode.py new file mode 100644 index 00000000..e5e88e37 --- /dev/null +++ b/packages/client/src/launchdarkly_ai_server/experimental/opencode.py @@ -0,0 +1,12 @@ +"""Experimental OpenCode handler. + +Import ``create_opencode_handler`` from this module. It is not part of the +package root. The names may change in a minor release. +""" + +from ._opencode import OpenCodeSessionError, create_opencode_handler + +__all__ = [ + "OpenCodeSessionError", + "create_opencode_handler", +] diff --git a/packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py b/packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py new file mode 100644 index 00000000..05bf5b6a --- /dev/null +++ b/packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py @@ -0,0 +1,10 @@ +"""Experimental reader for the richer stream. + +``config().stream()`` stays ``chunk`` then ``done``. Call :func:`stream` to +read the AG-UI text and tool events for the same run. This does not select a +product default, and there is no protocol field on ``configure``. +""" + +from ._rich_stream import stream + +__all__ = ["stream"] diff --git a/packages/client/src/launchdarkly_ai_server/tracking.py b/packages/client/src/launchdarkly_ai_server/tracking.py index 6e7a5638..70777254 100644 --- a/packages/client/src/launchdarkly_ai_server/tracking.py +++ b/packages/client/src/launchdarkly_ai_server/tracking.py @@ -236,7 +236,12 @@ async def execute_and_track( } -async def execute_and_stream( +# Private terminal of :func:`_drive_handler_stream`. Not a handler event and not +# forwarded by either reader. A handler that yields this type is ignored. +_SETTLED_EVENT = "__ld_settled" + + +async def _drive_handler_stream( *, config_key: str, config: AiConfigRep, @@ -248,14 +253,13 @@ async def execute_and_stream( variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None, -) -> AsyncGenerator[ExecuteStreamEvent, None]: - """ - Streaming counterpart to ``execute_and_track``. Yields ``chunk`` events - from the handler, then a single ``done`` event once the stream completes - and LD telemetry has been flushed. +) -> AsyncGenerator[dict[str, Any], None]: + """Run one handler stream and yield its events, then one settled record. - Falls back to calling the blocking handler when the handler has no - ``stream`` implementation. + The settled record carries the response, parsed usage, tracking payload and + trajectory. Handler ``done`` is consumed here. Telemetry matches + :func:`execute_and_stream`. A raise records the error metrics and propagates, + with no settled record. """ from .lifecycle import get_client @@ -297,6 +301,8 @@ async def execute_and_stream( start_time = time.monotonic() full_text = "" raw_usage: dict[str, Any] = {} + had_usage = False + saw_text_message = False try: if handler.has_stream: @@ -304,13 +310,24 @@ async def execute_and_stream( config, user_input, tracked_tool_handlers, merged_variables, history ) async for event in stream_gen: - if event.get("type") == "chunk": - full_text += event.get("text", "") - yield {"type": "chunk", "text": event["text"]} - elif event.get("type") == "done": + kind = event.get("type") + if kind == "chunk": + if not saw_text_message: + full_text += event.get("text", "") + yield event + elif kind == "TEXT_MESSAGE_CONTENT": + saw_text_message = True + delta = event.get("delta") + if isinstance(delta, str): + full_text += delta + yield event + elif kind == "done": if event.get("output") is not None: full_text = event["output"] raw_usage = event.get("usage", {}) + had_usage = bool(event.get("usage")) + elif kind != _SETTLED_EVENT: + yield event else: result = await handler( config, user_input, tracked_tool_handlers, merged_variables, history @@ -318,6 +335,7 @@ async def execute_and_stream( out = result.get("output") full_text = str(out) if out is not None else "" raw_usage = result.get("usage") or {} + had_usage = bool(result.get("usage")) if full_text: yield {"type": "chunk", "text": full_text} except Exception: @@ -339,9 +357,10 @@ async def execute_and_stream( client.track("$ld:ai:tokens:output", ld_ctx, track_data, usage["output"]) yield { - "type": "done", + "type": _SETTLED_EVENT, "response": full_text, "usage": usage, + "had_usage": had_usage, "track_data": track_data, "trajectory": render_trajectory( recorder.invocations, @@ -349,3 +368,66 @@ async def execute_and_stream( omitted=recorder.omitted, ), } + + +async def execute_and_stream( + *, + config_key: str, + config: AiConfigRep, + meta: VariationMeta, + user_context: LDContext, + handler: ProviderHandler, + user_input: str | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + variables: dict[str, Any] | None = None, + graph_key: str | None = None, + history: list[dict[str, Any]] | None = None, +) -> AsyncGenerator[ExecuteStreamEvent, None]: + """ + Streaming counterpart to ``execute_and_track``. Yields ``chunk`` events + from the handler, then a single ``done`` event once the stream completes + and LD telemetry has been flushed. + + Assistant text arrives as ``chunk``. ``TEXT_MESSAGE_CONTENT`` contributes + its ``delta`` as a ``chunk`` as well. A tool event, or any other handler + event, is not forwarded and does not end the turn. Only the handler's + ``done`` supplies output and usage. ``config().stream()`` therefore stays + ``chunk`` then ``done``. + + Falls back to calling the blocking handler when the handler has no + ``stream`` implementation. + """ + settled: dict[str, Any] | None = None + saw_text_message = False + async for event in _drive_handler_stream( + config_key=config_key, + config=config, + meta=meta, + user_context=user_context, + handler=handler, + user_input=user_input, + tool_handlers=tool_handlers, + variables=variables, + graph_key=graph_key, + history=history, + ): + if event.get("type") == _SETTLED_EVENT: + settled = event + continue + kind = event.get("type") + if kind == "TEXT_MESSAGE_CONTENT": + saw_text_message = True + delta = event.get("delta") + if isinstance(delta, str) and delta: + yield {"type": "chunk", "text": delta} + elif kind == "chunk" and not saw_text_message: + yield {"type": "chunk", "text": event["text"]} + if settled is None: + return + yield { + "type": "done", + "response": settled.get("response", ""), + "usage": settled.get("usage"), + "track_data": settled.get("track_data"), + "trajectory": settled.get("trajectory", ""), + } diff --git a/packages/client/tests/test_opencode.py b/packages/client/tests/test_opencode.py new file mode 100644 index 00000000..783e717f --- /dev/null +++ b/packages/client/tests/test_opencode.py @@ -0,0 +1,588 @@ +"""OpenCode handler: synthetic session events, stable stream, experimental reader.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, AsyncIterator, Iterator +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import launchdarkly_ai_server.lifecycle as lifecycle_module +from launchdarkly_ai_server import ProviderHandler, SDKOptions, config, configure +from launchdarkly_ai_server.experimental.opencode import ( + OpenCodeSessionError, + create_opencode_handler, +) +from launchdarkly_ai_server.experimental.rich_stream import stream as rich_stream + +CONTEXT = {"kind": "user", "key": "user-1"} + +VARIATION = { + "model": {"name": "test-model"}, + "provider": {"name": "Acme"}, + "instructions": "Be brief.", + "_ldMeta": { + "enabled": True, + "variationKey": "v1", + "version": 1, + "mode": "agent", + }, +} + + +def bus(kind: str, **properties: Any) -> dict[str, Any]: + return {"type": kind, "properties": properties} + + +def text_delta(message_id: str, part_id: str, delta: str) -> dict[str, Any]: + return bus( + "message.part.delta", + sessionID="ses", + messageID=message_id, + partID=part_id, + field="text", + delta=delta, + ) + + +def tool_part( + call_id: str, + status: str, + *, + message_id: str = "m1", + name: str = "bash", + raw_input: Any = None, + output: Any = None, + part_id: str | None = None, +) -> dict[str, Any]: + state: dict[str, Any] = {"status": status} + if raw_input is not None: + state["input"] = raw_input + if output is not None: + state["output"] = output + return bus( + "message.part.updated", + part={ + "id": part_id or f"part-{call_id}", + "messageID": message_id, + "type": "tool", + "callID": call_id, + "tool": name, + "state": state, + }, + ) + + +def assistant_completed( + message_id: str, + *, + tokens: dict[str, Any] | None = None, +) -> dict[str, Any]: + info: dict[str, Any] = { + "id": message_id, + "role": "assistant", + "time": {"created": 1, "completed": 2}, + } + if tokens is not None: + info["tokens"] = tokens + return bus("message.updated", info=info) + + +def source(events: list[dict[str, Any]]) -> Any: + async def _events() -> AsyncIterator[dict[str, Any]]: + for event in events: + yield event + + return _events + + +async def collect(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + built = create_opencode_handler(capture_content=True, events=source(events)) + gen = await built.stream({}, "hi", None, None) + return [event async for event in gen] + + +def types_of(events: list[dict[str, Any]]) -> list[str]: + return [str(event["type"]) for event in events] + + +@pytest.fixture(autouse=True) +def _reset_sdk() -> Iterator[None]: + lifecycle_module._reset_for_testing() + yield + lifecycle_module._reset_for_testing() + + +@pytest.fixture +def mock_ld_client() -> Iterator[MagicMock]: + client = MagicMock() + client.track = MagicMock() + client.flush = AsyncMock() + client.close = AsyncMock() + client.variation = AsyncMock(return_value=VARIATION) + lifecycle_module._set_client_for_testing(client) + yield client + lifecycle_module._reset_for_testing() + + +class TestSurface: + def test_provides_for_is_any_provider_in_agent_mode(self) -> None: + handler = create_opencode_handler(events=source([])) + assert handler.provides_for == ("*", "agent") + + def test_names_stay_off_the_package_root(self) -> None: + import launchdarkly_ai_server as package + from launchdarkly_ai_server.experimental import opencode + + assert set(opencode.__all__) == { + "OpenCodeSessionError", + "create_opencode_handler", + } + public = {name for name in dir(opencode) if not name.startswith("_")} + assert public == set(opencode.__all__) + for name in opencode.__all__: + assert name not in package.__all__ + assert not hasattr(package, name) + + +class TestTextAndTools: + async def test_text_and_a_successful_tool(self) -> None: + events = await collect( + [ + text_delta("m1", "p1", "Looking up"), + tool_part("c1", "running", raw_input={"command": "ls"}), + tool_part("c1", "running", raw_input={"command": "ls"}), + tool_part( + "c1", "completed", raw_input={"command": "ls"}, output="file.txt" + ), + tool_part( + "c1", "completed", raw_input={"command": "ls"}, output="file.txt" + ), + assistant_completed("m1"), + ], + ) + assert types_of(events) == [ + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TOOL_CALL_START", + "TOOL_CALL_ARGS", + "TOOL_CALL_END", + "TOOL_CALL_RESULT", + "TEXT_MESSAGE_END", + "done", + ] + assert events[0]["messageId"] == "m1" + assert events[1] == { + "type": "TEXT_MESSAGE_CONTENT", + "messageId": "m1", + "delta": "Looking up", + } + assert events[2]["toolCallId"] == "c1" + assert events[2]["toolCallName"] == "bash" + assert events[2]["parentMessageId"] == "m1" + assert events[3] == { + "type": "TOOL_CALL_ARGS", + "toolCallId": "c1", + "delta": '{"command":"ls"}', + } + assert "toolCallName" not in events[3] + assert events[5]["content"] == "file.txt" + assert events[5]["messageId"] != "c1" + assert "metadata" not in events[5] + assert events[-1]["output"] == "Looking up" + + async def test_a_failed_tool_is_masked(self) -> None: + events = await collect( + [ + tool_part( + "c1", + "error", + raw_input={"command": "rm"}, + output="disk on fire", + ) + ], + ) + result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") + assert result["content"] == "An error occurred." + assert result["metadata"] == {"launchdarkly": {"toolFailed": True}} + assert "disk on fire" not in str(result) + assert "parentMessageId" not in events[0] + + async def test_a_string_argument_is_not_encoded_again(self) -> None: + events = await collect( + [tool_part("c1", "running", raw_input="ls")], + ) + args = next(event for event in events if event["type"] == "TOOL_CALL_ARGS") + assert args["delta"] == "ls" + + async def test_nan_result_is_a_failed_tool(self) -> None: + events = await collect( + [tool_part("c1", "completed", raw_input={}, output=float("nan"))], + ) + result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") + assert result["metadata"] == {"launchdarkly": {"toolFailed": True}} + assert result["content"] == "An error occurred." + + async def test_part_id_is_the_call_id_when_call_id_is_absent(self) -> None: + part = tool_part("ignored", "running", raw_input={"q": 1}, part_id="part-9") + del part["properties"]["part"]["callID"] + events = await collect([part]) + assert events[0]["toolCallId"] == "part-9" + + async def test_interleaved_tools_keep_arrival_order(self) -> None: + events = await collect( + [ + tool_part("a", "running", raw_input={"q": "a"}, name="search"), + tool_part("b", "running", raw_input={"q": "b"}, name="weather"), + tool_part("b", "completed", raw_input={"q": "b"}, output="sun"), + tool_part("a", "completed", raw_input={"q": "a"}, output="rows"), + ], + ) + results = [event for event in events if event["type"] == "TOOL_CALL_RESULT"] + assert [event["toolCallId"] for event in results] == ["b", "a"] + assert [event["content"] for event in results] == ["sun", "rows"] + + async def test_completed_assistant_usage_is_summed_once(self) -> None: + tokens = {"input": 3, "output": 4, "cache": {"read": 1, "write": 2}} + events = await collect( + [ + text_delta("m1", "p1", "Hi"), + assistant_completed("m1", tokens=tokens), + assistant_completed("m1", tokens=tokens), + ], + ) + assert events[-1]["usage"] == { + "input_tokens": 3, + "output_tokens": 4, + "cache_read_input_tokens": 1, + "cache_creation_input_tokens": 2, + } + + async def test_text_after_a_tool_is_a_new_message(self) -> None: + events = await collect( + [ + text_delta("m1", "p1", "A"), + tool_part("c1", "running", raw_input={"q": 1}), + tool_part("c1", "completed", raw_input={"q": 1}, output="ok"), + assistant_completed("m1"), + text_delta("m2", "p2", "B"), + assistant_completed("m2"), + ], + ) + starts = [event for event in events if event["type"] == "TEXT_MESSAGE_START"] + assert [event["messageId"] for event in starts] == ["m1", "m2"] + assert events[-1]["output"] == "AB" + + async def test_a_text_snapshot_does_not_repeat_deltas(self) -> None: + events = await collect( + [ + text_delta("m1", "p1", "Hi"), + bus( + "message.part.updated", + part={ + "id": "p1", + "messageID": "m1", + "type": "text", + "text": "Hi", + }, + ), + assistant_completed("m1"), + ], + ) + contents = [ + event for event in events if event["type"] == "TEXT_MESSAGE_CONTENT" + ] + assert contents == [ + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "m1", "delta": "Hi"} + ] + + async def test_session_error_raises_without_the_provider_body(self) -> None: + handler = create_opencode_handler( + capture_content=True, + events=source( + [ + text_delta("m1", "p1", "Hi"), + { + "type": "session.error", + "properties": {"error": {"data": {"message": "secret-body"}}}, + }, + ] + ), + ) + seen: list[str] = [] + with pytest.raises(OpenCodeSessionError, match="session failed") as raised: + async for event in await handler.stream({}, "hi", None, None): + seen.append(event["type"]) + assert "secret-body" not in str(raised.value) + assert "done" not in seen + + async def test_pending_tool_state_emits_nothing(self) -> None: + events = await collect( + [tool_part("c1", "pending", raw_input={}, output=None)], + ) + assert types_of(events) == ["done"] + + +class TestCapture: + async def test_capture_off_omits_argument_and_result_text(self) -> None: + handler = create_opencode_handler( + capture_content=False, + events=source( + [ + tool_part( + "c1", + "completed", + raw_input={"command": "ls"}, + output="file.txt", + ) + ] + ), + ) + events = [event async for event in await handler.stream({}, None, None, None)] + assert "TOOL_CALL_ARGS" not in types_of(events) + result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") + assert result["content"] == "" + assert "metadata" not in result + + async def test_explicit_false_overrides_configure_true(self) -> None: + configure(options=SDKOptions(capture_content=True)) + try: + handler = create_opencode_handler( + capture_content=False, + events=source( + [tool_part("c1", "running", raw_input={"command": "ls"})] + ), + ) + events = [event async for event in await handler.stream({}, None, None, None)] + assert "TOOL_CALL_ARGS" not in types_of(events) + finally: + lifecycle_module._reset_for_testing() + + async def test_omitted_capture_reads_configure_at_invocation(self) -> None: + configure(options=SDKOptions(capture_content=True)) + try: + handler = create_opencode_handler( + events=source( + [tool_part("c1", "running", raw_input={"command": "ls"})] + ), + ) + events = [event async for event in await handler.stream({}, None, None, None)] + args = next(event for event in events if event["type"] == "TOOL_CALL_ARGS") + assert args["delta"] == '{"command":"ls"}' + finally: + lifecycle_module._reset_for_testing() + + +class TestStableStream: + async def test_tool_events_do_not_close_the_stable_stream( + self, mock_ld_client: MagicMock + ) -> None: + handler = create_opencode_handler( + capture_content=True, + events=source( + [ + text_delta("m1", "p1", "Looking up"), + tool_part("c1", "running", raw_input={"command": "ls"}), + tool_part( + "c1", + "completed", + raw_input={"command": "ls"}, + output="file.txt", + ), + assistant_completed("m1"), + text_delta("m2", "p2", "Done."), + assistant_completed("m2"), + ] + ), + ) + seen = [ + event + async for event in config(key="flag", handler=handler).stream("q", CONTEXT) + ] + assert [event["type"] for event in seen] == ["chunk", "chunk", "done"] + assert [event["text"] for event in seen if event["type"] == "chunk"] == [ + "Looking up", + "Done.", + ] + done = seen[-1] + assert done["response"] == "Looking upDone." + assert "toolCallId" not in done + assert done["usage"]["input"] == 0 + + async def test_session_error_does_not_yield_stable_done( + self, mock_ld_client: MagicMock + ) -> None: + handler = create_opencode_handler( + events=source( + [ + text_delta("m1", "p1", "Hi"), + {"type": "session.error", "properties": {"error": {"name": "X"}}}, + ] + ), + ) + seen: list[dict[str, Any]] = [] + with pytest.raises(OpenCodeSessionError): + async for event in config(key="flag", handler=handler).stream("q", CONTEXT): + seen.append(event) + assert [event["type"] for event in seen] == ["chunk"] + assert seen[0]["text"] == "Hi" + + async def test_a_chunk_handler_tool_event_is_not_the_done_event( + self, mock_ld_client: MagicMock + ) -> None: + async def invoke( + config: Any, + user_input: str | None, + tool_handlers: Any, + variables: Any, + history: Any = None, + ) -> dict[str, Any]: + return {"output": "Hi", "usage": {"input_tokens": 1, "output_tokens": 1}} + + async def stream_fn( + config: Any, + user_input: str | None, + tool_handlers: Any, + variables: Any, + history: Any = None, + ) -> AsyncGenerator[dict[str, Any], None]: + yield { + "type": "TOOL_CALL_START", + "toolCallId": "c1", + "toolCallName": "bash", + } + yield {"type": "chunk", "text": "Hi"} + yield { + "type": "done", + "output": "Hi", + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + + handler = ProviderHandler( + fn=invoke, provides_for=("Acme", "agent"), stream_fn=stream_fn + ) + seen = [ + event + async for event in config(key="flag", handler=handler).stream("q", CONTEXT) + ] + assert [event["type"] for event in seen] == ["chunk", "done"] + assert seen[-1]["response"] == "Hi" + assert "toolCallId" not in seen[-1] + + +class TestRichReader: + async def test_rich_reader_forwards_text_and_tools( + self, mock_ld_client: MagicMock + ) -> None: + handler = create_opencode_handler( + capture_content=True, + events=source( + [ + text_delta("m1", "p1", "Looking up"), + tool_part("c1", "running", raw_input={"command": "ls"}), + tool_part( + "c1", + "error", + raw_input={"command": "ls"}, + output="nope", + ), + assistant_completed( + "m1", + tokens={ + "input": 3, + "output": 4, + "cache": {"read": 1, "write": 2}, + }, + ), + ] + ), + ) + instance = config(key="flag", handler=handler) + seen = [event async for event in rich_stream(instance, "q", CONTEXT)] + kinds = [event["type"] for event in seen] + assert kinds[0] == "RUN_STARTED" + assert kinds[-1] == "RUN_FINISHED" + assert seen[0]["threadId"] == seen[-1]["threadId"] + assert seen[0]["runId"] == seen[-1]["runId"] + assert seen[0]["protocolVersion"] == "1.0" + assert "TEXT_MESSAGE_CONTENT" in kinds + assert "TOOL_CALL_RESULT" in kinds + result = next(event for event in seen if event["type"] == "TOOL_CALL_RESULT") + assert result["content"] == "An error occurred." + assert result["metadata"] == {"launchdarkly": {"toolFailed": True}} + finished = seen[-1] + assert finished["result"] == "Looking up" + assert finished["usage"] == [ + { + "inputTokens": 6, + "outputTokens": 4, + "totalTokens": 10, + "cachedInputTokens": 1, + "cacheWriteInputTokens": 2, + } + ] + assert "metadata" not in finished + + async def test_session_error_has_no_run_finished( + self, mock_ld_client: MagicMock + ) -> None: + handler = create_opencode_handler( + events=source( + [ + {"type": "session.error", "properties": {"error": {"name": "X"}}}, + ] + ), + ) + seen: list[str] = [] + with pytest.raises(OpenCodeSessionError): + async for event in rich_stream( + config(key="flag", handler=handler), "q", CONTEXT + ): + seen.append(event["type"]) + assert seen == ["RUN_STARTED"] + assert "RUN_FINISHED" not in seen + + async def test_legacy_chunks_are_one_text_message( + self, mock_ld_client: MagicMock + ) -> None: + async def invoke( + config: Any, + user_input: str | None, + tool_handlers: Any, + variables: Any, + history: Any = None, + ) -> dict[str, Any]: + return {"output": "Hi"} + + async def stream_fn( + config: Any, + user_input: str | None, + tool_handlers: Any, + variables: Any, + history: Any = None, + ) -> AsyncGenerator[dict[str, Any], None]: + yield {"type": "chunk", "text": "Hi"} + yield {"type": "TOOL_CALL_END", "toolCallId": "c1"} + yield {"type": "done", "output": "Hi"} + + handler = ProviderHandler( + fn=invoke, provides_for=("Acme", "agent"), stream_fn=stream_fn + ) + seen = [ + event + async for event in rich_stream( + config(key="flag", handler=handler), "q", CONTEXT + ) + ] + assert [event["type"] for event in seen] == [ + "RUN_STARTED", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TOOL_CALL_END", + "TEXT_MESSAGE_END", + "RUN_FINISHED", + ] + assert seen[2]["delta"] == "Hi" + assert seen[-1]["result"] == "Hi" + assert "usage" not in seen[-1] diff --git a/release-please-config.json b/release-please-config.json index 130d93b0..338b314b 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -98,6 +98,12 @@ { "type": "feat", "scope": "evaluations", "section": "Experimental" }, { "type": "fix", "scope": "evaluations", "section": "Experimental" }, { "type": "perf", "scope": "evaluations", "section": "Experimental" }, + { "type": "feat", "scope": "opencode", "section": "Experimental" }, + { "type": "fix", "scope": "opencode", "section": "Experimental" }, + { "type": "perf", "scope": "opencode", "section": "Experimental" }, + { "type": "feat", "scope": "rich-stream", "section": "Experimental" }, + { "type": "fix", "scope": "rich-stream", "section": "Experimental" }, + { "type": "perf", "scope": "rich-stream", "section": "Experimental" }, { "type": "feat", "section": "Features" }, { "type": "fix", "section": "Bug Fixes" }, { "type": "perf", "section": "Performance Improvements" }, From 19ea9570fb43cb5b7f0465d57eefc851b36ac990 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 23:53:07 +0000 Subject: [PATCH 03/28] style(opencode): format the handler tests --- packages/client/tests/test_opencode.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/client/tests/test_opencode.py b/packages/client/tests/test_opencode.py index 783e717f..da0cf810 100644 --- a/packages/client/tests/test_opencode.py +++ b/packages/client/tests/test_opencode.py @@ -354,7 +354,9 @@ async def test_explicit_false_overrides_configure_true(self) -> None: [tool_part("c1", "running", raw_input={"command": "ls"})] ), ) - events = [event async for event in await handler.stream({}, None, None, None)] + events = [ + event async for event in await handler.stream({}, None, None, None) + ] assert "TOOL_CALL_ARGS" not in types_of(events) finally: lifecycle_module._reset_for_testing() @@ -367,7 +369,9 @@ async def test_omitted_capture_reads_configure_at_invocation(self) -> None: [tool_part("c1", "running", raw_input={"command": "ls"})] ), ) - events = [event async for event in await handler.stream({}, None, None, None)] + events = [ + event async for event in await handler.stream({}, None, None, None) + ] args = next(event for event in events if event["type"] == "TOOL_CALL_ARGS") assert args["delta"] == '{"command":"ls"}' finally: From df46a04940cb82aacc9fbbadcee9f169e7c4fd2e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 00:23:54 +0000 Subject: [PATCH 04/28] feat(opencode-agents): ship the handler as a normal package Move the OpenCode handler into launchdarkly-ai-opencode-agents and export rich_stream from launchdarkly_ai_server. The handler reduces caller-supplied session events and does not depend on an OpenCode SDK. Assistant text waits until the message role is assistant, and a part snapshot emits only text that has not already been sent. --- .github/workflows/release-please.yml | 34 +++++ .release-please-manifest.json | 3 +- .sdk_metadata.json | 12 ++ AGENTS.md | 7 +- .../src/launchdarkly_ai_server/__init__.py | 2 + .../experimental/__init__.py | 4 +- .../experimental/opencode.py | 12 -- .../experimental/rich_stream.py | 10 -- .../_rich_stream.py => rich_stream.py} | 22 +-- packages/client/tests/test_public_surface.py | 1 + packages/opencode-agents/CHANGELOG.md | 8 ++ packages/opencode-agents/README.md | 19 +++ packages/opencode-agents/agents.md | 13 ++ packages/opencode-agents/pyproject.toml | 32 +++++ .../__init__.py | 17 +++ .../_version.py | 4 + .../handler.py} | 134 ++++++++++++++---- .../launchdarkly_ai_opencode_agents/py.typed | 0 .../tests/test_handler.py} | 102 +++++++++++-- pyproject.toml | 1 + release-please-config.json | 17 ++- uv.lock | 30 ++-- 22 files changed, 392 insertions(+), 92 deletions(-) delete mode 100644 packages/client/src/launchdarkly_ai_server/experimental/opencode.py delete mode 100644 packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py rename packages/client/src/launchdarkly_ai_server/{experimental/_rich_stream.py => rich_stream.py} (92%) create mode 100644 packages/opencode-agents/CHANGELOG.md create mode 100644 packages/opencode-agents/README.md create mode 100644 packages/opencode-agents/agents.md create mode 100644 packages/opencode-agents/pyproject.toml create mode 100644 packages/opencode-agents/src/launchdarkly_ai_opencode_agents/__init__.py create mode 100644 packages/opencode-agents/src/launchdarkly_ai_opencode_agents/_version.py rename packages/{client/src/launchdarkly_ai_server/experimental/_opencode.py => opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py} (76%) create mode 100644 packages/opencode-agents/src/launchdarkly_ai_opencode_agents/py.typed rename packages/{client/tests/test_opencode.py => opencode-agents/tests/test_handler.py} (86%) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 4559e28b..71f18344 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -32,6 +32,7 @@ on: - packages/openai-messages - packages/langchain-agents - packages/langchain-messages + - packages/opencode-agents - packages/vercel-agents - packages/vercel-messages dry_run: @@ -64,6 +65,8 @@ jobs: package-langchain-agents-tag-name: ${{ steps.release.outputs['packages/langchain-agents--tag_name'] }} package-langchain-messages-released: ${{ steps.release.outputs['packages/langchain-messages--release_created'] }} package-langchain-messages-tag-name: ${{ steps.release.outputs['packages/langchain-messages--tag_name'] }} + package-opencode-agents-released: ${{ steps.release.outputs['packages/opencode-agents--release_created'] }} + package-opencode-agents-tag-name: ${{ steps.release.outputs['packages/opencode-agents--tag_name'] }} package-vercel-agents-released: ${{ steps.release.outputs['packages/vercel-agents--release_created'] }} package-vercel-agents-tag-name: ${{ steps.release.outputs['packages/vercel-agents--tag_name'] }} package-vercel-messages-released: ${{ steps.release.outputs['packages/vercel-messages--release_created'] }} @@ -322,6 +325,37 @@ jobs: password: ${{ env.PYPI_AUTH_TOKEN }} packages-dir: packages/langchain-messages/dist/ + release-opencode-agents: + runs-on: ubuntu-latest + needs: ['release-please'] + permissions: + contents: read + id-token: write + attestations: write + if: ${{ needs.release-please.outputs.package-opencode-agents-released == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + workspace_path: packages/opencode-agents + - uses: ./.github/actions/build + with: + workspace_path: packages/opencode-agents + - name: Attest build provenance + uses: actions/attest@v4 + with: + subject-path: 'packages/opencode-agents/dist/*' + - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.2.0 + name: 'Get PyPI token' + with: + aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + ssm_parameter_pairs: '/production/common/releasing/pypi/token = PYPI_AUTH_TOKEN' + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + password: ${{ env.PYPI_AUTH_TOKEN }} + packages-dir: packages/opencode-agents/dist/ + release-vercel-agents: runs-on: ubuntu-latest needs: ['release-please'] diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6f6999ca..71c43717 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -6,5 +6,6 @@ "packages/openai-agents": "0.2.5", "packages/openai-messages": "0.2.4", "packages/langchain-agents": "0.2.5", - "packages/langchain-messages": "0.2.4" + "packages/langchain-messages": "0.2.4", + "packages/opencode-agents": "0.1.0" } diff --git a/.sdk_metadata.json b/.sdk_metadata.json index 5d7708f3..69348612 100644 --- a/.sdk_metadata.json +++ b/.sdk_metadata.json @@ -102,6 +102,18 @@ "aiAgentGraph": { "introduced": "0.1" }, "aiTrackMetrics": { "introduced": "0.1" } } + }, + "python-ai-opencode-agents": { + "name": "OpenCode Agents AI SDK for Python", + "type": "ai-provider", + "path": "packages/opencode-agents", + "languages": ["Python"], + "aiSdkNames": ["launchdarkly-ai-opencode-agents"], + "releases": { "tag-prefix": "launchdarkly-ai-opencode-agents-" }, + "features": { + "aiAgentConfig": { "introduced": "0.1" }, + "aiTrackMetrics": { "introduced": "0.1" } + } } } } diff --git a/AGENTS.md b/AGENTS.md index 2548208c..d022c77f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ The `UP046` rule (PEP 695 `type` statement syntax for generic classes) is **igno PRs are squash-merged with the PR title as the commit message, and release-please builds each package's changelog from those messages. -- **A change to an experimental feature uses that feature's scope:** `skills` (Agent Skills), `evaluations` (Evaluations from Code), `opencode` (OpenCode handler), and `rich-stream` (experimental AG-UI reader). `feat`, `fix` and `perf` commits with one of these scopes are listed under **Experimental** (`changelog-sections` in `release-please-config.json`), for example `feat(skills): watch skills for revocations`. Any other scope, such as `client`, lists the change as a core feature or fix. +- **A change to an experimental feature uses that feature's scope:** `skills` (Agent Skills) and `evaluations` (Evaluations from Code). `feat`, `fix` and `perf` commits with one of these scopes are listed under **Experimental** (`changelog-sections` in `release-please-config.json`), for example `feat(skills): watch skills for revocations`. Any other scope, such as `client`, lists the change as a core feature or fix. - **Use exactly one scope.** `feat(skills,client):` matches no Experimental row, so it lands under Features. - **A PR that changes both core and experimental behaviour** should be split. If it can't be, add a `BEGIN_COMMIT_OVERRIDE` block to its description with one conventional-commit line per change. An override also corrects a merged PR's title, as long as the release PR hasn't merged yet. - **A breaking experimental change** uses `!` while the package is 0.x: `feat(skills)!:` bumps the minor version, which is what an experimental break is allowed. After 1.0, `!` would force a major release. Use `feat(skills):` without `!` instead, and say the change is breaking in the title. @@ -99,6 +99,7 @@ graph TD claude["launchdarkly-ai-claude-agents"] openai["launchdarkly-ai-openai-agents"] langchain["launchdarkly-ai-langchain-agents"] + opencode["launchdarkly-ai-opencode-agents"] newHandler["launchdarkly-ai-new-provider\n(future)"] end subgraph tier0 ["Tier 0 — Core"] @@ -109,11 +110,13 @@ graph TD app --> claude app --> openai app --> langchain + app --> opencode app --> newHandler app --> ai claude --> client openai --> client langchain --> client + opencode --> client newHandler --> client ai --> client ``` @@ -122,7 +125,7 @@ graph TD - **Tier 0 — Core** (`launchdarkly-ai-server`): The foundation. Owns all LaunchDarkly integration, telemetry orchestration, shared data types, and the primary entry points (`config()`, `graph()`, `resolve_graph()`). Has no dependency on any other `launchdarkly-ai-*` package. - **Tier 0 — Convenience barrel** (`launchdarkly-ai-python`): A pure re-export package that makes all of `launchdarkly-ai-server` available under a shorter install name. No new logic — intended as the default install for most Python applications. -- **Tier 1 — Handler packages** (`launchdarkly-ai-claude-agents`, `launchdarkly-ai-claude-messages`, `launchdarkly-ai-openai-agents`, `launchdarkly-ai-openai-messages`, `launchdarkly-ai-langchain-agents`, `launchdarkly-ai-langchain-messages`, …): Each wraps a specific AI provider SDK. Depends on `launchdarkly-ai-server` for shared types and utilities. Must not depend on other Tier 1 packages. +- **Tier 1 — Handler packages** (`launchdarkly-ai-claude-agents`, `launchdarkly-ai-claude-messages`, `launchdarkly-ai-openai-agents`, `launchdarkly-ai-openai-messages`, `launchdarkly-ai-langchain-agents`, `launchdarkly-ai-langchain-messages`, `launchdarkly-ai-opencode-agents`, …): Each wraps a specific AI provider SDK. Depends on `launchdarkly-ai-server` for shared types and utilities. Must not depend on other Tier 1 packages. The OpenCode handler reduces caller-supplied session events and does not depend on an OpenCode SDK. - **Tier 2 — Consumer applications** (e.g. `main.py`, downstream projects): Imports from one or more handler packages and either `launchdarkly-ai-python` or `launchdarkly-ai-server`. Owns tool implementations and orchestration logic. No `launchdarkly-ai-*` package should ever depend on Tier 2 code. ### Rules diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index 8726e950..b4b19ec6 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -66,6 +66,7 @@ resolve_handlers, resolve_tools, ) +from .rich_stream import stream as rich_stream from .sdk_info import SDK_INFO_CONTEXT, SDK_INFO_EVENT, register_ai_sdk_package from .sdk_usage import report_usage from .tracking import execute_and_stream, execute_and_track, wrap_tool_handlers @@ -257,6 +258,7 @@ # client "config", "ConfigInstance", + "rich_stream", # conversation "ConversationIdSpanProcessor", "conversation_id", diff --git a/packages/client/src/launchdarkly_ai_server/experimental/__init__.py b/packages/client/src/launchdarkly_ai_server/experimental/__init__.py index 11472de7..80ecfd7d 100644 --- a/packages/client/src/launchdarkly_ai_server/experimental/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/experimental/__init__.py @@ -1,9 +1,7 @@ """ Experimental features of the LaunchDarkly AI SDK. -Each feature is a submodule, for example ``launchdarkly_ai_server.experimental.skills``, -``launchdarkly_ai_server.experimental.opencode``, and -``launchdarkly_ai_server.experimental.rich_stream``. +Each feature is a submodule, for example ``launchdarkly_ai_server.experimental.skills``. Names imported from here may change in a minor release, so review the changelog when you upgrade. Names from the package root change only in a major release. """ diff --git a/packages/client/src/launchdarkly_ai_server/experimental/opencode.py b/packages/client/src/launchdarkly_ai_server/experimental/opencode.py deleted file mode 100644 index e5e88e37..00000000 --- a/packages/client/src/launchdarkly_ai_server/experimental/opencode.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Experimental OpenCode handler. - -Import ``create_opencode_handler`` from this module. It is not part of the -package root. The names may change in a minor release. -""" - -from ._opencode import OpenCodeSessionError, create_opencode_handler - -__all__ = [ - "OpenCodeSessionError", - "create_opencode_handler", -] diff --git a/packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py b/packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py deleted file mode 100644 index 05bf5b6a..00000000 --- a/packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Experimental reader for the richer stream. - -``config().stream()`` stays ``chunk`` then ``done``. Call :func:`stream` to -read the AG-UI text and tool events for the same run. This does not select a -product default, and there is no protocol field on ``configure``. -""" - -from ._rich_stream import stream - -__all__ = ["stream"] diff --git a/packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py b/packages/client/src/launchdarkly_ai_server/rich_stream.py similarity index 92% rename from packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py rename to packages/client/src/launchdarkly_ai_server/rich_stream.py index 3df97c0a..09744972 100644 --- a/packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py +++ b/packages/client/src/launchdarkly_ai_server/rich_stream.py @@ -11,21 +11,23 @@ from collections.abc import AsyncGenerator from typing import Any -from ..client import ConfigInstance -from ..conversation import bind_conversation_id -from ..judges import run_judges -from ..lifecycle import extract_variation -from ..registry import resolve_handlers, resolve_tools -from ..sdk_usage import report_usage -from ..tracking import _SETTLED_EVENT, _drive_handler_stream -from ..types import LDContext -from ..utils import select_handler +from .client import ConfigInstance +from .conversation import bind_conversation_id +from .judges import run_judges +from .lifecycle import extract_variation +from .registry import resolve_handlers, resolve_tools +from .sdk_usage import report_usage +from .tracking import _SETTLED_EVENT, _drive_handler_stream +from .types import LDContext +from .utils import select_handler _TEXT = frozenset({"TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END"}) _TOOLS = frozenset( {"TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT"} ) +__all__ = ["stream"] + def stream( config_instance: ConfigInstance, @@ -41,7 +43,7 @@ def stream( is unchanged. This function is the opt-in reader; it is not a default, and it does not read or set a protocol field. """ - report_usage("client.experimental.richStream") + report_usage("client.richStream") return bind_conversation_id( _events(config_instance, user_input, context, variables, history) ) diff --git a/packages/client/tests/test_public_surface.py b/packages/client/tests/test_public_surface.py index 06bcbf4e..4d6106e7 100644 --- a/packages/client/tests/test_public_surface.py +++ b/packages/client/tests/test_public_surface.py @@ -109,6 +109,7 @@ "register_ai_sdk_package", "report_usage", "resolve_graph", + "rich_stream", "resolve_handlers", "resolve_tools", "run_judge", diff --git a/packages/opencode-agents/CHANGELOG.md b/packages/opencode-agents/CHANGELOG.md new file mode 100644 index 00000000..40a518bd --- /dev/null +++ b/packages/opencode-agents/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/packages/opencode-agents/README.md b/packages/opencode-agents/README.md new file mode 100644 index 00000000..bb3b1775 --- /dev/null +++ b/packages/opencode-agents/README.md @@ -0,0 +1,19 @@ +# `launchdarkly-ai-opencode-agents` + +OpenCode handler for `launchdarkly-ai-server`. It reduces caller-supplied OpenCode session events into assistant text and tool events. It does not start OpenCode and it does not depend on an OpenCode SDK. + +**`provides_for`:** `['*', 'agent']` — matches an agent-mode variation for any provider. + +```bash +pip install launchdarkly-ai-server launchdarkly-ai-opencode-agents +``` + +```python +from launchdarkly_ai_opencode_agents import create_opencode_handler +from launchdarkly_ai_server import config + +handler = create_opencode_handler(events=session_events) +config(key="my-agent", handler=handler).stream("Hello", {"kind": "user", "key": "user-1"}) +``` + +`events` is called on every invocation and must return a fresh async iterable of OpenCode session events. `config().stream()` yields `chunk` then `done`. Tool events are available from `launchdarkly_ai_server.rich_stream`. diff --git a/packages/opencode-agents/agents.md b/packages/opencode-agents/agents.md new file mode 100644 index 00000000..1c9f558d --- /dev/null +++ b/packages/opencode-agents/agents.md @@ -0,0 +1,13 @@ +# Agent Guide — `launchdarkly-ai-opencode-agents` + +```python +provides_for = ('*', 'agent') +``` + +The handler reduces OpenCode session events the caller supplies. It does not import or construct `anomalyco/opencode-sdk-python`, and it does not start an OpenCode process. + +`create_opencode_handler(*, capture_content=None, events=None)` returns a `ProviderHandler`. `events` is called per invocation and returns an async iterable of bus events (`message.part.delta`, `message.part.updated`, `message.updated`, `session.error`). + +Assistant text is emitted only after `message.updated` marks that message `role == "assistant"`. A part snapshot is the full text of that part so far; a later delta appends. Tool argument text and tool results are omitted unless content capture is on. + +`config().stream()` still yields `chunk` then `done`. `launchdarkly_ai_server.rich_stream` forwards the text and tool events. diff --git a/packages/opencode-agents/pyproject.toml b/packages/opencode-agents/pyproject.toml new file mode 100644 index 00000000..5e55d11d --- /dev/null +++ b/packages/opencode-agents/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "launchdarkly-ai-opencode-agents" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "launchdarkly-ai-server", +] +description = "OpenCode agent handler for LaunchDarkly AI SDK" +readme = "README.md" +license = "Apache-2.0" +authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] +keywords = ["launchdarkly", "ai", "opencode", "agents"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", +] + +[project.urls] +Homepage = "https://github.com/launchdarkly/python-ai-sdk" +Repository = "https://github.com/launchdarkly/python-ai-sdk" +"Bug Tracker" = "https://github.com/launchdarkly/python-ai-sdk/issues" + +[build-system] +requires = ["hatchling>=1.32,<2"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/launchdarkly_ai_opencode_agents"] diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/__init__.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/__init__.py new file mode 100644 index 00000000..9b3ca199 --- /dev/null +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/__init__.py @@ -0,0 +1,17 @@ +"""LaunchDarkly AI SDK - integration for OpenCode agents. + +See https://launchdarkly.com/docs for usage. +""" + +from launchdarkly_ai_server import register_ai_sdk_package + +from ._version import PACKAGE_NAME, __version__ +from .handler import OpenCodeSessionError, create_opencode_handler, opencode_agents + +__all__ = [ + "OpenCodeSessionError", + "create_opencode_handler", + "opencode_agents", +] + +register_ai_sdk_package(PACKAGE_NAME, __version__) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/_version.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/_version.py new file mode 100644 index 00000000..e019305d --- /dev/null +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/_version.py @@ -0,0 +1,4 @@ +"""This package's identity, as sent with ``$ld:ai:sdk:info`` and ``$ld:ai:sdk:usage``.""" + +PACKAGE_NAME = "launchdarkly-ai-opencode-agents" +__version__ = "0.1.0" # x-release-please-version diff --git a/packages/client/src/launchdarkly_ai_server/experimental/_opencode.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py similarity index 76% rename from packages/client/src/launchdarkly_ai_server/experimental/_opencode.py rename to packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index a0c2bc26..78495182 100644 --- a/packages/client/src/launchdarkly_ai_server/experimental/_opencode.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -12,8 +12,17 @@ from collections.abc import AsyncGenerator, AsyncIterable, Callable, Mapping from typing import Any -from ..types import AiConfigRep, ProviderHandler -from ..utils import _create_handler, number_or_zero +from launchdarkly_ai_server import ( + AiConfigRep, + ProviderHandler, + _config, + _create_handler, +) +from launchdarkly_ai_server.lifecycle import effective_capture_content +from launchdarkly_ai_server.sdk_usage import report_usage +from launchdarkly_ai_server.utils import number_or_zero + +from ._version import PACKAGE_NAME, __version__ _TOOL_FAILURE = "An error occurred." @@ -27,14 +36,20 @@ class OpenCodeSessionError(Exception): class _Reducer: - """One pass over a session. Repeated snapshots do not emit a second event.""" + """One pass over a session. + + Text is emitted only for messages ``message.updated`` has marked assistant. + A part snapshot is that part's full text so far: a later delta still appends, + and a snapshot that repeats text already emitted adds nothing. + """ def __init__(self, capture_content: bool) -> None: self._capture = capture_content self._open_message: str | None = None self._deltas: list[str] = [] - self._delta_parts: set[str] = set() - self._snapshot_parts: set[str] = set() + self._roles: dict[str, str] = {} + self._held: dict[str, list[tuple[str, str, str]]] = {} + self._part_text: dict[str, str] = {} self._tools: dict[str, str] = {} self._usage_ids: set[str] = set() self._input = 0 @@ -72,6 +87,46 @@ async def reduce( for event in self._end_open(): yield event + def _accept_text( + self, message_id: str, part_id: str, kind: str, text: str + ) -> list[dict[str, Any]]: + role = self._roles.get(message_id) + if role is None: + self._held.setdefault(message_id, []).append((kind, part_id, text)) + return [] + if role != "assistant": + return [] + return self._apply_text(message_id, part_id, kind, text) + + def _flush_held(self, message_id: str) -> list[dict[str, Any]]: + held = self._held.pop(message_id, []) + events: list[dict[str, Any]] = [] + for kind, part_id, text in held: + events.extend(self._apply_text(message_id, part_id, kind, text)) + return events + + def _apply_text( + self, message_id: str, part_id: str, kind: str, text: str + ) -> list[dict[str, Any]]: + if kind == "delta": + if part_id: + self._part_text[part_id] = self._part_text.get(part_id, "") + text + return self._text(message_id, text) + already = self._part_text.get(part_id, "") + if text.startswith(already): + suffix = text[len(already) :] + if part_id: + self._part_text[part_id] = text + elif not already: + suffix = text + if part_id: + self._part_text[part_id] = text + else: + return [] + if not suffix: + return [] + return self._text(message_id, suffix) + def _event(self, raw: Mapping[str, Any] | object) -> list[dict[str, Any]]: if not isinstance(raw, Mapping): return [] @@ -104,11 +159,9 @@ def _delta(self, properties: Mapping[str, Any]) -> list[dict[str, Any]]: if not isinstance(delta, str) or not delta or not isinstance(message_id, str): return [] part_id = properties.get("partID") - if isinstance(part_id, str): - if part_id in self._snapshot_parts: - return [] - self._delta_parts.add(part_id) - return self._text(message_id, delta) + return self._accept_text( + message_id, part_id if isinstance(part_id, str) else "", "delta", delta + ) def _text_snapshot(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: message_id = part.get("messageID") @@ -121,10 +174,7 @@ def _text_snapshot(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: or not isinstance(part_id, str) ): return [] - if part_id in self._delta_parts or part_id in self._snapshot_parts: - return [] - self._snapshot_parts.add(part_id) - return self._text(message_id, text) + return self._accept_text(message_id, part_id, "snapshot", text) def _text(self, message_id: str, delta: str) -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] @@ -150,14 +200,21 @@ def _end_open(self) -> list[dict[str, Any]]: return [{"type": "TEXT_MESSAGE_END", "messageId": message_id}] def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: - if info.get("role") != "assistant": - return [] - completed = info.get("time") - if not isinstance(completed, Mapping) or "completed" not in completed: - return [] message_id = info.get("id") + role = info.get("role") events: list[dict[str, Any]] = [] - if isinstance(message_id, str) and message_id == self._open_message: + if isinstance(message_id, str) and isinstance(role, str): + self._roles[message_id] = role + if role == "assistant": + events.extend(self._flush_held(message_id)) + else: + self._held.pop(message_id, None) + if role != "assistant" or not isinstance(message_id, str): + return events + completed = info.get("time") + if not isinstance(completed, Mapping) or "completed" not in completed: + return events + if message_id == self._open_message: events.extend(self._end_open()) if isinstance(message_id, str) and message_id not in self._usage_ids: tokens = info.get("tokens") @@ -307,13 +364,10 @@ def create_opencode_handler( default from ``configure`` when the call runs, and ``True`` or ``False`` overrides it. Tool argument text and tool results are omitted from the stream unless capture is on. ``config().stream()`` still yields assistant - text as ``chunk`` and one ``done``. Tool events stay on the experimental - reader. + text as ``chunk`` and one ``done``. Tool events are on + ``launchdarkly_ai_server.rich_stream``. """ - from ..lifecycle import effective_capture_content - from ..sdk_usage import report_usage - - report_usage("client.experimental.opencode.createHandler") + report_usage("opencode-agents.createOpenCodeHandler", PACKAGE_NAME, __version__) return _create_opencode_handler( capture_content=capture_content, events=events, @@ -321,6 +375,34 @@ def create_opencode_handler( ) +def opencode_agents( + config_key: str, + user_input: str, + context: dict[str, Any], + **kwargs: Any, +) -> Any: + """Convenience wrapper: creates a handler and calls ``config(...).invoke()``. + + *events* is the session event source, the same callable + :func:`create_opencode_handler` takes. This does not construct an OpenCode + SDK client. + """ + report_usage("opencode-agents.opencodeAgents", PACKAGE_NAME, __version__) + variables = kwargs.pop("variables", None) + history = kwargs.pop("history", None) + capture_content = kwargs.pop("capture_content", None) + events = kwargs.pop("events", None) + return _config( + key=config_key, + handler=_create_opencode_handler( + capture_content=capture_content, + events=events, + resolve_capture=effective_capture_content, + ), + **kwargs, + ).invoke(user_input, context, variables, history) + + def _create_opencode_handler( *, capture_content: bool | None, diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/py.typed b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/client/tests/test_opencode.py b/packages/opencode-agents/tests/test_handler.py similarity index 86% rename from packages/client/tests/test_opencode.py rename to packages/opencode-agents/tests/test_handler.py index da0cf810..9d929857 100644 --- a/packages/client/tests/test_opencode.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -1,4 +1,4 @@ -"""OpenCode handler: synthetic session events, stable stream, experimental reader.""" +"""OpenCode handler: synthetic session events, stable stream, rich stream reader.""" from __future__ import annotations @@ -9,12 +9,17 @@ import pytest import launchdarkly_ai_server.lifecycle as lifecycle_module -from launchdarkly_ai_server import ProviderHandler, SDKOptions, config, configure -from launchdarkly_ai_server.experimental.opencode import ( +from launchdarkly_ai_opencode_agents import ( OpenCodeSessionError, create_opencode_handler, ) -from launchdarkly_ai_server.experimental.rich_stream import stream as rich_stream +from launchdarkly_ai_server import ( + ProviderHandler, + SDKOptions, + config, + configure, + rich_stream, +) CONTEXT = {"kind": "user", "key": "user-1"} @@ -74,6 +79,13 @@ def tool_part( ) +def assistant_open(message_id: str) -> dict[str, Any]: + return bus( + "message.updated", + info={"id": message_id, "role": "assistant", "time": {"created": 1}}, + ) + + def assistant_completed( message_id: str, *, @@ -131,25 +143,23 @@ def test_provides_for_is_any_provider_in_agent_mode(self) -> None: handler = create_opencode_handler(events=source([])) assert handler.provides_for == ("*", "agent") - def test_names_stay_off_the_package_root(self) -> None: - import launchdarkly_ai_server as package - from launchdarkly_ai_server.experimental import opencode + def test_package_exports_the_handler(self) -> None: + import launchdarkly_ai_opencode_agents as package - assert set(opencode.__all__) == { + assert set(package.__all__) == { "OpenCodeSessionError", "create_opencode_handler", + "opencode_agents", } - public = {name for name in dir(opencode) if not name.startswith("_")} - assert public == set(opencode.__all__) - for name in opencode.__all__: - assert name not in package.__all__ - assert not hasattr(package, name) + for name in package.__all__: + assert hasattr(package, name) class TestTextAndTools: async def test_text_and_a_successful_tool(self) -> None: events = await collect( [ + assistant_open("m1"), text_delta("m1", "p1", "Looking up"), tool_part("c1", "running", raw_input={"command": "ls"}), tool_part("c1", "running", raw_input={"command": "ls"}), @@ -247,6 +257,7 @@ async def test_completed_assistant_usage_is_summed_once(self) -> None: tokens = {"input": 3, "output": 4, "cache": {"read": 1, "write": 2}} events = await collect( [ + assistant_open("m1"), text_delta("m1", "p1", "Hi"), assistant_completed("m1", tokens=tokens), assistant_completed("m1", tokens=tokens), @@ -262,10 +273,12 @@ async def test_completed_assistant_usage_is_summed_once(self) -> None: async def test_text_after_a_tool_is_a_new_message(self) -> None: events = await collect( [ + assistant_open("m1"), text_delta("m1", "p1", "A"), tool_part("c1", "running", raw_input={"q": 1}), tool_part("c1", "completed", raw_input={"q": 1}, output="ok"), assistant_completed("m1"), + assistant_open("m2"), text_delta("m2", "p2", "B"), assistant_completed("m2"), ], @@ -277,6 +290,7 @@ async def test_text_after_a_tool_is_a_new_message(self) -> None: async def test_a_text_snapshot_does_not_repeat_deltas(self) -> None: events = await collect( [ + assistant_open("m1"), text_delta("m1", "p1", "Hi"), bus( "message.part.updated", @@ -297,11 +311,69 @@ async def test_a_text_snapshot_does_not_repeat_deltas(self) -> None: {"type": "TEXT_MESSAGE_CONTENT", "messageId": "m1", "delta": "Hi"} ] + async def test_a_snapshot_then_later_deltas_append(self) -> None: + events = await collect( + [ + assistant_open("m1"), + bus( + "message.part.updated", + part={"id": "p1", "messageID": "m1", "type": "text", "text": "He"}, + ), + text_delta("m1", "p1", "llo"), + bus( + "message.part.updated", + part={ + "id": "p1", + "messageID": "m1", + "type": "text", + "text": "Hello", + }, + ), + assistant_completed("m1"), + ], + ) + contents = [ + event["delta"] + for event in events + if event["type"] == "TEXT_MESSAGE_CONTENT" + ] + assert contents == ["He", "llo"] + assert events[-1]["output"] == "Hello" + + async def test_user_text_is_not_the_assistant_reply(self) -> None: + events = await collect( + [ + bus( + "message.part.updated", + part={ + "id": "pu", + "messageID": "u1", + "type": "text", + "text": "secret prompt", + }, + ), + bus( + "message.updated", + info={ + "id": "u1", + "role": "user", + "time": {"created": 1, "completed": 1}, + }, + ), + assistant_open("m1"), + text_delta("m1", "p1", "Hi"), + assistant_completed("m1"), + ], + ) + assert events[-1]["output"] == "Hi" + assert "secret prompt" not in str(events) + async def test_session_error_raises_without_the_provider_body(self) -> None: handler = create_opencode_handler( capture_content=True, events=source( [ + assistant_open("m1"), text_delta("m1", "p1", "Hi"), { "type": "session.error", @@ -386,6 +458,7 @@ async def test_tool_events_do_not_close_the_stable_stream( capture_content=True, events=source( [ + assistant_open("m1"), text_delta("m1", "p1", "Looking up"), tool_part("c1", "running", raw_input={"command": "ls"}), tool_part( @@ -395,6 +468,7 @@ async def test_tool_events_do_not_close_the_stable_stream( output="file.txt", ), assistant_completed("m1"), + assistant_open("m2"), text_delta("m2", "p2", "Done."), assistant_completed("m2"), ] @@ -420,6 +494,7 @@ async def test_session_error_does_not_yield_stable_done( handler = create_opencode_handler( events=source( [ + assistant_open("m1"), text_delta("m1", "p1", "Hi"), {"type": "session.error", "properties": {"error": {"name": "X"}}}, ] @@ -483,6 +558,7 @@ async def test_rich_reader_forwards_text_and_tools( capture_content=True, events=source( [ + assistant_open("m1"), text_delta("m1", "p1", "Looking up"), tool_part("c1", "running", raw_input={"command": "ls"}), tool_part( diff --git a/pyproject.toml b/pyproject.toml index 99e24585..0dae9678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,4 +66,5 @@ known-first-party = [ "launchdarkly_ai_langchain_messages", "launchdarkly_ai_openai_agents", "launchdarkly_ai_openai_messages", + "launchdarkly_ai_opencode_agents", ] diff --git a/release-please-config.json b/release-please-config.json index 338b314b..18bfdc82 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -89,6 +89,17 @@ "src/launchdarkly_ai_langchain_messages/_version.py" ], "component": "launchdarkly-ai-langchain-messages" + }, + "packages/opencode-agents": { + "release-type": "python", + "versioning": "default", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "include-v-in-tag": false, + "extra-files": [ + "src/launchdarkly_ai_opencode_agents/_version.py" + ], + "component": "launchdarkly-ai-opencode-agents" } }, "changelog-sections": [ @@ -98,12 +109,6 @@ { "type": "feat", "scope": "evaluations", "section": "Experimental" }, { "type": "fix", "scope": "evaluations", "section": "Experimental" }, { "type": "perf", "scope": "evaluations", "section": "Experimental" }, - { "type": "feat", "scope": "opencode", "section": "Experimental" }, - { "type": "fix", "scope": "opencode", "section": "Experimental" }, - { "type": "perf", "scope": "opencode", "section": "Experimental" }, - { "type": "feat", "scope": "rich-stream", "section": "Experimental" }, - { "type": "fix", "scope": "rich-stream", "section": "Experimental" }, - { "type": "perf", "scope": "rich-stream", "section": "Experimental" }, { "type": "feat", "section": "Features" }, { "type": "fix", "section": "Bug Fixes" }, { "type": "perf", "section": "Performance Improvements" }, diff --git a/uv.lock b/uv.lock index d8fba767..860a08fa 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", @@ -19,6 +19,7 @@ members = [ "launchdarkly-ai-langchain-messages", "launchdarkly-ai-openai-agents", "launchdarkly-ai-openai-messages", + "launchdarkly-ai-opencode-agents", "launchdarkly-ai-python", "launchdarkly-ai-server", ] @@ -844,7 +845,7 @@ wheels = [ [[package]] name = "launchdarkly-ai-claude-agents" -version = "0.2.3" +version = "0.2.5" source = { editable = "packages/claude-agents" } dependencies = [ { name = "anthropic" }, @@ -863,7 +864,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-claude-messages" -version = "0.2.3" +version = "0.2.4" source = { editable = "packages/claude-messages" } dependencies = [ { name = "anthropic" }, @@ -880,7 +881,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-langchain-agents" -version = "0.2.3" +version = "0.2.5" source = { editable = "packages/langchain-agents" } dependencies = [ { name = "langchain-core" }, @@ -899,7 +900,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-langchain-messages" -version = "0.2.3" +version = "0.2.4" source = { editable = "packages/langchain-messages" } dependencies = [ { name = "langchain-core" }, @@ -916,7 +917,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-openai-agents" -version = "0.2.3" +version = "0.2.5" source = { editable = "packages/openai-agents" } dependencies = [ { name = "launchdarkly-ai-server" }, @@ -935,7 +936,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-openai-messages" -version = "0.2.3" +version = "0.2.4" source = { editable = "packages/openai-messages" } dependencies = [ { name = "launchdarkly-ai-server" }, @@ -950,9 +951,20 @@ requires-dist = [ { name = "opentelemetry-api", specifier = ">=1.25" }, ] +[[package]] +name = "launchdarkly-ai-opencode-agents" +version = "0.1.0" +source = { editable = "packages/opencode-agents" } +dependencies = [ + { name = "launchdarkly-ai-server" }, +] + +[package.metadata] +requires-dist = [{ name = "launchdarkly-ai-server", editable = "packages/client" }] + [[package]] name = "launchdarkly-ai-python" -version = "0.1.7" +version = "0.2.0" source = { editable = "packages/ai" } dependencies = [ { name = "launchdarkly-ai-server" }, @@ -972,7 +984,7 @@ provides-extras = ["otel"] [[package]] name = "launchdarkly-ai-server" -version = "0.2.3" +version = "0.3.0" source = { editable = "packages/client" } dependencies = [ { name = "opentelemetry-api" }, From b4259ddffd3aba0a49c03f3a3c00f3c3517e08da Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 02:57:57 +0000 Subject: [PATCH 05/28] docs(opencode-agents): install the server SDK with the handler init_client() discovers launchdarkly-server-sdk at runtime, so the package README install line has to include it. Co-authored-by: alexis --- packages/opencode-agents/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode-agents/README.md b/packages/opencode-agents/README.md index bb3b1775..9bdb72e7 100644 --- a/packages/opencode-agents/README.md +++ b/packages/opencode-agents/README.md @@ -5,7 +5,7 @@ OpenCode handler for `launchdarkly-ai-server`. It reduces caller-supplied OpenCo **`provides_for`:** `['*', 'agent']` — matches an agent-mode variation for any provider. ```bash -pip install launchdarkly-ai-server launchdarkly-ai-opencode-agents +pip install launchdarkly-ai-server launchdarkly-server-sdk launchdarkly-ai-opencode-agents ``` ```python From ec7b918c1ab22194beee58dc211ec64a84194701 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 13:58:55 +0000 Subject: [PATCH 06/28] refactor(opencode-agents): yield chunk then done The public stream matches the other handlers. This change does not export a rich stream reader or forward tool events. --- .../src/launchdarkly_ai_server/__init__.py | 2 - .../src/launchdarkly_ai_server/rich_stream.py | 167 -------- .../src/launchdarkly_ai_server/tracking.py | 108 +----- packages/client/tests/test_public_surface.py | 1 - packages/opencode-agents/README.md | 4 +- packages/opencode-agents/agents.md | 4 +- .../handler.py | 206 ++-------- .../opencode-agents/tests/test_handler.py | 365 ++---------------- 8 files changed, 78 insertions(+), 779 deletions(-) delete mode 100644 packages/client/src/launchdarkly_ai_server/rich_stream.py diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index b4b19ec6..8726e950 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -66,7 +66,6 @@ resolve_handlers, resolve_tools, ) -from .rich_stream import stream as rich_stream from .sdk_info import SDK_INFO_CONTEXT, SDK_INFO_EVENT, register_ai_sdk_package from .sdk_usage import report_usage from .tracking import execute_and_stream, execute_and_track, wrap_tool_handlers @@ -258,7 +257,6 @@ # client "config", "ConfigInstance", - "rich_stream", # conversation "ConversationIdSpanProcessor", "conversation_id", diff --git a/packages/client/src/launchdarkly_ai_server/rich_stream.py b/packages/client/src/launchdarkly_ai_server/rich_stream.py deleted file mode 100644 index 09744972..00000000 --- a/packages/client/src/launchdarkly_ai_server/rich_stream.py +++ /dev/null @@ -1,167 +0,0 @@ -"""AG-UI reader over the same handler drive as ``config().stream()``. - -The stable reader keeps yielding ``chunk`` then ``done``. This one forwards -text and tool events and brackets them with ``RUN_STARTED`` and ``RUN_FINISHED``. -A raised turn ends the generator with neither ``RUN_FINISHED`` nor ``RUN_ERROR``. -""" - -from __future__ import annotations - -import uuid -from collections.abc import AsyncGenerator -from typing import Any - -from .client import ConfigInstance -from .conversation import bind_conversation_id -from .judges import run_judges -from .lifecycle import extract_variation -from .registry import resolve_handlers, resolve_tools -from .sdk_usage import report_usage -from .tracking import _SETTLED_EVENT, _drive_handler_stream -from .types import LDContext -from .utils import select_handler - -_TEXT = frozenset({"TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END"}) -_TOOLS = frozenset( - {"TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT"} -) - -__all__ = ["stream"] - - -def stream( - config_instance: ConfigInstance, - user_input: str | None, - context: LDContext, - *, - variables: dict[str, Any] | None = None, - history: list[dict[str, Any]] | None = None, -) -> AsyncGenerator[dict[str, Any], None]: - """Yield AG-UI events for one ``config()`` call. - - Takes the same arguments as ``ConfigInstance.stream``. ``config().stream()`` - is unchanged. This function is the opt-in reader; it is not a default, and - it does not read or set a protocol field. - """ - report_usage("client.richStream") - return bind_conversation_id( - _events(config_instance, user_input, context, variables, history) - ) - - -async def _events( - config_instance: ConfigInstance, - user_input: str | None, - context: LDContext, - variables: dict[str, Any] | None, - history: list[dict[str, Any]] | None, -) -> AsyncGenerator[dict[str, Any], None]: - resolved_handler_list = resolve_handlers( - config_instance._registry, config_instance._normalize_handlers() - ) - resolved_tools = resolve_tools( - config_instance._registry, config_instance._tool_handlers - ) - variation = await extract_variation(config_instance._key, context) - config = variation["config"] - meta = variation["meta"] - if not resolved_handler_list: - raise ValueError("No handlers provided to config()") - handler = select_handler(config, meta, resolved_handler_list) - - thread_id = str(uuid.uuid4()) - run_id = str(uuid.uuid4()) - yield { - "type": "RUN_STARTED", - "threadId": thread_id, - "runId": run_id, - "protocolVersion": "1.0", - } - - settled: dict[str, Any] | None = None - synthetic_id: str | None = None - saw_canonical_text = False - async for event in _drive_handler_stream( - config_key=config_instance._key, - config=config, - meta=meta, - user_context=context, - handler=handler, - user_input=user_input, - tool_handlers=resolved_tools, - variables=variables, - history=history, - ): - if event.get("type") == _SETTLED_EVENT: - settled = event - continue - kind = event.get("type") - if kind in _TEXT: - if synthetic_id is not None: - yield {"type": "TEXT_MESSAGE_END", "messageId": synthetic_id} - synthetic_id = None - saw_canonical_text = True - yield event - elif kind == "chunk" and not saw_canonical_text: - text = event.get("text") - if not isinstance(text, str) or not text: - continue - if synthetic_id is None: - synthetic_id = str(uuid.uuid4()) - yield {"type": "TEXT_MESSAGE_START", "messageId": synthetic_id} - yield { - "type": "TEXT_MESSAGE_CONTENT", - "messageId": synthetic_id, - "delta": text, - } - elif kind in _TOOLS: - yield event - - if synthetic_id is not None: - yield {"type": "TEXT_MESSAGE_END", "messageId": synthetic_id} - - if settled is None: - return - - judge_results: dict[str, Any] = {} - if not config_instance._skip_judges: - judge_results = await run_judges( - config=config, - user_context=context, - handler=handler, - handlers=resolved_handler_list, - user_input=user_input, - trajectory=settled.get("trajectory", ""), - llm_response=settled.get("response", ""), - base_track_data=settled.get("track_data", {}), - tool_handlers=resolved_tools, - ) - - finished: dict[str, Any] = { - "type": "RUN_FINISHED", - "threadId": thread_id, - "runId": run_id, - } - response = settled.get("response") - if isinstance(response, str) and response: - finished["result"] = response - if settled.get("had_usage"): - usage = settled.get("usage") - if isinstance(usage, dict): - finished["usage"] = _token_usage(usage) - if judge_results: - finished["metadata"] = {"launchdarkly": {"judgeResults": judge_results}} - yield finished - - -def _token_usage(parsed: dict[str, Any]) -> list[dict[str, Any]]: - entry: dict[str, Any] = { - "inputTokens": parsed.get("input", 0), - "outputTokens": parsed.get("output", 0), - "totalTokens": parsed.get("total", 0), - } - details = parsed.get("input_details") - if isinstance(details, dict): - entry["cachedInputTokens"] = details.get("cache_read", 0) - entry["cacheWriteInputTokens"] = details.get("cache_creation", 0) - return [entry] diff --git a/packages/client/src/launchdarkly_ai_server/tracking.py b/packages/client/src/launchdarkly_ai_server/tracking.py index 70777254..6e7a5638 100644 --- a/packages/client/src/launchdarkly_ai_server/tracking.py +++ b/packages/client/src/launchdarkly_ai_server/tracking.py @@ -236,12 +236,7 @@ async def execute_and_track( } -# Private terminal of :func:`_drive_handler_stream`. Not a handler event and not -# forwarded by either reader. A handler that yields this type is ignored. -_SETTLED_EVENT = "__ld_settled" - - -async def _drive_handler_stream( +async def execute_and_stream( *, config_key: str, config: AiConfigRep, @@ -253,13 +248,14 @@ async def _drive_handler_stream( variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None, -) -> AsyncGenerator[dict[str, Any], None]: - """Run one handler stream and yield its events, then one settled record. +) -> AsyncGenerator[ExecuteStreamEvent, None]: + """ + Streaming counterpart to ``execute_and_track``. Yields ``chunk`` events + from the handler, then a single ``done`` event once the stream completes + and LD telemetry has been flushed. - The settled record carries the response, parsed usage, tracking payload and - trajectory. Handler ``done`` is consumed here. Telemetry matches - :func:`execute_and_stream`. A raise records the error metrics and propagates, - with no settled record. + Falls back to calling the blocking handler when the handler has no + ``stream`` implementation. """ from .lifecycle import get_client @@ -301,8 +297,6 @@ async def _drive_handler_stream( start_time = time.monotonic() full_text = "" raw_usage: dict[str, Any] = {} - had_usage = False - saw_text_message = False try: if handler.has_stream: @@ -310,24 +304,13 @@ async def _drive_handler_stream( config, user_input, tracked_tool_handlers, merged_variables, history ) async for event in stream_gen: - kind = event.get("type") - if kind == "chunk": - if not saw_text_message: - full_text += event.get("text", "") - yield event - elif kind == "TEXT_MESSAGE_CONTENT": - saw_text_message = True - delta = event.get("delta") - if isinstance(delta, str): - full_text += delta - yield event - elif kind == "done": + if event.get("type") == "chunk": + full_text += event.get("text", "") + yield {"type": "chunk", "text": event["text"]} + elif event.get("type") == "done": if event.get("output") is not None: full_text = event["output"] raw_usage = event.get("usage", {}) - had_usage = bool(event.get("usage")) - elif kind != _SETTLED_EVENT: - yield event else: result = await handler( config, user_input, tracked_tool_handlers, merged_variables, history @@ -335,7 +318,6 @@ async def _drive_handler_stream( out = result.get("output") full_text = str(out) if out is not None else "" raw_usage = result.get("usage") or {} - had_usage = bool(result.get("usage")) if full_text: yield {"type": "chunk", "text": full_text} except Exception: @@ -357,10 +339,9 @@ async def _drive_handler_stream( client.track("$ld:ai:tokens:output", ld_ctx, track_data, usage["output"]) yield { - "type": _SETTLED_EVENT, + "type": "done", "response": full_text, "usage": usage, - "had_usage": had_usage, "track_data": track_data, "trajectory": render_trajectory( recorder.invocations, @@ -368,66 +349,3 @@ async def _drive_handler_stream( omitted=recorder.omitted, ), } - - -async def execute_and_stream( - *, - config_key: str, - config: AiConfigRep, - meta: VariationMeta, - user_context: LDContext, - handler: ProviderHandler, - user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, - variables: dict[str, Any] | None = None, - graph_key: str | None = None, - history: list[dict[str, Any]] | None = None, -) -> AsyncGenerator[ExecuteStreamEvent, None]: - """ - Streaming counterpart to ``execute_and_track``. Yields ``chunk`` events - from the handler, then a single ``done`` event once the stream completes - and LD telemetry has been flushed. - - Assistant text arrives as ``chunk``. ``TEXT_MESSAGE_CONTENT`` contributes - its ``delta`` as a ``chunk`` as well. A tool event, or any other handler - event, is not forwarded and does not end the turn. Only the handler's - ``done`` supplies output and usage. ``config().stream()`` therefore stays - ``chunk`` then ``done``. - - Falls back to calling the blocking handler when the handler has no - ``stream`` implementation. - """ - settled: dict[str, Any] | None = None - saw_text_message = False - async for event in _drive_handler_stream( - config_key=config_key, - config=config, - meta=meta, - user_context=user_context, - handler=handler, - user_input=user_input, - tool_handlers=tool_handlers, - variables=variables, - graph_key=graph_key, - history=history, - ): - if event.get("type") == _SETTLED_EVENT: - settled = event - continue - kind = event.get("type") - if kind == "TEXT_MESSAGE_CONTENT": - saw_text_message = True - delta = event.get("delta") - if isinstance(delta, str) and delta: - yield {"type": "chunk", "text": delta} - elif kind == "chunk" and not saw_text_message: - yield {"type": "chunk", "text": event["text"]} - if settled is None: - return - yield { - "type": "done", - "response": settled.get("response", ""), - "usage": settled.get("usage"), - "track_data": settled.get("track_data"), - "trajectory": settled.get("trajectory", ""), - } diff --git a/packages/client/tests/test_public_surface.py b/packages/client/tests/test_public_surface.py index 4d6106e7..06bcbf4e 100644 --- a/packages/client/tests/test_public_surface.py +++ b/packages/client/tests/test_public_surface.py @@ -109,7 +109,6 @@ "register_ai_sdk_package", "report_usage", "resolve_graph", - "rich_stream", "resolve_handlers", "resolve_tools", "run_judge", diff --git a/packages/opencode-agents/README.md b/packages/opencode-agents/README.md index 9bdb72e7..fff1a306 100644 --- a/packages/opencode-agents/README.md +++ b/packages/opencode-agents/README.md @@ -1,6 +1,6 @@ # `launchdarkly-ai-opencode-agents` -OpenCode handler for `launchdarkly-ai-server`. It reduces caller-supplied OpenCode session events into assistant text and tool events. It does not start OpenCode and it does not depend on an OpenCode SDK. +OpenCode handler for `launchdarkly-ai-server`. It reduces caller-supplied OpenCode session events into assistant text. It does not start OpenCode and it does not depend on an OpenCode SDK. **`provides_for`:** `['*', 'agent']` — matches an agent-mode variation for any provider. @@ -16,4 +16,4 @@ handler = create_opencode_handler(events=session_events) config(key="my-agent", handler=handler).stream("Hello", {"kind": "user", "key": "user-1"}) ``` -`events` is called on every invocation and must return a fresh async iterable of OpenCode session events. `config().stream()` yields `chunk` then `done`. Tool events are available from `launchdarkly_ai_server.rich_stream`. +`events` is called on every invocation and must return a fresh async iterable of OpenCode session events. `config().stream()` yields `chunk` then `done`. diff --git a/packages/opencode-agents/agents.md b/packages/opencode-agents/agents.md index 1c9f558d..17af5d0b 100644 --- a/packages/opencode-agents/agents.md +++ b/packages/opencode-agents/agents.md @@ -8,6 +8,4 @@ The handler reduces OpenCode session events the caller supplies. It does not imp `create_opencode_handler(*, capture_content=None, events=None)` returns a `ProviderHandler`. `events` is called per invocation and returns an async iterable of bus events (`message.part.delta`, `message.part.updated`, `message.updated`, `session.error`). -Assistant text is emitted only after `message.updated` marks that message `role == "assistant"`. A part snapshot is the full text of that part so far; a later delta appends. Tool argument text and tool results are omitted unless content capture is on. - -`config().stream()` still yields `chunk` then `done`. `launchdarkly_ai_server.rich_stream` forwards the text and tool events. +Assistant text is emitted only after `message.updated` marks that message `role == "assistant"`. A part snapshot is the full text of that part so far; a later delta appends. The public stream is `chunk` then `done`. diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 78495182..f61c5970 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -1,14 +1,12 @@ """Reduce OpenCode session events into handler stream events. The session is a caller-supplied async iterable of OpenCode bus events. This -module does not start a process. Tool execution stays inside OpenCode: the -events record a call that already ran. +module does not start a process and does not construct an OpenCode SDK client. +The public stream is assistant text as ``chunk``, then one ``done``. """ from __future__ import annotations -import json -import uuid from collections.abc import AsyncGenerator, AsyncIterable, Callable, Mapping from typing import Any @@ -18,20 +16,17 @@ _config, _create_handler, ) -from launchdarkly_ai_server.lifecycle import effective_capture_content from launchdarkly_ai_server.sdk_usage import report_usage from launchdarkly_ai_server.utils import number_or_zero from ._version import PACKAGE_NAME, __version__ -_TOOL_FAILURE = "An error occurred." - class OpenCodeSessionError(Exception): """The OpenCode session failed. - The provider error body is not included. A failed session is not a tool - result and does not finish the run. + The provider error body is not included. A failed session does not finish + the run. """ @@ -40,17 +35,15 @@ class _Reducer: Text is emitted only for messages ``message.updated`` has marked assistant. A part snapshot is that part's full text so far: a later delta still appends, - and a snapshot that repeats text already emitted adds nothing. + and a snapshot that repeats text already emitted adds nothing. Tool parts + are not forwarded. """ - def __init__(self, capture_content: bool) -> None: - self._capture = capture_content - self._open_message: str | None = None + def __init__(self) -> None: self._deltas: list[str] = [] self._roles: dict[str, str] = {} self._held: dict[str, list[tuple[str, str, str]]] = {} self._part_text: dict[str, str] = {} - self._tools: dict[str, str] = {} self._usage_ids: set[str] = set() self._input = 0 self._output = 0 @@ -84,8 +77,6 @@ async def reduce( raise OpenCodeSessionError("The OpenCode session failed.") for event in self._event(raw): yield event - for event in self._end_open(): - yield event def _accept_text( self, message_id: str, part_id: str, kind: str, text: str @@ -96,22 +87,20 @@ def _accept_text( return [] if role != "assistant": return [] - return self._apply_text(message_id, part_id, kind, text) + return self._apply_text(part_id, kind, text) def _flush_held(self, message_id: str) -> list[dict[str, Any]]: held = self._held.pop(message_id, []) events: list[dict[str, Any]] = [] for kind, part_id, text in held: - events.extend(self._apply_text(message_id, part_id, kind, text)) + events.extend(self._apply_text(part_id, kind, text)) return events - def _apply_text( - self, message_id: str, part_id: str, kind: str, text: str - ) -> list[dict[str, Any]]: + def _apply_text(self, part_id: str, kind: str, text: str) -> list[dict[str, Any]]: if kind == "delta": if part_id: self._part_text[part_id] = self._part_text.get(part_id, "") + text - return self._text(message_id, text) + return self._chunk(text) already = self._part_text.get(part_id, "") if text.startswith(already): suffix = text[len(already) :] @@ -125,7 +114,7 @@ def _apply_text( return [] if not suffix: return [] - return self._text(message_id, suffix) + return self._chunk(suffix) def _event(self, raw: Mapping[str, Any] | object) -> list[dict[str, Any]]: if not isinstance(raw, Mapping): @@ -138,13 +127,9 @@ def _event(self, raw: Mapping[str, Any] | object) -> list[dict[str, Any]]: return self._delta(properties) if kind == "message.part.updated": part = properties.get("part") - if not isinstance(part, Mapping): + if not isinstance(part, Mapping) or part.get("type") != "text": return [] - if part.get("type") == "tool": - return self._tool(part) - if part.get("type") == "text": - return self._text_snapshot(part) - return [] + return self._text_snapshot(part) if kind == "message.updated": info = properties.get("info") if isinstance(info, Mapping): @@ -176,28 +161,9 @@ def _text_snapshot(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: return [] return self._accept_text(message_id, part_id, "snapshot", text) - def _text(self, message_id: str, delta: str) -> list[dict[str, Any]]: - events: list[dict[str, Any]] = [] - if self._open_message != message_id: - events.extend(self._end_open()) - self._open_message = message_id - events.append({"type": "TEXT_MESSAGE_START", "messageId": message_id}) - events.append( - { - "type": "TEXT_MESSAGE_CONTENT", - "messageId": message_id, - "delta": delta, - } - ) - self._deltas.append(delta) - return events - - def _end_open(self) -> list[dict[str, Any]]: - if self._open_message is None: - return [] - message_id = self._open_message - self._open_message = None - return [{"type": "TEXT_MESSAGE_END", "messageId": message_id}] + def _chunk(self, text: str) -> list[dict[str, Any]]: + self._deltas.append(text) + return [{"type": "chunk", "text": text}] def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: message_id = info.get("id") @@ -214,9 +180,7 @@ def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: completed = info.get("time") if not isinstance(completed, Mapping) or "completed" not in completed: return events - if message_id == self._open_message: - events.extend(self._end_open()) - if isinstance(message_id, str) and message_id not in self._usage_ids: + if message_id not in self._usage_ids: tokens = info.get("tokens") if isinstance(tokens, Mapping): self._add_usage(tokens) @@ -233,109 +197,6 @@ def _add_usage(self, tokens: Mapping[str, Any]) -> None: self._cache_read += number_or_zero(cache.get("read")) self._cache_write += number_or_zero(cache.get("write")) - def _tool(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: - state = part.get("state") - if not isinstance(state, Mapping): - return [] - call_id = part.get("callID") or part.get("id") - name = part.get("tool") - if ( - not isinstance(call_id, str) - or not call_id - or not isinstance(name, str) - or not name - ): - return [] - status = state.get("status") - if status not in ("running", "completed", "error"): - return [] - events: list[dict[str, Any]] = [] - if self._tools.get(call_id) is None and ( - "input" in state or status != "running" - ): - events.extend(self._open_tool(call_id, name, state.get("input"))) - self._tools[call_id] = "open" - if status == "completed" and self._tools.get(call_id) == "open": - events.append(self._result(call_id, state.get("output", ""), failed=False)) - self._tools[call_id] = "done" - elif status == "error" and self._tools.get(call_id) == "open": - events.append(self._result(call_id, None, failed=True)) - self._tools[call_id] = "done" - return events - - def _open_tool( - self, call_id: str, name: str, raw_input: object - ) -> list[dict[str, Any]]: - start: dict[str, Any] = { - "type": "TOOL_CALL_START", - "toolCallId": call_id, - "toolCallName": name, - } - if self._open_message is not None: - start["parentMessageId"] = self._open_message - events = [start] - if self._capture: - text = _argument_text(raw_input) - if text: - events.append( - {"type": "TOOL_CALL_ARGS", "toolCallId": call_id, "delta": text} - ) - events.append({"type": "TOOL_CALL_END", "toolCallId": call_id}) - return events - - def _result(self, call_id: str, output: object, *, failed: bool) -> dict[str, Any]: - content = "" - metadata: dict[str, Any] | None = None - if failed: - metadata = {"launchdarkly": {"toolFailed": True}} - if self._capture: - content = _TOOL_FAILURE - else: - rendered = _result_text(output) - if rendered is None: - metadata = {"launchdarkly": {"toolFailed": True}} - if self._capture: - content = _TOOL_FAILURE - elif self._capture: - content = rendered - event: dict[str, Any] = { - "type": "TOOL_CALL_RESULT", - "messageId": str(uuid.uuid4()), - "toolCallId": call_id, - "content": content, - } - if metadata is not None: - event["metadata"] = metadata - return event - - -def _argument_text(value: object) -> str: - """Argument text for one ``TOOL_CALL_ARGS`` delta. Empty when there is nothing to send.""" - if value is None: - return "" - if isinstance(value, str): - return value - rendered = _json_text(value) - return rendered or "" - - -def _result_text(value: object) -> str | None: - """Success content, or ``None`` when the value cannot be placed on the event.""" - if isinstance(value, str): - return value - return _json_text(value) - - -def _json_text(value: object) -> str | None: - if isinstance(value, (bytes, bytearray, memoryview)): - return None - try: - return json.dumps( - value, ensure_ascii=False, separators=(",", ":"), allow_nan=False - ) - except (TypeError, ValueError): - return None - def _open_events( events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None, @@ -358,21 +219,14 @@ def create_opencode_handler( ``provides_for`` is ``('*', 'agent')``, so the handler matches an agent-mode variation for any provider. *events* is called on every invocation and must return a fresh async iterable of OpenCode session events. This function does - not start OpenCode. - - *capture_content* follows the other handlers: ``None`` reads the process - default from ``configure`` when the call runs, and ``True`` or ``False`` - overrides it. Tool argument text and tool results are omitted from the - stream unless capture is on. ``config().stream()`` still yields assistant - text as ``chunk`` and one ``done``. Tool events are on - ``launchdarkly_ai_server.rich_stream``. + not start OpenCode and does not construct an OpenCode SDK client. + + The stream yields assistant text as ``chunk`` and one ``done``. *capture_content* + follows the other handlers: ``None`` reads the process default from + ``configure`` when content is read, and ``True`` or ``False`` overrides it. """ report_usage("opencode-agents.createOpenCodeHandler", PACKAGE_NAME, __version__) - return _create_opencode_handler( - capture_content=capture_content, - events=events, - resolve_capture=effective_capture_content, - ) + return _create_opencode_handler(capture_content=capture_content, events=events) def opencode_agents( @@ -395,9 +249,7 @@ def opencode_agents( return _config( key=config_key, handler=_create_opencode_handler( - capture_content=capture_content, - events=events, - resolve_capture=effective_capture_content, + capture_content=capture_content, events=events ), **kwargs, ).invoke(user_input, context, variables, history) @@ -407,10 +259,7 @@ def _create_opencode_handler( *, capture_content: bool | None, events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None, - resolve_capture: Callable[[bool | None], bool], ) -> ProviderHandler: - content_override = capture_content - async def _stream_impl( config: AiConfigRep, user_input: str | None, @@ -418,8 +267,7 @@ async def _stream_impl( variables: dict[str, Any] | None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: - capture = resolve_capture(content_override) - reducer = _Reducer(capture) + reducer = _Reducer() async for event in reducer.reduce(_open_events(events)): yield event done: dict[str, Any] = {"type": "done", "output": "".join(reducer.deltas)} @@ -451,5 +299,5 @@ async def _call_impl( ("*", "agent"), _call_impl, _stream_impl, - capture_content=content_override, + capture_content=capture_content, ) diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 9d929857..988ae226 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -1,8 +1,8 @@ -"""OpenCode handler: synthetic session events, stable stream, rich stream reader.""" +"""OpenCode handler: synthetic session events, stable chunk then done stream.""" from __future__ import annotations -from collections.abc import AsyncGenerator, AsyncIterator, Iterator +from collections.abc import AsyncIterator, Iterator from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -13,13 +13,7 @@ OpenCodeSessionError, create_opencode_handler, ) -from launchdarkly_ai_server import ( - ProviderHandler, - SDKOptions, - config, - configure, - rich_stream, -) +from launchdarkly_ai_server import SDKOptions, config, configure CONTEXT = {"kind": "user", "key": "user-1"} @@ -154,9 +148,16 @@ def test_package_exports_the_handler(self) -> None: for name in package.__all__: assert hasattr(package, name) + def test_capture_content_follows_configure(self) -> None: + configure(options=SDKOptions(capture_content=True)) + omitted = create_opencode_handler(events=source([])) + explicit = create_opencode_handler(capture_content=False, events=source([])) + assert omitted.capture_content is True + assert explicit.capture_content is False + -class TestTextAndTools: - async def test_text_and_a_successful_tool(self) -> None: +class TestText: + async def test_text_around_a_tool_is_chunks_then_done(self) -> None: events = await collect( [ assistant_open("m1"), @@ -166,43 +167,22 @@ async def test_text_and_a_successful_tool(self) -> None: tool_part( "c1", "completed", raw_input={"command": "ls"}, output="file.txt" ), - tool_part( - "c1", "completed", raw_input={"command": "ls"}, output="file.txt" - ), assistant_completed("m1"), + assistant_open("m2"), + text_delta("m2", "p2", "Done."), + assistant_completed("m2"), ], ) - assert types_of(events) == [ - "TEXT_MESSAGE_START", - "TEXT_MESSAGE_CONTENT", - "TOOL_CALL_START", - "TOOL_CALL_ARGS", - "TOOL_CALL_END", - "TOOL_CALL_RESULT", - "TEXT_MESSAGE_END", - "done", + assert types_of(events) == ["chunk", "chunk", "done"] + assert [event["text"] for event in events if event["type"] == "chunk"] == [ + "Looking up", + "Done.", ] - assert events[0]["messageId"] == "m1" - assert events[1] == { - "type": "TEXT_MESSAGE_CONTENT", - "messageId": "m1", - "delta": "Looking up", - } - assert events[2]["toolCallId"] == "c1" - assert events[2]["toolCallName"] == "bash" - assert events[2]["parentMessageId"] == "m1" - assert events[3] == { - "type": "TOOL_CALL_ARGS", - "toolCallId": "c1", - "delta": '{"command":"ls"}', - } - assert "toolCallName" not in events[3] - assert events[5]["content"] == "file.txt" - assert events[5]["messageId"] != "c1" - assert "metadata" not in events[5] - assert events[-1]["output"] == "Looking up" + assert events[-1]["output"] == "Looking upDone." + assert "file.txt" not in str(events) + assert "toolCallId" not in str(events) - async def test_a_failed_tool_is_masked(self) -> None: + async def test_a_failed_tool_does_not_leak_into_the_stream(self) -> None: events = await collect( [ tool_part( @@ -213,45 +193,15 @@ async def test_a_failed_tool_is_masked(self) -> None: ) ], ) - result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") - assert result["content"] == "An error occurred." - assert result["metadata"] == {"launchdarkly": {"toolFailed": True}} - assert "disk on fire" not in str(result) - assert "parentMessageId" not in events[0] - - async def test_a_string_argument_is_not_encoded_again(self) -> None: - events = await collect( - [tool_part("c1", "running", raw_input="ls")], - ) - args = next(event for event in events if event["type"] == "TOOL_CALL_ARGS") - assert args["delta"] == "ls" - - async def test_nan_result_is_a_failed_tool(self) -> None: - events = await collect( - [tool_part("c1", "completed", raw_input={}, output=float("nan"))], - ) - result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") - assert result["metadata"] == {"launchdarkly": {"toolFailed": True}} - assert result["content"] == "An error occurred." - - async def test_part_id_is_the_call_id_when_call_id_is_absent(self) -> None: - part = tool_part("ignored", "running", raw_input={"q": 1}, part_id="part-9") - del part["properties"]["part"]["callID"] - events = await collect([part]) - assert events[0]["toolCallId"] == "part-9" + assert types_of(events) == ["done"] + assert events[-1]["output"] == "" + assert "disk on fire" not in str(events) - async def test_interleaved_tools_keep_arrival_order(self) -> None: + async def test_pending_tool_state_emits_nothing(self) -> None: events = await collect( - [ - tool_part("a", "running", raw_input={"q": "a"}, name="search"), - tool_part("b", "running", raw_input={"q": "b"}, name="weather"), - tool_part("b", "completed", raw_input={"q": "b"}, output="sun"), - tool_part("a", "completed", raw_input={"q": "a"}, output="rows"), - ], + [tool_part("c1", "pending", raw_input={}, output=None)], ) - results = [event for event in events if event["type"] == "TOOL_CALL_RESULT"] - assert [event["toolCallId"] for event in results] == ["b", "a"] - assert [event["content"] for event in results] == ["sun", "rows"] + assert types_of(events) == ["done"] async def test_completed_assistant_usage_is_summed_once(self) -> None: tokens = {"input": 3, "output": 4, "cache": {"read": 1, "write": 2}} @@ -270,23 +220,6 @@ async def test_completed_assistant_usage_is_summed_once(self) -> None: "cache_creation_input_tokens": 2, } - async def test_text_after_a_tool_is_a_new_message(self) -> None: - events = await collect( - [ - assistant_open("m1"), - text_delta("m1", "p1", "A"), - tool_part("c1", "running", raw_input={"q": 1}), - tool_part("c1", "completed", raw_input={"q": 1}, output="ok"), - assistant_completed("m1"), - assistant_open("m2"), - text_delta("m2", "p2", "B"), - assistant_completed("m2"), - ], - ) - starts = [event for event in events if event["type"] == "TEXT_MESSAGE_START"] - assert [event["messageId"] for event in starts] == ["m1", "m2"] - assert events[-1]["output"] == "AB" - async def test_a_text_snapshot_does_not_repeat_deltas(self) -> None: events = await collect( [ @@ -304,12 +237,9 @@ async def test_a_text_snapshot_does_not_repeat_deltas(self) -> None: assistant_completed("m1"), ], ) - contents = [ - event for event in events if event["type"] == "TEXT_MESSAGE_CONTENT" - ] - assert contents == [ - {"type": "TEXT_MESSAGE_CONTENT", "messageId": "m1", "delta": "Hi"} - ] + chunks = [event for event in events if event["type"] == "chunk"] + assert chunks == [{"type": "chunk", "text": "Hi"}] + assert events[-1]["output"] == "Hi" async def test_a_snapshot_then_later_deltas_append(self) -> None: events = await collect( @@ -332,12 +262,8 @@ async def test_a_snapshot_then_later_deltas_append(self) -> None: assistant_completed("m1"), ], ) - contents = [ - event["delta"] - for event in events - if event["type"] == "TEXT_MESSAGE_CONTENT" - ] - assert contents == ["He", "llo"] + chunks = [event["text"] for event in events if event["type"] == "chunk"] + assert chunks == ["He", "llo"] assert events[-1]["output"] == "Hello" async def test_user_text_is_not_the_assistant_reply(self) -> None: @@ -389,73 +315,12 @@ async def test_session_error_raises_without_the_provider_body(self) -> None: assert "secret-body" not in str(raised.value) assert "done" not in seen - async def test_pending_tool_state_emits_nothing(self) -> None: - events = await collect( - [tool_part("c1", "pending", raw_input={}, output=None)], - ) - assert types_of(events) == ["done"] - - -class TestCapture: - async def test_capture_off_omits_argument_and_result_text(self) -> None: - handler = create_opencode_handler( - capture_content=False, - events=source( - [ - tool_part( - "c1", - "completed", - raw_input={"command": "ls"}, - output="file.txt", - ) - ] - ), - ) - events = [event async for event in await handler.stream({}, None, None, None)] - assert "TOOL_CALL_ARGS" not in types_of(events) - result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") - assert result["content"] == "" - assert "metadata" not in result - - async def test_explicit_false_overrides_configure_true(self) -> None: - configure(options=SDKOptions(capture_content=True)) - try: - handler = create_opencode_handler( - capture_content=False, - events=source( - [tool_part("c1", "running", raw_input={"command": "ls"})] - ), - ) - events = [ - event async for event in await handler.stream({}, None, None, None) - ] - assert "TOOL_CALL_ARGS" not in types_of(events) - finally: - lifecycle_module._reset_for_testing() - - async def test_omitted_capture_reads_configure_at_invocation(self) -> None: - configure(options=SDKOptions(capture_content=True)) - try: - handler = create_opencode_handler( - events=source( - [tool_part("c1", "running", raw_input={"command": "ls"})] - ), - ) - events = [ - event async for event in await handler.stream({}, None, None, None) - ] - args = next(event for event in events if event["type"] == "TOOL_CALL_ARGS") - assert args["delta"] == '{"command":"ls"}' - finally: - lifecycle_module._reset_for_testing() - class TestStableStream: - async def test_tool_events_do_not_close_the_stable_stream( + async def test_a_tool_does_not_close_the_stream( self, mock_ld_client: MagicMock ) -> None: handler = create_opencode_handler( - capture_content=True, events=source( [ assistant_open("m1"), @@ -506,163 +371,3 @@ async def test_session_error_does_not_yield_stable_done( seen.append(event) assert [event["type"] for event in seen] == ["chunk"] assert seen[0]["text"] == "Hi" - - async def test_a_chunk_handler_tool_event_is_not_the_done_event( - self, mock_ld_client: MagicMock - ) -> None: - async def invoke( - config: Any, - user_input: str | None, - tool_handlers: Any, - variables: Any, - history: Any = None, - ) -> dict[str, Any]: - return {"output": "Hi", "usage": {"input_tokens": 1, "output_tokens": 1}} - - async def stream_fn( - config: Any, - user_input: str | None, - tool_handlers: Any, - variables: Any, - history: Any = None, - ) -> AsyncGenerator[dict[str, Any], None]: - yield { - "type": "TOOL_CALL_START", - "toolCallId": "c1", - "toolCallName": "bash", - } - yield {"type": "chunk", "text": "Hi"} - yield { - "type": "done", - "output": "Hi", - "usage": {"input_tokens": 1, "output_tokens": 1}, - } - - handler = ProviderHandler( - fn=invoke, provides_for=("Acme", "agent"), stream_fn=stream_fn - ) - seen = [ - event - async for event in config(key="flag", handler=handler).stream("q", CONTEXT) - ] - assert [event["type"] for event in seen] == ["chunk", "done"] - assert seen[-1]["response"] == "Hi" - assert "toolCallId" not in seen[-1] - - -class TestRichReader: - async def test_rich_reader_forwards_text_and_tools( - self, mock_ld_client: MagicMock - ) -> None: - handler = create_opencode_handler( - capture_content=True, - events=source( - [ - assistant_open("m1"), - text_delta("m1", "p1", "Looking up"), - tool_part("c1", "running", raw_input={"command": "ls"}), - tool_part( - "c1", - "error", - raw_input={"command": "ls"}, - output="nope", - ), - assistant_completed( - "m1", - tokens={ - "input": 3, - "output": 4, - "cache": {"read": 1, "write": 2}, - }, - ), - ] - ), - ) - instance = config(key="flag", handler=handler) - seen = [event async for event in rich_stream(instance, "q", CONTEXT)] - kinds = [event["type"] for event in seen] - assert kinds[0] == "RUN_STARTED" - assert kinds[-1] == "RUN_FINISHED" - assert seen[0]["threadId"] == seen[-1]["threadId"] - assert seen[0]["runId"] == seen[-1]["runId"] - assert seen[0]["protocolVersion"] == "1.0" - assert "TEXT_MESSAGE_CONTENT" in kinds - assert "TOOL_CALL_RESULT" in kinds - result = next(event for event in seen if event["type"] == "TOOL_CALL_RESULT") - assert result["content"] == "An error occurred." - assert result["metadata"] == {"launchdarkly": {"toolFailed": True}} - finished = seen[-1] - assert finished["result"] == "Looking up" - assert finished["usage"] == [ - { - "inputTokens": 6, - "outputTokens": 4, - "totalTokens": 10, - "cachedInputTokens": 1, - "cacheWriteInputTokens": 2, - } - ] - assert "metadata" not in finished - - async def test_session_error_has_no_run_finished( - self, mock_ld_client: MagicMock - ) -> None: - handler = create_opencode_handler( - events=source( - [ - {"type": "session.error", "properties": {"error": {"name": "X"}}}, - ] - ), - ) - seen: list[str] = [] - with pytest.raises(OpenCodeSessionError): - async for event in rich_stream( - config(key="flag", handler=handler), "q", CONTEXT - ): - seen.append(event["type"]) - assert seen == ["RUN_STARTED"] - assert "RUN_FINISHED" not in seen - - async def test_legacy_chunks_are_one_text_message( - self, mock_ld_client: MagicMock - ) -> None: - async def invoke( - config: Any, - user_input: str | None, - tool_handlers: Any, - variables: Any, - history: Any = None, - ) -> dict[str, Any]: - return {"output": "Hi"} - - async def stream_fn( - config: Any, - user_input: str | None, - tool_handlers: Any, - variables: Any, - history: Any = None, - ) -> AsyncGenerator[dict[str, Any], None]: - yield {"type": "chunk", "text": "Hi"} - yield {"type": "TOOL_CALL_END", "toolCallId": "c1"} - yield {"type": "done", "output": "Hi"} - - handler = ProviderHandler( - fn=invoke, provides_for=("Acme", "agent"), stream_fn=stream_fn - ) - seen = [ - event - async for event in rich_stream( - config(key="flag", handler=handler), "q", CONTEXT - ) - ] - assert [event["type"] for event in seen] == [ - "RUN_STARTED", - "TEXT_MESSAGE_START", - "TEXT_MESSAGE_CONTENT", - "TOOL_CALL_END", - "TEXT_MESSAGE_END", - "RUN_FINISHED", - ] - assert seen[2]["delta"] == "Hi" - assert seen[-1]["result"] == "Hi" - assert "usage" not in seen[-1] From fb516505c432d2b7f6ec00403c64a2a0d29ed868 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 14:18:55 +0000 Subject: [PATCH 07/28] feat(agui): add a shared reader for handler streams Handlers can yield the AG-UI text and tool subset. config().stream() still yields chunk then done. stream() is the opt-in entry. Co-authored-by: alexis --- .../client/src/launchdarkly_ai_server/agui.py | 584 ++++++++++++++++++ .../src/launchdarkly_ai_server/client.py | 58 +- .../src/launchdarkly_ai_server/graph.py | 31 +- .../src/launchdarkly_ai_server/tracking.py | 37 +- .../src/launchdarkly_ai_server/types.py | 7 +- packages/client/tests/test_agui.py | 469 ++++++++++++++ 6 files changed, 1150 insertions(+), 36 deletions(-) create mode 100644 packages/client/src/launchdarkly_ai_server/agui.py create mode 100644 packages/client/tests/test_agui.py diff --git a/packages/client/src/launchdarkly_ai_server/agui.py b/packages/client/src/launchdarkly_ai_server/agui.py new file mode 100644 index 00000000..bdf7ec17 --- /dev/null +++ b/packages/client/src/launchdarkly_ai_server/agui.py @@ -0,0 +1,584 @@ +"""Opt-in AG-UI reader for one AI Config turn. + +Handlers may yield ``chunk`` or the text and tool subset below, then one +``done``. :func:`stream` is the opt-in entry: it brackets that subset with +``RUN_STARTED`` and ``RUN_FINISHED``. ``config().stream()`` still yields +``chunk`` then ``done``. The SDK runs the tool loop. This stream only reports it. + +Argument deltas and tool results are content. :class:`TurnEmitter` omits them +when the handler was built with ``capture_content`` off, and still emits the +tool lifecycle. +""" + +from __future__ import annotations + +import json +import math +import uuid +from collections.abc import AsyncGenerator +from typing import Any + +from .conversation import bind_conversation_id +from .sdk_usage import report_usage +from .types import JudgeResult, LDContext + +PROTOCOL_VERSION = "1.0" + +TEXT_MESSAGE_START = "TEXT_MESSAGE_START" +TEXT_MESSAGE_CONTENT = "TEXT_MESSAGE_CONTENT" +TEXT_MESSAGE_END = "TEXT_MESSAGE_END" +TOOL_CALL_START = "TOOL_CALL_START" +TOOL_CALL_ARGS = "TOOL_CALL_ARGS" +TOOL_CALL_END = "TOOL_CALL_END" +TOOL_CALL_RESULT = "TOOL_CALL_RESULT" +RUN_STARTED = "RUN_STARTED" +RUN_FINISHED = "RUN_FINISHED" + +TEXT_TYPES = frozenset({TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, TEXT_MESSAGE_END}) +TOOL_TYPES = frozenset( + {TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_END, TOOL_CALL_RESULT} +) +HANDLER_RICH_TYPES = TEXT_TYPES | TOOL_TYPES + +_FAILURE_CONTENT = "An error occurred." + + +def argument_text(value: Any) -> str | None: + """The argument text for one ``TOOL_CALL_ARGS`` delta. + + A string is that string. Any other JSON value is compact JSON. ``None`` and + a value that is not JSON under :func:`json.dumps` with ``allow_nan=False`` + produce no delta. + """ + if value is None: + return None + if isinstance(value, str): + return value or None + if isinstance(value, (bytes, bytearray, memoryview)): + return None + if isinstance(value, float) and (math.isnan(value) or math.isinf(value)): + return None + try: + return json.dumps( + value, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ) + except (TypeError, ValueError): + return None + + +def safe_tool_content(value: Any) -> tuple[str, bool]: + """``(content, failed)`` for a tool result. + + A string is not encoded again. Any other JSON value is compact JSON. + A value that is not a string and not serializable under that rule is a + failed result: the caller masks it. The model-facing value is unchanged. + """ + if isinstance(value, str): + return value, False + if isinstance(value, (bytes, bytearray, memoryview)): + return _FAILURE_CONTENT, True + if isinstance(value, float) and (math.isnan(value) or math.isinf(value)): + return _FAILURE_CONTENT, True + try: + return ( + json.dumps( + value, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ), + False, + ) + except (TypeError, ValueError): + return _FAILURE_CONTENT, True + + +class TurnEmitter: + """Yields the text and tool subset for one handler run. + + ``capture_content`` gates argument deltas and result content. The tool name + and call id are always present on ``TOOL_CALL_START``. A closed call is not + reopened. + """ + + def __init__(self, *, capture_content: bool) -> None: + self.capture_content = capture_content + self._open: str | None = None + self._seq = 0 + self._started: set[str] = set() + self._args: set[str] = set() + self._ended: set[str] = set() + self._resulted: set[str] = set() + + def _mint(self, prefix: str) -> str: + self._seq += 1 + return f"{prefix}-{self._seq}" + + def text(self, delta: str) -> list[dict[str, Any]]: + """Append assistant text. The first slice opens the message.""" + if not delta: + return [] + events: list[dict[str, Any]] = [] + if self._open is None: + self._open = self._mint("msg") + events.append({"type": TEXT_MESSAGE_START, "messageId": self._open}) + events.append( + { + "type": TEXT_MESSAGE_CONTENT, + "messageId": self._open, + "delta": delta, + } + ) + return events + + def end_text(self) -> list[dict[str, Any]]: + if self._open is None: + return [] + event = {"type": TEXT_MESSAGE_END, "messageId": self._open} + self._open = None + return [event] + + def tool_start( + self, tool_call_id: str, tool_call_name: str + ) -> list[dict[str, Any]]: + if not tool_call_id or not tool_call_name or tool_call_id in self._started: + return [] + self._started.add(tool_call_id) + event: dict[str, Any] = { + "type": TOOL_CALL_START, + "toolCallId": tool_call_id, + "toolCallName": tool_call_name, + } + if self._open is not None: + event["parentMessageId"] = self._open + return [event] + + def tool_args(self, tool_call_id: str, delta: str) -> list[dict[str, Any]]: + if ( + not self.capture_content + or not delta + or tool_call_id not in self._started + or tool_call_id in self._ended + ): + return [] + self._args.add(tool_call_id) + return [{"type": TOOL_CALL_ARGS, "toolCallId": tool_call_id, "delta": delta}] + + def tool_end( + self, tool_call_id: str, *, arguments: Any = None + ) -> list[dict[str, Any]]: + """Close the argument text. One delta is emitted when none arrived.""" + if tool_call_id not in self._started or tool_call_id in self._ended: + return [] + events: list[dict[str, Any]] = [] + if ( + self.capture_content + and tool_call_id not in self._args + and arguments is not None + ): + text = argument_text(arguments) + if text: + self._args.add(tool_call_id) + events.append( + {"type": TOOL_CALL_ARGS, "toolCallId": tool_call_id, "delta": text} + ) + self._ended.add(tool_call_id) + events.append({"type": TOOL_CALL_END, "toolCallId": tool_call_id}) + return events + + def tool_result( + self, tool_call_id: str, value: Any = None, *, failed: bool = False + ) -> list[dict[str, Any]]: + """Report a finished call, including failure. Does not run the tool.""" + if tool_call_id not in self._started or tool_call_id in self._resulted: + return [] + events = self.tool_end(tool_call_id) if tool_call_id not in self._ended else [] + self._resulted.add(tool_call_id) + event: dict[str, Any] = { + "type": TOOL_CALL_RESULT, + "messageId": self._mint("toolmsg"), + "toolCallId": tool_call_id, + } + if failed: + event["metadata"] = {"launchdarkly": {"toolFailed": True}} + if self.capture_content: + event["content"] = _FAILURE_CONTENT + elif self.capture_content: + content, bad = safe_tool_content(value) + if bad: + event["content"] = _FAILURE_CONTENT + event["metadata"] = {"launchdarkly": {"toolFailed": True}} + else: + event["content"] = content + events.append(event) + return events + + +class StableProjector: + """Projects a handler stream onto ``chunk`` then one internal ``done``. + + ``TEXT_MESSAGE_CONTENT`` becomes a chunk. Tool events and any other type are + dropped and do not close the turn. Only the handler's ``done`` does. + """ + + def __init__(self) -> None: + self.saw_text = False + self.done: dict[str, Any] | None = None + + def feed(self, event: dict[str, Any]) -> list[dict[str, Any]]: + typ = event.get("type") + if typ in TEXT_TYPES: + self.saw_text = True + if typ == "done": + self.done = event + return [] + if typ == "chunk": + if self.saw_text: + return [] + text = event.get("text", "") + if not isinstance(text, str): + return [] + return [{"type": "chunk", "text": text}] + if typ == TEXT_MESSAGE_CONTENT: + delta = event.get("delta") or "" + if not delta: + return [] + return [{"type": "chunk", "text": delta}] + return [] + + +def _token_usage( + parsed: dict[str, Any], *, provider: str, model: str +) -> dict[str, Any]: + usage: dict[str, Any] = { + "inputTokens": int(parsed.get("input") or 0), + "outputTokens": int(parsed.get("output") or 0), + "totalTokens": int(parsed.get("total") or 0), + } + details = parsed.get("input_details") + if isinstance(details, dict): + usage["cachedInputTokens"] = int(details.get("cache_read") or 0) + usage["cacheWriteInputTokens"] = int(details.get("cache_creation") or 0) + if provider: + usage["provider"] = provider + if model: + usage["model"] = model + return usage + + +def _judge_payload(results: dict[str, JudgeResult]) -> dict[str, Any] | None: + if not results: + return None + payload: dict[str, Any] = {} + for key, result in results.items(): + usage: dict[str, Any] = { + "input": result.usage.input, + "output": result.usage.output, + "total": result.usage.total, + } + details = result.usage.input_details + if details is not None: + usage["input_details"] = { + "uncached": details.uncached, + "cache_read": details.cache_read, + "cache_creation": details.cache_creation, + } + payload[key] = { + "usage": usage, + "response": result.response, + "score": result.score, + } + return payload + + +class RunReader: + """Maps one handler stream onto one AG-UI run. + + Legacy ``chunk`` events become one text message when the handler yielded no + ``TEXT_MESSAGE_*`` event. ``RUN_STARTED`` and ``RUN_FINISHED`` are minted + here. The handler's ``done`` is consumed, not forwarded. + """ + + def __init__(self) -> None: + self.thread_id = str(uuid.uuid4()) + self.run_id = str(uuid.uuid4()) + self._mode: str | None = None + self._open: str | None = None + self._deltas: list[str] = [] + self._started: set[str] = set() + self._ended: set[str] = set() + self._resulted: set[str] = set() + self._seq = 0 + + def start(self) -> dict[str, Any]: + return { + "type": RUN_STARTED, + "threadId": self.thread_id, + "runId": self.run_id, + "protocolVersion": PROTOCOL_VERSION, + } + + def _mint(self, prefix: str) -> str: + self._seq += 1 + return f"{prefix}-{self._seq}" + + def feed(self, event: dict[str, Any]) -> list[dict[str, Any]]: + typ = event.get("type") + if typ == "chunk": + return self._chunk(event.get("text") or "") + if typ == TEXT_MESSAGE_START: + return self._text_start(event.get("messageId")) + if typ == TEXT_MESSAGE_CONTENT: + return self._text_content(event.get("messageId"), event.get("delta")) + if typ == TEXT_MESSAGE_END: + return self._text_end(event.get("messageId")) + if typ == TOOL_CALL_START: + return self._tool_start(event) + if typ == TOOL_CALL_ARGS: + return self._tool_args(event) + if typ == TOOL_CALL_END: + return self._tool_end(event) + if typ == TOOL_CALL_RESULT: + return self._tool_result(event) + return [] + + def finish( + self, + done: dict[str, Any], + judge_results: dict[str, JudgeResult] | None, + *, + provider: str, + model: str, + ) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + if self._open is not None: + events.append({"type": TEXT_MESSAGE_END, "messageId": self._open}) + self._open = None + response = done.get("response") + if not isinstance(response, str): + response = "".join(self._deltas) + finished: dict[str, Any] = { + "type": RUN_FINISHED, + "threadId": self.thread_id, + "runId": self.run_id, + } + if response: + finished["result"] = response + parsed = done.get("usage") + if done.get("_had_usage") and isinstance(parsed, dict): + finished["usage"] = [_token_usage(parsed, provider=provider, model=model)] + judges = _judge_payload(judge_results or {}) + if judges is not None: + finished["metadata"] = {"launchdarkly": {"judgeResults": judges}} + events.append(finished) + return events + + def _chunk(self, text: str) -> list[dict[str, Any]]: + if self._mode == "message" or not text: + return [] + self._mode = "chunk" + events: list[dict[str, Any]] = [] + if self._open is None: + self._open = self._mint("msg") + events.append({"type": TEXT_MESSAGE_START, "messageId": self._open}) + events.append( + {"type": TEXT_MESSAGE_CONTENT, "messageId": self._open, "delta": text} + ) + self._deltas.append(text) + return events + + def _close_open(self, message_id: str) -> list[dict[str, Any]]: + if self._open is None or self._open == message_id: + return [] + event = {"type": TEXT_MESSAGE_END, "messageId": self._open} + self._open = None + return [event] + + def _text_start(self, message_id: Any) -> list[dict[str, Any]]: + if not isinstance(message_id, str) or not message_id: + return [] + self._mode = "message" + if self._open == message_id: + return [] + events = self._close_open(message_id) + self._open = message_id + events.append({"type": TEXT_MESSAGE_START, "messageId": message_id}) + return events + + def _text_content(self, message_id: Any, delta: Any) -> list[dict[str, Any]]: + if not isinstance(delta, str): + return [] + self._mode = "message" + mid = message_id if isinstance(message_id, str) and message_id else None + events: list[dict[str, Any]] = [] + if self._open is None: + if mid is None: + mid = self._mint("msg") + self._open = mid + events.append({"type": TEXT_MESSAGE_START, "messageId": mid}) + elif mid is not None and mid != self._open: + events.extend(self._close_open(mid)) + self._open = mid + events.append({"type": TEXT_MESSAGE_START, "messageId": mid}) + events.append( + { + "type": TEXT_MESSAGE_CONTENT, + "messageId": self._open, + "delta": delta, + } + ) + self._deltas.append(delta) + return events + + def _text_end(self, message_id: Any) -> list[dict[str, Any]]: + if self._open is None: + return [] + if isinstance(message_id, str) and message_id and message_id != self._open: + return [] + event = {"type": TEXT_MESSAGE_END, "messageId": self._open} + self._open = None + return [event] + + def _tool_start(self, event: dict[str, Any]) -> list[dict[str, Any]]: + call_id = event.get("toolCallId") + name = event.get("toolCallName") + if ( + not isinstance(call_id, str) + or not call_id + or not isinstance(name, str) + or not name + or call_id in self._started + ): + return [] + self._started.add(call_id) + started: dict[str, Any] = { + "type": TOOL_CALL_START, + "toolCallId": call_id, + "toolCallName": name, + } + parent = event.get("parentMessageId") + if isinstance(parent, str) and parent: + started["parentMessageId"] = parent + return [started] + + def _tool_args(self, event: dict[str, Any]) -> list[dict[str, Any]]: + call_id = event.get("toolCallId") + delta = event.get("delta") + if ( + not isinstance(call_id, str) + or call_id not in self._started + or call_id in self._ended + or not isinstance(delta, str) + ): + return [] + return [{"type": TOOL_CALL_ARGS, "toolCallId": call_id, "delta": delta}] + + def _tool_end(self, event: dict[str, Any]) -> list[dict[str, Any]]: + call_id = event.get("toolCallId") + if ( + not isinstance(call_id, str) + or call_id not in self._started + or call_id in self._ended + ): + return [] + self._ended.add(call_id) + return [{"type": TOOL_CALL_END, "toolCallId": call_id}] + + def _tool_result(self, event: dict[str, Any]) -> list[dict[str, Any]]: + call_id = event.get("toolCallId") + if ( + not isinstance(call_id, str) + or call_id not in self._started + or call_id in self._resulted + ): + return [] + self._resulted.add(call_id) + message_id = event.get("messageId") + if not isinstance(message_id, str) or not message_id: + message_id = self._mint("toolmsg") + result: dict[str, Any] = { + "type": TOOL_CALL_RESULT, + "messageId": message_id, + "toolCallId": call_id, + } + content = event.get("content") + if isinstance(content, str): + result["content"] = content + metadata = event.get("metadata") + if isinstance(metadata, dict): + launchdarkly = metadata.get("launchdarkly") + if ( + isinstance(launchdarkly, dict) + and launchdarkly.get("toolFailed") is True + ): + result["metadata"] = {"launchdarkly": {"toolFailed": True}} + return [result] + + +def stream( + instance: Any, + user_input: str | None, + context: LDContext, + *, + variables: dict[str, Any] | None = None, + history: list[dict[str, Any]] | None = None, +) -> AsyncGenerator[dict[str, Any], None]: + """Stream one config turn as AG-UI events. + + Same arguments as ``ConfigInstance.stream``. ``RUN_STARTED`` is first and + ``RUN_FINISHED`` is last. A failure, including cancellation, raises and does + not yield ``RUN_FINISHED``. + """ + report_usage("client.agui.stream") + return bind_conversation_id( + _stream_events(instance, user_input, context, variables, history) + ) + + +async def _stream_events( + instance: Any, + user_input: str | None, + context: LDContext, + variables: dict[str, Any] | None, + history: list[dict[str, Any]] | None, +) -> AsyncGenerator[dict[str, Any], None]: + from .judges import run_judges + + sink: dict[str, Any] = {} + reader = RunReader() + started = False + done: dict[str, Any] | None = None + async for event in instance._execute_stream( + user_input, context, variables, history, sink + ): + if not started: + yield reader.start() + started = True + if event.get("type") == "done": + done = event + continue + for item in reader.feed(event): + yield item + if not started: + yield reader.start() + if done is None: + done = {"response": "", "usage": {}, "_had_usage": False} + judge_results: dict[str, JudgeResult] = {} + if not instance._skip_judges and sink: + judge_results = await run_judges( + config=sink["config"], + user_context=context, + handler=sink["handler"], + handlers=sink["handlers"], + user_input=user_input, + trajectory=done.get("trajectory", ""), + llm_response=done.get("response", "") + if isinstance(done.get("response"), str) + else "", + base_track_data=done.get("track_data", {}), + tool_handlers=sink["tools"], + ) + for item in reader.finish( + done, + judge_results, + provider=str(sink.get("provider") or ""), + model=str(sink.get("model") or ""), + ): + yield item diff --git a/packages/client/src/launchdarkly_ai_server/client.py b/packages/client/src/launchdarkly_ai_server/client.py index 0f77383f..9ea95962 100644 --- a/packages/client/src/launchdarkly_ai_server/client.py +++ b/packages/client/src/launchdarkly_ai_server/client.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Callable from typing import Any +from .agui import StableProjector from .conversation import bind_conversation_id from .judges import _build_judge_tasks, run_judges from .lifecycle import extract_variation @@ -192,13 +193,20 @@ def _stream( self._stream_events(user_input, context, variables, history) ) - async def _stream_events( + async def _execute_stream( self, user_input: str | None, context: LDContext, - variables: dict[str, Any] | None = None, - history: list[dict[str, Any]] | None = None, - ) -> AsyncGenerator[StreamEvent, None]: + variables: dict[str, Any] | None, + history: list[dict[str, Any]] | None, + sink: dict[str, Any], + ) -> AsyncGenerator[dict[str, Any], None]: + """Yield handler events and the internal ``done``. + + Fills *sink* with the resolved config and handler before the first event, + so both ``config().stream()`` and the AG-UI reader run judges from one + pass. + """ resolved_handler_list = resolve_handlers( self._registry, self._normalize_handlers() ) @@ -211,8 +219,17 @@ async def _stream_events( if not resolved_handler_list: raise ValueError("No handlers provided to config()") handler = select_handler(config, meta, resolved_handler_list) - - done_event: dict[str, Any] | None = None + provider = "" + model = "" + if isinstance(config, dict): + provider = str((config.get("provider") or {}).get("name") or "") + model = str((config.get("model") or {}).get("name") or "") + sink["config"] = config + sink["handler"] = handler + sink["handlers"] = resolved_handler_list + sink["tools"] = resolved_tools + sink["provider"] = provider + sink["model"] = model async for event in execute_and_stream( config_key=self._key, @@ -225,10 +242,23 @@ async def _stream_events( variables=variables, history=history, ): - if event.get("type") == "chunk": - yield event - else: - done_event = event + yield event + + async def _stream_events( + self, + user_input: str | None, + context: LDContext, + variables: dict[str, Any] | None = None, + history: list[dict[str, Any]] | None = None, + ) -> AsyncGenerator[StreamEvent, None]: + sink: dict[str, Any] = {} + projector = StableProjector() + async for event in self._execute_stream( + user_input, context, variables, history, sink + ): + for stable in projector.feed(event): + yield stable + done_event = projector.done if done_event: track_data = done_event.get("track_data", {}) @@ -236,15 +266,15 @@ async def _stream_events( {} if self._skip_judges else await run_judges( - config=config, + config=sink["config"], user_context=context, - handler=handler, - handlers=resolved_handler_list, + handler=sink["handler"], + handlers=sink["handlers"], user_input=user_input, trajectory=done_event.get("trajectory", ""), llm_response=done_event.get("response", ""), base_track_data=track_data, - tool_handlers=resolved_tools, + tool_handlers=sink["tools"], ) ) yield { diff --git a/packages/client/src/launchdarkly_ai_server/graph.py b/packages/client/src/launchdarkly_ai_server/graph.py index beb39d97..63e68736 100644 --- a/packages/client/src/launchdarkly_ai_server/graph.py +++ b/packages/client/src/launchdarkly_ai_server/graph.py @@ -10,6 +10,7 @@ from collections.abc import AsyncGenerator, Callable from typing import Any +from .agui import StableProjector from .conversation import bind_conversation_id, bind_span_context from .registry import resolve_handlers, resolve_tools from .types import ( @@ -509,6 +510,7 @@ async def stream_node( "graphKey": key, } + projector = StableProjector() async for event in execute_and_stream( config_key=node.key, config=node.config, @@ -521,17 +523,17 @@ async def stream_node( graph_key=key, history=opts.get("history"), ): - if event.get("type") == "chunk": + for stable in projector.feed(event): yield { "type": "chunk", - "text": event["text"], + "text": stable["text"], "nodeKey": node.key, } - else: - response = event.get("response", "") - usage = event.get("usage") or usage - track_data = event.get("track_data") or track_data - trajectory = event.get("trajectory") or trajectory + if projector.done: + response = projector.done.get("response", "") + usage = projector.done.get("usage") or usage + track_data = projector.done.get("track_data") or track_data + trajectory = projector.done.get("trajectory") or trajectory judge_results = await run_judges( config=node.config, @@ -648,6 +650,7 @@ async def stream_route( merged_tool_handlers = {**(tool_handlers or {}), **handoff_handlers} + projector = StableProjector() async for event in execute_and_stream( config_key=node.key, config=routed_config, @@ -660,17 +663,17 @@ async def stream_route( graph_key=key, history=opts.get("history"), ): - if event.get("type") == "chunk": + for stable in projector.feed(event): yield { "type": "chunk", - "text": event["text"], + "text": stable["text"], "nodeKey": node.key, } - else: - response = event.get("response", "") - usage = event.get("usage") or usage - track_data = event.get("track_data") or track_data - trajectory = event.get("trajectory") or trajectory + if projector.done: + response = projector.done.get("response", "") + usage = projector.done.get("usage") or usage + track_data = projector.done.get("track_data") or track_data + trajectory = projector.done.get("trajectory") or trajectory # Judge against the node's original config, not the routing-augmented one. judge_results = await run_judges( diff --git a/packages/client/src/launchdarkly_ai_server/tracking.py b/packages/client/src/launchdarkly_ai_server/tracking.py index 6e7a5638..a2d3454d 100644 --- a/packages/client/src/launchdarkly_ai_server/tracking.py +++ b/packages/client/src/launchdarkly_ai_server/tracking.py @@ -7,6 +7,7 @@ from collections.abc import AsyncGenerator, Callable from typing import Any +from .agui import HANDLER_RICH_TYPES, TEXT_MESSAGE_CONTENT, TEXT_TYPES from .trajectory import HANDOFF_TOOL_PREFIX, TrajectoryRecorder, render_trajectory from .types import ( NATIVE_TOOL_KEY, @@ -250,9 +251,10 @@ async def execute_and_stream( history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[ExecuteStreamEvent, None]: """ - Streaming counterpart to ``execute_and_track``. Yields ``chunk`` events - from the handler, then a single ``done`` event once the stream completes - and LD telemetry has been flushed. + Streaming counterpart to ``execute_and_track``. Yields ``chunk`` events, + the AG-UI text and tool subset when the handler emits it, then a single + ``done`` event once the stream completes and LD telemetry has been flushed. + ``config().stream()`` projects that subset back to ``chunk`` then ``done``. Falls back to calling the blocking handler when the handler has no ``stream`` implementation. @@ -297,6 +299,8 @@ async def execute_and_stream( start_time = time.monotonic() full_text = "" raw_usage: dict[str, Any] = {} + had_usage = False + saw_text = False try: if handler.has_stream: @@ -304,20 +308,38 @@ async def execute_and_stream( config, user_input, tracked_tool_handlers, merged_variables, history ) async for event in stream_gen: - if event.get("type") == "chunk": + typ = event.get("type") + if typ in TEXT_TYPES: + saw_text = True + if typ == "chunk": + if saw_text: + continue full_text += event.get("text", "") yield {"type": "chunk", "text": event["text"]} - elif event.get("type") == "done": + elif typ == TEXT_MESSAGE_CONTENT: + delta = event.get("delta") or "" + if isinstance(delta, str): + full_text += delta + yield event + elif typ in HANDLER_RICH_TYPES: + yield event + elif typ == "done": if event.get("output") is not None: full_text = event["output"] - raw_usage = event.get("usage", {}) + usage_value = event.get("usage") + if isinstance(usage_value, dict) and usage_value: + raw_usage = usage_value + had_usage = True else: result = await handler( config, user_input, tracked_tool_handlers, merged_variables, history ) out = result.get("output") full_text = str(out) if out is not None else "" - raw_usage = result.get("usage") or {} + usage_value = result.get("usage") + if isinstance(usage_value, dict) and usage_value: + raw_usage = usage_value + had_usage = True if full_text: yield {"type": "chunk", "text": full_text} except Exception: @@ -348,4 +370,5 @@ async def execute_and_stream( observable_tools=exposed_tool_keys, omitted=recorder.omitted, ), + "_had_usage": had_usage, } diff --git a/packages/client/src/launchdarkly_ai_server/types.py b/packages/client/src/launchdarkly_ai_server/types.py index 44c876fc..09455788 100644 --- a/packages/client/src/launchdarkly_ai_server/types.py +++ b/packages/client/src/launchdarkly_ai_server/types.py @@ -114,7 +114,12 @@ class Message: class HandlerStreamEvent: - """Union of chunk / done events yielded by a handler's stream method.""" + """Events a handler stream yields. + + ``chunk`` and ``done`` remain valid. A handler may instead yield the AG-UI + text and tool subset (``TEXT_MESSAGE_*``, ``TOOL_CALL_*``) and one ``done``. + ``config().stream()`` still yields ``chunk`` then ``done``. + """ pass diff --git a/packages/client/tests/test_agui.py b/packages/client/tests/test_agui.py new file mode 100644 index 00000000..28ed548b --- /dev/null +++ b/packages/client/tests/test_agui.py @@ -0,0 +1,469 @@ +"""The opt-in AG-UI reader, and the stable chunk/done projection.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import launchdarkly_ai_server.lifecycle as lifecycle_module +from launchdarkly_ai_server import ProviderHandler, config +from launchdarkly_ai_server.agui import ( + RUN_FINISHED, + RUN_STARTED, + TEXT_MESSAGE_CONTENT, + TEXT_MESSAGE_END, + TEXT_MESSAGE_START, + TOOL_CALL_ARGS, + TOOL_CALL_END, + TOOL_CALL_RESULT, + TOOL_CALL_START, + RunReader, + StableProjector, + TurnEmitter, + stream, +) +from launchdarkly_ai_server.types import InputTokenDetails, JudgeResult, UsageDict + +CONTEXT = {"kind": "user", "key": "u1"} + + +def _client() -> MagicMock: + client = MagicMock() + client.track = MagicMock() + client.flush = AsyncMock() + client.close = AsyncMock() + client.variation = AsyncMock( + return_value={ + "model": {"name": "gpt-4"}, + "provider": {"name": "TestProvider"}, + "instructions": "Be helpful.", + "_ldMeta": { + "enabled": True, + "variationKey": "v1", + "version": 1, + "mode": "messages", + }, + } + ) + return client + + +@pytest.fixture +def mock_ld_client() -> MagicMock: + client = _client() + lifecycle_module._set_client_for_testing(client) + yield client + lifecycle_module._reset_for_testing() + + +def _handler( + events: list[dict[str, Any]], + *, + capture_content: bool = False, +) -> ProviderHandler: + async def fn(cfg, user_input, tool_handlers, variables, history=None) -> dict: # type: ignore[override] + return {"output": "", "usage": {}} + + async def stream_fn( + cfg, user_input, tool_handlers, variables, history=None + ) -> AsyncGenerator[dict[str, Any], None]: + for event in events: + yield event + + return ProviderHandler( + fn=fn, + provides_for=("TestProvider", "messages"), + stream_fn=stream_fn, + capture_content=capture_content, + ) + + +def _types(events: list[dict[str, Any]]) -> list[str]: + return [event["type"] for event in events] + + +class TestTurnEmitter: + def test_capture_on_includes_args_and_result(self) -> None: + emitter = TurnEmitter(capture_content=True) + events: list[dict[str, Any]] = [] + events.extend(emitter.text("Looking up")) + events.extend(emitter.tool_start("a", "search")) + events.extend(emitter.tool_args("a", '{"q":')) + events.extend(emitter.tool_end("a")) + events.extend(emitter.end_text()) + events.extend(emitter.tool_result("a", {"ok": True})) + assert _types(events) == [ + TEXT_MESSAGE_START, + TEXT_MESSAGE_CONTENT, + TOOL_CALL_START, + TOOL_CALL_ARGS, + TOOL_CALL_END, + TEXT_MESSAGE_END, + TOOL_CALL_RESULT, + ] + assert events[3]["delta"] == '{"q":' + assert events[2]["parentMessageId"] == events[0]["messageId"] + assert events[6]["content"] == '{"ok":true}' + assert events[6]["messageId"] != "a" + + def test_capture_off_keeps_the_lifecycle_without_payloads(self) -> None: + emitter = TurnEmitter(capture_content=False) + events: list[dict[str, Any]] = [] + events.extend(emitter.tool_start("a", "search")) + events.extend(emitter.tool_args("a", '{"q":')) + events.extend(emitter.tool_end("a", arguments={"q": "x"})) + events.extend(emitter.tool_result("a", "secret")) + assert _types(events) == [TOOL_CALL_START, TOOL_CALL_END, TOOL_CALL_RESULT] + assert events[0]["toolCallName"] == "search" + assert "parentMessageId" not in events[0] + assert "content" not in events[2] + assert "delta" not in str(events) + + def test_failure_is_masked_and_a_string_is_not_reencoded(self) -> None: + emitter = TurnEmitter(capture_content=True) + events: list[dict[str, Any]] = [] + events.extend(emitter.tool_start("a", "search")) + events.extend(emitter.tool_result("a", failed=True)) + events.extend(emitter.tool_start("b", "search")) + events.extend(emitter.tool_result("b", "plain")) + failed = events[2] + assert failed["content"] == "An error occurred." + assert failed["metadata"] == {"launchdarkly": {"toolFailed": True}} + assert events[5]["content"] == "plain" + + def test_unserializable_result_is_a_failed_result(self) -> None: + emitter = TurnEmitter(capture_content=True) + events = emitter.tool_start("a", "search") + events.extend(emitter.tool_result("a", float("nan"))) + assert events[-1]["content"] == "An error occurred." + assert events[-1]["metadata"]["launchdarkly"]["toolFailed"] is True + + def test_a_second_start_for_the_same_id_is_dropped(self) -> None: + emitter = TurnEmitter(capture_content=True) + assert emitter.tool_start("a", "search") + assert emitter.tool_start("a", "search") == [] + + +class TestRunReader: + def test_legacy_chunks_become_one_text_message(self) -> None: + reader = RunReader() + events = [reader.start()] + events.extend(reader.feed({"type": "chunk", "text": "Hi"})) + events.extend(reader.feed({"type": "chunk", "text": "!"})) + events.extend( + reader.finish( + { + "response": "Hi!", + "usage": {"input": 2, "output": 1, "total": 3}, + "_had_usage": True, + }, + None, + provider="OpenAI", + model="gpt-4", + ) + ) + assert _types(events) == [ + RUN_STARTED, + TEXT_MESSAGE_START, + TEXT_MESSAGE_CONTENT, + TEXT_MESSAGE_CONTENT, + TEXT_MESSAGE_END, + RUN_FINISHED, + ] + assert events[0]["protocolVersion"] == "1.0" + assert events[0]["threadId"] == events[-1]["threadId"] + assert events[-1]["result"] == "Hi!" + assert events[-1]["usage"] == [ + { + "inputTokens": 2, + "outputTokens": 1, + "totalTokens": 3, + "provider": "OpenAI", + "model": "gpt-4", + } + ] + assert "metadata" not in events[-1] + + def test_forwards_the_subset_and_drops_a_third_type(self) -> None: + reader = RunReader() + events = [reader.start()] + events.extend(reader.feed({"type": TEXT_MESSAGE_START, "messageId": "m1"})) + events.extend( + reader.feed( + {"type": TEXT_MESSAGE_CONTENT, "messageId": "m1", "delta": "Hi"} + ) + ) + events.extend(reader.feed({"type": "widget", "text": "nope"})) + events.extend(reader.feed({"type": "chunk", "text": "dropped"})) + events.extend( + reader.feed( + { + "type": TOOL_CALL_START, + "toolCallId": "a", + "toolCallName": "search", + "parentMessageId": "m1", + } + ) + ) + events.extend( + reader.feed({"type": TOOL_CALL_ARGS, "toolCallId": "a", "delta": "{}"}) + ) + events.extend(reader.feed({"type": TOOL_CALL_END, "toolCallId": "a"})) + events.extend(reader.feed({"type": TEXT_MESSAGE_END, "messageId": "m1"})) + events.extend( + reader.feed( + { + "type": TOOL_CALL_RESULT, + "messageId": "tm1", + "toolCallId": "a", + "content": "ok", + } + ) + ) + events.extend( + reader.feed( + { + "type": TOOL_CALL_START, + "toolCallId": "a", + "toolCallName": "search", + } + ) + ) + events.extend( + reader.finish( + {"response": "Hi", "_had_usage": False}, + None, + provider="", + model="", + ) + ) + assert "widget" not in _types(events) + assert "chunk" not in _types(events) + assert "dropped" not in str(events) + assert _types(events).count(TOOL_CALL_START) == 1 + assert events[-1]["type"] == RUN_FINISHED + assert "usage" not in events[-1] + assert events[-1]["result"] == "Hi" + + def test_cache_split_and_judge_metadata(self) -> None: + reader = RunReader() + finished = reader.finish( + { + "response": "", + "_had_usage": True, + "usage": { + "input": 5, + "output": 1, + "total": 6, + "input_details": { + "uncached": 3, + "cache_read": 1, + "cache_creation": 1, + }, + }, + }, + { + "quality": JudgeResult( + usage=UsageDict( + input=1, + output=1, + total=2, + input_details=InputTokenDetails( + uncached=1, cache_read=0, cache_creation=0 + ), + ), + response="fine", + score=1.0, + ) + }, + provider="", + model="", + ) + usage = finished[-1]["usage"][0] + assert usage["cachedInputTokens"] == 1 + assert usage["cacheWriteInputTokens"] == 1 + assert "provider" not in usage + assert "result" not in finished[-1] + assert ( + finished[-1]["metadata"]["launchdarkly"]["judgeResults"]["quality"]["score"] + == 1.0 + ) + + +class TestStableProjector: + def test_text_content_is_a_chunk_and_a_third_type_is_not_done(self) -> None: + projector = StableProjector() + assert projector.feed({"type": "widget"}) == [] + assert projector.done is None + assert projector.feed( + {"type": TEXT_MESSAGE_CONTENT, "messageId": "m", "delta": "Hi"} + ) == [{"type": "chunk", "text": "Hi"}] + assert projector.feed({"type": "chunk", "text": "nope"}) == [] + assert ( + projector.feed( + {"type": TOOL_CALL_START, "toolCallId": "a", "toolCallName": "search"} + ) + == [] + ) + done = {"type": "done", "response": "Hi", "usage": {"input": 1}} + assert projector.feed(done) == [] + assert projector.done is done + + +class TestConfigAndReader: + async def test_config_stream_stays_chunk_then_done( + self, mock_ld_client: MagicMock + ) -> None: + handler = _handler( + [ + {"type": TEXT_MESSAGE_START, "messageId": "m1"}, + {"type": TEXT_MESSAGE_CONTENT, "messageId": "m1", "delta": "Hi"}, + { + "type": TOOL_CALL_START, + "toolCallId": "a", + "toolCallName": "search", + "parentMessageId": "m1", + }, + {"type": TOOL_CALL_ARGS, "toolCallId": "a", "delta": '{"q":"x"}'}, + {"type": TOOL_CALL_END, "toolCallId": "a"}, + {"type": TEXT_MESSAGE_END, "messageId": "m1"}, + { + "type": TOOL_CALL_RESULT, + "messageId": "tm", + "toolCallId": "a", + "content": "secret", + }, + {"type": "widget", "name": "nope"}, + { + "type": "done", + "output": "Hi", + "usage": {"input_tokens": 3, "output_tokens": 1}, + }, + ] + ) + events = [ + event + async for event in config(key="flag", handler=handler).stream("q", CONTEXT) + ] + assert events == [ + {"type": "chunk", "text": "Hi"}, + { + "type": "done", + "response": "Hi", + "usage": {"input": 3, "output": 1, "total": 4}, + "judge_results": None, + }, + ] + + async def test_reader_brackets_the_subset(self, mock_ld_client: MagicMock) -> None: + handler = _handler( + [ + {"type": "chunk", "text": "Hi"}, + { + "type": "done", + "output": "Hi", + "usage": {"input_tokens": 3, "output_tokens": 1}, + }, + ] + ) + events = [ + event + async for event in stream(config(key="flag", handler=handler), "q", CONTEXT) + ] + assert _types(events) == [ + RUN_STARTED, + TEXT_MESSAGE_START, + TEXT_MESSAGE_CONTENT, + TEXT_MESSAGE_END, + RUN_FINISHED, + ] + assert events[-1]["result"] == "Hi" + assert events[-1]["usage"][0]["provider"] == "TestProvider" + assert events[-1]["usage"][0]["model"] == "gpt-4" + assert events[-1]["usage"][0]["inputTokens"] == 3 + + async def test_a_raised_turn_has_no_run_finished( + self, mock_ld_client: MagicMock + ) -> None: + async def fn(cfg, user_input, tool_handlers, variables, history=None) -> dict: # type: ignore[override] + return {"output": "", "usage": {}} + + async def stream_fn( + cfg, user_input, tool_handlers, variables, history=None + ) -> AsyncGenerator[dict[str, Any], None]: + yield {"type": "chunk", "text": "Hi"} + raise RuntimeError("stream-fail") + yield # pragma: no cover + + handler = ProviderHandler( + fn=fn, + provides_for=("TestProvider", "messages"), + stream_fn=stream_fn, + ) + seen: list[str] = [] + with pytest.raises(RuntimeError, match="stream-fail"): + async for event in stream( + config(key="flag", handler=handler), "q", CONTEXT + ): + seen.append(event["type"]) + assert seen == [RUN_STARTED, TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT] + assert RUN_FINISHED not in seen + + async def test_judge_results_land_on_run_finished( + self, mock_ld_client: MagicMock + ) -> None: + handler = _handler( + [ + {"type": "chunk", "text": "Hi"}, + { + "type": "done", + "output": "Hi", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ] + ) + judged = { + "quality": JudgeResult( + usage=UsageDict(input=1, output=1, total=2), + response="fine", + score=0.5, + ) + } + with patch( + "launchdarkly_ai_server.judges.run_judges", + new=AsyncMock(return_value=judged), + ): + events = [ + event + async for event in stream( + config(key="flag", handler=handler), "q", CONTEXT + ) + ] + assert ( + events[-1]["metadata"]["launchdarkly"]["judgeResults"]["quality"]["score"] + == 0.5 + ) + + async def test_closing_early_does_not_finish_the_run( + self, mock_ld_client: MagicMock + ) -> None: + handler = _handler( + [ + {"type": "chunk", "text": "Hi"}, + {"type": "chunk", "text": " there"}, + { + "type": "done", + "output": "Hi there", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ] + ) + gen = stream(config(key="flag", handler=handler), "q", CONTEXT) + first = await gen.__anext__() + second = await gen.__anext__() + await gen.aclose() + assert first["type"] == RUN_STARTED + assert second["type"] == TEXT_MESSAGE_START From b7a9f3acde08367167f7f839500c799f3e06dac5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 14:21:05 +0000 Subject: [PATCH 08/28] feat(agui): close an unfinished tool call as a failure A handler that raises mid-call can still report the lifecycle without the tool result. Co-authored-by: alexis --- packages/client/src/launchdarkly_ai_server/agui.py | 8 ++++++++ packages/client/tests/test_agui.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/client/src/launchdarkly_ai_server/agui.py b/packages/client/src/launchdarkly_ai_server/agui.py index bdf7ec17..cb750553 100644 --- a/packages/client/src/launchdarkly_ai_server/agui.py +++ b/packages/client/src/launchdarkly_ai_server/agui.py @@ -210,6 +210,14 @@ def tool_result( events.append(event) return events + def fail_open(self) -> list[dict[str, Any]]: + """Close every call that started and did not finish, as a masked failure.""" + events: list[dict[str, Any]] = [] + for call_id in list(self._started): + if call_id not in self._resulted: + events.extend(self.tool_result(call_id, failed=True)) + return events + class StableProjector: """Projects a handler stream onto ``chunk`` then one internal ``done``. diff --git a/packages/client/tests/test_agui.py b/packages/client/tests/test_agui.py index 28ed548b..b104f266 100644 --- a/packages/client/tests/test_agui.py +++ b/packages/client/tests/test_agui.py @@ -141,6 +141,14 @@ def test_unserializable_result_is_a_failed_result(self) -> None: assert events[-1]["content"] == "An error occurred." assert events[-1]["metadata"]["launchdarkly"]["toolFailed"] is True + def test_fail_open_masks_a_call_that_never_finished(self) -> None: + emitter = TurnEmitter(capture_content=False) + emitter.tool_start("a", "search") + events = emitter.fail_open() + assert [event["type"] for event in events] == [TOOL_CALL_END, TOOL_CALL_RESULT] + assert "content" not in events[-1] + assert events[-1]["metadata"]["launchdarkly"]["toolFailed"] is True + def test_a_second_start_for_the_same_id_is_dropped(self) -> None: emitter = TurnEmitter(capture_content=True) assert emitter.tool_start("a", "search") From 82350931d5093ddc4bf5ee63fdc74eaf2f567821 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 14:37:15 +0000 Subject: [PATCH 09/28] feat(opencode-agents): emit the AG-UI text and tool subset OpenCode session events map onto the shared reader. config().stream() still yields chunk then done, and capture_content gates tool payloads. Co-authored-by: alexis --- .../handler.py | 88 +++++++++++++++++-- .../opencode-agents/tests/test_handler.py | 39 ++++++-- 2 files changed, 109 insertions(+), 18 deletions(-) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index f61c5970..68f0f96b 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -2,7 +2,8 @@ The session is a caller-supplied async iterable of OpenCode bus events. This module does not start a process and does not construct an OpenCode SDK client. -The public stream is assistant text as ``chunk``, then one ``done``. +The handler yields the AG-UI text and tool subset, then one ``done``. +``config().stream()`` still yields ``chunk`` then ``done``. """ from __future__ import annotations @@ -16,6 +17,8 @@ _config, _create_handler, ) +from launchdarkly_ai_server.agui import TurnEmitter +from launchdarkly_ai_server.lifecycle import effective_capture_content from launchdarkly_ai_server.sdk_usage import report_usage from launchdarkly_ai_server.utils import number_or_zero @@ -35,11 +38,16 @@ class _Reducer: Text is emitted only for messages ``message.updated`` has marked assistant. A part snapshot is that part's full text so far: a later delta still appends, - and a snapshot that repeats text already emitted adds nothing. Tool parts - are not forwarded. + and a snapshot that repeats text already emitted adds nothing. The first + ``running`` tool state with complete ``state.input`` opens the call. OpenCode + runs the tool. ``state.raw`` is not diffed into argument text. """ - def __init__(self) -> None: + def __init__(self, *, capture_content: bool) -> None: + self._emitter = TurnEmitter(capture_content=capture_content) + self._text_message_id: str | None = None + self._started_tools: set[str] = set() + self._resulted_tools: set[str] = set() self._deltas: list[str] = [] self._roles: dict[str, str] = {} self._held: dict[str, list[tuple[str, str, str]]] = {} @@ -87,7 +95,9 @@ def _accept_text( return [] if role != "assistant": return [] - return self._apply_text(part_id, kind, text) + events = self._begin_message(message_id) + events.extend(self._apply_text(part_id, kind, text)) + return events def _flush_held(self, message_id: str) -> list[dict[str, Any]]: held = self._held.pop(message_id, []) @@ -116,6 +126,19 @@ def _apply_text(self, part_id: str, kind: str, text: str) -> list[dict[str, Any] return [] return self._chunk(suffix) + def _begin_message(self, message_id: str) -> list[dict[str, Any]]: + if self._text_message_id == message_id: + return [] + events = self._emitter.end_text() + self._text_message_id = message_id + return events + + def close(self) -> list[dict[str, Any]]: + events = self._emitter.fail_open() + events.extend(self._emitter.end_text()) + self._text_message_id = None + return events + def _event(self, raw: Mapping[str, Any] | object) -> list[dict[str, Any]]: if not isinstance(raw, Mapping): return [] @@ -127,7 +150,11 @@ def _event(self, raw: Mapping[str, Any] | object) -> list[dict[str, Any]]: return self._delta(properties) if kind == "message.part.updated": part = properties.get("part") - if not isinstance(part, Mapping) or part.get("type") != "text": + if not isinstance(part, Mapping): + return [] + if part.get("type") == "tool": + return self._tool(part) + if part.get("type") != "text": return [] return self._text_snapshot(part) if kind == "message.updated": @@ -163,7 +190,38 @@ def _text_snapshot(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: def _chunk(self, text: str) -> list[dict[str, Any]]: self._deltas.append(text) - return [{"type": "chunk", "text": text}] + return self._emitter.text(text) + + def _tool(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: + state = part.get("state") + if not isinstance(state, Mapping): + return [] + status = state.get("status") + raw_id = part.get("callID") or part.get("id") or "" + call_id = raw_id if isinstance(raw_id, str) else "" + name = part.get("tool") if isinstance(part.get("tool"), str) else "" + if not call_id or status == "pending": + return [] + if status == "running": + if "input" not in state or call_id in self._started_tools or not name: + return [] + self._started_tools.add(call_id) + events = self._emitter.tool_start(call_id, name) + events.extend(self._emitter.tool_end(call_id, arguments=state.get("input"))) + return events + if status not in ("completed", "error") or call_id in self._resulted_tools: + return [] + opened: list[dict[str, Any]] = [] + if call_id not in self._started_tools and name: + self._started_tools.add(call_id) + opened.extend(self._emitter.tool_start(call_id, name)) + opened.extend(self._emitter.tool_end(call_id, arguments=state.get("input"))) + self._resulted_tools.add(call_id) + if status == "error": + opened.extend(self._emitter.tool_result(call_id, failed=True)) + else: + opened.extend(self._emitter.tool_result(call_id, state.get("output"))) + return opened def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: message_id = info.get("id") @@ -172,6 +230,7 @@ def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: if isinstance(message_id, str) and isinstance(role, str): self._roles[message_id] = role if role == "assistant": + events.extend(self._begin_message(message_id)) events.extend(self._flush_held(message_id)) else: self._held.pop(message_id, None) @@ -180,6 +239,14 @@ def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: completed = info.get("time") if not isinstance(completed, Mapping) or "completed" not in completed: return events + completed_time = info.get("time") + if ( + isinstance(completed_time, Mapping) + and "completed" in completed_time + and message_id == self._text_message_id + ): + events.extend(self._emitter.end_text()) + self._text_message_id = None if message_id not in self._usage_ids: tokens = info.get("tokens") if isinstance(tokens, Mapping): @@ -221,7 +288,8 @@ def create_opencode_handler( return a fresh async iterable of OpenCode session events. This function does not start OpenCode and does not construct an OpenCode SDK client. - The stream yields assistant text as ``chunk`` and one ``done``. *capture_content* + The stream yields the AG-UI text and tool subset and one ``done``. + ``config().stream()`` still yields ``chunk`` then ``done``. *capture_content* follows the other handlers: ``None`` reads the process default from ``configure`` when content is read, and ``True`` or ``False`` overrides it. """ @@ -267,9 +335,11 @@ async def _stream_impl( variables: dict[str, Any] | None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: - reducer = _Reducer() + reducer = _Reducer(capture_content=effective_capture_content(capture_content)) async for event in reducer.reduce(_open_events(events)): yield event + for event in reducer.close(): + yield event done: dict[str, Any] = {"type": "done", "output": "".join(reducer.deltas)} usage = reducer.usage_payload() if usage is not None: diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 988ae226..e89028d9 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -173,14 +173,24 @@ async def test_text_around_a_tool_is_chunks_then_done(self) -> None: assistant_completed("m2"), ], ) - assert types_of(events) == ["chunk", "chunk", "done"] - assert [event["text"] for event in events if event["type"] == "chunk"] == [ + assert [ + event["delta"] + for event in events + if event["type"] == "TEXT_MESSAGE_CONTENT" + ] == [ "Looking up", "Done.", ] + start = next(event for event in events if event["type"] == "TOOL_CALL_START") + assert start["toolCallId"] == "c1" + assert start["toolCallName"] == "bash" + assert start["parentMessageId"] + args = next(event for event in events if event["type"] == "TOOL_CALL_ARGS") + assert args["delta"] == '{"command":"ls"}' + result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") + assert result["content"] == "file.txt" assert events[-1]["output"] == "Looking upDone." - assert "file.txt" not in str(events) - assert "toolCallId" not in str(events) + assert types_of(events).count("TOOL_CALL_START") == 1 async def test_a_failed_tool_does_not_leak_into_the_stream(self) -> None: events = await collect( @@ -193,7 +203,10 @@ async def test_a_failed_tool_does_not_leak_into_the_stream(self) -> None: ) ], ) - assert types_of(events) == ["done"] + result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") + assert result["content"] == "An error occurred." + assert result["metadata"] == {"launchdarkly": {"toolFailed": True}} + assert events[-1]["type"] == "done" assert events[-1]["output"] == "" assert "disk on fire" not in str(events) @@ -237,8 +250,12 @@ async def test_a_text_snapshot_does_not_repeat_deltas(self) -> None: assistant_completed("m1"), ], ) - chunks = [event for event in events if event["type"] == "chunk"] - assert chunks == [{"type": "chunk", "text": "Hi"}] + deltas = [ + event["delta"] + for event in events + if event["type"] == "TEXT_MESSAGE_CONTENT" + ] + assert deltas == ["Hi"] assert events[-1]["output"] == "Hi" async def test_a_snapshot_then_later_deltas_append(self) -> None: @@ -262,8 +279,12 @@ async def test_a_snapshot_then_later_deltas_append(self) -> None: assistant_completed("m1"), ], ) - chunks = [event["text"] for event in events if event["type"] == "chunk"] - assert chunks == ["He", "llo"] + deltas = [ + event["delta"] + for event in events + if event["type"] == "TEXT_MESSAGE_CONTENT" + ] + assert deltas == ["He", "llo"] assert events[-1]["output"] == "Hello" async def test_user_text_is_not_the_assistant_reply(self) -> None: From d4fb9b1e4e586969217529bb8bad94502907f935 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 16:00:45 +0000 Subject: [PATCH 10/28] fix(opencode-agents): hold tool events until the message role is known A tool part that arrives before message.updated stays with the held text. Assistant messages flush text first, so the call can name its parent. User messages drop the call. Co-authored-by: alexis --- .../handler.py | 23 ++++++++++++++- .../opencode-agents/tests/test_handler.py | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 68f0f96b..36fa3afb 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -48,6 +48,7 @@ def __init__(self, *, capture_content: bool) -> None: self._text_message_id: str | None = None self._started_tools: set[str] = set() self._resulted_tools: set[str] = set() + self._held_tools: dict[str, list[Mapping[str, Any]]] = {} self._deltas: list[str] = [] self._roles: dict[str, str] = {} self._held: dict[str, list[tuple[str, str, str]]] = {} @@ -134,7 +135,13 @@ def _begin_message(self, message_id: str) -> list[dict[str, Any]]: return events def close(self) -> list[dict[str, Any]]: - events = self._emitter.fail_open() + events: list[dict[str, Any]] = [] + for message_id, parts in list(self._held_tools.items()): + if self._roles.get(message_id) is None: + for part in parts: + events.extend(self._emit_tool(part)) + self._held_tools.clear() + events.extend(self._emitter.fail_open()) events.extend(self._emitter.end_text()) self._text_message_id = None return events @@ -193,6 +200,17 @@ def _chunk(self, text: str) -> list[dict[str, Any]]: return self._emitter.text(text) def _tool(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: + message_id = part.get("messageID") + if isinstance(message_id, str): + role = self._roles.get(message_id) + if role is None: + self._held_tools.setdefault(message_id, []).append(part) + return [] + if role != "assistant": + return [] + return self._emit_tool(part) + + def _emit_tool(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: state = part.get("state") if not isinstance(state, Mapping): return [] @@ -232,8 +250,11 @@ def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: if role == "assistant": events.extend(self._begin_message(message_id)) events.extend(self._flush_held(message_id)) + for part in self._held_tools.pop(message_id, []): + events.extend(self._emit_tool(part)) else: self._held.pop(message_id, None) + self._held_tools.pop(message_id, None) if role != "assistant" or not isinstance(message_id, str): return events completed = info.get("time") diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index e89028d9..4986f2e1 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -210,6 +210,35 @@ async def test_a_failed_tool_does_not_leak_into_the_stream(self) -> None: assert events[-1]["output"] == "" assert "disk on fire" not in str(events) + async def test_a_tool_before_the_role_waits_for_the_assistant_message(self) -> None: + events = await collect( + [ + tool_part("c1", "running", raw_input={"command": "ls"}), + text_delta("m1", "p1", "Looking"), + assistant_open("m1"), + tool_part("c1", "completed", output="file.txt"), + assistant_completed("m1"), + ] + ) + start = next(event for event in events if event["type"] == "TOOL_CALL_START") + text = next(event for event in events if event["type"] == "TEXT_MESSAGE_START") + assert start["parentMessageId"] == text["messageId"] + assert events.index(text) < events.index(start) + + async def test_a_user_message_tool_is_not_emitted(self) -> None: + events = await collect( + [ + tool_part( + "c1", "running", message_id="u1", raw_input={"command": "ls"} + ), + bus( + "message.updated", + info={"id": "u1", "role": "user", "time": {"created": 1}}, + ), + ] + ) + assert not any(event["type"].startswith("TOOL_CALL") for event in events) + async def test_pending_tool_state_emits_nothing(self) -> None: events = await collect( [tool_part("c1", "pending", raw_input={}, output=None)], From ae29f0f630cd2b3995037c7d7261194ee06b2a08 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 16:32:09 +0000 Subject: [PATCH 11/28] fix(opencode-agents): detach unlabeled tools from the open message close() emitted a tool whose role never arrived while an assistant message was still open, so tool_start copied that message into parentMessageId. End the text message first. Co-authored-by: alexis --- .../launchdarkly_ai_opencode_agents/handler.py | 7 +++++-- packages/opencode-agents/tests/test_handler.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 36fa3afb..2e03e27d 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -136,14 +136,17 @@ def _begin_message(self, message_id: str) -> list[dict[str, Any]]: def close(self) -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] + # End the open assistant message before an unlabeled tool. Otherwise + # tool_start copies that message into parentMessageId, and fail_open + # then marks the call failed while it is still nested there. + events.extend(self._emitter.end_text()) + self._text_message_id = None for message_id, parts in list(self._held_tools.items()): if self._roles.get(message_id) is None: for part in parts: events.extend(self._emit_tool(part)) self._held_tools.clear() events.extend(self._emitter.fail_open()) - events.extend(self._emitter.end_text()) - self._text_message_id = None return events def _event(self, raw: Mapping[str, Any] | object) -> list[dict[str, Any]]: diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 4986f2e1..a0741e95 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -225,6 +225,23 @@ async def test_a_tool_before_the_role_waits_for_the_assistant_message(self) -> N assert start["parentMessageId"] == text["messageId"] assert events.index(text) < events.index(start) + async def test_an_unlabeled_tool_does_not_inherit_an_open_message(self) -> None: + events = await collect( + [ + assistant_open("m1"), + text_delta("m1", "p1", "Hi"), + tool_part( + "c1", "running", message_id="m2", raw_input={"command": "ls"} + ), + ] + ) + text = next(event for event in events if event["type"] == "TEXT_MESSAGE_START") + start = next(event for event in events if event["type"] == "TOOL_CALL_START") + assert "parentMessageId" not in start + ended = next(event for event in events if event["type"] == "TEXT_MESSAGE_END") + assert events.index(ended) < events.index(start) + assert ended["messageId"] == text["messageId"] + async def test_a_user_message_tool_is_not_emitted(self) -> None: events = await collect( [ From f1fd88011a075cd19ec95f0a7db6c005d7a18c9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 18:35:59 +0000 Subject: [PATCH 12/28] fix(opencode-agents): keep a later message intact when an earlier one completes A completion for an earlier assistant message was closing the message still being read. Capture-off now has a test that tool payloads stay out of the stream. Co-authored-by: alexis --- .../handler.py | 21 +++++---- .../opencode-agents/tests/test_handler.py | 46 +++++++++++++++++++ 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 2e03e27d..be75b8be 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -251,7 +251,15 @@ def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: if isinstance(message_id, str) and isinstance(role, str): self._roles[message_id] = role if role == "assistant": - events.extend(self._begin_message(message_id)) + completed_time = info.get("time") + finished = ( + isinstance(completed_time, Mapping) + and "completed" in completed_time + ) + # A completion for an earlier message must not close the + # message the consumer is reading now. + if not finished or message_id == self._text_message_id: + events.extend(self._begin_message(message_id)) events.extend(self._flush_held(message_id)) for part in self._held_tools.pop(message_id, []): events.extend(self._emit_tool(part)) @@ -260,15 +268,10 @@ def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: self._held_tools.pop(message_id, None) if role != "assistant" or not isinstance(message_id, str): return events - completed = info.get("time") - if not isinstance(completed, Mapping) or "completed" not in completed: - return events completed_time = info.get("time") - if ( - isinstance(completed_time, Mapping) - and "completed" in completed_time - and message_id == self._text_message_id - ): + if not isinstance(completed_time, Mapping) or "completed" not in completed_time: + return events + if message_id == self._text_message_id: events.extend(self._emitter.end_text()) self._text_message_id = None if message_id not in self._usage_ids: diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index a0741e95..f7974639 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -225,6 +225,52 @@ async def test_a_tool_before_the_role_waits_for_the_assistant_message(self) -> N assert start["parentMessageId"] == text["messageId"] assert events.index(text) < events.index(start) + async def test_a_late_completion_does_not_split_the_open_message(self) -> None: + events = await collect( + [ + assistant_open("m1"), + text_delta("m1", "p1", "A"), + assistant_open("m2"), + text_delta("m2", "p2", "B1"), + assistant_completed("m1"), + text_delta("m2", "p3", "B2"), + assistant_completed("m2"), + ] + ) + texts = [ + event["delta"] + for event in events + if event["type"] == "TEXT_MESSAGE_CONTENT" + ] + assert texts == ["A", "B1", "B2"] + message_ids = [ + event["messageId"] + for event in events + if event["type"] == "TEXT_MESSAGE_CONTENT" + ] + assert message_ids[1] == message_ids[2] + assert message_ids[0] != message_ids[1] + + async def test_capture_off_hides_tool_content(self) -> None: + built = create_opencode_handler( + capture_content=False, + events=source( + [ + assistant_open("m1"), + tool_part("c1", "running", raw_input={"command": "ls"}), + tool_part("c1", "completed", output="file.txt"), + assistant_completed("m1"), + ] + ), + ) + gen = await built.stream({}, "hi", None, None) + events = [event async for event in gen] + assert any(event["type"] == "TOOL_CALL_START" for event in events) + assert not any(event["type"] == "TOOL_CALL_ARGS" for event in events) + rendered = str(events) + assert "ls" not in rendered + assert "file.txt" not in rendered + async def test_an_unlabeled_tool_does_not_inherit_an_open_message(self) -> None: events = await collect( [ From 62593975f3cf2651b63a4a34e6040f59fa0a4c88 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 18:41:51 +0000 Subject: [PATCH 13/28] fix(opencode-agents): drop stale held parts on late completion Co-authored-by: alexis --- .../handler.py | 21 +++++++++++++------ .../opencode-agents/tests/test_handler.py | 17 ++++++++++++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index be75b8be..aac701c8 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -256,13 +256,22 @@ def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: isinstance(completed_time, Mapping) and "completed" in completed_time ) - # A completion for an earlier message must not close the - # message the consumer is reading now. - if not finished or message_id == self._text_message_id: + late_completion = ( + finished + and self._text_message_id is not None + and message_id != self._text_message_id + ) + # Held parts for an earlier completed message cannot be + # inserted without either merging them into the current + # message or splitting that message. Drop those stale parts. + if late_completion: + self._held.pop(message_id, None) + self._held_tools.pop(message_id, None) + else: events.extend(self._begin_message(message_id)) - events.extend(self._flush_held(message_id)) - for part in self._held_tools.pop(message_id, []): - events.extend(self._emit_tool(part)) + events.extend(self._flush_held(message_id)) + for part in self._held_tools.pop(message_id, []): + events.extend(self._emit_tool(part)) else: self._held.pop(message_id, None) self._held_tools.pop(message_id, None) diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index f7974639..675a38dd 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -232,8 +232,17 @@ async def test_a_late_completion_does_not_split_the_open_message(self) -> None: text_delta("m1", "p1", "A"), assistant_open("m2"), text_delta("m2", "p2", "B1"), + text_delta("m3", "p3", "stale"), + tool_part( + "c3", + "completed", + message_id="m3", + raw_input={"command": "old"}, + output="old.txt", + ), + assistant_completed("m3"), assistant_completed("m1"), - text_delta("m2", "p3", "B2"), + text_delta("m2", "p4", "B2"), assistant_completed("m2"), ] ) @@ -250,6 +259,12 @@ async def test_a_late_completion_does_not_split_the_open_message(self) -> None: ] assert message_ids[1] == message_ids[2] assert message_ids[0] != message_ids[1] + assert "stale" not in texts + assert not any( + event.get("toolCallId") == "c3" + for event in events + if event["type"].startswith("TOOL_CALL") + ) async def test_capture_off_hides_tool_content(self) -> None: built = create_opencode_handler( From 449d8712f9381a4e6f8be499bf60593169b4bcff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 18:55:02 +0000 Subject: [PATCH 14/28] fix(opencode-agents): pass the prompt into the session events receives the config, input, variables, history, and session id. Other sessions are dropped and session.idle ends the run. A judged config must set skip_judges=True. The run opens an invoke_agent span. --- packages/opencode-agents/README.md | 40 ++- packages/opencode-agents/agents.md | 36 ++- packages/opencode-agents/pyproject.toml | 1 + .../handler.py | 227 +++++++++++--- .../launchdarkly_ai_opencode_agents/spans.py | 146 +++++++++ .../opencode-agents/tests/test_handler.py | 278 +++++++++++++++++- .../opencode-agents/tests/test_sdk_usage.py | 91 ++++++ tests/test_cross_handler_parity.py | 1 + uv.lock | 6 +- 9 files changed, 759 insertions(+), 67 deletions(-) create mode 100644 packages/opencode-agents/src/launchdarkly_ai_opencode_agents/spans.py create mode 100644 packages/opencode-agents/tests/test_sdk_usage.py diff --git a/packages/opencode-agents/README.md b/packages/opencode-agents/README.md index fff1a306..4fba15f0 100644 --- a/packages/opencode-agents/README.md +++ b/packages/opencode-agents/README.md @@ -9,11 +9,43 @@ pip install launchdarkly-ai-server launchdarkly-server-sdk launchdarkly-ai-openc ``` ```python +import asyncio +from collections.abc import AsyncIterator + from launchdarkly_ai_opencode_agents import create_opencode_handler -from launchdarkly_ai_server import config +from launchdarkly_ai_server import config, init_client, shutdown + + +async def session_events(config, user_input, variables, history, session_id): + """Subscribe to session_id, send user_input, and stop at session.idle.""" + del config, variables, history + yield {"type": "message.updated", "properties": {"info": {"id": "m1", "role": "assistant", "time": {"created": 1}}}} + yield { + "type": "message.part.delta", + "properties": { + "sessionID": session_id, + "messageID": "m1", + "partID": "p1", + "field": "text", + "delta": "Hello", + }, + } + yield {"type": "session.idle", "properties": {"sessionID": session_id}} + -handler = create_opencode_handler(events=session_events) -config(key="my-agent", handler=handler).stream("Hello", {"kind": "user", "key": "user-1"}) +async def main() -> None: + await init_client() + handler = create_opencode_handler(events=session_events, session_id="ses") + async for event in config( + key="my-agent", handler=handler, skip_judges=True + ).stream("Hello", {"kind": "user", "key": "user-1"}): + print(event) + await shutdown() + + +asyncio.run(main()) ``` -`events` is called on every invocation and must return a fresh async iterable of OpenCode session events. `config().stream()` yields `chunk` then `done`. +`events` is required. Each invocation calls `events(config, user_input, variables, history, session_id)` and the return value must be a fresh async iterable of that session only. The iterable must end: return it after `session.idle`, or close it yourself. `config().stream()` yields `chunk` then `done`. + +Pass `skip_judges=True`. This handler matches every agent-mode provider, including a judge, and it does not run the judge prompt. diff --git a/packages/opencode-agents/agents.md b/packages/opencode-agents/agents.md index 17af5d0b..e82939f4 100644 --- a/packages/opencode-agents/agents.md +++ b/packages/opencode-agents/agents.md @@ -6,6 +6,38 @@ provides_for = ('*', 'agent') The handler reduces OpenCode session events the caller supplies. It does not import or construct `anomalyco/opencode-sdk-python`, and it does not start an OpenCode process. -`create_opencode_handler(*, capture_content=None, events=None)` returns a `ProviderHandler`. `events` is called per invocation and returns an async iterable of bus events (`message.part.delta`, `message.part.updated`, `message.updated`, `session.error`). +This package is Python-only. There is no JS counterpart in this slice. The spec section, API surface list, and `usage-ids.txt` row live in `launchdarkly/ai-sdks-monorepo`, which is not this repository. The usage helper ids are `opencode-agents.createOpenCodeHandler` and `opencode-agents.opencodeAgents`. The package is a normal pre-v1 package, same as the other handlers. -Assistant text is emitted only after `message.updated` marks that message `role == "assistant"`. A part snapshot is the full text of that part so far; a later delta appends. The public stream is `chunk` then `done`. +## File map + +| File | Role | +| --- | --- | +| `handler.py` | Reducer, `create_opencode_handler`, `opencode_agents` | +| `spans.py` | `invoke_agent` root, and the `chat` / `execute_tool` constructors the parity checks call | +| `_version.py` | Package name and version sent with usage events | + +## Dependencies + +`launchdarkly-ai-server` and `opentelemetry-api`. `launchdarkly-server-sdk` is what `init_client()` loads for a normal process. No OpenCode SDK. + +## init_client + +Call `init_client()` before `config()`, the same way as the other handlers. The example in `README.md` does. + +## OTel + +A run opens one `invoke_agent` span and sets `launchdarkly.operation.type`. Tool parts are not forwarded and do not open `execute_tool` spans. `config().stream()` is `chunk` then `done`. + +## Call shape + +`create_opencode_handler(*, events, session_id=None, capture_content=None)` returns a `ProviderHandler`. `events` is required. Each invocation calls `events(config, user_input, variables, history, session_id)` and expects a fresh async iterable. + +Assistant text is emitted only after `message.updated` marks that message `role == "assistant"`. A part snapshot is the full text of that part so far; a later delta appends. A text delta without `partID` is dropped. + +## Common pitfalls + +The AI Config, user input, variables, and history are arguments to `events`. A callable that ignores them never sees the prompt. + +`provides_for` is `('*', 'agent')`, so a judge is routed here too. Pass `skip_judges=True` until this handler runs the prompt it is given. Otherwise `events` runs again on the judge prompt and the score is wrong. + +The reducer keeps one session. Pass `session_id`, or the first `sessionID` on an event wins. Other sessions are dropped, including their `session.error`. `session.idle` for this session ends the run. If the iterable is a server-wide stream and you neither send `session.idle` nor close it, `done` never arrives. Filter and terminate the iterable in `events` when you own the subscription. diff --git a/packages/opencode-agents/pyproject.toml b/packages/opencode-agents/pyproject.toml index 5e55d11d..cd65e988 100644 --- a/packages/opencode-agents/pyproject.toml +++ b/packages/opencode-agents/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" requires-python = ">=3.12" dependencies = [ "launchdarkly-ai-server", + "opentelemetry-api>=1.25", ] description = "OpenCode agent handler for LaunchDarkly AI SDK" readme = "README.md" diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index f61c5970..39b3103f 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -13,20 +13,40 @@ from launchdarkly_ai_server import ( AiConfigRep, ProviderHandler, + SpanMessage, + SpanUsage, _config, _create_handler, + add_cached_tokens_to_input, + end_span_once, + set_input_content_attributes, + set_output_content_attributes, + text_message, ) +from launchdarkly_ai_server.lifecycle import effective_capture_content from launchdarkly_ai_server.sdk_usage import report_usage from launchdarkly_ai_server.utils import number_or_zero from ._version import PACKAGE_NAME, __version__ +from .spans import fail_span, finish_root_span, mark_ok, start_root_span + +Events = Callable[ + [ + AiConfigRep, + str | None, + dict[str, Any] | None, + list[dict[str, Any]] | None, + str | None, + ], + AsyncIterable[Mapping[str, Any]], +] class OpenCodeSessionError(Exception): """The OpenCode session failed. - The provider error body is not included. A failed session does not finish - the run. + The provider error body is not included. The error ``name`` is, when the + event has one. A failed session does not finish the run. """ @@ -35,11 +55,17 @@ class _Reducer: Text is emitted only for messages ``message.updated`` has marked assistant. A part snapshot is that part's full text so far: a later delta still appends, - and a snapshot that repeats text already emitted adds nothing. Tool parts - are not forwarded. + and a snapshot that repeats text already emitted adds nothing. A text delta + without ``partID`` is dropped, because the snapshot of that part cannot be + matched to it. Tool parts are not forwarded. + + Events whose ``sessionID`` is not this session are ignored. The first + ``sessionID`` seen is the session when the caller did not pass one. A + ``session.idle`` for this session ends the iterable. """ - def __init__(self) -> None: + def __init__(self, session_id: str | None) -> None: + self._session_id = session_id self._deltas: list[str] = [] self._roles: dict[str, str] = {} self._held: dict[str, list[tuple[str, str, str]]] = {} @@ -73,8 +99,20 @@ async def reduce( self, events: AsyncIterable[Mapping[str, Any]] ) -> AsyncGenerator[dict[str, Any], None]: async for raw in events: - if isinstance(raw, Mapping) and raw.get("type") == "session.error": - raise OpenCodeSessionError("The OpenCode session failed.") + if not isinstance(raw, Mapping): + continue + kind = raw.get("type") + properties = raw.get("properties") + session_id = _session_id(properties) + if session_id is not None: + if self._session_id is None: + self._session_id = session_id + elif session_id != self._session_id: + continue + if kind == "session.idle": + return + if kind == "session.error": + raise OpenCodeSessionError(_session_error_message(properties)) for event in self._event(raw): yield event @@ -98,27 +136,22 @@ def _flush_held(self, message_id: str) -> list[dict[str, Any]]: def _apply_text(self, part_id: str, kind: str, text: str) -> list[dict[str, Any]]: if kind == "delta": - if part_id: - self._part_text[part_id] = self._part_text.get(part_id, "") + text + self._part_text[part_id] = self._part_text.get(part_id, "") + text return self._chunk(text) already = self._part_text.get(part_id, "") if text.startswith(already): suffix = text[len(already) :] - if part_id: - self._part_text[part_id] = text + self._part_text[part_id] = text elif not already: suffix = text - if part_id: - self._part_text[part_id] = text + self._part_text[part_id] = text else: return [] if not suffix: return [] return self._chunk(suffix) - def _event(self, raw: Mapping[str, Any] | object) -> list[dict[str, Any]]: - if not isinstance(raw, Mapping): - return [] + def _event(self, raw: Mapping[str, Any]) -> list[dict[str, Any]]: kind = raw.get("type") properties = raw.get("properties") if not isinstance(properties, Mapping): @@ -141,12 +174,16 @@ def _delta(self, properties: Mapping[str, Any]) -> list[dict[str, Any]]: return [] delta = properties.get("delta") message_id = properties.get("messageID") - if not isinstance(delta, str) or not delta or not isinstance(message_id, str): - return [] part_id = properties.get("partID") - return self._accept_text( - message_id, part_id if isinstance(part_id, str) else "", "delta", delta - ) + if ( + not isinstance(delta, str) + or not delta + or not isinstance(message_id, str) + or not isinstance(part_id, str) + or not part_id + ): + return [] + return self._accept_text(message_id, part_id, "delta", delta) def _text_snapshot(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: message_id = part.get("messageID") @@ -198,12 +235,54 @@ def _add_usage(self, tokens: Mapping[str, Any]) -> None: self._cache_write += number_or_zero(cache.get("write")) +def _session_id(properties: object) -> str | None: + if not isinstance(properties, Mapping): + return None + session_id = properties.get("sessionID") + if isinstance(session_id, str) and session_id: + return session_id + return None + + +def _session_error_message(properties: object) -> str: + name = None + if isinstance(properties, Mapping): + error = properties.get("error") + if isinstance(error, Mapping): + raw_name = error.get("name") + if isinstance(raw_name, str) and raw_name: + name = raw_name + if name is None: + return "The OpenCode session failed." + return f"The OpenCode session failed ({name})." + + +def _input_messages( + user_input: str | None, + history: list[dict[str, Any]] | None, +) -> list[SpanMessage]: + messages: list[SpanMessage] = [] + for item in history or []: + if not isinstance(item, dict): + continue + role = item.get("role") + content = item.get("content") + if isinstance(role, str) and role and isinstance(content, str): + messages.append(text_message(role, content)) + if isinstance(user_input, str): + messages.append(text_message("user", user_input)) + return messages + + def _open_events( - events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None, + events: Events, + config: AiConfigRep, + user_input: str | None, + variables: dict[str, Any] | None, + history: list[dict[str, Any]] | None, + session_id: str | None, ) -> AsyncIterable[Mapping[str, Any]]: - if events is None: - raise OpenCodeSessionError("The OpenCode handler has no session event source.") - produced = events() + produced = events(config, user_input, variables, history, session_id) if not hasattr(produced, "__aiter__"): raise TypeError("OpenCode event source must return an async iterable.") return produced @@ -211,22 +290,29 @@ def _open_events( def create_opencode_handler( *, + events: Events, + session_id: str | None = None, capture_content: bool | None = None, - events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None = None, ) -> ProviderHandler: """Return a handler that runs an OpenCode session supplied by *events*. ``provides_for`` is ``('*', 'agent')``, so the handler matches an agent-mode - variation for any provider. *events* is called on every invocation and must - return a fresh async iterable of OpenCode session events. This function does - not start OpenCode and does not construct an OpenCode SDK client. - - The stream yields assistant text as ``chunk`` and one ``done``. *capture_content* - follows the other handlers: ``None`` reads the process default from - ``configure`` when content is read, and ``True`` or ``False`` overrides it. + variation for any provider. *events* is called on every invocation as + ``events(config, user_input, variables, history, session_id)`` and must + return a fresh async iterable of that session's bus events. This function + does not start OpenCode and does not construct an OpenCode SDK client. + + The stream yields assistant text as ``chunk`` and one ``done``. Pass + ``skip_judges=True`` to ``config()``: a judge would otherwise be routed + here and *events* would run again on the judge prompt. *capture_content* + follows the other handlers. """ report_usage("opencode-agents.createOpenCodeHandler", PACKAGE_NAME, __version__) - return _create_opencode_handler(capture_content=capture_content, events=events) + return _create_opencode_handler( + events=events, + session_id=session_id, + capture_content=capture_content, + ) def opencode_agents( @@ -237,19 +323,23 @@ def opencode_agents( ) -> Any: """Convenience wrapper: creates a handler and calls ``config(...).invoke()``. - *events* is the session event source, the same callable - :func:`create_opencode_handler` takes. This does not construct an OpenCode - SDK client. + *events* is required and is the same callable :func:`create_opencode_handler` + takes. *variables*, *history*, *session_id*, and *capture_content* configure + that call. This does not construct an OpenCode SDK client. Pass + ``skip_judges=True`` so a judged config does not run *events* again. """ report_usage("opencode-agents.opencodeAgents", PACKAGE_NAME, __version__) variables = kwargs.pop("variables", None) history = kwargs.pop("history", None) capture_content = kwargs.pop("capture_content", None) - events = kwargs.pop("events", None) + session_id = kwargs.pop("session_id", None) + events = kwargs.pop("events") return _config( key=config_key, handler=_create_opencode_handler( - capture_content=capture_content, events=events + events=events, + session_id=session_id, + capture_content=capture_content, ), **kwargs, ).invoke(user_input, context, variables, history) @@ -257,9 +347,13 @@ def opencode_agents( def _create_opencode_handler( *, + events: Events, + session_id: str | None, capture_content: bool | None, - events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None, ) -> ProviderHandler: + content_override = capture_content + bound_session = session_id + async def _stream_impl( config: AiConfigRep, user_input: str | None, @@ -267,14 +361,49 @@ async def _stream_impl( variables: dict[str, Any] | None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: - reducer = _Reducer() - async for event in reducer.reduce(_open_events(events)): - yield event - done: dict[str, Any] = {"type": "done", "output": "".join(reducer.deltas)} - usage = reducer.usage_payload() - if usage is not None: - done["usage"] = usage - yield done + capture = effective_capture_content(content_override) + span = start_root_span(config, variables or {}) + ended: set[int] = set() + reducer = _Reducer(bound_session) + try: + instructions = ( + config.get("instructions") if isinstance(config, dict) else None + ) + set_input_content_attributes( + span, + capture, + system_instructions=instructions + if isinstance(instructions, str) + else None, + messages=_input_messages(user_input, history), + ) + async for event in reducer.reduce( + _open_events( + events, config, user_input, variables, history, bound_session + ) + ): + yield event + output = "".join(reducer.deltas) + if output: + set_output_content_attributes( + span, capture, [text_message("assistant", output)] + ) + usage = reducer.usage_payload() + finish_root_span( + span, + config, + add_cached_tokens_to_input(usage) if usage is not None else SpanUsage(), + ) + mark_ok(span) + done: dict[str, Any] = {"type": "done", "output": output} + if usage is not None: + done["usage"] = usage + yield done + except Exception as exc: + fail_span(span, exc, ended) + raise + finally: + end_span_once(span, ended) async def _call_impl( config: AiConfigRep, @@ -299,5 +428,5 @@ async def _call_impl( ("*", "agent"), _call_impl, _stream_impl, - capture_content=capture_content, + capture_content=content_override, ) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/spans.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/spans.py new file mode 100644 index 00000000..b03e9b17 --- /dev/null +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/spans.py @@ -0,0 +1,146 @@ +"""Span construction for the OpenCode handler. + +The public stream is ``chunk`` then ``done``. This module opens the +``invoke_agent`` root the other handlers open, plus the ``chat`` and +``execute_tool`` constructors the shared parity checks call. The reducer does +not emit tool events, so a run only starts the root. +""" + +from __future__ import annotations + +from typing import Any + +from launchdarkly_ai_server import ( + AiConfigRep, + SpanUsage, + set_ld_span_attributes, + set_model_identity_attributes, + set_usage_span_attributes, +) + +try: + from opentelemetry import trace + from opentelemetry.trace import StatusCode as SpanStatusCode + + _HAS_OTEL = True +except ImportError: # pragma: no cover - exercised when the API package is absent + _HAS_OTEL = False + +TRACER_NAME = "@launchdarkly/ai-opencode-agents" + + +def model_name(config: AiConfigRep) -> str: + if not isinstance(config, dict): + return "" + model = config.get("model") + if not isinstance(model, dict): + return "" + name = model.get("name") + return name if isinstance(name, str) else "" + + +def provider_name(config: AiConfigRep) -> str: + """Who served the model, from the variation, or ``opencode`` when it is blank.""" + if isinstance(config, dict): + provider = config.get("provider") + if isinstance(provider, dict): + name = provider.get("name") + if isinstance(name, str) and name.strip(): + return name.strip().lower() + return "opencode" + + +def start_root_span(config: AiConfigRep, variables: dict[str, Any]) -> Any: + """Opens the ``invoke_agent`` root and returns it, or ``None`` when OTel is absent.""" + if not _HAS_OTEL: + return None + span = trace.get_tracer(TRACER_NAME).start_span("invoke_agent") + span.set_attribute("gen_ai.operation.name", "invoke_agent") + set_model_identity_attributes(span, provider_name(config), model_name(config)) + set_ld_span_attributes(span, variables) + return span + + +def parent_context_of(span: Any) -> Any: + """The context a child span should be parented to.""" + if not _HAS_OTEL or span is None: + return None + return trace.set_span_in_context(span) + + +def start_model_span(config: AiConfigRep, parent: Any) -> Any: + """Opens one ``chat {model}`` span for one model turn.""" + if not _HAS_OTEL: + return None + name = model_name(config) + span = trace.get_tracer(TRACER_NAME).start_span(f"chat {name}", context=parent) + span.set_attribute("gen_ai.operation.name", "chat") + set_model_identity_attributes(span, provider_name(config), name) + return span + + +def start_tool_span(tool_name: str, tool_call_id: str, parent: Any) -> Any: + """Opens one ``execute_tool {name}`` span for one tool call.""" + if not _HAS_OTEL: + return None + span = trace.get_tracer(TRACER_NAME).start_span( + f"execute_tool {tool_name}", context=parent + ) + span.set_attribute("gen_ai.operation.name", "execute_tool") + span.set_attribute("gen_ai.tool.name", tool_name) + span.set_attribute("gen_ai.tool.call.id", tool_call_id) + return span + + +def finish_root_span(span: Any, config: AiConfigRep, usage: SpanUsage) -> None: + """Writes the requested model and token totals onto the root. Does not end it.""" + if span is None: + return + span.set_attribute("gen_ai.response.model", model_name(config)) + set_usage_span_attributes(span, usage) + + +def finish_model_span( + span: Any, + config: AiConfigRep, + usage: SpanUsage, + finish_reason: str | None = None, +) -> None: + """Ends one ``chat`` span successfully.""" + if span is None: + return + span.set_attribute("gen_ai.response.model", model_name(config)) + if finish_reason: + span.set_attribute("gen_ai.response.finish_reasons", [finish_reason]) + set_usage_span_attributes(span, usage) + span.set_status(SpanStatusCode.OK) + span.end() + + +def succeed_span(span: Any) -> None: + """Marks a span OK and ends it.""" + if span is None: + return + span.set_status(SpanStatusCode.OK) + span.end() + + +def mark_ok(span: Any) -> None: + """Marks a span OK without ending it. The streaming ``finally`` owns the end.""" + if span is None: + return + span.set_status(SpanStatusCode.OK) + + +def fail_span(span: Any, error: BaseException, tracker: set[int] | None = None) -> None: + """Records the exception, sets ERROR, and ends the span.""" + if span is None: + return + span.record_exception(error) + span.set_status(SpanStatusCode.ERROR, str(error)) + if tracker is not None: + from launchdarkly_ai_server import end_span_once + + end_span_once(span, tracker) + else: + span.end() diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 988ae226..7ffb14e9 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -12,6 +12,7 @@ from launchdarkly_ai_opencode_agents import ( OpenCodeSessionError, create_opencode_handler, + opencode_agents, ) from launchdarkly_ai_server import SDKOptions, config, configure @@ -34,15 +35,22 @@ def bus(kind: str, **properties: Any) -> dict[str, Any]: return {"type": kind, "properties": properties} -def text_delta(message_id: str, part_id: str, delta: str) -> dict[str, Any]: - return bus( - "message.part.delta", - sessionID="ses", - messageID=message_id, - partID=part_id, - field="text", - delta=delta, - ) +def text_delta( + message_id: str, + part_id: str | None, + delta: str, + *, + session_id: str = "ses", +) -> dict[str, Any]: + properties: dict[str, Any] = { + "sessionID": session_id, + "messageID": message_id, + "field": "text", + "delta": delta, + } + if part_id is not None: + properties["partID"] = part_id + return bus("message.part.delta", **properties) def tool_part( @@ -96,7 +104,14 @@ def assistant_completed( def source(events: list[dict[str, Any]]) -> Any: - async def _events() -> AsyncIterator[dict[str, Any]]: + async def _events( + config: Any, + user_input: str | None, + variables: Any, + history: Any, + session_id: str | None, + ) -> AsyncIterator[dict[str, Any]]: + del config, user_input, variables, history, session_id for event in events: yield event @@ -303,7 +318,12 @@ async def test_session_error_raises_without_the_provider_body(self) -> None: text_delta("m1", "p1", "Hi"), { "type": "session.error", - "properties": {"error": {"data": {"message": "secret-body"}}}, + "properties": { + "error": { + "name": "ProviderAuthError", + "data": {"message": "secret-body"}, + } + }, }, ] ), @@ -312,6 +332,7 @@ async def test_session_error_raises_without_the_provider_body(self) -> None: with pytest.raises(OpenCodeSessionError, match="session failed") as raised: async for event in await handler.stream({}, "hi", None, None): seen.append(event["type"]) + assert "ProviderAuthError" in str(raised.value) assert "secret-body" not in str(raised.value) assert "done" not in seen @@ -371,3 +392,238 @@ async def test_session_error_does_not_yield_stable_done( seen.append(event) assert [event["type"] for event in seen] == ["chunk"] assert seen[0]["text"] == "Hi" + + +class TestInvocation: + def test_events_is_required(self) -> None: + with pytest.raises(TypeError): + create_opencode_handler() # type: ignore[call-arg] + + async def test_events_receives_the_prompt_and_the_session(self) -> None: + seen: dict[str, Any] = {} + + async def events( + config: Any, + user_input: str | None, + variables: Any, + history: Any, + session_id: str | None, + ) -> AsyncIterator[dict[str, Any]]: + seen["instructions"] = config.get("instructions") + seen["user_input"] = user_input + seen["variables"] = variables + seen["history"] = history + seen["session_id"] = session_id + yield assistant_open("m1") + yield text_delta("m1", "p1", "Hi") + yield assistant_completed("m1") + + handler = create_opencode_handler(events=events, session_id="ses") + events_out = [ + event + async for event in await handler.stream( + {"instructions": "Be brief."}, + "Hello", + None, + {"name": "Ada"}, + [{"role": "user", "content": "earlier"}], + ) + ] + assert seen == { + "instructions": "Be brief.", + "user_input": "Hello", + "variables": {"name": "Ada"}, + "history": [{"role": "user", "content": "earlier"}], + "session_id": "ses", + } + assert events_out[-1]["output"] == "Hi" + + async def test_other_sessions_are_ignored_and_idle_ends_the_run(self) -> None: + events = await collect( + [ + assistant_open("m1"), + text_delta("m1", "p1", "Hi", session_id="ses"), + text_delta("m9", "p9", " OTHER", session_id="other"), + { + "type": "session.error", + "properties": { + "sessionID": "other", + "error": {"name": "OtherError", "data": {"message": "nope"}}, + }, + }, + text_delta("m1", "p1", "!", session_id="ses"), + bus("session.idle", sessionID="ses"), + text_delta("m1", "p1", "AFTER", session_id="ses"), + ], + ) + assert events[-1]["output"] == "Hi!" + assert "OTHER" not in events[-1]["output"] + assert "AFTER" not in events[-1]["output"] + assert "nope" not in str(events) + + async def test_a_delta_without_part_id_is_not_repeated_by_its_snapshot( + self, + ) -> None: + events = await collect( + [ + assistant_open("m1"), + text_delta("m1", None, "Hi"), + bus( + "message.part.updated", + part={"id": "p1", "messageID": "m1", "type": "text", "text": "Hi"}, + ), + assistant_completed("m1"), + ], + ) + chunks = [event["text"] for event in events if event["type"] == "chunk"] + assert chunks == ["Hi"] + assert events[-1]["output"] == "Hi" + + async def test_skip_judges_runs_events_once( + self, mock_ld_client: MagicMock + ) -> None: + judged = { + **VARIATION, + "judgeConfiguration": { + "judges": [{"key": "judge-flag", "samplingRate": 1}] + }, + } + mock_ld_client.variation = AsyncMock(return_value=judged) + calls: list[str | None] = [] + + async def events( + config: Any, + user_input: str | None, + variables: Any, + history: Any, + session_id: str | None, + ) -> AsyncIterator[dict[str, Any]]: + del config, variables, history, session_id + calls.append(user_input) + yield assistant_open("m1") + yield text_delta("m1", "p1", "Hi") + yield assistant_completed("m1") + + handler = create_opencode_handler(events=events, session_id="ses") + skipped = [ + event + async for event in config( + key="flag", handler=handler, skip_judges=True + ).stream("q", CONTEXT) + ] + assert [event["type"] for event in skipped] == ["chunk", "done"] + assert calls == ["q"] + + calls.clear() + await _drain(config(key="flag", handler=handler).stream("q", CONTEXT)) + assert calls[0] == "q" + assert len(calls) == 2 + assert calls[1] != "q" + + async def test_the_wrapper_forwards_variables_history_and_session( + self, mock_ld_client: MagicMock + ) -> None: + seen: dict[str, Any] = {} + + async def events( + config: Any, + user_input: str | None, + variables: Any, + history: Any, + session_id: str | None, + ) -> AsyncIterator[dict[str, Any]]: + seen["user_input"] = user_input + seen["name"] = ( + variables.get("name") if isinstance(variables, dict) else None + ) + seen["history"] = history + seen["session_id"] = session_id + seen["instructions"] = ( + config.get("instructions") if isinstance(config, dict) else None + ) + yield assistant_open("m1") + yield text_delta("m1", "p1", "Hi") + yield assistant_completed("m1") + + result = await opencode_agents( + "flag", + "Hello", + CONTEXT, + events=events, + session_id="ses", + variables={"name": "Ada"}, + history=[{"role": "user", "content": "earlier"}], + skip_judges=True, + ) + assert result.response == "Hi" + assert seen["user_input"] == "Hello" + assert seen["name"] == "Ada" + assert seen["history"] == [{"role": "user", "content": "earlier"}] + assert seen["session_id"] == "ses" + assert seen["instructions"] == "Be brief." + + async def test_a_run_opens_an_invoke_agent_span( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from launchdarkly_ai_opencode_agents import spans as span_module + + class _Span: + def __init__(self, name: str, context: Any = None) -> None: + self.name = name + self.context = context + self.attributes: dict[str, Any] = {} + self.events: list[str] = [] + + def set_attribute(self, key: str, value: Any) -> None: + self.attributes[key] = value + + def add_event( + self, name: str, attributes: dict[str, Any] | None = None + ) -> None: + self.events.append(name) + + def set_status(self, code: Any, description: str | None = None) -> None: + return None + + def record_exception(self, exc: BaseException) -> None: + return None + + def end(self) -> None: + return None + + class _Tracer: + def __init__(self) -> None: + self.spans: list[_Span] = [] + + def get_tracer(self, name: str) -> _Tracer: + return self + + def start_span(self, name: str, context: Any = None) -> _Span: + span = _Span(name, context) + self.spans.append(span) + return span + + def set_span_in_context(self, span: _Span) -> Any: + return span + + tracer = _Tracer() + monkeypatch.setattr(span_module, "trace", tracer) + handler = create_opencode_handler( + events=source( + [ + assistant_open("m1"), + text_delta("m1", "p1", "Hi"), + assistant_completed("m1"), + ] + ) + ) + seen = [event async for event in await handler.stream({}, "hi", None, None)] + assert seen[-1]["output"] == "Hi" + assert tracer.spans[0].name == "invoke_agent" + assert tracer.spans[0].attributes["gen_ai.operation.name"] == "invoke_agent" + assert tracer.spans[0].attributes["launchdarkly.operation.type"] == "gen_ai" + + +async def _drain(stream: Any) -> None: + async for _event in stream: + pass diff --git a/packages/opencode-agents/tests/test_sdk_usage.py b/packages/opencode-agents/tests/test_sdk_usage.py new file mode 100644 index 00000000..1b88f948 --- /dev/null +++ b/packages/opencode-agents/tests/test_sdk_usage.py @@ -0,0 +1,91 @@ +"""Tests for TESTING.md §3.27 — opencode-agents.""" + +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import launchdarkly_ai_server.lifecycle as lifecycle_module +from launchdarkly_ai_opencode_agents import create_opencode_handler, opencode_agents +from launchdarkly_ai_opencode_agents._version import PACKAGE_NAME +from launchdarkly_ai_opencode_agents._version import __version__ as PACKAGE_VERSION +from launchdarkly_ai_server import SDK_INFO_CONTEXT, __version__, init_client, shutdown + +CONTEXT = {"kind": "user", "key": "user-1"} + + +def _fake() -> MagicMock: + client = MagicMock() + client.track = MagicMock() + client.variation = AsyncMock(return_value=None) + client.flush = AsyncMock() + client.close = AsyncMock() + return client + + +async def _events( + config: Any, + user_input: str | None, + variables: Any, + history: Any, + session_id: str | None, +) -> AsyncIterator[dict[str, Any]]: + del config, user_input, variables, history, session_id + if False: + yield {} + + +async def _settle(value: Any) -> None: + if hasattr(value, "__await__"): + try: + await value + except Exception: + return + + +@pytest.fixture +async def installed() -> Any: + await shutdown() + client = _fake() + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + await init_client(client=client) + client.track.reset_mock() + yield client + await shutdown() + + +def _usage(client: MagicMock) -> list[Any]: + return [ + entry + for entry in client.track.call_args_list + if entry.args[0] == "$ld:ai:sdk:usage" + ] + + +async def test_create_handler_sends_one_usage_event(installed: MagicMock) -> None: + create_opencode_handler(events=_events) + calls = _usage(installed) + assert [entry.args[2].get("helper") for entry in calls] == [ + "opencode-agents.createOpenCodeHandler" + ] + assert calls[0].args[1] == SDK_INFO_CONTEXT + assert calls[0].args[2] == { + "aiSdkName": "launchdarkly-ai-server", + "aiSdkVersion": __version__, + "aiSdkLanguage": "python", + "helper": "opencode-agents.createOpenCodeHandler", + "helperPackageName": PACKAGE_NAME, + "helperPackageVersion": PACKAGE_VERSION, + } + assert calls[0].args[3] == 1 + + +async def test_wrapper_sends_one_usage_event(installed: MagicMock) -> None: + await _settle(opencode_agents("k", "q", CONTEXT, events=_events)) + calls = _usage(installed) + assert [entry.args[2].get("helper") for entry in calls] == [ + "opencode-agents.opencodeAgents" + ] + assert calls[0].args[2]["helperPackageName"] == PACKAGE_NAME + assert calls[0].args[2]["helperPackageVersion"] == PACKAGE_VERSION diff --git a/tests/test_cross_handler_parity.py b/tests/test_cross_handler_parity.py index 9ab77678..5669b26b 100644 --- a/tests/test_cross_handler_parity.py +++ b/tests/test_cross_handler_parity.py @@ -41,6 +41,7 @@ "openai-agents": "launchdarkly_ai_openai_agents.spans", "langchain-messages": "launchdarkly_ai_langchain_messages.spans", "langchain-agents": "launchdarkly_ai_langchain_agents.spans", + "opencode-agents": "launchdarkly_ai_opencode_agents.spans", } #: `claude-agents` builds its `chat` span inside an inference tracker rather than in a standalone diff --git a/uv.lock b/uv.lock index 860a08fa..8aebcdd1 100644 --- a/uv.lock +++ b/uv.lock @@ -957,10 +957,14 @@ version = "0.1.0" source = { editable = "packages/opencode-agents" } dependencies = [ { name = "launchdarkly-ai-server" }, + { name = "opentelemetry-api" }, ] [package.metadata] -requires-dist = [{ name = "launchdarkly-ai-server", editable = "packages/client" }] +requires-dist = [ + { name = "launchdarkly-ai-server", editable = "packages/client" }, + { name = "opentelemetry-api", specifier = ">=1.25" }, +] [[package]] name = "launchdarkly-ai-python" From 72708e330ee0a31648d60f363c26499f8657eaeb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 19:08:28 +0000 Subject: [PATCH 15/28] fix(opencode-agents): read nested OpenCode session ids message.updated carries sessionID on info, and message.part.updated carries it on part. A foreign event no longer latches the run. --- .../handler.py | 34 ++++++++++++--- .../opencode-agents/tests/test_handler.py | 43 +++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 39b3103f..e1a575ce 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -103,7 +103,7 @@ async def reduce( continue kind = raw.get("type") properties = raw.get("properties") - session_id = _session_id(properties) + session_id = _session_id(raw) if session_id is not None: if self._session_id is None: self._session_id = session_id @@ -235,12 +235,34 @@ def _add_usage(self, tokens: Mapping[str, Any]) -> None: self._cache_write += number_or_zero(cache.get("write")) -def _session_id(properties: object) -> str | None: - if not isinstance(properties, Mapping): +def _string_id(value: object) -> str | None: + if isinstance(value, str) and value: + return value + return None + + +def _session_id(raw: object) -> str | None: + """Session id from a bus event. + + Deltas carry it on ``properties``. ``message.updated`` carries it on + ``properties.info``, and ``message.part.updated`` on ``properties.part``. + """ + if not isinstance(raw, Mapping): return None - session_id = properties.get("sessionID") - if isinstance(session_id, str) and session_id: - return session_id + properties = raw.get("properties") + if not isinstance(properties, Mapping): + return _string_id(raw.get("sessionID")) + found = _string_id(properties.get("sessionID")) + if found is not None: + return found + info = properties.get("info") + if isinstance(info, Mapping): + found = _string_id(info.get("sessionID")) + if found is not None: + return found + part = properties.get("part") + if isinstance(part, Mapping): + return _string_id(part.get("sessionID")) return None diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 7ffb14e9..01eee754 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -461,6 +461,49 @@ async def test_other_sessions_are_ignored_and_idle_ends_the_run(self) -> None: assert "AFTER" not in events[-1]["output"] assert "nope" not in str(events) + async def test_nested_session_ids_do_not_lock_the_run(self) -> None: + events = await collect( + [ + bus( + "message.updated", + info={ + "id": "m1", + "role": "assistant", + "sessionID": "ses", + "time": {"created": 1}, + }, + ), + text_delta("m1", "p1", "Hi", session_id="ses"), + bus( + "message.part.updated", + part={ + "id": "px", + "messageID": "mx", + "sessionID": "other", + "type": "text", + "text": "FOREIGN", + }, + ), + bus( + "message.updated", + info={ + "id": "mx", + "role": "assistant", + "sessionID": "other", + "time": {"created": 1, "completed": 2}, + "tokens": {"input": 9, "output": 9}, + }, + ), + text_delta("m1", "p1", "!", session_id="ses"), + bus("session.idle", sessionID="ses"), + text_delta("m1", "p1", "AFTER", session_id="ses"), + ], + ) + assert events[-1]["output"] == "Hi!" + assert "FOREIGN" not in events[-1]["output"] + assert "AFTER" not in events[-1]["output"] + assert "usage" not in events[-1] + async def test_a_delta_without_part_id_is_not_repeated_by_its_snapshot( self, ) -> None: From 760d8e3984e68492703a96968522121e57a427ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 19:08:28 +0000 Subject: [PATCH 16/28] fix(opencode-agents): open text before its tool calls Hold a tool until its assistant text is open, so parentMessageId is set. End that text before an unlabeled tool. A completed message is emitted on its own when no AG-UI message is open, and is not merged into one that is. --- .../handler.py | 105 +++++++++++++----- .../opencode-agents/tests/test_handler.py | 44 ++++++++ 2 files changed, 119 insertions(+), 30 deletions(-) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 0fea8859..7a7f2861 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -71,6 +71,7 @@ def __init__(self, *, capture_content: bool, session_id: str | None) -> None: self._emitter = TurnEmitter(capture_content=capture_content) self._session_id = session_id self._text_message_id: str | None = None + self._text_open = False self._held_tools: dict[str, list[Mapping[str, Any]]] = {} self._deltas: list[str] = [] self._roles: dict[str, str] = {} @@ -109,7 +110,7 @@ async def reduce( continue kind = raw.get("type") properties = raw.get("properties") - session_id = _session_id(properties) + session_id = _session_id(raw) if session_id is not None: if self._session_id is None: self._session_id = session_id @@ -133,6 +134,8 @@ def _accept_text( return [] events = self._begin_message(message_id) events.extend(self._apply_text(part_id, kind, text)) + if self._text_open and self._text_message_id == message_id: + events.extend(self._release_tools(message_id)) return events def _flush_held(self, message_id: str) -> list[dict[str, Any]]: @@ -165,16 +168,28 @@ def _apply_text(self, part_id: str, kind: str, text: str) -> list[dict[str, Any] def _begin_message(self, message_id: str) -> list[dict[str, Any]]: if self._text_message_id == message_id: return [] - events = self._emitter.end_text() + events = self._end_open_text() self._text_message_id = message_id return events + def _end_open_text(self) -> list[dict[str, Any]]: + events = self._emitter.end_text() + self._text_message_id = None + self._text_open = False + return events + + def _release_tools(self, message_id: str) -> list[dict[str, Any]]: + parts = self._held_tools.pop(message_id, []) + events: list[dict[str, Any]] = [] + for part in parts: + events.extend(self._emit_tool(part)) + return events + def close(self) -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] - # End the open assistant message before an unlabeled tool. Otherwise - # tool_start copies that message into parentMessageId. - events.extend(self._emitter.end_text()) - self._text_message_id = None + # End the open assistant message before any tool whose role never + # arrived. tool_start copies the open text message into parentMessageId. + events.extend(self._end_open_text()) for message_id, parts in list(self._held_tools.items()): if self._roles.get(message_id) is None: for part in parts: @@ -236,18 +251,26 @@ def _text_snapshot(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: def _chunk(self, text: str) -> list[dict[str, Any]]: self._deltas.append(text) - return self._emitter.text(text) + events = self._emitter.text(text) + if events: + self._text_open = True + return events def _tool(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: message_id = part.get("messageID") - if isinstance(message_id, str): - role = self._roles.get(message_id) - if role is None: - self._held_tools.setdefault(message_id, []).append(part) - return [] - if role != "assistant": - return [] - return self._emit_tool(part) + if not isinstance(message_id, str): + self._held_tools.setdefault("", []).append(part) + return [] + role = self._roles.get(message_id) + if role is None: + self._held_tools.setdefault(message_id, []).append(part) + return [] + if role != "assistant": + return [] + if self._text_open and self._text_message_id == message_id: + return self._emit_tool(part) + self._held_tools.setdefault(message_id, []).append(part) + return [] def _emit_tool(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: state = part.get("state") @@ -286,29 +309,29 @@ def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: if isinstance(message_id, str) and isinstance(role, str): self._roles[message_id] = role if role == "assistant": + # A reserved id is not an open AG-UI message. Only text that + # TurnEmitter has already yielded can take a parent or be split. late_completion = ( - finished - and self._text_message_id is not None - and message_id != self._text_message_id + finished and self._text_open and message_id != self._text_message_id ) - # Held parts for an earlier completed message cannot be - # inserted without merging them into the current message or - # splitting that message. Drop those stale parts. if late_completion: self._held.pop(message_id, None) self._held_tools.pop(message_id, None) else: events.extend(self._begin_message(message_id)) events.extend(self._flush_held(message_id)) - for part in self._held_tools.pop(message_id, []): - events.extend(self._emit_tool(part)) + if self._text_open and self._text_message_id == message_id: + events.extend(self._release_tools(message_id)) + elif finished: + events.extend(self._release_tools(message_id)) else: self._held.pop(message_id, None) self._held_tools.pop(message_id, None) if role != "assistant" or not isinstance(message_id, str) or not finished: return events - if message_id == self._text_message_id: - events.extend(self._emitter.end_text()) + if self._text_message_id == message_id and self._text_open: + events.extend(self._end_open_text()) + elif self._text_message_id == message_id: self._text_message_id = None if message_id not in self._usage_ids: tokens = info.get("tokens") @@ -328,12 +351,34 @@ def _add_usage(self, tokens: Mapping[str, Any]) -> None: self._cache_write += number_or_zero(cache.get("write")) -def _session_id(properties: object) -> str | None: - if not isinstance(properties, Mapping): +def _string_id(value: object) -> str | None: + if isinstance(value, str) and value: + return value + return None + + +def _session_id(raw: object) -> str | None: + """Session id from a bus event. + + Deltas carry it on ``properties``. ``message.updated`` carries it on + ``properties.info``, and ``message.part.updated`` on ``properties.part``. + """ + if not isinstance(raw, Mapping): return None - session_id = properties.get("sessionID") - if isinstance(session_id, str) and session_id: - return session_id + properties = raw.get("properties") + if not isinstance(properties, Mapping): + return _string_id(raw.get("sessionID")) + found = _string_id(properties.get("sessionID")) + if found is not None: + return found + info = properties.get("info") + if isinstance(info, Mapping): + found = _string_id(info.get("sessionID")) + if found is not None: + return found + part = properties.get("part") + if isinstance(part, Mapping): + return _string_id(part.get("sessionID")) return None diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index cb323f86..891ec386 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -225,6 +225,23 @@ async def test_a_failed_tool_masks_the_provider_body(self) -> None: assert events[-1]["output"] == "" assert "disk on fire" not in str(events) + async def test_a_tool_waits_until_assistant_text_is_open(self) -> None: + events = await collect( + [ + tool_part("c1", "running", raw_input={"command": "ls"}), + assistant_open("m1"), + text_delta("m1", "p1", "Looking"), + tool_part("c1", "completed", output="file.txt"), + assistant_completed("m1"), + ] + ) + text = next(event for event in events if event["type"] == "TEXT_MESSAGE_START") + start = next(event for event in events if event["type"] == "TOOL_CALL_START") + assert events.index(text) < events.index(start) + assert start["parentMessageId"] == text["messageId"] + ended = next(event for event in events if event["type"] == "TEXT_MESSAGE_END") + assert ended["messageId"] == text["messageId"] + async def test_a_tool_before_the_role_waits_for_the_assistant_message(self) -> None: events = await collect( [ @@ -280,6 +297,33 @@ async def test_a_late_completion_does_not_split_the_open_message(self) -> None: for event in events if event["type"].startswith("TOOL_CALL") ) + assert any( + event["type"] == "TEXT_MESSAGE_END" and event["messageId"] == message_ids[1] + for event in events + ) + + async def test_a_completed_message_is_emitted_when_nothing_is_open(self) -> None: + events = await collect( + [ + assistant_open("m1"), + text_delta("m2", "p2", "Kept"), + assistant_completed("m2"), + text_delta("m1", "p1", "After"), + assistant_completed("m1"), + ] + ) + contents = [ + event for event in events if event["type"] == "TEXT_MESSAGE_CONTENT" + ] + assert [event["delta"] for event in contents] == ["Kept", "After"] + assert contents[0]["messageId"] != contents[1]["messageId"] + ends = [ + event["messageId"] + for event in events + if event["type"] == "TEXT_MESSAGE_END" + ] + assert ends == [contents[0]["messageId"], contents[1]["messageId"]] + assert events[-1]["output"] == "KeptAfter" async def test_capture_off_hides_tool_content(self) -> None: built = create_opencode_handler( From 0843ab42b697ce94ab5f47b5df2f910822d77e2e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 19:20:48 +0000 Subject: [PATCH 17/28] fix(opencode-agents): record a tool that finishes after its text A completion snapshot for a call that already started is applied immediately. close() applies leftover assistant tools before fail_open can mark them failed. --- .../client/src/launchdarkly_ai_server/agui.py | 4 ++++ .../handler.py | 19 ++++++++++++++----- .../opencode-agents/tests/test_handler.py | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/client/src/launchdarkly_ai_server/agui.py b/packages/client/src/launchdarkly_ai_server/agui.py index cb750553..1340a401 100644 --- a/packages/client/src/launchdarkly_ai_server/agui.py +++ b/packages/client/src/launchdarkly_ai_server/agui.py @@ -135,6 +135,10 @@ def end_text(self) -> list[dict[str, Any]]: self._open = None return [event] + def has_started(self, tool_call_id: str) -> bool: + """True once this call has emitted ``TOOL_CALL_START``.""" + return tool_call_id in self._started + def tool_start( self, tool_call_id: str, tool_call_name: str ) -> list[dict[str, Any]]: diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 7a7f2861..527774e6 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -187,11 +187,14 @@ def _release_tools(self, message_id: str) -> list[dict[str, Any]]: def close(self) -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] - # End the open assistant message before any tool whose role never - # arrived. tool_start copies the open text message into parentMessageId. + # End the open assistant message before leftover tools. tool_start + # copies that message into parentMessageId. Then apply every held + # assistant call, including one that completed after the text closed, + # before fail_open marks a started call as a failure. events.extend(self._end_open_text()) for message_id, parts in list(self._held_tools.items()): - if self._roles.get(message_id) is None: + role = self._roles.get(message_id) + if role is None or role == "assistant": for part in parts: events.extend(self._emit_tool(part)) self._held_tools.clear() @@ -267,10 +270,16 @@ def _tool(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: return [] if role != "assistant": return [] + raw_id = part.get("callID") or part.get("id") or "" + call_id = raw_id if isinstance(raw_id, str) else "" + if call_id and self._emitter.has_started(call_id): + return self._emit_tool(part) if self._text_open and self._text_message_id == message_id: return self._emit_tool(part) - self._held_tools.setdefault(message_id, []).append(part) - return [] + if self._text_message_id == message_id and not self._text_open: + self._held_tools.setdefault(message_id, []).append(part) + return [] + return self._emit_tool(part) def _emit_tool(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: state = part.get("state") diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index c1ac8503..a1ca5f2f 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -225,6 +225,21 @@ async def test_a_failed_tool_masks_the_provider_body(self) -> None: assert events[-1]["output"] == "" assert "disk on fire" not in str(events) + async def test_a_tool_that_finishes_after_the_text_is_not_a_failure(self) -> None: + events = await collect( + [ + assistant_open("m1"), + text_delta("m1", "p1", "Hi"), + tool_part("c1", "running", raw_input={"command": "ls"}), + assistant_completed("m1"), + tool_part("c1", "completed", output="file.txt"), + ] + ) + result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") + assert result["content"] == "file.txt" + assert "metadata" not in result + assert events[-1]["output"] == "Hi" + async def test_a_tool_waits_until_assistant_text_is_open(self) -> None: events = await collect( [ From 1029dc386fca4851a288798c7059eed2c124985a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 19:35:13 +0000 Subject: [PATCH 18/28] fix(opencode-agents): keep a tool off another open message A call whose text is not the one streaming is held until that message ends, so tool_start does not copy the wrong parentMessageId. --- .../handler.py | 4 +++- .../opencode-agents/tests/test_handler.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 527774e6..eaecf8a8 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -276,7 +276,9 @@ def _tool(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: return self._emit_tool(part) if self._text_open and self._text_message_id == message_id: return self._emit_tool(part) - if self._text_message_id == message_id and not self._text_open: + # Another message is streaming, or this message's text has not opened + # yet. Emitting now would parent the call to the wrong message. + if self._text_open or self._text_message_id == message_id: self._held_tools.setdefault(message_id, []).append(part) return [] return self._emit_tool(part) diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index a1ca5f2f..0987b9e8 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -225,6 +225,27 @@ async def test_a_failed_tool_masks_the_provider_body(self) -> None: assert events[-1]["output"] == "" assert "disk on fire" not in str(events) + async def test_a_tool_for_another_message_does_not_attach_to_open_text( + self, + ) -> None: + events = await collect( + [ + assistant_open("m2"), + assistant_open("m1"), + text_delta("m1", "p1", "A"), + tool_part( + "c2", "running", message_id="m2", raw_input={"command": "ls"} + ), + assistant_completed("m1"), + ] + ) + text = next(event for event in events if event["type"] == "TEXT_MESSAGE_START") + start = next(event for event in events if event["type"] == "TOOL_CALL_START") + ended = next(event for event in events if event["type"] == "TEXT_MESSAGE_END") + assert "parentMessageId" not in start + assert events.index(ended) < events.index(start) + assert ended["messageId"] == text["messageId"] + async def test_a_tool_that_finishes_after_the_text_is_not_a_failure(self) -> None: events = await collect( [ From d72e29936c188a7d84645126d757894b1782f339 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 23:11:14 +0000 Subject: [PATCH 19/28] feat(opencode): add an experimental OpenCode handler Reduce OpenCode session events into text and tool events. config().stream() stays chunk then done, so a tool event does not close it. The experimental rich_stream reader is how a caller opts into those events. --- AGENTS.md | 2 +- .../experimental/__init__.py | 4 +- .../experimental/_opencode.py | 373 +++++++++++ .../experimental/_rich_stream.py | 165 +++++ .../experimental/opencode.py | 12 + .../experimental/rich_stream.py | 10 + .../src/launchdarkly_ai_server/tracking.py | 108 +++- packages/client/tests/test_opencode.py | 588 ++++++++++++++++++ release-please-config.json | 6 + 9 files changed, 1253 insertions(+), 15 deletions(-) create mode 100644 packages/client/src/launchdarkly_ai_server/experimental/_opencode.py create mode 100644 packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py create mode 100644 packages/client/src/launchdarkly_ai_server/experimental/opencode.py create mode 100644 packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py create mode 100644 packages/client/tests/test_opencode.py diff --git a/AGENTS.md b/AGENTS.md index dac8348d..2548208c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ The `UP046` rule (PEP 695 `type` statement syntax for generic classes) is **igno PRs are squash-merged with the PR title as the commit message, and release-please builds each package's changelog from those messages. -- **A change to an experimental feature uses that feature's scope:** `skills` (Agent Skills) and `evaluations` (Evaluations from Code). `feat`, `fix` and `perf` commits with one of these scopes are listed under **Experimental** (`changelog-sections` in `release-please-config.json`), for example `feat(skills): watch skills for revocations`. Any other scope, such as `client`, lists the change as a core feature or fix. +- **A change to an experimental feature uses that feature's scope:** `skills` (Agent Skills), `evaluations` (Evaluations from Code), `opencode` (OpenCode handler), and `rich-stream` (experimental AG-UI reader). `feat`, `fix` and `perf` commits with one of these scopes are listed under **Experimental** (`changelog-sections` in `release-please-config.json`), for example `feat(skills): watch skills for revocations`. Any other scope, such as `client`, lists the change as a core feature or fix. - **Use exactly one scope.** `feat(skills,client):` matches no Experimental row, so it lands under Features. - **A PR that changes both core and experimental behaviour** should be split. If it can't be, add a `BEGIN_COMMIT_OVERRIDE` block to its description with one conventional-commit line per change. An override also corrects a merged PR's title, as long as the release PR hasn't merged yet. - **A breaking experimental change** uses `!` while the package is 0.x: `feat(skills)!:` bumps the minor version, which is what an experimental break is allowed. After 1.0, `!` would force a major release. Use `feat(skills):` without `!` instead, and say the change is breaking in the title. diff --git a/packages/client/src/launchdarkly_ai_server/experimental/__init__.py b/packages/client/src/launchdarkly_ai_server/experimental/__init__.py index 80ecfd7d..11472de7 100644 --- a/packages/client/src/launchdarkly_ai_server/experimental/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/experimental/__init__.py @@ -1,7 +1,9 @@ """ Experimental features of the LaunchDarkly AI SDK. -Each feature is a submodule, for example ``launchdarkly_ai_server.experimental.skills``. +Each feature is a submodule, for example ``launchdarkly_ai_server.experimental.skills``, +``launchdarkly_ai_server.experimental.opencode``, and +``launchdarkly_ai_server.experimental.rich_stream``. Names imported from here may change in a minor release, so review the changelog when you upgrade. Names from the package root change only in a major release. """ diff --git a/packages/client/src/launchdarkly_ai_server/experimental/_opencode.py b/packages/client/src/launchdarkly_ai_server/experimental/_opencode.py new file mode 100644 index 00000000..a0c2bc26 --- /dev/null +++ b/packages/client/src/launchdarkly_ai_server/experimental/_opencode.py @@ -0,0 +1,373 @@ +"""Reduce OpenCode session events into handler stream events. + +The session is a caller-supplied async iterable of OpenCode bus events. This +module does not start a process. Tool execution stays inside OpenCode: the +events record a call that already ran. +""" + +from __future__ import annotations + +import json +import uuid +from collections.abc import AsyncGenerator, AsyncIterable, Callable, Mapping +from typing import Any + +from ..types import AiConfigRep, ProviderHandler +from ..utils import _create_handler, number_or_zero + +_TOOL_FAILURE = "An error occurred." + + +class OpenCodeSessionError(Exception): + """The OpenCode session failed. + + The provider error body is not included. A failed session is not a tool + result and does not finish the run. + """ + + +class _Reducer: + """One pass over a session. Repeated snapshots do not emit a second event.""" + + def __init__(self, capture_content: bool) -> None: + self._capture = capture_content + self._open_message: str | None = None + self._deltas: list[str] = [] + self._delta_parts: set[str] = set() + self._snapshot_parts: set[str] = set() + self._tools: dict[str, str] = {} + self._usage_ids: set[str] = set() + self._input = 0 + self._output = 0 + self._cache_read = 0 + self._cache_write = 0 + self._saw_usage = False + self._saw_cache = False + + @property + def deltas(self) -> list[str]: + return self._deltas + + def usage_payload(self) -> dict[str, int] | None: + """Raw usage for ``parse_usage``, or ``None`` when no assistant message completed.""" + if not self._saw_usage: + return None + payload: dict[str, int] = { + "input_tokens": self._input, + "output_tokens": self._output, + } + if self._saw_cache: + payload["cache_read_input_tokens"] = self._cache_read + payload["cache_creation_input_tokens"] = self._cache_write + return payload + + async def reduce( + self, events: AsyncIterable[Mapping[str, Any]] + ) -> AsyncGenerator[dict[str, Any], None]: + async for raw in events: + if isinstance(raw, Mapping) and raw.get("type") == "session.error": + raise OpenCodeSessionError("The OpenCode session failed.") + for event in self._event(raw): + yield event + for event in self._end_open(): + yield event + + def _event(self, raw: Mapping[str, Any] | object) -> list[dict[str, Any]]: + if not isinstance(raw, Mapping): + return [] + kind = raw.get("type") + properties = raw.get("properties") + if not isinstance(properties, Mapping): + return [] + if kind == "message.part.delta": + return self._delta(properties) + if kind == "message.part.updated": + part = properties.get("part") + if not isinstance(part, Mapping): + return [] + if part.get("type") == "tool": + return self._tool(part) + if part.get("type") == "text": + return self._text_snapshot(part) + return [] + if kind == "message.updated": + info = properties.get("info") + if isinstance(info, Mapping): + return self._message(info) + return [] + + def _delta(self, properties: Mapping[str, Any]) -> list[dict[str, Any]]: + if properties.get("field") != "text": + return [] + delta = properties.get("delta") + message_id = properties.get("messageID") + if not isinstance(delta, str) or not delta or not isinstance(message_id, str): + return [] + part_id = properties.get("partID") + if isinstance(part_id, str): + if part_id in self._snapshot_parts: + return [] + self._delta_parts.add(part_id) + return self._text(message_id, delta) + + def _text_snapshot(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: + message_id = part.get("messageID") + text = part.get("text") + part_id = part.get("id") + if ( + not isinstance(message_id, str) + or not isinstance(text, str) + or not text + or not isinstance(part_id, str) + ): + return [] + if part_id in self._delta_parts or part_id in self._snapshot_parts: + return [] + self._snapshot_parts.add(part_id) + return self._text(message_id, text) + + def _text(self, message_id: str, delta: str) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + if self._open_message != message_id: + events.extend(self._end_open()) + self._open_message = message_id + events.append({"type": "TEXT_MESSAGE_START", "messageId": message_id}) + events.append( + { + "type": "TEXT_MESSAGE_CONTENT", + "messageId": message_id, + "delta": delta, + } + ) + self._deltas.append(delta) + return events + + def _end_open(self) -> list[dict[str, Any]]: + if self._open_message is None: + return [] + message_id = self._open_message + self._open_message = None + return [{"type": "TEXT_MESSAGE_END", "messageId": message_id}] + + def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: + if info.get("role") != "assistant": + return [] + completed = info.get("time") + if not isinstance(completed, Mapping) or "completed" not in completed: + return [] + message_id = info.get("id") + events: list[dict[str, Any]] = [] + if isinstance(message_id, str) and message_id == self._open_message: + events.extend(self._end_open()) + if isinstance(message_id, str) and message_id not in self._usage_ids: + tokens = info.get("tokens") + if isinstance(tokens, Mapping): + self._add_usage(tokens) + self._usage_ids.add(message_id) + return events + + def _add_usage(self, tokens: Mapping[str, Any]) -> None: + self._saw_usage = True + self._input += number_or_zero(tokens.get("input")) + self._output += number_or_zero(tokens.get("output")) + cache = tokens.get("cache") + if isinstance(cache, Mapping): + self._saw_cache = True + self._cache_read += number_or_zero(cache.get("read")) + self._cache_write += number_or_zero(cache.get("write")) + + def _tool(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: + state = part.get("state") + if not isinstance(state, Mapping): + return [] + call_id = part.get("callID") or part.get("id") + name = part.get("tool") + if ( + not isinstance(call_id, str) + or not call_id + or not isinstance(name, str) + or not name + ): + return [] + status = state.get("status") + if status not in ("running", "completed", "error"): + return [] + events: list[dict[str, Any]] = [] + if self._tools.get(call_id) is None and ( + "input" in state or status != "running" + ): + events.extend(self._open_tool(call_id, name, state.get("input"))) + self._tools[call_id] = "open" + if status == "completed" and self._tools.get(call_id) == "open": + events.append(self._result(call_id, state.get("output", ""), failed=False)) + self._tools[call_id] = "done" + elif status == "error" and self._tools.get(call_id) == "open": + events.append(self._result(call_id, None, failed=True)) + self._tools[call_id] = "done" + return events + + def _open_tool( + self, call_id: str, name: str, raw_input: object + ) -> list[dict[str, Any]]: + start: dict[str, Any] = { + "type": "TOOL_CALL_START", + "toolCallId": call_id, + "toolCallName": name, + } + if self._open_message is not None: + start["parentMessageId"] = self._open_message + events = [start] + if self._capture: + text = _argument_text(raw_input) + if text: + events.append( + {"type": "TOOL_CALL_ARGS", "toolCallId": call_id, "delta": text} + ) + events.append({"type": "TOOL_CALL_END", "toolCallId": call_id}) + return events + + def _result(self, call_id: str, output: object, *, failed: bool) -> dict[str, Any]: + content = "" + metadata: dict[str, Any] | None = None + if failed: + metadata = {"launchdarkly": {"toolFailed": True}} + if self._capture: + content = _TOOL_FAILURE + else: + rendered = _result_text(output) + if rendered is None: + metadata = {"launchdarkly": {"toolFailed": True}} + if self._capture: + content = _TOOL_FAILURE + elif self._capture: + content = rendered + event: dict[str, Any] = { + "type": "TOOL_CALL_RESULT", + "messageId": str(uuid.uuid4()), + "toolCallId": call_id, + "content": content, + } + if metadata is not None: + event["metadata"] = metadata + return event + + +def _argument_text(value: object) -> str: + """Argument text for one ``TOOL_CALL_ARGS`` delta. Empty when there is nothing to send.""" + if value is None: + return "" + if isinstance(value, str): + return value + rendered = _json_text(value) + return rendered or "" + + +def _result_text(value: object) -> str | None: + """Success content, or ``None`` when the value cannot be placed on the event.""" + if isinstance(value, str): + return value + return _json_text(value) + + +def _json_text(value: object) -> str | None: + if isinstance(value, (bytes, bytearray, memoryview)): + return None + try: + return json.dumps( + value, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ) + except (TypeError, ValueError): + return None + + +def _open_events( + events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None, +) -> AsyncIterable[Mapping[str, Any]]: + if events is None: + raise OpenCodeSessionError("The OpenCode handler has no session event source.") + produced = events() + if not hasattr(produced, "__aiter__"): + raise TypeError("OpenCode event source must return an async iterable.") + return produced + + +def create_opencode_handler( + *, + capture_content: bool | None = None, + events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None = None, +) -> ProviderHandler: + """Return a handler that runs an OpenCode session supplied by *events*. + + ``provides_for`` is ``('*', 'agent')``, so the handler matches an agent-mode + variation for any provider. *events* is called on every invocation and must + return a fresh async iterable of OpenCode session events. This function does + not start OpenCode. + + *capture_content* follows the other handlers: ``None`` reads the process + default from ``configure`` when the call runs, and ``True`` or ``False`` + overrides it. Tool argument text and tool results are omitted from the + stream unless capture is on. ``config().stream()`` still yields assistant + text as ``chunk`` and one ``done``. Tool events stay on the experimental + reader. + """ + from ..lifecycle import effective_capture_content + from ..sdk_usage import report_usage + + report_usage("client.experimental.opencode.createHandler") + return _create_opencode_handler( + capture_content=capture_content, + events=events, + resolve_capture=effective_capture_content, + ) + + +def _create_opencode_handler( + *, + capture_content: bool | None, + events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None, + resolve_capture: Callable[[bool | None], bool], +) -> ProviderHandler: + content_override = capture_content + + async def _stream_impl( + config: AiConfigRep, + user_input: str | None, + tool_handlers: dict[str, Any] | None, + variables: dict[str, Any] | None, + history: list[dict[str, Any]] | None = None, + ) -> AsyncGenerator[dict[str, Any], None]: + capture = resolve_capture(content_override) + reducer = _Reducer(capture) + async for event in reducer.reduce(_open_events(events)): + yield event + done: dict[str, Any] = {"type": "done", "output": "".join(reducer.deltas)} + usage = reducer.usage_payload() + if usage is not None: + done["usage"] = usage + yield done + + async def _call_impl( + config: AiConfigRep, + user_input: str | None, + tool_handlers: dict[str, Any] | None, + variables: dict[str, Any] | None, + history: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + output = "" + usage: dict[str, Any] = {} + async for event in _stream_impl( + config, user_input, tool_handlers, variables, history + ): + if event.get("type") == "done": + raw_output = event.get("output") + output = raw_output if isinstance(raw_output, str) else "" + raw_usage = event.get("usage") + usage = raw_usage if isinstance(raw_usage, dict) else {} + return {"output": output, "usage": usage} + + return _create_handler( + ("*", "agent"), + _call_impl, + _stream_impl, + capture_content=content_override, + ) diff --git a/packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py b/packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py new file mode 100644 index 00000000..3df97c0a --- /dev/null +++ b/packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py @@ -0,0 +1,165 @@ +"""AG-UI reader over the same handler drive as ``config().stream()``. + +The stable reader keeps yielding ``chunk`` then ``done``. This one forwards +text and tool events and brackets them with ``RUN_STARTED`` and ``RUN_FINISHED``. +A raised turn ends the generator with neither ``RUN_FINISHED`` nor ``RUN_ERROR``. +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncGenerator +from typing import Any + +from ..client import ConfigInstance +from ..conversation import bind_conversation_id +from ..judges import run_judges +from ..lifecycle import extract_variation +from ..registry import resolve_handlers, resolve_tools +from ..sdk_usage import report_usage +from ..tracking import _SETTLED_EVENT, _drive_handler_stream +from ..types import LDContext +from ..utils import select_handler + +_TEXT = frozenset({"TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END"}) +_TOOLS = frozenset( + {"TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT"} +) + + +def stream( + config_instance: ConfigInstance, + user_input: str | None, + context: LDContext, + *, + variables: dict[str, Any] | None = None, + history: list[dict[str, Any]] | None = None, +) -> AsyncGenerator[dict[str, Any], None]: + """Yield AG-UI events for one ``config()`` call. + + Takes the same arguments as ``ConfigInstance.stream``. ``config().stream()`` + is unchanged. This function is the opt-in reader; it is not a default, and + it does not read or set a protocol field. + """ + report_usage("client.experimental.richStream") + return bind_conversation_id( + _events(config_instance, user_input, context, variables, history) + ) + + +async def _events( + config_instance: ConfigInstance, + user_input: str | None, + context: LDContext, + variables: dict[str, Any] | None, + history: list[dict[str, Any]] | None, +) -> AsyncGenerator[dict[str, Any], None]: + resolved_handler_list = resolve_handlers( + config_instance._registry, config_instance._normalize_handlers() + ) + resolved_tools = resolve_tools( + config_instance._registry, config_instance._tool_handlers + ) + variation = await extract_variation(config_instance._key, context) + config = variation["config"] + meta = variation["meta"] + if not resolved_handler_list: + raise ValueError("No handlers provided to config()") + handler = select_handler(config, meta, resolved_handler_list) + + thread_id = str(uuid.uuid4()) + run_id = str(uuid.uuid4()) + yield { + "type": "RUN_STARTED", + "threadId": thread_id, + "runId": run_id, + "protocolVersion": "1.0", + } + + settled: dict[str, Any] | None = None + synthetic_id: str | None = None + saw_canonical_text = False + async for event in _drive_handler_stream( + config_key=config_instance._key, + config=config, + meta=meta, + user_context=context, + handler=handler, + user_input=user_input, + tool_handlers=resolved_tools, + variables=variables, + history=history, + ): + if event.get("type") == _SETTLED_EVENT: + settled = event + continue + kind = event.get("type") + if kind in _TEXT: + if synthetic_id is not None: + yield {"type": "TEXT_MESSAGE_END", "messageId": synthetic_id} + synthetic_id = None + saw_canonical_text = True + yield event + elif kind == "chunk" and not saw_canonical_text: + text = event.get("text") + if not isinstance(text, str) or not text: + continue + if synthetic_id is None: + synthetic_id = str(uuid.uuid4()) + yield {"type": "TEXT_MESSAGE_START", "messageId": synthetic_id} + yield { + "type": "TEXT_MESSAGE_CONTENT", + "messageId": synthetic_id, + "delta": text, + } + elif kind in _TOOLS: + yield event + + if synthetic_id is not None: + yield {"type": "TEXT_MESSAGE_END", "messageId": synthetic_id} + + if settled is None: + return + + judge_results: dict[str, Any] = {} + if not config_instance._skip_judges: + judge_results = await run_judges( + config=config, + user_context=context, + handler=handler, + handlers=resolved_handler_list, + user_input=user_input, + trajectory=settled.get("trajectory", ""), + llm_response=settled.get("response", ""), + base_track_data=settled.get("track_data", {}), + tool_handlers=resolved_tools, + ) + + finished: dict[str, Any] = { + "type": "RUN_FINISHED", + "threadId": thread_id, + "runId": run_id, + } + response = settled.get("response") + if isinstance(response, str) and response: + finished["result"] = response + if settled.get("had_usage"): + usage = settled.get("usage") + if isinstance(usage, dict): + finished["usage"] = _token_usage(usage) + if judge_results: + finished["metadata"] = {"launchdarkly": {"judgeResults": judge_results}} + yield finished + + +def _token_usage(parsed: dict[str, Any]) -> list[dict[str, Any]]: + entry: dict[str, Any] = { + "inputTokens": parsed.get("input", 0), + "outputTokens": parsed.get("output", 0), + "totalTokens": parsed.get("total", 0), + } + details = parsed.get("input_details") + if isinstance(details, dict): + entry["cachedInputTokens"] = details.get("cache_read", 0) + entry["cacheWriteInputTokens"] = details.get("cache_creation", 0) + return [entry] diff --git a/packages/client/src/launchdarkly_ai_server/experimental/opencode.py b/packages/client/src/launchdarkly_ai_server/experimental/opencode.py new file mode 100644 index 00000000..e5e88e37 --- /dev/null +++ b/packages/client/src/launchdarkly_ai_server/experimental/opencode.py @@ -0,0 +1,12 @@ +"""Experimental OpenCode handler. + +Import ``create_opencode_handler`` from this module. It is not part of the +package root. The names may change in a minor release. +""" + +from ._opencode import OpenCodeSessionError, create_opencode_handler + +__all__ = [ + "OpenCodeSessionError", + "create_opencode_handler", +] diff --git a/packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py b/packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py new file mode 100644 index 00000000..05bf5b6a --- /dev/null +++ b/packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py @@ -0,0 +1,10 @@ +"""Experimental reader for the richer stream. + +``config().stream()`` stays ``chunk`` then ``done``. Call :func:`stream` to +read the AG-UI text and tool events for the same run. This does not select a +product default, and there is no protocol field on ``configure``. +""" + +from ._rich_stream import stream + +__all__ = ["stream"] diff --git a/packages/client/src/launchdarkly_ai_server/tracking.py b/packages/client/src/launchdarkly_ai_server/tracking.py index 6e7a5638..70777254 100644 --- a/packages/client/src/launchdarkly_ai_server/tracking.py +++ b/packages/client/src/launchdarkly_ai_server/tracking.py @@ -236,7 +236,12 @@ async def execute_and_track( } -async def execute_and_stream( +# Private terminal of :func:`_drive_handler_stream`. Not a handler event and not +# forwarded by either reader. A handler that yields this type is ignored. +_SETTLED_EVENT = "__ld_settled" + + +async def _drive_handler_stream( *, config_key: str, config: AiConfigRep, @@ -248,14 +253,13 @@ async def execute_and_stream( variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None, -) -> AsyncGenerator[ExecuteStreamEvent, None]: - """ - Streaming counterpart to ``execute_and_track``. Yields ``chunk`` events - from the handler, then a single ``done`` event once the stream completes - and LD telemetry has been flushed. +) -> AsyncGenerator[dict[str, Any], None]: + """Run one handler stream and yield its events, then one settled record. - Falls back to calling the blocking handler when the handler has no - ``stream`` implementation. + The settled record carries the response, parsed usage, tracking payload and + trajectory. Handler ``done`` is consumed here. Telemetry matches + :func:`execute_and_stream`. A raise records the error metrics and propagates, + with no settled record. """ from .lifecycle import get_client @@ -297,6 +301,8 @@ async def execute_and_stream( start_time = time.monotonic() full_text = "" raw_usage: dict[str, Any] = {} + had_usage = False + saw_text_message = False try: if handler.has_stream: @@ -304,13 +310,24 @@ async def execute_and_stream( config, user_input, tracked_tool_handlers, merged_variables, history ) async for event in stream_gen: - if event.get("type") == "chunk": - full_text += event.get("text", "") - yield {"type": "chunk", "text": event["text"]} - elif event.get("type") == "done": + kind = event.get("type") + if kind == "chunk": + if not saw_text_message: + full_text += event.get("text", "") + yield event + elif kind == "TEXT_MESSAGE_CONTENT": + saw_text_message = True + delta = event.get("delta") + if isinstance(delta, str): + full_text += delta + yield event + elif kind == "done": if event.get("output") is not None: full_text = event["output"] raw_usage = event.get("usage", {}) + had_usage = bool(event.get("usage")) + elif kind != _SETTLED_EVENT: + yield event else: result = await handler( config, user_input, tracked_tool_handlers, merged_variables, history @@ -318,6 +335,7 @@ async def execute_and_stream( out = result.get("output") full_text = str(out) if out is not None else "" raw_usage = result.get("usage") or {} + had_usage = bool(result.get("usage")) if full_text: yield {"type": "chunk", "text": full_text} except Exception: @@ -339,9 +357,10 @@ async def execute_and_stream( client.track("$ld:ai:tokens:output", ld_ctx, track_data, usage["output"]) yield { - "type": "done", + "type": _SETTLED_EVENT, "response": full_text, "usage": usage, + "had_usage": had_usage, "track_data": track_data, "trajectory": render_trajectory( recorder.invocations, @@ -349,3 +368,66 @@ async def execute_and_stream( omitted=recorder.omitted, ), } + + +async def execute_and_stream( + *, + config_key: str, + config: AiConfigRep, + meta: VariationMeta, + user_context: LDContext, + handler: ProviderHandler, + user_input: str | None = None, + tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, + variables: dict[str, Any] | None = None, + graph_key: str | None = None, + history: list[dict[str, Any]] | None = None, +) -> AsyncGenerator[ExecuteStreamEvent, None]: + """ + Streaming counterpart to ``execute_and_track``. Yields ``chunk`` events + from the handler, then a single ``done`` event once the stream completes + and LD telemetry has been flushed. + + Assistant text arrives as ``chunk``. ``TEXT_MESSAGE_CONTENT`` contributes + its ``delta`` as a ``chunk`` as well. A tool event, or any other handler + event, is not forwarded and does not end the turn. Only the handler's + ``done`` supplies output and usage. ``config().stream()`` therefore stays + ``chunk`` then ``done``. + + Falls back to calling the blocking handler when the handler has no + ``stream`` implementation. + """ + settled: dict[str, Any] | None = None + saw_text_message = False + async for event in _drive_handler_stream( + config_key=config_key, + config=config, + meta=meta, + user_context=user_context, + handler=handler, + user_input=user_input, + tool_handlers=tool_handlers, + variables=variables, + graph_key=graph_key, + history=history, + ): + if event.get("type") == _SETTLED_EVENT: + settled = event + continue + kind = event.get("type") + if kind == "TEXT_MESSAGE_CONTENT": + saw_text_message = True + delta = event.get("delta") + if isinstance(delta, str) and delta: + yield {"type": "chunk", "text": delta} + elif kind == "chunk" and not saw_text_message: + yield {"type": "chunk", "text": event["text"]} + if settled is None: + return + yield { + "type": "done", + "response": settled.get("response", ""), + "usage": settled.get("usage"), + "track_data": settled.get("track_data"), + "trajectory": settled.get("trajectory", ""), + } diff --git a/packages/client/tests/test_opencode.py b/packages/client/tests/test_opencode.py new file mode 100644 index 00000000..783e717f --- /dev/null +++ b/packages/client/tests/test_opencode.py @@ -0,0 +1,588 @@ +"""OpenCode handler: synthetic session events, stable stream, experimental reader.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, AsyncIterator, Iterator +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import launchdarkly_ai_server.lifecycle as lifecycle_module +from launchdarkly_ai_server import ProviderHandler, SDKOptions, config, configure +from launchdarkly_ai_server.experimental.opencode import ( + OpenCodeSessionError, + create_opencode_handler, +) +from launchdarkly_ai_server.experimental.rich_stream import stream as rich_stream + +CONTEXT = {"kind": "user", "key": "user-1"} + +VARIATION = { + "model": {"name": "test-model"}, + "provider": {"name": "Acme"}, + "instructions": "Be brief.", + "_ldMeta": { + "enabled": True, + "variationKey": "v1", + "version": 1, + "mode": "agent", + }, +} + + +def bus(kind: str, **properties: Any) -> dict[str, Any]: + return {"type": kind, "properties": properties} + + +def text_delta(message_id: str, part_id: str, delta: str) -> dict[str, Any]: + return bus( + "message.part.delta", + sessionID="ses", + messageID=message_id, + partID=part_id, + field="text", + delta=delta, + ) + + +def tool_part( + call_id: str, + status: str, + *, + message_id: str = "m1", + name: str = "bash", + raw_input: Any = None, + output: Any = None, + part_id: str | None = None, +) -> dict[str, Any]: + state: dict[str, Any] = {"status": status} + if raw_input is not None: + state["input"] = raw_input + if output is not None: + state["output"] = output + return bus( + "message.part.updated", + part={ + "id": part_id or f"part-{call_id}", + "messageID": message_id, + "type": "tool", + "callID": call_id, + "tool": name, + "state": state, + }, + ) + + +def assistant_completed( + message_id: str, + *, + tokens: dict[str, Any] | None = None, +) -> dict[str, Any]: + info: dict[str, Any] = { + "id": message_id, + "role": "assistant", + "time": {"created": 1, "completed": 2}, + } + if tokens is not None: + info["tokens"] = tokens + return bus("message.updated", info=info) + + +def source(events: list[dict[str, Any]]) -> Any: + async def _events() -> AsyncIterator[dict[str, Any]]: + for event in events: + yield event + + return _events + + +async def collect(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + built = create_opencode_handler(capture_content=True, events=source(events)) + gen = await built.stream({}, "hi", None, None) + return [event async for event in gen] + + +def types_of(events: list[dict[str, Any]]) -> list[str]: + return [str(event["type"]) for event in events] + + +@pytest.fixture(autouse=True) +def _reset_sdk() -> Iterator[None]: + lifecycle_module._reset_for_testing() + yield + lifecycle_module._reset_for_testing() + + +@pytest.fixture +def mock_ld_client() -> Iterator[MagicMock]: + client = MagicMock() + client.track = MagicMock() + client.flush = AsyncMock() + client.close = AsyncMock() + client.variation = AsyncMock(return_value=VARIATION) + lifecycle_module._set_client_for_testing(client) + yield client + lifecycle_module._reset_for_testing() + + +class TestSurface: + def test_provides_for_is_any_provider_in_agent_mode(self) -> None: + handler = create_opencode_handler(events=source([])) + assert handler.provides_for == ("*", "agent") + + def test_names_stay_off_the_package_root(self) -> None: + import launchdarkly_ai_server as package + from launchdarkly_ai_server.experimental import opencode + + assert set(opencode.__all__) == { + "OpenCodeSessionError", + "create_opencode_handler", + } + public = {name for name in dir(opencode) if not name.startswith("_")} + assert public == set(opencode.__all__) + for name in opencode.__all__: + assert name not in package.__all__ + assert not hasattr(package, name) + + +class TestTextAndTools: + async def test_text_and_a_successful_tool(self) -> None: + events = await collect( + [ + text_delta("m1", "p1", "Looking up"), + tool_part("c1", "running", raw_input={"command": "ls"}), + tool_part("c1", "running", raw_input={"command": "ls"}), + tool_part( + "c1", "completed", raw_input={"command": "ls"}, output="file.txt" + ), + tool_part( + "c1", "completed", raw_input={"command": "ls"}, output="file.txt" + ), + assistant_completed("m1"), + ], + ) + assert types_of(events) == [ + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TOOL_CALL_START", + "TOOL_CALL_ARGS", + "TOOL_CALL_END", + "TOOL_CALL_RESULT", + "TEXT_MESSAGE_END", + "done", + ] + assert events[0]["messageId"] == "m1" + assert events[1] == { + "type": "TEXT_MESSAGE_CONTENT", + "messageId": "m1", + "delta": "Looking up", + } + assert events[2]["toolCallId"] == "c1" + assert events[2]["toolCallName"] == "bash" + assert events[2]["parentMessageId"] == "m1" + assert events[3] == { + "type": "TOOL_CALL_ARGS", + "toolCallId": "c1", + "delta": '{"command":"ls"}', + } + assert "toolCallName" not in events[3] + assert events[5]["content"] == "file.txt" + assert events[5]["messageId"] != "c1" + assert "metadata" not in events[5] + assert events[-1]["output"] == "Looking up" + + async def test_a_failed_tool_is_masked(self) -> None: + events = await collect( + [ + tool_part( + "c1", + "error", + raw_input={"command": "rm"}, + output="disk on fire", + ) + ], + ) + result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") + assert result["content"] == "An error occurred." + assert result["metadata"] == {"launchdarkly": {"toolFailed": True}} + assert "disk on fire" not in str(result) + assert "parentMessageId" not in events[0] + + async def test_a_string_argument_is_not_encoded_again(self) -> None: + events = await collect( + [tool_part("c1", "running", raw_input="ls")], + ) + args = next(event for event in events if event["type"] == "TOOL_CALL_ARGS") + assert args["delta"] == "ls" + + async def test_nan_result_is_a_failed_tool(self) -> None: + events = await collect( + [tool_part("c1", "completed", raw_input={}, output=float("nan"))], + ) + result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") + assert result["metadata"] == {"launchdarkly": {"toolFailed": True}} + assert result["content"] == "An error occurred." + + async def test_part_id_is_the_call_id_when_call_id_is_absent(self) -> None: + part = tool_part("ignored", "running", raw_input={"q": 1}, part_id="part-9") + del part["properties"]["part"]["callID"] + events = await collect([part]) + assert events[0]["toolCallId"] == "part-9" + + async def test_interleaved_tools_keep_arrival_order(self) -> None: + events = await collect( + [ + tool_part("a", "running", raw_input={"q": "a"}, name="search"), + tool_part("b", "running", raw_input={"q": "b"}, name="weather"), + tool_part("b", "completed", raw_input={"q": "b"}, output="sun"), + tool_part("a", "completed", raw_input={"q": "a"}, output="rows"), + ], + ) + results = [event for event in events if event["type"] == "TOOL_CALL_RESULT"] + assert [event["toolCallId"] for event in results] == ["b", "a"] + assert [event["content"] for event in results] == ["sun", "rows"] + + async def test_completed_assistant_usage_is_summed_once(self) -> None: + tokens = {"input": 3, "output": 4, "cache": {"read": 1, "write": 2}} + events = await collect( + [ + text_delta("m1", "p1", "Hi"), + assistant_completed("m1", tokens=tokens), + assistant_completed("m1", tokens=tokens), + ], + ) + assert events[-1]["usage"] == { + "input_tokens": 3, + "output_tokens": 4, + "cache_read_input_tokens": 1, + "cache_creation_input_tokens": 2, + } + + async def test_text_after_a_tool_is_a_new_message(self) -> None: + events = await collect( + [ + text_delta("m1", "p1", "A"), + tool_part("c1", "running", raw_input={"q": 1}), + tool_part("c1", "completed", raw_input={"q": 1}, output="ok"), + assistant_completed("m1"), + text_delta("m2", "p2", "B"), + assistant_completed("m2"), + ], + ) + starts = [event for event in events if event["type"] == "TEXT_MESSAGE_START"] + assert [event["messageId"] for event in starts] == ["m1", "m2"] + assert events[-1]["output"] == "AB" + + async def test_a_text_snapshot_does_not_repeat_deltas(self) -> None: + events = await collect( + [ + text_delta("m1", "p1", "Hi"), + bus( + "message.part.updated", + part={ + "id": "p1", + "messageID": "m1", + "type": "text", + "text": "Hi", + }, + ), + assistant_completed("m1"), + ], + ) + contents = [ + event for event in events if event["type"] == "TEXT_MESSAGE_CONTENT" + ] + assert contents == [ + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "m1", "delta": "Hi"} + ] + + async def test_session_error_raises_without_the_provider_body(self) -> None: + handler = create_opencode_handler( + capture_content=True, + events=source( + [ + text_delta("m1", "p1", "Hi"), + { + "type": "session.error", + "properties": {"error": {"data": {"message": "secret-body"}}}, + }, + ] + ), + ) + seen: list[str] = [] + with pytest.raises(OpenCodeSessionError, match="session failed") as raised: + async for event in await handler.stream({}, "hi", None, None): + seen.append(event["type"]) + assert "secret-body" not in str(raised.value) + assert "done" not in seen + + async def test_pending_tool_state_emits_nothing(self) -> None: + events = await collect( + [tool_part("c1", "pending", raw_input={}, output=None)], + ) + assert types_of(events) == ["done"] + + +class TestCapture: + async def test_capture_off_omits_argument_and_result_text(self) -> None: + handler = create_opencode_handler( + capture_content=False, + events=source( + [ + tool_part( + "c1", + "completed", + raw_input={"command": "ls"}, + output="file.txt", + ) + ] + ), + ) + events = [event async for event in await handler.stream({}, None, None, None)] + assert "TOOL_CALL_ARGS" not in types_of(events) + result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") + assert result["content"] == "" + assert "metadata" not in result + + async def test_explicit_false_overrides_configure_true(self) -> None: + configure(options=SDKOptions(capture_content=True)) + try: + handler = create_opencode_handler( + capture_content=False, + events=source( + [tool_part("c1", "running", raw_input={"command": "ls"})] + ), + ) + events = [event async for event in await handler.stream({}, None, None, None)] + assert "TOOL_CALL_ARGS" not in types_of(events) + finally: + lifecycle_module._reset_for_testing() + + async def test_omitted_capture_reads_configure_at_invocation(self) -> None: + configure(options=SDKOptions(capture_content=True)) + try: + handler = create_opencode_handler( + events=source( + [tool_part("c1", "running", raw_input={"command": "ls"})] + ), + ) + events = [event async for event in await handler.stream({}, None, None, None)] + args = next(event for event in events if event["type"] == "TOOL_CALL_ARGS") + assert args["delta"] == '{"command":"ls"}' + finally: + lifecycle_module._reset_for_testing() + + +class TestStableStream: + async def test_tool_events_do_not_close_the_stable_stream( + self, mock_ld_client: MagicMock + ) -> None: + handler = create_opencode_handler( + capture_content=True, + events=source( + [ + text_delta("m1", "p1", "Looking up"), + tool_part("c1", "running", raw_input={"command": "ls"}), + tool_part( + "c1", + "completed", + raw_input={"command": "ls"}, + output="file.txt", + ), + assistant_completed("m1"), + text_delta("m2", "p2", "Done."), + assistant_completed("m2"), + ] + ), + ) + seen = [ + event + async for event in config(key="flag", handler=handler).stream("q", CONTEXT) + ] + assert [event["type"] for event in seen] == ["chunk", "chunk", "done"] + assert [event["text"] for event in seen if event["type"] == "chunk"] == [ + "Looking up", + "Done.", + ] + done = seen[-1] + assert done["response"] == "Looking upDone." + assert "toolCallId" not in done + assert done["usage"]["input"] == 0 + + async def test_session_error_does_not_yield_stable_done( + self, mock_ld_client: MagicMock + ) -> None: + handler = create_opencode_handler( + events=source( + [ + text_delta("m1", "p1", "Hi"), + {"type": "session.error", "properties": {"error": {"name": "X"}}}, + ] + ), + ) + seen: list[dict[str, Any]] = [] + with pytest.raises(OpenCodeSessionError): + async for event in config(key="flag", handler=handler).stream("q", CONTEXT): + seen.append(event) + assert [event["type"] for event in seen] == ["chunk"] + assert seen[0]["text"] == "Hi" + + async def test_a_chunk_handler_tool_event_is_not_the_done_event( + self, mock_ld_client: MagicMock + ) -> None: + async def invoke( + config: Any, + user_input: str | None, + tool_handlers: Any, + variables: Any, + history: Any = None, + ) -> dict[str, Any]: + return {"output": "Hi", "usage": {"input_tokens": 1, "output_tokens": 1}} + + async def stream_fn( + config: Any, + user_input: str | None, + tool_handlers: Any, + variables: Any, + history: Any = None, + ) -> AsyncGenerator[dict[str, Any], None]: + yield { + "type": "TOOL_CALL_START", + "toolCallId": "c1", + "toolCallName": "bash", + } + yield {"type": "chunk", "text": "Hi"} + yield { + "type": "done", + "output": "Hi", + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + + handler = ProviderHandler( + fn=invoke, provides_for=("Acme", "agent"), stream_fn=stream_fn + ) + seen = [ + event + async for event in config(key="flag", handler=handler).stream("q", CONTEXT) + ] + assert [event["type"] for event in seen] == ["chunk", "done"] + assert seen[-1]["response"] == "Hi" + assert "toolCallId" not in seen[-1] + + +class TestRichReader: + async def test_rich_reader_forwards_text_and_tools( + self, mock_ld_client: MagicMock + ) -> None: + handler = create_opencode_handler( + capture_content=True, + events=source( + [ + text_delta("m1", "p1", "Looking up"), + tool_part("c1", "running", raw_input={"command": "ls"}), + tool_part( + "c1", + "error", + raw_input={"command": "ls"}, + output="nope", + ), + assistant_completed( + "m1", + tokens={ + "input": 3, + "output": 4, + "cache": {"read": 1, "write": 2}, + }, + ), + ] + ), + ) + instance = config(key="flag", handler=handler) + seen = [event async for event in rich_stream(instance, "q", CONTEXT)] + kinds = [event["type"] for event in seen] + assert kinds[0] == "RUN_STARTED" + assert kinds[-1] == "RUN_FINISHED" + assert seen[0]["threadId"] == seen[-1]["threadId"] + assert seen[0]["runId"] == seen[-1]["runId"] + assert seen[0]["protocolVersion"] == "1.0" + assert "TEXT_MESSAGE_CONTENT" in kinds + assert "TOOL_CALL_RESULT" in kinds + result = next(event for event in seen if event["type"] == "TOOL_CALL_RESULT") + assert result["content"] == "An error occurred." + assert result["metadata"] == {"launchdarkly": {"toolFailed": True}} + finished = seen[-1] + assert finished["result"] == "Looking up" + assert finished["usage"] == [ + { + "inputTokens": 6, + "outputTokens": 4, + "totalTokens": 10, + "cachedInputTokens": 1, + "cacheWriteInputTokens": 2, + } + ] + assert "metadata" not in finished + + async def test_session_error_has_no_run_finished( + self, mock_ld_client: MagicMock + ) -> None: + handler = create_opencode_handler( + events=source( + [ + {"type": "session.error", "properties": {"error": {"name": "X"}}}, + ] + ), + ) + seen: list[str] = [] + with pytest.raises(OpenCodeSessionError): + async for event in rich_stream( + config(key="flag", handler=handler), "q", CONTEXT + ): + seen.append(event["type"]) + assert seen == ["RUN_STARTED"] + assert "RUN_FINISHED" not in seen + + async def test_legacy_chunks_are_one_text_message( + self, mock_ld_client: MagicMock + ) -> None: + async def invoke( + config: Any, + user_input: str | None, + tool_handlers: Any, + variables: Any, + history: Any = None, + ) -> dict[str, Any]: + return {"output": "Hi"} + + async def stream_fn( + config: Any, + user_input: str | None, + tool_handlers: Any, + variables: Any, + history: Any = None, + ) -> AsyncGenerator[dict[str, Any], None]: + yield {"type": "chunk", "text": "Hi"} + yield {"type": "TOOL_CALL_END", "toolCallId": "c1"} + yield {"type": "done", "output": "Hi"} + + handler = ProviderHandler( + fn=invoke, provides_for=("Acme", "agent"), stream_fn=stream_fn + ) + seen = [ + event + async for event in rich_stream( + config(key="flag", handler=handler), "q", CONTEXT + ) + ] + assert [event["type"] for event in seen] == [ + "RUN_STARTED", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TOOL_CALL_END", + "TEXT_MESSAGE_END", + "RUN_FINISHED", + ] + assert seen[2]["delta"] == "Hi" + assert seen[-1]["result"] == "Hi" + assert "usage" not in seen[-1] diff --git a/release-please-config.json b/release-please-config.json index 130d93b0..338b314b 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -98,6 +98,12 @@ { "type": "feat", "scope": "evaluations", "section": "Experimental" }, { "type": "fix", "scope": "evaluations", "section": "Experimental" }, { "type": "perf", "scope": "evaluations", "section": "Experimental" }, + { "type": "feat", "scope": "opencode", "section": "Experimental" }, + { "type": "fix", "scope": "opencode", "section": "Experimental" }, + { "type": "perf", "scope": "opencode", "section": "Experimental" }, + { "type": "feat", "scope": "rich-stream", "section": "Experimental" }, + { "type": "fix", "scope": "rich-stream", "section": "Experimental" }, + { "type": "perf", "scope": "rich-stream", "section": "Experimental" }, { "type": "feat", "section": "Features" }, { "type": "fix", "section": "Bug Fixes" }, { "type": "perf", "section": "Performance Improvements" }, From deef34b25cd0e1e88310966ebdf60904537a7cbb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 23:53:07 +0000 Subject: [PATCH 20/28] style(opencode): format the handler tests --- packages/client/tests/test_opencode.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/client/tests/test_opencode.py b/packages/client/tests/test_opencode.py index 783e717f..da0cf810 100644 --- a/packages/client/tests/test_opencode.py +++ b/packages/client/tests/test_opencode.py @@ -354,7 +354,9 @@ async def test_explicit_false_overrides_configure_true(self) -> None: [tool_part("c1", "running", raw_input={"command": "ls"})] ), ) - events = [event async for event in await handler.stream({}, None, None, None)] + events = [ + event async for event in await handler.stream({}, None, None, None) + ] assert "TOOL_CALL_ARGS" not in types_of(events) finally: lifecycle_module._reset_for_testing() @@ -367,7 +369,9 @@ async def test_omitted_capture_reads_configure_at_invocation(self) -> None: [tool_part("c1", "running", raw_input={"command": "ls"})] ), ) - events = [event async for event in await handler.stream({}, None, None, None)] + events = [ + event async for event in await handler.stream({}, None, None, None) + ] args = next(event for event in events if event["type"] == "TOOL_CALL_ARGS") assert args["delta"] == '{"command":"ls"}' finally: From 030784f711a3d000963224bd83031a2e3c2f0830 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 00:23:54 +0000 Subject: [PATCH 21/28] feat(opencode-agents): ship the handler as a normal package Move the OpenCode handler into launchdarkly-ai-opencode-agents and export rich_stream from launchdarkly_ai_server. The handler reduces caller-supplied session events and does not depend on an OpenCode SDK. Assistant text waits until the message role is assistant, and a part snapshot emits only text that has not already been sent. --- .github/workflows/release-please.yml | 34 +++++ .release-please-manifest.json | 3 +- .sdk_metadata.json | 12 ++ AGENTS.md | 7 +- .../src/launchdarkly_ai_server/__init__.py | 2 + .../experimental/__init__.py | 4 +- .../experimental/opencode.py | 12 -- .../experimental/rich_stream.py | 10 -- .../_rich_stream.py => rich_stream.py} | 22 +-- packages/client/tests/test_public_surface.py | 1 + packages/opencode-agents/CHANGELOG.md | 8 ++ packages/opencode-agents/README.md | 19 +++ packages/opencode-agents/agents.md | 13 ++ packages/opencode-agents/pyproject.toml | 32 +++++ .../__init__.py | 17 +++ .../_version.py | 4 + .../handler.py} | 134 ++++++++++++++---- .../launchdarkly_ai_opencode_agents/py.typed | 0 .../tests/test_handler.py} | 102 +++++++++++-- pyproject.toml | 1 + release-please-config.json | 17 ++- uv.lock | 30 ++-- 22 files changed, 392 insertions(+), 92 deletions(-) delete mode 100644 packages/client/src/launchdarkly_ai_server/experimental/opencode.py delete mode 100644 packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py rename packages/client/src/launchdarkly_ai_server/{experimental/_rich_stream.py => rich_stream.py} (92%) create mode 100644 packages/opencode-agents/CHANGELOG.md create mode 100644 packages/opencode-agents/README.md create mode 100644 packages/opencode-agents/agents.md create mode 100644 packages/opencode-agents/pyproject.toml create mode 100644 packages/opencode-agents/src/launchdarkly_ai_opencode_agents/__init__.py create mode 100644 packages/opencode-agents/src/launchdarkly_ai_opencode_agents/_version.py rename packages/{client/src/launchdarkly_ai_server/experimental/_opencode.py => opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py} (76%) create mode 100644 packages/opencode-agents/src/launchdarkly_ai_opencode_agents/py.typed rename packages/{client/tests/test_opencode.py => opencode-agents/tests/test_handler.py} (86%) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 4559e28b..71f18344 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -32,6 +32,7 @@ on: - packages/openai-messages - packages/langchain-agents - packages/langchain-messages + - packages/opencode-agents - packages/vercel-agents - packages/vercel-messages dry_run: @@ -64,6 +65,8 @@ jobs: package-langchain-agents-tag-name: ${{ steps.release.outputs['packages/langchain-agents--tag_name'] }} package-langchain-messages-released: ${{ steps.release.outputs['packages/langchain-messages--release_created'] }} package-langchain-messages-tag-name: ${{ steps.release.outputs['packages/langchain-messages--tag_name'] }} + package-opencode-agents-released: ${{ steps.release.outputs['packages/opencode-agents--release_created'] }} + package-opencode-agents-tag-name: ${{ steps.release.outputs['packages/opencode-agents--tag_name'] }} package-vercel-agents-released: ${{ steps.release.outputs['packages/vercel-agents--release_created'] }} package-vercel-agents-tag-name: ${{ steps.release.outputs['packages/vercel-agents--tag_name'] }} package-vercel-messages-released: ${{ steps.release.outputs['packages/vercel-messages--release_created'] }} @@ -322,6 +325,37 @@ jobs: password: ${{ env.PYPI_AUTH_TOKEN }} packages-dir: packages/langchain-messages/dist/ + release-opencode-agents: + runs-on: ubuntu-latest + needs: ['release-please'] + permissions: + contents: read + id-token: write + attestations: write + if: ${{ needs.release-please.outputs.package-opencode-agents-released == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/ci + with: + workspace_path: packages/opencode-agents + - uses: ./.github/actions/build + with: + workspace_path: packages/opencode-agents + - name: Attest build provenance + uses: actions/attest@v4 + with: + subject-path: 'packages/opencode-agents/dist/*' + - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.2.0 + name: 'Get PyPI token' + with: + aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + ssm_parameter_pairs: '/production/common/releasing/pypi/token = PYPI_AUTH_TOKEN' + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + password: ${{ env.PYPI_AUTH_TOKEN }} + packages-dir: packages/opencode-agents/dist/ + release-vercel-agents: runs-on: ubuntu-latest needs: ['release-please'] diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6f6999ca..71c43717 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -6,5 +6,6 @@ "packages/openai-agents": "0.2.5", "packages/openai-messages": "0.2.4", "packages/langchain-agents": "0.2.5", - "packages/langchain-messages": "0.2.4" + "packages/langchain-messages": "0.2.4", + "packages/opencode-agents": "0.1.0" } diff --git a/.sdk_metadata.json b/.sdk_metadata.json index 5d7708f3..69348612 100644 --- a/.sdk_metadata.json +++ b/.sdk_metadata.json @@ -102,6 +102,18 @@ "aiAgentGraph": { "introduced": "0.1" }, "aiTrackMetrics": { "introduced": "0.1" } } + }, + "python-ai-opencode-agents": { + "name": "OpenCode Agents AI SDK for Python", + "type": "ai-provider", + "path": "packages/opencode-agents", + "languages": ["Python"], + "aiSdkNames": ["launchdarkly-ai-opencode-agents"], + "releases": { "tag-prefix": "launchdarkly-ai-opencode-agents-" }, + "features": { + "aiAgentConfig": { "introduced": "0.1" }, + "aiTrackMetrics": { "introduced": "0.1" } + } } } } diff --git a/AGENTS.md b/AGENTS.md index 2548208c..d022c77f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ The `UP046` rule (PEP 695 `type` statement syntax for generic classes) is **igno PRs are squash-merged with the PR title as the commit message, and release-please builds each package's changelog from those messages. -- **A change to an experimental feature uses that feature's scope:** `skills` (Agent Skills), `evaluations` (Evaluations from Code), `opencode` (OpenCode handler), and `rich-stream` (experimental AG-UI reader). `feat`, `fix` and `perf` commits with one of these scopes are listed under **Experimental** (`changelog-sections` in `release-please-config.json`), for example `feat(skills): watch skills for revocations`. Any other scope, such as `client`, lists the change as a core feature or fix. +- **A change to an experimental feature uses that feature's scope:** `skills` (Agent Skills) and `evaluations` (Evaluations from Code). `feat`, `fix` and `perf` commits with one of these scopes are listed under **Experimental** (`changelog-sections` in `release-please-config.json`), for example `feat(skills): watch skills for revocations`. Any other scope, such as `client`, lists the change as a core feature or fix. - **Use exactly one scope.** `feat(skills,client):` matches no Experimental row, so it lands under Features. - **A PR that changes both core and experimental behaviour** should be split. If it can't be, add a `BEGIN_COMMIT_OVERRIDE` block to its description with one conventional-commit line per change. An override also corrects a merged PR's title, as long as the release PR hasn't merged yet. - **A breaking experimental change** uses `!` while the package is 0.x: `feat(skills)!:` bumps the minor version, which is what an experimental break is allowed. After 1.0, `!` would force a major release. Use `feat(skills):` without `!` instead, and say the change is breaking in the title. @@ -99,6 +99,7 @@ graph TD claude["launchdarkly-ai-claude-agents"] openai["launchdarkly-ai-openai-agents"] langchain["launchdarkly-ai-langchain-agents"] + opencode["launchdarkly-ai-opencode-agents"] newHandler["launchdarkly-ai-new-provider\n(future)"] end subgraph tier0 ["Tier 0 — Core"] @@ -109,11 +110,13 @@ graph TD app --> claude app --> openai app --> langchain + app --> opencode app --> newHandler app --> ai claude --> client openai --> client langchain --> client + opencode --> client newHandler --> client ai --> client ``` @@ -122,7 +125,7 @@ graph TD - **Tier 0 — Core** (`launchdarkly-ai-server`): The foundation. Owns all LaunchDarkly integration, telemetry orchestration, shared data types, and the primary entry points (`config()`, `graph()`, `resolve_graph()`). Has no dependency on any other `launchdarkly-ai-*` package. - **Tier 0 — Convenience barrel** (`launchdarkly-ai-python`): A pure re-export package that makes all of `launchdarkly-ai-server` available under a shorter install name. No new logic — intended as the default install for most Python applications. -- **Tier 1 — Handler packages** (`launchdarkly-ai-claude-agents`, `launchdarkly-ai-claude-messages`, `launchdarkly-ai-openai-agents`, `launchdarkly-ai-openai-messages`, `launchdarkly-ai-langchain-agents`, `launchdarkly-ai-langchain-messages`, …): Each wraps a specific AI provider SDK. Depends on `launchdarkly-ai-server` for shared types and utilities. Must not depend on other Tier 1 packages. +- **Tier 1 — Handler packages** (`launchdarkly-ai-claude-agents`, `launchdarkly-ai-claude-messages`, `launchdarkly-ai-openai-agents`, `launchdarkly-ai-openai-messages`, `launchdarkly-ai-langchain-agents`, `launchdarkly-ai-langchain-messages`, `launchdarkly-ai-opencode-agents`, …): Each wraps a specific AI provider SDK. Depends on `launchdarkly-ai-server` for shared types and utilities. Must not depend on other Tier 1 packages. The OpenCode handler reduces caller-supplied session events and does not depend on an OpenCode SDK. - **Tier 2 — Consumer applications** (e.g. `main.py`, downstream projects): Imports from one or more handler packages and either `launchdarkly-ai-python` or `launchdarkly-ai-server`. Owns tool implementations and orchestration logic. No `launchdarkly-ai-*` package should ever depend on Tier 2 code. ### Rules diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index 44163c5c..7473d7de 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -52,6 +52,7 @@ resolve_handlers, resolve_tools, ) +from .rich_stream import stream as rich_stream from .sdk_info import SDK_INFO_CONTEXT, SDK_INFO_EVENT, register_ai_sdk_package from .sdk_usage import report_usage from .tracking import execute_and_stream, execute_and_track, wrap_tool_handlers @@ -226,6 +227,7 @@ # client "config", "ConfigInstance", + "rich_stream", # conversation "ConversationIdSpanProcessor", "conversation_id", diff --git a/packages/client/src/launchdarkly_ai_server/experimental/__init__.py b/packages/client/src/launchdarkly_ai_server/experimental/__init__.py index 11472de7..80ecfd7d 100644 --- a/packages/client/src/launchdarkly_ai_server/experimental/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/experimental/__init__.py @@ -1,9 +1,7 @@ """ Experimental features of the LaunchDarkly AI SDK. -Each feature is a submodule, for example ``launchdarkly_ai_server.experimental.skills``, -``launchdarkly_ai_server.experimental.opencode``, and -``launchdarkly_ai_server.experimental.rich_stream``. +Each feature is a submodule, for example ``launchdarkly_ai_server.experimental.skills``. Names imported from here may change in a minor release, so review the changelog when you upgrade. Names from the package root change only in a major release. """ diff --git a/packages/client/src/launchdarkly_ai_server/experimental/opencode.py b/packages/client/src/launchdarkly_ai_server/experimental/opencode.py deleted file mode 100644 index e5e88e37..00000000 --- a/packages/client/src/launchdarkly_ai_server/experimental/opencode.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Experimental OpenCode handler. - -Import ``create_opencode_handler`` from this module. It is not part of the -package root. The names may change in a minor release. -""" - -from ._opencode import OpenCodeSessionError, create_opencode_handler - -__all__ = [ - "OpenCodeSessionError", - "create_opencode_handler", -] diff --git a/packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py b/packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py deleted file mode 100644 index 05bf5b6a..00000000 --- a/packages/client/src/launchdarkly_ai_server/experimental/rich_stream.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Experimental reader for the richer stream. - -``config().stream()`` stays ``chunk`` then ``done``. Call :func:`stream` to -read the AG-UI text and tool events for the same run. This does not select a -product default, and there is no protocol field on ``configure``. -""" - -from ._rich_stream import stream - -__all__ = ["stream"] diff --git a/packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py b/packages/client/src/launchdarkly_ai_server/rich_stream.py similarity index 92% rename from packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py rename to packages/client/src/launchdarkly_ai_server/rich_stream.py index 3df97c0a..09744972 100644 --- a/packages/client/src/launchdarkly_ai_server/experimental/_rich_stream.py +++ b/packages/client/src/launchdarkly_ai_server/rich_stream.py @@ -11,21 +11,23 @@ from collections.abc import AsyncGenerator from typing import Any -from ..client import ConfigInstance -from ..conversation import bind_conversation_id -from ..judges import run_judges -from ..lifecycle import extract_variation -from ..registry import resolve_handlers, resolve_tools -from ..sdk_usage import report_usage -from ..tracking import _SETTLED_EVENT, _drive_handler_stream -from ..types import LDContext -from ..utils import select_handler +from .client import ConfigInstance +from .conversation import bind_conversation_id +from .judges import run_judges +from .lifecycle import extract_variation +from .registry import resolve_handlers, resolve_tools +from .sdk_usage import report_usage +from .tracking import _SETTLED_EVENT, _drive_handler_stream +from .types import LDContext +from .utils import select_handler _TEXT = frozenset({"TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END"}) _TOOLS = frozenset( {"TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT"} ) +__all__ = ["stream"] + def stream( config_instance: ConfigInstance, @@ -41,7 +43,7 @@ def stream( is unchanged. This function is the opt-in reader; it is not a default, and it does not read or set a protocol field. """ - report_usage("client.experimental.richStream") + report_usage("client.richStream") return bind_conversation_id( _events(config_instance, user_input, context, variables, history) ) diff --git a/packages/client/tests/test_public_surface.py b/packages/client/tests/test_public_surface.py index e77b854e..cf1e408e 100644 --- a/packages/client/tests/test_public_surface.py +++ b/packages/client/tests/test_public_surface.py @@ -97,6 +97,7 @@ "register_ai_sdk_package", "report_usage", "resolve_graph", + "rich_stream", "resolve_handlers", "resolve_tools", "run_judge", diff --git a/packages/opencode-agents/CHANGELOG.md b/packages/opencode-agents/CHANGELOG.md new file mode 100644 index 00000000..40a518bd --- /dev/null +++ b/packages/opencode-agents/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/packages/opencode-agents/README.md b/packages/opencode-agents/README.md new file mode 100644 index 00000000..bb3b1775 --- /dev/null +++ b/packages/opencode-agents/README.md @@ -0,0 +1,19 @@ +# `launchdarkly-ai-opencode-agents` + +OpenCode handler for `launchdarkly-ai-server`. It reduces caller-supplied OpenCode session events into assistant text and tool events. It does not start OpenCode and it does not depend on an OpenCode SDK. + +**`provides_for`:** `['*', 'agent']` — matches an agent-mode variation for any provider. + +```bash +pip install launchdarkly-ai-server launchdarkly-ai-opencode-agents +``` + +```python +from launchdarkly_ai_opencode_agents import create_opencode_handler +from launchdarkly_ai_server import config + +handler = create_opencode_handler(events=session_events) +config(key="my-agent", handler=handler).stream("Hello", {"kind": "user", "key": "user-1"}) +``` + +`events` is called on every invocation and must return a fresh async iterable of OpenCode session events. `config().stream()` yields `chunk` then `done`. Tool events are available from `launchdarkly_ai_server.rich_stream`. diff --git a/packages/opencode-agents/agents.md b/packages/opencode-agents/agents.md new file mode 100644 index 00000000..1c9f558d --- /dev/null +++ b/packages/opencode-agents/agents.md @@ -0,0 +1,13 @@ +# Agent Guide — `launchdarkly-ai-opencode-agents` + +```python +provides_for = ('*', 'agent') +``` + +The handler reduces OpenCode session events the caller supplies. It does not import or construct `anomalyco/opencode-sdk-python`, and it does not start an OpenCode process. + +`create_opencode_handler(*, capture_content=None, events=None)` returns a `ProviderHandler`. `events` is called per invocation and returns an async iterable of bus events (`message.part.delta`, `message.part.updated`, `message.updated`, `session.error`). + +Assistant text is emitted only after `message.updated` marks that message `role == "assistant"`. A part snapshot is the full text of that part so far; a later delta appends. Tool argument text and tool results are omitted unless content capture is on. + +`config().stream()` still yields `chunk` then `done`. `launchdarkly_ai_server.rich_stream` forwards the text and tool events. diff --git a/packages/opencode-agents/pyproject.toml b/packages/opencode-agents/pyproject.toml new file mode 100644 index 00000000..5e55d11d --- /dev/null +++ b/packages/opencode-agents/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "launchdarkly-ai-opencode-agents" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "launchdarkly-ai-server", +] +description = "OpenCode agent handler for LaunchDarkly AI SDK" +readme = "README.md" +license = "Apache-2.0" +authors = [{name = "LaunchDarkly", email = "team@launchdarkly.com"}] +keywords = ["launchdarkly", "ai", "opencode", "agents"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries", +] + +[project.urls] +Homepage = "https://github.com/launchdarkly/python-ai-sdk" +Repository = "https://github.com/launchdarkly/python-ai-sdk" +"Bug Tracker" = "https://github.com/launchdarkly/python-ai-sdk/issues" + +[build-system] +requires = ["hatchling>=1.32,<2"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/launchdarkly_ai_opencode_agents"] diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/__init__.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/__init__.py new file mode 100644 index 00000000..9b3ca199 --- /dev/null +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/__init__.py @@ -0,0 +1,17 @@ +"""LaunchDarkly AI SDK - integration for OpenCode agents. + +See https://launchdarkly.com/docs for usage. +""" + +from launchdarkly_ai_server import register_ai_sdk_package + +from ._version import PACKAGE_NAME, __version__ +from .handler import OpenCodeSessionError, create_opencode_handler, opencode_agents + +__all__ = [ + "OpenCodeSessionError", + "create_opencode_handler", + "opencode_agents", +] + +register_ai_sdk_package(PACKAGE_NAME, __version__) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/_version.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/_version.py new file mode 100644 index 00000000..e019305d --- /dev/null +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/_version.py @@ -0,0 +1,4 @@ +"""This package's identity, as sent with ``$ld:ai:sdk:info`` and ``$ld:ai:sdk:usage``.""" + +PACKAGE_NAME = "launchdarkly-ai-opencode-agents" +__version__ = "0.1.0" # x-release-please-version diff --git a/packages/client/src/launchdarkly_ai_server/experimental/_opencode.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py similarity index 76% rename from packages/client/src/launchdarkly_ai_server/experimental/_opencode.py rename to packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index a0c2bc26..78495182 100644 --- a/packages/client/src/launchdarkly_ai_server/experimental/_opencode.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -12,8 +12,17 @@ from collections.abc import AsyncGenerator, AsyncIterable, Callable, Mapping from typing import Any -from ..types import AiConfigRep, ProviderHandler -from ..utils import _create_handler, number_or_zero +from launchdarkly_ai_server import ( + AiConfigRep, + ProviderHandler, + _config, + _create_handler, +) +from launchdarkly_ai_server.lifecycle import effective_capture_content +from launchdarkly_ai_server.sdk_usage import report_usage +from launchdarkly_ai_server.utils import number_or_zero + +from ._version import PACKAGE_NAME, __version__ _TOOL_FAILURE = "An error occurred." @@ -27,14 +36,20 @@ class OpenCodeSessionError(Exception): class _Reducer: - """One pass over a session. Repeated snapshots do not emit a second event.""" + """One pass over a session. + + Text is emitted only for messages ``message.updated`` has marked assistant. + A part snapshot is that part's full text so far: a later delta still appends, + and a snapshot that repeats text already emitted adds nothing. + """ def __init__(self, capture_content: bool) -> None: self._capture = capture_content self._open_message: str | None = None self._deltas: list[str] = [] - self._delta_parts: set[str] = set() - self._snapshot_parts: set[str] = set() + self._roles: dict[str, str] = {} + self._held: dict[str, list[tuple[str, str, str]]] = {} + self._part_text: dict[str, str] = {} self._tools: dict[str, str] = {} self._usage_ids: set[str] = set() self._input = 0 @@ -72,6 +87,46 @@ async def reduce( for event in self._end_open(): yield event + def _accept_text( + self, message_id: str, part_id: str, kind: str, text: str + ) -> list[dict[str, Any]]: + role = self._roles.get(message_id) + if role is None: + self._held.setdefault(message_id, []).append((kind, part_id, text)) + return [] + if role != "assistant": + return [] + return self._apply_text(message_id, part_id, kind, text) + + def _flush_held(self, message_id: str) -> list[dict[str, Any]]: + held = self._held.pop(message_id, []) + events: list[dict[str, Any]] = [] + for kind, part_id, text in held: + events.extend(self._apply_text(message_id, part_id, kind, text)) + return events + + def _apply_text( + self, message_id: str, part_id: str, kind: str, text: str + ) -> list[dict[str, Any]]: + if kind == "delta": + if part_id: + self._part_text[part_id] = self._part_text.get(part_id, "") + text + return self._text(message_id, text) + already = self._part_text.get(part_id, "") + if text.startswith(already): + suffix = text[len(already) :] + if part_id: + self._part_text[part_id] = text + elif not already: + suffix = text + if part_id: + self._part_text[part_id] = text + else: + return [] + if not suffix: + return [] + return self._text(message_id, suffix) + def _event(self, raw: Mapping[str, Any] | object) -> list[dict[str, Any]]: if not isinstance(raw, Mapping): return [] @@ -104,11 +159,9 @@ def _delta(self, properties: Mapping[str, Any]) -> list[dict[str, Any]]: if not isinstance(delta, str) or not delta or not isinstance(message_id, str): return [] part_id = properties.get("partID") - if isinstance(part_id, str): - if part_id in self._snapshot_parts: - return [] - self._delta_parts.add(part_id) - return self._text(message_id, delta) + return self._accept_text( + message_id, part_id if isinstance(part_id, str) else "", "delta", delta + ) def _text_snapshot(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: message_id = part.get("messageID") @@ -121,10 +174,7 @@ def _text_snapshot(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: or not isinstance(part_id, str) ): return [] - if part_id in self._delta_parts or part_id in self._snapshot_parts: - return [] - self._snapshot_parts.add(part_id) - return self._text(message_id, text) + return self._accept_text(message_id, part_id, "snapshot", text) def _text(self, message_id: str, delta: str) -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] @@ -150,14 +200,21 @@ def _end_open(self) -> list[dict[str, Any]]: return [{"type": "TEXT_MESSAGE_END", "messageId": message_id}] def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: - if info.get("role") != "assistant": - return [] - completed = info.get("time") - if not isinstance(completed, Mapping) or "completed" not in completed: - return [] message_id = info.get("id") + role = info.get("role") events: list[dict[str, Any]] = [] - if isinstance(message_id, str) and message_id == self._open_message: + if isinstance(message_id, str) and isinstance(role, str): + self._roles[message_id] = role + if role == "assistant": + events.extend(self._flush_held(message_id)) + else: + self._held.pop(message_id, None) + if role != "assistant" or not isinstance(message_id, str): + return events + completed = info.get("time") + if not isinstance(completed, Mapping) or "completed" not in completed: + return events + if message_id == self._open_message: events.extend(self._end_open()) if isinstance(message_id, str) and message_id not in self._usage_ids: tokens = info.get("tokens") @@ -307,13 +364,10 @@ def create_opencode_handler( default from ``configure`` when the call runs, and ``True`` or ``False`` overrides it. Tool argument text and tool results are omitted from the stream unless capture is on. ``config().stream()`` still yields assistant - text as ``chunk`` and one ``done``. Tool events stay on the experimental - reader. + text as ``chunk`` and one ``done``. Tool events are on + ``launchdarkly_ai_server.rich_stream``. """ - from ..lifecycle import effective_capture_content - from ..sdk_usage import report_usage - - report_usage("client.experimental.opencode.createHandler") + report_usage("opencode-agents.createOpenCodeHandler", PACKAGE_NAME, __version__) return _create_opencode_handler( capture_content=capture_content, events=events, @@ -321,6 +375,34 @@ def create_opencode_handler( ) +def opencode_agents( + config_key: str, + user_input: str, + context: dict[str, Any], + **kwargs: Any, +) -> Any: + """Convenience wrapper: creates a handler and calls ``config(...).invoke()``. + + *events* is the session event source, the same callable + :func:`create_opencode_handler` takes. This does not construct an OpenCode + SDK client. + """ + report_usage("opencode-agents.opencodeAgents", PACKAGE_NAME, __version__) + variables = kwargs.pop("variables", None) + history = kwargs.pop("history", None) + capture_content = kwargs.pop("capture_content", None) + events = kwargs.pop("events", None) + return _config( + key=config_key, + handler=_create_opencode_handler( + capture_content=capture_content, + events=events, + resolve_capture=effective_capture_content, + ), + **kwargs, + ).invoke(user_input, context, variables, history) + + def _create_opencode_handler( *, capture_content: bool | None, diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/py.typed b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/client/tests/test_opencode.py b/packages/opencode-agents/tests/test_handler.py similarity index 86% rename from packages/client/tests/test_opencode.py rename to packages/opencode-agents/tests/test_handler.py index da0cf810..9d929857 100644 --- a/packages/client/tests/test_opencode.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -1,4 +1,4 @@ -"""OpenCode handler: synthetic session events, stable stream, experimental reader.""" +"""OpenCode handler: synthetic session events, stable stream, rich stream reader.""" from __future__ import annotations @@ -9,12 +9,17 @@ import pytest import launchdarkly_ai_server.lifecycle as lifecycle_module -from launchdarkly_ai_server import ProviderHandler, SDKOptions, config, configure -from launchdarkly_ai_server.experimental.opencode import ( +from launchdarkly_ai_opencode_agents import ( OpenCodeSessionError, create_opencode_handler, ) -from launchdarkly_ai_server.experimental.rich_stream import stream as rich_stream +from launchdarkly_ai_server import ( + ProviderHandler, + SDKOptions, + config, + configure, + rich_stream, +) CONTEXT = {"kind": "user", "key": "user-1"} @@ -74,6 +79,13 @@ def tool_part( ) +def assistant_open(message_id: str) -> dict[str, Any]: + return bus( + "message.updated", + info={"id": message_id, "role": "assistant", "time": {"created": 1}}, + ) + + def assistant_completed( message_id: str, *, @@ -131,25 +143,23 @@ def test_provides_for_is_any_provider_in_agent_mode(self) -> None: handler = create_opencode_handler(events=source([])) assert handler.provides_for == ("*", "agent") - def test_names_stay_off_the_package_root(self) -> None: - import launchdarkly_ai_server as package - from launchdarkly_ai_server.experimental import opencode + def test_package_exports_the_handler(self) -> None: + import launchdarkly_ai_opencode_agents as package - assert set(opencode.__all__) == { + assert set(package.__all__) == { "OpenCodeSessionError", "create_opencode_handler", + "opencode_agents", } - public = {name for name in dir(opencode) if not name.startswith("_")} - assert public == set(opencode.__all__) - for name in opencode.__all__: - assert name not in package.__all__ - assert not hasattr(package, name) + for name in package.__all__: + assert hasattr(package, name) class TestTextAndTools: async def test_text_and_a_successful_tool(self) -> None: events = await collect( [ + assistant_open("m1"), text_delta("m1", "p1", "Looking up"), tool_part("c1", "running", raw_input={"command": "ls"}), tool_part("c1", "running", raw_input={"command": "ls"}), @@ -247,6 +257,7 @@ async def test_completed_assistant_usage_is_summed_once(self) -> None: tokens = {"input": 3, "output": 4, "cache": {"read": 1, "write": 2}} events = await collect( [ + assistant_open("m1"), text_delta("m1", "p1", "Hi"), assistant_completed("m1", tokens=tokens), assistant_completed("m1", tokens=tokens), @@ -262,10 +273,12 @@ async def test_completed_assistant_usage_is_summed_once(self) -> None: async def test_text_after_a_tool_is_a_new_message(self) -> None: events = await collect( [ + assistant_open("m1"), text_delta("m1", "p1", "A"), tool_part("c1", "running", raw_input={"q": 1}), tool_part("c1", "completed", raw_input={"q": 1}, output="ok"), assistant_completed("m1"), + assistant_open("m2"), text_delta("m2", "p2", "B"), assistant_completed("m2"), ], @@ -277,6 +290,7 @@ async def test_text_after_a_tool_is_a_new_message(self) -> None: async def test_a_text_snapshot_does_not_repeat_deltas(self) -> None: events = await collect( [ + assistant_open("m1"), text_delta("m1", "p1", "Hi"), bus( "message.part.updated", @@ -297,11 +311,69 @@ async def test_a_text_snapshot_does_not_repeat_deltas(self) -> None: {"type": "TEXT_MESSAGE_CONTENT", "messageId": "m1", "delta": "Hi"} ] + async def test_a_snapshot_then_later_deltas_append(self) -> None: + events = await collect( + [ + assistant_open("m1"), + bus( + "message.part.updated", + part={"id": "p1", "messageID": "m1", "type": "text", "text": "He"}, + ), + text_delta("m1", "p1", "llo"), + bus( + "message.part.updated", + part={ + "id": "p1", + "messageID": "m1", + "type": "text", + "text": "Hello", + }, + ), + assistant_completed("m1"), + ], + ) + contents = [ + event["delta"] + for event in events + if event["type"] == "TEXT_MESSAGE_CONTENT" + ] + assert contents == ["He", "llo"] + assert events[-1]["output"] == "Hello" + + async def test_user_text_is_not_the_assistant_reply(self) -> None: + events = await collect( + [ + bus( + "message.part.updated", + part={ + "id": "pu", + "messageID": "u1", + "type": "text", + "text": "secret prompt", + }, + ), + bus( + "message.updated", + info={ + "id": "u1", + "role": "user", + "time": {"created": 1, "completed": 1}, + }, + ), + assistant_open("m1"), + text_delta("m1", "p1", "Hi"), + assistant_completed("m1"), + ], + ) + assert events[-1]["output"] == "Hi" + assert "secret prompt" not in str(events) + async def test_session_error_raises_without_the_provider_body(self) -> None: handler = create_opencode_handler( capture_content=True, events=source( [ + assistant_open("m1"), text_delta("m1", "p1", "Hi"), { "type": "session.error", @@ -386,6 +458,7 @@ async def test_tool_events_do_not_close_the_stable_stream( capture_content=True, events=source( [ + assistant_open("m1"), text_delta("m1", "p1", "Looking up"), tool_part("c1", "running", raw_input={"command": "ls"}), tool_part( @@ -395,6 +468,7 @@ async def test_tool_events_do_not_close_the_stable_stream( output="file.txt", ), assistant_completed("m1"), + assistant_open("m2"), text_delta("m2", "p2", "Done."), assistant_completed("m2"), ] @@ -420,6 +494,7 @@ async def test_session_error_does_not_yield_stable_done( handler = create_opencode_handler( events=source( [ + assistant_open("m1"), text_delta("m1", "p1", "Hi"), {"type": "session.error", "properties": {"error": {"name": "X"}}}, ] @@ -483,6 +558,7 @@ async def test_rich_reader_forwards_text_and_tools( capture_content=True, events=source( [ + assistant_open("m1"), text_delta("m1", "p1", "Looking up"), tool_part("c1", "running", raw_input={"command": "ls"}), tool_part( diff --git a/pyproject.toml b/pyproject.toml index 99e24585..0dae9678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,4 +66,5 @@ known-first-party = [ "launchdarkly_ai_langchain_messages", "launchdarkly_ai_openai_agents", "launchdarkly_ai_openai_messages", + "launchdarkly_ai_opencode_agents", ] diff --git a/release-please-config.json b/release-please-config.json index 338b314b..18bfdc82 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -89,6 +89,17 @@ "src/launchdarkly_ai_langchain_messages/_version.py" ], "component": "launchdarkly-ai-langchain-messages" + }, + "packages/opencode-agents": { + "release-type": "python", + "versioning": "default", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "include-v-in-tag": false, + "extra-files": [ + "src/launchdarkly_ai_opencode_agents/_version.py" + ], + "component": "launchdarkly-ai-opencode-agents" } }, "changelog-sections": [ @@ -98,12 +109,6 @@ { "type": "feat", "scope": "evaluations", "section": "Experimental" }, { "type": "fix", "scope": "evaluations", "section": "Experimental" }, { "type": "perf", "scope": "evaluations", "section": "Experimental" }, - { "type": "feat", "scope": "opencode", "section": "Experimental" }, - { "type": "fix", "scope": "opencode", "section": "Experimental" }, - { "type": "perf", "scope": "opencode", "section": "Experimental" }, - { "type": "feat", "scope": "rich-stream", "section": "Experimental" }, - { "type": "fix", "scope": "rich-stream", "section": "Experimental" }, - { "type": "perf", "scope": "rich-stream", "section": "Experimental" }, { "type": "feat", "section": "Features" }, { "type": "fix", "section": "Bug Fixes" }, { "type": "perf", "section": "Performance Improvements" }, diff --git a/uv.lock b/uv.lock index d8fba767..860a08fa 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", @@ -19,6 +19,7 @@ members = [ "launchdarkly-ai-langchain-messages", "launchdarkly-ai-openai-agents", "launchdarkly-ai-openai-messages", + "launchdarkly-ai-opencode-agents", "launchdarkly-ai-python", "launchdarkly-ai-server", ] @@ -844,7 +845,7 @@ wheels = [ [[package]] name = "launchdarkly-ai-claude-agents" -version = "0.2.3" +version = "0.2.5" source = { editable = "packages/claude-agents" } dependencies = [ { name = "anthropic" }, @@ -863,7 +864,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-claude-messages" -version = "0.2.3" +version = "0.2.4" source = { editable = "packages/claude-messages" } dependencies = [ { name = "anthropic" }, @@ -880,7 +881,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-langchain-agents" -version = "0.2.3" +version = "0.2.5" source = { editable = "packages/langchain-agents" } dependencies = [ { name = "langchain-core" }, @@ -899,7 +900,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-langchain-messages" -version = "0.2.3" +version = "0.2.4" source = { editable = "packages/langchain-messages" } dependencies = [ { name = "langchain-core" }, @@ -916,7 +917,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-openai-agents" -version = "0.2.3" +version = "0.2.5" source = { editable = "packages/openai-agents" } dependencies = [ { name = "launchdarkly-ai-server" }, @@ -935,7 +936,7 @@ requires-dist = [ [[package]] name = "launchdarkly-ai-openai-messages" -version = "0.2.3" +version = "0.2.4" source = { editable = "packages/openai-messages" } dependencies = [ { name = "launchdarkly-ai-server" }, @@ -950,9 +951,20 @@ requires-dist = [ { name = "opentelemetry-api", specifier = ">=1.25" }, ] +[[package]] +name = "launchdarkly-ai-opencode-agents" +version = "0.1.0" +source = { editable = "packages/opencode-agents" } +dependencies = [ + { name = "launchdarkly-ai-server" }, +] + +[package.metadata] +requires-dist = [{ name = "launchdarkly-ai-server", editable = "packages/client" }] + [[package]] name = "launchdarkly-ai-python" -version = "0.1.7" +version = "0.2.0" source = { editable = "packages/ai" } dependencies = [ { name = "launchdarkly-ai-server" }, @@ -972,7 +984,7 @@ provides-extras = ["otel"] [[package]] name = "launchdarkly-ai-server" -version = "0.2.3" +version = "0.3.0" source = { editable = "packages/client" } dependencies = [ { name = "opentelemetry-api" }, From be393402a1719a9907a667b29f34ece8f6daf6d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 02:57:57 +0000 Subject: [PATCH 22/28] docs(opencode-agents): install the server SDK with the handler init_client() discovers launchdarkly-server-sdk at runtime, so the package README install line has to include it. Co-authored-by: alexis --- packages/opencode-agents/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode-agents/README.md b/packages/opencode-agents/README.md index bb3b1775..9bdb72e7 100644 --- a/packages/opencode-agents/README.md +++ b/packages/opencode-agents/README.md @@ -5,7 +5,7 @@ OpenCode handler for `launchdarkly-ai-server`. It reduces caller-supplied OpenCo **`provides_for`:** `['*', 'agent']` — matches an agent-mode variation for any provider. ```bash -pip install launchdarkly-ai-server launchdarkly-ai-opencode-agents +pip install launchdarkly-ai-server launchdarkly-server-sdk launchdarkly-ai-opencode-agents ``` ```python From e640266482b114fac842d9e02fa405157c68c5f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 13:58:55 +0000 Subject: [PATCH 23/28] refactor(opencode-agents): yield chunk then done The public stream matches the other handlers. This change does not export a rich stream reader or forward tool events. --- .../src/launchdarkly_ai_server/__init__.py | 2 - .../src/launchdarkly_ai_server/rich_stream.py | 167 -------- .../src/launchdarkly_ai_server/tracking.py | 108 +----- packages/client/tests/test_public_surface.py | 1 - packages/opencode-agents/README.md | 4 +- packages/opencode-agents/agents.md | 4 +- .../handler.py | 206 ++-------- .../opencode-agents/tests/test_handler.py | 365 ++---------------- 8 files changed, 78 insertions(+), 779 deletions(-) delete mode 100644 packages/client/src/launchdarkly_ai_server/rich_stream.py diff --git a/packages/client/src/launchdarkly_ai_server/__init__.py b/packages/client/src/launchdarkly_ai_server/__init__.py index 7473d7de..44163c5c 100644 --- a/packages/client/src/launchdarkly_ai_server/__init__.py +++ b/packages/client/src/launchdarkly_ai_server/__init__.py @@ -52,7 +52,6 @@ resolve_handlers, resolve_tools, ) -from .rich_stream import stream as rich_stream from .sdk_info import SDK_INFO_CONTEXT, SDK_INFO_EVENT, register_ai_sdk_package from .sdk_usage import report_usage from .tracking import execute_and_stream, execute_and_track, wrap_tool_handlers @@ -227,7 +226,6 @@ # client "config", "ConfigInstance", - "rich_stream", # conversation "ConversationIdSpanProcessor", "conversation_id", diff --git a/packages/client/src/launchdarkly_ai_server/rich_stream.py b/packages/client/src/launchdarkly_ai_server/rich_stream.py deleted file mode 100644 index 09744972..00000000 --- a/packages/client/src/launchdarkly_ai_server/rich_stream.py +++ /dev/null @@ -1,167 +0,0 @@ -"""AG-UI reader over the same handler drive as ``config().stream()``. - -The stable reader keeps yielding ``chunk`` then ``done``. This one forwards -text and tool events and brackets them with ``RUN_STARTED`` and ``RUN_FINISHED``. -A raised turn ends the generator with neither ``RUN_FINISHED`` nor ``RUN_ERROR``. -""" - -from __future__ import annotations - -import uuid -from collections.abc import AsyncGenerator -from typing import Any - -from .client import ConfigInstance -from .conversation import bind_conversation_id -from .judges import run_judges -from .lifecycle import extract_variation -from .registry import resolve_handlers, resolve_tools -from .sdk_usage import report_usage -from .tracking import _SETTLED_EVENT, _drive_handler_stream -from .types import LDContext -from .utils import select_handler - -_TEXT = frozenset({"TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END"}) -_TOOLS = frozenset( - {"TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT"} -) - -__all__ = ["stream"] - - -def stream( - config_instance: ConfigInstance, - user_input: str | None, - context: LDContext, - *, - variables: dict[str, Any] | None = None, - history: list[dict[str, Any]] | None = None, -) -> AsyncGenerator[dict[str, Any], None]: - """Yield AG-UI events for one ``config()`` call. - - Takes the same arguments as ``ConfigInstance.stream``. ``config().stream()`` - is unchanged. This function is the opt-in reader; it is not a default, and - it does not read or set a protocol field. - """ - report_usage("client.richStream") - return bind_conversation_id( - _events(config_instance, user_input, context, variables, history) - ) - - -async def _events( - config_instance: ConfigInstance, - user_input: str | None, - context: LDContext, - variables: dict[str, Any] | None, - history: list[dict[str, Any]] | None, -) -> AsyncGenerator[dict[str, Any], None]: - resolved_handler_list = resolve_handlers( - config_instance._registry, config_instance._normalize_handlers() - ) - resolved_tools = resolve_tools( - config_instance._registry, config_instance._tool_handlers - ) - variation = await extract_variation(config_instance._key, context) - config = variation["config"] - meta = variation["meta"] - if not resolved_handler_list: - raise ValueError("No handlers provided to config()") - handler = select_handler(config, meta, resolved_handler_list) - - thread_id = str(uuid.uuid4()) - run_id = str(uuid.uuid4()) - yield { - "type": "RUN_STARTED", - "threadId": thread_id, - "runId": run_id, - "protocolVersion": "1.0", - } - - settled: dict[str, Any] | None = None - synthetic_id: str | None = None - saw_canonical_text = False - async for event in _drive_handler_stream( - config_key=config_instance._key, - config=config, - meta=meta, - user_context=context, - handler=handler, - user_input=user_input, - tool_handlers=resolved_tools, - variables=variables, - history=history, - ): - if event.get("type") == _SETTLED_EVENT: - settled = event - continue - kind = event.get("type") - if kind in _TEXT: - if synthetic_id is not None: - yield {"type": "TEXT_MESSAGE_END", "messageId": synthetic_id} - synthetic_id = None - saw_canonical_text = True - yield event - elif kind == "chunk" and not saw_canonical_text: - text = event.get("text") - if not isinstance(text, str) or not text: - continue - if synthetic_id is None: - synthetic_id = str(uuid.uuid4()) - yield {"type": "TEXT_MESSAGE_START", "messageId": synthetic_id} - yield { - "type": "TEXT_MESSAGE_CONTENT", - "messageId": synthetic_id, - "delta": text, - } - elif kind in _TOOLS: - yield event - - if synthetic_id is not None: - yield {"type": "TEXT_MESSAGE_END", "messageId": synthetic_id} - - if settled is None: - return - - judge_results: dict[str, Any] = {} - if not config_instance._skip_judges: - judge_results = await run_judges( - config=config, - user_context=context, - handler=handler, - handlers=resolved_handler_list, - user_input=user_input, - trajectory=settled.get("trajectory", ""), - llm_response=settled.get("response", ""), - base_track_data=settled.get("track_data", {}), - tool_handlers=resolved_tools, - ) - - finished: dict[str, Any] = { - "type": "RUN_FINISHED", - "threadId": thread_id, - "runId": run_id, - } - response = settled.get("response") - if isinstance(response, str) and response: - finished["result"] = response - if settled.get("had_usage"): - usage = settled.get("usage") - if isinstance(usage, dict): - finished["usage"] = _token_usage(usage) - if judge_results: - finished["metadata"] = {"launchdarkly": {"judgeResults": judge_results}} - yield finished - - -def _token_usage(parsed: dict[str, Any]) -> list[dict[str, Any]]: - entry: dict[str, Any] = { - "inputTokens": parsed.get("input", 0), - "outputTokens": parsed.get("output", 0), - "totalTokens": parsed.get("total", 0), - } - details = parsed.get("input_details") - if isinstance(details, dict): - entry["cachedInputTokens"] = details.get("cache_read", 0) - entry["cacheWriteInputTokens"] = details.get("cache_creation", 0) - return [entry] diff --git a/packages/client/src/launchdarkly_ai_server/tracking.py b/packages/client/src/launchdarkly_ai_server/tracking.py index 70777254..6e7a5638 100644 --- a/packages/client/src/launchdarkly_ai_server/tracking.py +++ b/packages/client/src/launchdarkly_ai_server/tracking.py @@ -236,12 +236,7 @@ async def execute_and_track( } -# Private terminal of :func:`_drive_handler_stream`. Not a handler event and not -# forwarded by either reader. A handler that yields this type is ignored. -_SETTLED_EVENT = "__ld_settled" - - -async def _drive_handler_stream( +async def execute_and_stream( *, config_key: str, config: AiConfigRep, @@ -253,13 +248,14 @@ async def _drive_handler_stream( variables: dict[str, Any] | None = None, graph_key: str | None = None, history: list[dict[str, Any]] | None = None, -) -> AsyncGenerator[dict[str, Any], None]: - """Run one handler stream and yield its events, then one settled record. +) -> AsyncGenerator[ExecuteStreamEvent, None]: + """ + Streaming counterpart to ``execute_and_track``. Yields ``chunk`` events + from the handler, then a single ``done`` event once the stream completes + and LD telemetry has been flushed. - The settled record carries the response, parsed usage, tracking payload and - trajectory. Handler ``done`` is consumed here. Telemetry matches - :func:`execute_and_stream`. A raise records the error metrics and propagates, - with no settled record. + Falls back to calling the blocking handler when the handler has no + ``stream`` implementation. """ from .lifecycle import get_client @@ -301,8 +297,6 @@ async def _drive_handler_stream( start_time = time.monotonic() full_text = "" raw_usage: dict[str, Any] = {} - had_usage = False - saw_text_message = False try: if handler.has_stream: @@ -310,24 +304,13 @@ async def _drive_handler_stream( config, user_input, tracked_tool_handlers, merged_variables, history ) async for event in stream_gen: - kind = event.get("type") - if kind == "chunk": - if not saw_text_message: - full_text += event.get("text", "") - yield event - elif kind == "TEXT_MESSAGE_CONTENT": - saw_text_message = True - delta = event.get("delta") - if isinstance(delta, str): - full_text += delta - yield event - elif kind == "done": + if event.get("type") == "chunk": + full_text += event.get("text", "") + yield {"type": "chunk", "text": event["text"]} + elif event.get("type") == "done": if event.get("output") is not None: full_text = event["output"] raw_usage = event.get("usage", {}) - had_usage = bool(event.get("usage")) - elif kind != _SETTLED_EVENT: - yield event else: result = await handler( config, user_input, tracked_tool_handlers, merged_variables, history @@ -335,7 +318,6 @@ async def _drive_handler_stream( out = result.get("output") full_text = str(out) if out is not None else "" raw_usage = result.get("usage") or {} - had_usage = bool(result.get("usage")) if full_text: yield {"type": "chunk", "text": full_text} except Exception: @@ -357,10 +339,9 @@ async def _drive_handler_stream( client.track("$ld:ai:tokens:output", ld_ctx, track_data, usage["output"]) yield { - "type": _SETTLED_EVENT, + "type": "done", "response": full_text, "usage": usage, - "had_usage": had_usage, "track_data": track_data, "trajectory": render_trajectory( recorder.invocations, @@ -368,66 +349,3 @@ async def _drive_handler_stream( omitted=recorder.omitted, ), } - - -async def execute_and_stream( - *, - config_key: str, - config: AiConfigRep, - meta: VariationMeta, - user_context: LDContext, - handler: ProviderHandler, - user_input: str | None = None, - tool_handlers: dict[str, Callable[..., Any] | NativeTool] | None = None, - variables: dict[str, Any] | None = None, - graph_key: str | None = None, - history: list[dict[str, Any]] | None = None, -) -> AsyncGenerator[ExecuteStreamEvent, None]: - """ - Streaming counterpart to ``execute_and_track``. Yields ``chunk`` events - from the handler, then a single ``done`` event once the stream completes - and LD telemetry has been flushed. - - Assistant text arrives as ``chunk``. ``TEXT_MESSAGE_CONTENT`` contributes - its ``delta`` as a ``chunk`` as well. A tool event, or any other handler - event, is not forwarded and does not end the turn. Only the handler's - ``done`` supplies output and usage. ``config().stream()`` therefore stays - ``chunk`` then ``done``. - - Falls back to calling the blocking handler when the handler has no - ``stream`` implementation. - """ - settled: dict[str, Any] | None = None - saw_text_message = False - async for event in _drive_handler_stream( - config_key=config_key, - config=config, - meta=meta, - user_context=user_context, - handler=handler, - user_input=user_input, - tool_handlers=tool_handlers, - variables=variables, - graph_key=graph_key, - history=history, - ): - if event.get("type") == _SETTLED_EVENT: - settled = event - continue - kind = event.get("type") - if kind == "TEXT_MESSAGE_CONTENT": - saw_text_message = True - delta = event.get("delta") - if isinstance(delta, str) and delta: - yield {"type": "chunk", "text": delta} - elif kind == "chunk" and not saw_text_message: - yield {"type": "chunk", "text": event["text"]} - if settled is None: - return - yield { - "type": "done", - "response": settled.get("response", ""), - "usage": settled.get("usage"), - "track_data": settled.get("track_data"), - "trajectory": settled.get("trajectory", ""), - } diff --git a/packages/client/tests/test_public_surface.py b/packages/client/tests/test_public_surface.py index cf1e408e..e77b854e 100644 --- a/packages/client/tests/test_public_surface.py +++ b/packages/client/tests/test_public_surface.py @@ -97,7 +97,6 @@ "register_ai_sdk_package", "report_usage", "resolve_graph", - "rich_stream", "resolve_handlers", "resolve_tools", "run_judge", diff --git a/packages/opencode-agents/README.md b/packages/opencode-agents/README.md index 9bdb72e7..fff1a306 100644 --- a/packages/opencode-agents/README.md +++ b/packages/opencode-agents/README.md @@ -1,6 +1,6 @@ # `launchdarkly-ai-opencode-agents` -OpenCode handler for `launchdarkly-ai-server`. It reduces caller-supplied OpenCode session events into assistant text and tool events. It does not start OpenCode and it does not depend on an OpenCode SDK. +OpenCode handler for `launchdarkly-ai-server`. It reduces caller-supplied OpenCode session events into assistant text. It does not start OpenCode and it does not depend on an OpenCode SDK. **`provides_for`:** `['*', 'agent']` — matches an agent-mode variation for any provider. @@ -16,4 +16,4 @@ handler = create_opencode_handler(events=session_events) config(key="my-agent", handler=handler).stream("Hello", {"kind": "user", "key": "user-1"}) ``` -`events` is called on every invocation and must return a fresh async iterable of OpenCode session events. `config().stream()` yields `chunk` then `done`. Tool events are available from `launchdarkly_ai_server.rich_stream`. +`events` is called on every invocation and must return a fresh async iterable of OpenCode session events. `config().stream()` yields `chunk` then `done`. diff --git a/packages/opencode-agents/agents.md b/packages/opencode-agents/agents.md index 1c9f558d..17af5d0b 100644 --- a/packages/opencode-agents/agents.md +++ b/packages/opencode-agents/agents.md @@ -8,6 +8,4 @@ The handler reduces OpenCode session events the caller supplies. It does not imp `create_opencode_handler(*, capture_content=None, events=None)` returns a `ProviderHandler`. `events` is called per invocation and returns an async iterable of bus events (`message.part.delta`, `message.part.updated`, `message.updated`, `session.error`). -Assistant text is emitted only after `message.updated` marks that message `role == "assistant"`. A part snapshot is the full text of that part so far; a later delta appends. Tool argument text and tool results are omitted unless content capture is on. - -`config().stream()` still yields `chunk` then `done`. `launchdarkly_ai_server.rich_stream` forwards the text and tool events. +Assistant text is emitted only after `message.updated` marks that message `role == "assistant"`. A part snapshot is the full text of that part so far; a later delta appends. The public stream is `chunk` then `done`. diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 78495182..f61c5970 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -1,14 +1,12 @@ """Reduce OpenCode session events into handler stream events. The session is a caller-supplied async iterable of OpenCode bus events. This -module does not start a process. Tool execution stays inside OpenCode: the -events record a call that already ran. +module does not start a process and does not construct an OpenCode SDK client. +The public stream is assistant text as ``chunk``, then one ``done``. """ from __future__ import annotations -import json -import uuid from collections.abc import AsyncGenerator, AsyncIterable, Callable, Mapping from typing import Any @@ -18,20 +16,17 @@ _config, _create_handler, ) -from launchdarkly_ai_server.lifecycle import effective_capture_content from launchdarkly_ai_server.sdk_usage import report_usage from launchdarkly_ai_server.utils import number_or_zero from ._version import PACKAGE_NAME, __version__ -_TOOL_FAILURE = "An error occurred." - class OpenCodeSessionError(Exception): """The OpenCode session failed. - The provider error body is not included. A failed session is not a tool - result and does not finish the run. + The provider error body is not included. A failed session does not finish + the run. """ @@ -40,17 +35,15 @@ class _Reducer: Text is emitted only for messages ``message.updated`` has marked assistant. A part snapshot is that part's full text so far: a later delta still appends, - and a snapshot that repeats text already emitted adds nothing. + and a snapshot that repeats text already emitted adds nothing. Tool parts + are not forwarded. """ - def __init__(self, capture_content: bool) -> None: - self._capture = capture_content - self._open_message: str | None = None + def __init__(self) -> None: self._deltas: list[str] = [] self._roles: dict[str, str] = {} self._held: dict[str, list[tuple[str, str, str]]] = {} self._part_text: dict[str, str] = {} - self._tools: dict[str, str] = {} self._usage_ids: set[str] = set() self._input = 0 self._output = 0 @@ -84,8 +77,6 @@ async def reduce( raise OpenCodeSessionError("The OpenCode session failed.") for event in self._event(raw): yield event - for event in self._end_open(): - yield event def _accept_text( self, message_id: str, part_id: str, kind: str, text: str @@ -96,22 +87,20 @@ def _accept_text( return [] if role != "assistant": return [] - return self._apply_text(message_id, part_id, kind, text) + return self._apply_text(part_id, kind, text) def _flush_held(self, message_id: str) -> list[dict[str, Any]]: held = self._held.pop(message_id, []) events: list[dict[str, Any]] = [] for kind, part_id, text in held: - events.extend(self._apply_text(message_id, part_id, kind, text)) + events.extend(self._apply_text(part_id, kind, text)) return events - def _apply_text( - self, message_id: str, part_id: str, kind: str, text: str - ) -> list[dict[str, Any]]: + def _apply_text(self, part_id: str, kind: str, text: str) -> list[dict[str, Any]]: if kind == "delta": if part_id: self._part_text[part_id] = self._part_text.get(part_id, "") + text - return self._text(message_id, text) + return self._chunk(text) already = self._part_text.get(part_id, "") if text.startswith(already): suffix = text[len(already) :] @@ -125,7 +114,7 @@ def _apply_text( return [] if not suffix: return [] - return self._text(message_id, suffix) + return self._chunk(suffix) def _event(self, raw: Mapping[str, Any] | object) -> list[dict[str, Any]]: if not isinstance(raw, Mapping): @@ -138,13 +127,9 @@ def _event(self, raw: Mapping[str, Any] | object) -> list[dict[str, Any]]: return self._delta(properties) if kind == "message.part.updated": part = properties.get("part") - if not isinstance(part, Mapping): + if not isinstance(part, Mapping) or part.get("type") != "text": return [] - if part.get("type") == "tool": - return self._tool(part) - if part.get("type") == "text": - return self._text_snapshot(part) - return [] + return self._text_snapshot(part) if kind == "message.updated": info = properties.get("info") if isinstance(info, Mapping): @@ -176,28 +161,9 @@ def _text_snapshot(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: return [] return self._accept_text(message_id, part_id, "snapshot", text) - def _text(self, message_id: str, delta: str) -> list[dict[str, Any]]: - events: list[dict[str, Any]] = [] - if self._open_message != message_id: - events.extend(self._end_open()) - self._open_message = message_id - events.append({"type": "TEXT_MESSAGE_START", "messageId": message_id}) - events.append( - { - "type": "TEXT_MESSAGE_CONTENT", - "messageId": message_id, - "delta": delta, - } - ) - self._deltas.append(delta) - return events - - def _end_open(self) -> list[dict[str, Any]]: - if self._open_message is None: - return [] - message_id = self._open_message - self._open_message = None - return [{"type": "TEXT_MESSAGE_END", "messageId": message_id}] + def _chunk(self, text: str) -> list[dict[str, Any]]: + self._deltas.append(text) + return [{"type": "chunk", "text": text}] def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: message_id = info.get("id") @@ -214,9 +180,7 @@ def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: completed = info.get("time") if not isinstance(completed, Mapping) or "completed" not in completed: return events - if message_id == self._open_message: - events.extend(self._end_open()) - if isinstance(message_id, str) and message_id not in self._usage_ids: + if message_id not in self._usage_ids: tokens = info.get("tokens") if isinstance(tokens, Mapping): self._add_usage(tokens) @@ -233,109 +197,6 @@ def _add_usage(self, tokens: Mapping[str, Any]) -> None: self._cache_read += number_or_zero(cache.get("read")) self._cache_write += number_or_zero(cache.get("write")) - def _tool(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: - state = part.get("state") - if not isinstance(state, Mapping): - return [] - call_id = part.get("callID") or part.get("id") - name = part.get("tool") - if ( - not isinstance(call_id, str) - or not call_id - or not isinstance(name, str) - or not name - ): - return [] - status = state.get("status") - if status not in ("running", "completed", "error"): - return [] - events: list[dict[str, Any]] = [] - if self._tools.get(call_id) is None and ( - "input" in state or status != "running" - ): - events.extend(self._open_tool(call_id, name, state.get("input"))) - self._tools[call_id] = "open" - if status == "completed" and self._tools.get(call_id) == "open": - events.append(self._result(call_id, state.get("output", ""), failed=False)) - self._tools[call_id] = "done" - elif status == "error" and self._tools.get(call_id) == "open": - events.append(self._result(call_id, None, failed=True)) - self._tools[call_id] = "done" - return events - - def _open_tool( - self, call_id: str, name: str, raw_input: object - ) -> list[dict[str, Any]]: - start: dict[str, Any] = { - "type": "TOOL_CALL_START", - "toolCallId": call_id, - "toolCallName": name, - } - if self._open_message is not None: - start["parentMessageId"] = self._open_message - events = [start] - if self._capture: - text = _argument_text(raw_input) - if text: - events.append( - {"type": "TOOL_CALL_ARGS", "toolCallId": call_id, "delta": text} - ) - events.append({"type": "TOOL_CALL_END", "toolCallId": call_id}) - return events - - def _result(self, call_id: str, output: object, *, failed: bool) -> dict[str, Any]: - content = "" - metadata: dict[str, Any] | None = None - if failed: - metadata = {"launchdarkly": {"toolFailed": True}} - if self._capture: - content = _TOOL_FAILURE - else: - rendered = _result_text(output) - if rendered is None: - metadata = {"launchdarkly": {"toolFailed": True}} - if self._capture: - content = _TOOL_FAILURE - elif self._capture: - content = rendered - event: dict[str, Any] = { - "type": "TOOL_CALL_RESULT", - "messageId": str(uuid.uuid4()), - "toolCallId": call_id, - "content": content, - } - if metadata is not None: - event["metadata"] = metadata - return event - - -def _argument_text(value: object) -> str: - """Argument text for one ``TOOL_CALL_ARGS`` delta. Empty when there is nothing to send.""" - if value is None: - return "" - if isinstance(value, str): - return value - rendered = _json_text(value) - return rendered or "" - - -def _result_text(value: object) -> str | None: - """Success content, or ``None`` when the value cannot be placed on the event.""" - if isinstance(value, str): - return value - return _json_text(value) - - -def _json_text(value: object) -> str | None: - if isinstance(value, (bytes, bytearray, memoryview)): - return None - try: - return json.dumps( - value, ensure_ascii=False, separators=(",", ":"), allow_nan=False - ) - except (TypeError, ValueError): - return None - def _open_events( events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None, @@ -358,21 +219,14 @@ def create_opencode_handler( ``provides_for`` is ``('*', 'agent')``, so the handler matches an agent-mode variation for any provider. *events* is called on every invocation and must return a fresh async iterable of OpenCode session events. This function does - not start OpenCode. - - *capture_content* follows the other handlers: ``None`` reads the process - default from ``configure`` when the call runs, and ``True`` or ``False`` - overrides it. Tool argument text and tool results are omitted from the - stream unless capture is on. ``config().stream()`` still yields assistant - text as ``chunk`` and one ``done``. Tool events are on - ``launchdarkly_ai_server.rich_stream``. + not start OpenCode and does not construct an OpenCode SDK client. + + The stream yields assistant text as ``chunk`` and one ``done``. *capture_content* + follows the other handlers: ``None`` reads the process default from + ``configure`` when content is read, and ``True`` or ``False`` overrides it. """ report_usage("opencode-agents.createOpenCodeHandler", PACKAGE_NAME, __version__) - return _create_opencode_handler( - capture_content=capture_content, - events=events, - resolve_capture=effective_capture_content, - ) + return _create_opencode_handler(capture_content=capture_content, events=events) def opencode_agents( @@ -395,9 +249,7 @@ def opencode_agents( return _config( key=config_key, handler=_create_opencode_handler( - capture_content=capture_content, - events=events, - resolve_capture=effective_capture_content, + capture_content=capture_content, events=events ), **kwargs, ).invoke(user_input, context, variables, history) @@ -407,10 +259,7 @@ def _create_opencode_handler( *, capture_content: bool | None, events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None, - resolve_capture: Callable[[bool | None], bool], ) -> ProviderHandler: - content_override = capture_content - async def _stream_impl( config: AiConfigRep, user_input: str | None, @@ -418,8 +267,7 @@ async def _stream_impl( variables: dict[str, Any] | None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: - capture = resolve_capture(content_override) - reducer = _Reducer(capture) + reducer = _Reducer() async for event in reducer.reduce(_open_events(events)): yield event done: dict[str, Any] = {"type": "done", "output": "".join(reducer.deltas)} @@ -451,5 +299,5 @@ async def _call_impl( ("*", "agent"), _call_impl, _stream_impl, - capture_content=content_override, + capture_content=capture_content, ) diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 9d929857..988ae226 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -1,8 +1,8 @@ -"""OpenCode handler: synthetic session events, stable stream, rich stream reader.""" +"""OpenCode handler: synthetic session events, stable chunk then done stream.""" from __future__ import annotations -from collections.abc import AsyncGenerator, AsyncIterator, Iterator +from collections.abc import AsyncIterator, Iterator from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -13,13 +13,7 @@ OpenCodeSessionError, create_opencode_handler, ) -from launchdarkly_ai_server import ( - ProviderHandler, - SDKOptions, - config, - configure, - rich_stream, -) +from launchdarkly_ai_server import SDKOptions, config, configure CONTEXT = {"kind": "user", "key": "user-1"} @@ -154,9 +148,16 @@ def test_package_exports_the_handler(self) -> None: for name in package.__all__: assert hasattr(package, name) + def test_capture_content_follows_configure(self) -> None: + configure(options=SDKOptions(capture_content=True)) + omitted = create_opencode_handler(events=source([])) + explicit = create_opencode_handler(capture_content=False, events=source([])) + assert omitted.capture_content is True + assert explicit.capture_content is False + -class TestTextAndTools: - async def test_text_and_a_successful_tool(self) -> None: +class TestText: + async def test_text_around_a_tool_is_chunks_then_done(self) -> None: events = await collect( [ assistant_open("m1"), @@ -166,43 +167,22 @@ async def test_text_and_a_successful_tool(self) -> None: tool_part( "c1", "completed", raw_input={"command": "ls"}, output="file.txt" ), - tool_part( - "c1", "completed", raw_input={"command": "ls"}, output="file.txt" - ), assistant_completed("m1"), + assistant_open("m2"), + text_delta("m2", "p2", "Done."), + assistant_completed("m2"), ], ) - assert types_of(events) == [ - "TEXT_MESSAGE_START", - "TEXT_MESSAGE_CONTENT", - "TOOL_CALL_START", - "TOOL_CALL_ARGS", - "TOOL_CALL_END", - "TOOL_CALL_RESULT", - "TEXT_MESSAGE_END", - "done", + assert types_of(events) == ["chunk", "chunk", "done"] + assert [event["text"] for event in events if event["type"] == "chunk"] == [ + "Looking up", + "Done.", ] - assert events[0]["messageId"] == "m1" - assert events[1] == { - "type": "TEXT_MESSAGE_CONTENT", - "messageId": "m1", - "delta": "Looking up", - } - assert events[2]["toolCallId"] == "c1" - assert events[2]["toolCallName"] == "bash" - assert events[2]["parentMessageId"] == "m1" - assert events[3] == { - "type": "TOOL_CALL_ARGS", - "toolCallId": "c1", - "delta": '{"command":"ls"}', - } - assert "toolCallName" not in events[3] - assert events[5]["content"] == "file.txt" - assert events[5]["messageId"] != "c1" - assert "metadata" not in events[5] - assert events[-1]["output"] == "Looking up" + assert events[-1]["output"] == "Looking upDone." + assert "file.txt" not in str(events) + assert "toolCallId" not in str(events) - async def test_a_failed_tool_is_masked(self) -> None: + async def test_a_failed_tool_does_not_leak_into_the_stream(self) -> None: events = await collect( [ tool_part( @@ -213,45 +193,15 @@ async def test_a_failed_tool_is_masked(self) -> None: ) ], ) - result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") - assert result["content"] == "An error occurred." - assert result["metadata"] == {"launchdarkly": {"toolFailed": True}} - assert "disk on fire" not in str(result) - assert "parentMessageId" not in events[0] - - async def test_a_string_argument_is_not_encoded_again(self) -> None: - events = await collect( - [tool_part("c1", "running", raw_input="ls")], - ) - args = next(event for event in events if event["type"] == "TOOL_CALL_ARGS") - assert args["delta"] == "ls" - - async def test_nan_result_is_a_failed_tool(self) -> None: - events = await collect( - [tool_part("c1", "completed", raw_input={}, output=float("nan"))], - ) - result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") - assert result["metadata"] == {"launchdarkly": {"toolFailed": True}} - assert result["content"] == "An error occurred." - - async def test_part_id_is_the_call_id_when_call_id_is_absent(self) -> None: - part = tool_part("ignored", "running", raw_input={"q": 1}, part_id="part-9") - del part["properties"]["part"]["callID"] - events = await collect([part]) - assert events[0]["toolCallId"] == "part-9" + assert types_of(events) == ["done"] + assert events[-1]["output"] == "" + assert "disk on fire" not in str(events) - async def test_interleaved_tools_keep_arrival_order(self) -> None: + async def test_pending_tool_state_emits_nothing(self) -> None: events = await collect( - [ - tool_part("a", "running", raw_input={"q": "a"}, name="search"), - tool_part("b", "running", raw_input={"q": "b"}, name="weather"), - tool_part("b", "completed", raw_input={"q": "b"}, output="sun"), - tool_part("a", "completed", raw_input={"q": "a"}, output="rows"), - ], + [tool_part("c1", "pending", raw_input={}, output=None)], ) - results = [event for event in events if event["type"] == "TOOL_CALL_RESULT"] - assert [event["toolCallId"] for event in results] == ["b", "a"] - assert [event["content"] for event in results] == ["sun", "rows"] + assert types_of(events) == ["done"] async def test_completed_assistant_usage_is_summed_once(self) -> None: tokens = {"input": 3, "output": 4, "cache": {"read": 1, "write": 2}} @@ -270,23 +220,6 @@ async def test_completed_assistant_usage_is_summed_once(self) -> None: "cache_creation_input_tokens": 2, } - async def test_text_after_a_tool_is_a_new_message(self) -> None: - events = await collect( - [ - assistant_open("m1"), - text_delta("m1", "p1", "A"), - tool_part("c1", "running", raw_input={"q": 1}), - tool_part("c1", "completed", raw_input={"q": 1}, output="ok"), - assistant_completed("m1"), - assistant_open("m2"), - text_delta("m2", "p2", "B"), - assistant_completed("m2"), - ], - ) - starts = [event for event in events if event["type"] == "TEXT_MESSAGE_START"] - assert [event["messageId"] for event in starts] == ["m1", "m2"] - assert events[-1]["output"] == "AB" - async def test_a_text_snapshot_does_not_repeat_deltas(self) -> None: events = await collect( [ @@ -304,12 +237,9 @@ async def test_a_text_snapshot_does_not_repeat_deltas(self) -> None: assistant_completed("m1"), ], ) - contents = [ - event for event in events if event["type"] == "TEXT_MESSAGE_CONTENT" - ] - assert contents == [ - {"type": "TEXT_MESSAGE_CONTENT", "messageId": "m1", "delta": "Hi"} - ] + chunks = [event for event in events if event["type"] == "chunk"] + assert chunks == [{"type": "chunk", "text": "Hi"}] + assert events[-1]["output"] == "Hi" async def test_a_snapshot_then_later_deltas_append(self) -> None: events = await collect( @@ -332,12 +262,8 @@ async def test_a_snapshot_then_later_deltas_append(self) -> None: assistant_completed("m1"), ], ) - contents = [ - event["delta"] - for event in events - if event["type"] == "TEXT_MESSAGE_CONTENT" - ] - assert contents == ["He", "llo"] + chunks = [event["text"] for event in events if event["type"] == "chunk"] + assert chunks == ["He", "llo"] assert events[-1]["output"] == "Hello" async def test_user_text_is_not_the_assistant_reply(self) -> None: @@ -389,73 +315,12 @@ async def test_session_error_raises_without_the_provider_body(self) -> None: assert "secret-body" not in str(raised.value) assert "done" not in seen - async def test_pending_tool_state_emits_nothing(self) -> None: - events = await collect( - [tool_part("c1", "pending", raw_input={}, output=None)], - ) - assert types_of(events) == ["done"] - - -class TestCapture: - async def test_capture_off_omits_argument_and_result_text(self) -> None: - handler = create_opencode_handler( - capture_content=False, - events=source( - [ - tool_part( - "c1", - "completed", - raw_input={"command": "ls"}, - output="file.txt", - ) - ] - ), - ) - events = [event async for event in await handler.stream({}, None, None, None)] - assert "TOOL_CALL_ARGS" not in types_of(events) - result = next(event for event in events if event["type"] == "TOOL_CALL_RESULT") - assert result["content"] == "" - assert "metadata" not in result - - async def test_explicit_false_overrides_configure_true(self) -> None: - configure(options=SDKOptions(capture_content=True)) - try: - handler = create_opencode_handler( - capture_content=False, - events=source( - [tool_part("c1", "running", raw_input={"command": "ls"})] - ), - ) - events = [ - event async for event in await handler.stream({}, None, None, None) - ] - assert "TOOL_CALL_ARGS" not in types_of(events) - finally: - lifecycle_module._reset_for_testing() - - async def test_omitted_capture_reads_configure_at_invocation(self) -> None: - configure(options=SDKOptions(capture_content=True)) - try: - handler = create_opencode_handler( - events=source( - [tool_part("c1", "running", raw_input={"command": "ls"})] - ), - ) - events = [ - event async for event in await handler.stream({}, None, None, None) - ] - args = next(event for event in events if event["type"] == "TOOL_CALL_ARGS") - assert args["delta"] == '{"command":"ls"}' - finally: - lifecycle_module._reset_for_testing() - class TestStableStream: - async def test_tool_events_do_not_close_the_stable_stream( + async def test_a_tool_does_not_close_the_stream( self, mock_ld_client: MagicMock ) -> None: handler = create_opencode_handler( - capture_content=True, events=source( [ assistant_open("m1"), @@ -506,163 +371,3 @@ async def test_session_error_does_not_yield_stable_done( seen.append(event) assert [event["type"] for event in seen] == ["chunk"] assert seen[0]["text"] == "Hi" - - async def test_a_chunk_handler_tool_event_is_not_the_done_event( - self, mock_ld_client: MagicMock - ) -> None: - async def invoke( - config: Any, - user_input: str | None, - tool_handlers: Any, - variables: Any, - history: Any = None, - ) -> dict[str, Any]: - return {"output": "Hi", "usage": {"input_tokens": 1, "output_tokens": 1}} - - async def stream_fn( - config: Any, - user_input: str | None, - tool_handlers: Any, - variables: Any, - history: Any = None, - ) -> AsyncGenerator[dict[str, Any], None]: - yield { - "type": "TOOL_CALL_START", - "toolCallId": "c1", - "toolCallName": "bash", - } - yield {"type": "chunk", "text": "Hi"} - yield { - "type": "done", - "output": "Hi", - "usage": {"input_tokens": 1, "output_tokens": 1}, - } - - handler = ProviderHandler( - fn=invoke, provides_for=("Acme", "agent"), stream_fn=stream_fn - ) - seen = [ - event - async for event in config(key="flag", handler=handler).stream("q", CONTEXT) - ] - assert [event["type"] for event in seen] == ["chunk", "done"] - assert seen[-1]["response"] == "Hi" - assert "toolCallId" not in seen[-1] - - -class TestRichReader: - async def test_rich_reader_forwards_text_and_tools( - self, mock_ld_client: MagicMock - ) -> None: - handler = create_opencode_handler( - capture_content=True, - events=source( - [ - assistant_open("m1"), - text_delta("m1", "p1", "Looking up"), - tool_part("c1", "running", raw_input={"command": "ls"}), - tool_part( - "c1", - "error", - raw_input={"command": "ls"}, - output="nope", - ), - assistant_completed( - "m1", - tokens={ - "input": 3, - "output": 4, - "cache": {"read": 1, "write": 2}, - }, - ), - ] - ), - ) - instance = config(key="flag", handler=handler) - seen = [event async for event in rich_stream(instance, "q", CONTEXT)] - kinds = [event["type"] for event in seen] - assert kinds[0] == "RUN_STARTED" - assert kinds[-1] == "RUN_FINISHED" - assert seen[0]["threadId"] == seen[-1]["threadId"] - assert seen[0]["runId"] == seen[-1]["runId"] - assert seen[0]["protocolVersion"] == "1.0" - assert "TEXT_MESSAGE_CONTENT" in kinds - assert "TOOL_CALL_RESULT" in kinds - result = next(event for event in seen if event["type"] == "TOOL_CALL_RESULT") - assert result["content"] == "An error occurred." - assert result["metadata"] == {"launchdarkly": {"toolFailed": True}} - finished = seen[-1] - assert finished["result"] == "Looking up" - assert finished["usage"] == [ - { - "inputTokens": 6, - "outputTokens": 4, - "totalTokens": 10, - "cachedInputTokens": 1, - "cacheWriteInputTokens": 2, - } - ] - assert "metadata" not in finished - - async def test_session_error_has_no_run_finished( - self, mock_ld_client: MagicMock - ) -> None: - handler = create_opencode_handler( - events=source( - [ - {"type": "session.error", "properties": {"error": {"name": "X"}}}, - ] - ), - ) - seen: list[str] = [] - with pytest.raises(OpenCodeSessionError): - async for event in rich_stream( - config(key="flag", handler=handler), "q", CONTEXT - ): - seen.append(event["type"]) - assert seen == ["RUN_STARTED"] - assert "RUN_FINISHED" not in seen - - async def test_legacy_chunks_are_one_text_message( - self, mock_ld_client: MagicMock - ) -> None: - async def invoke( - config: Any, - user_input: str | None, - tool_handlers: Any, - variables: Any, - history: Any = None, - ) -> dict[str, Any]: - return {"output": "Hi"} - - async def stream_fn( - config: Any, - user_input: str | None, - tool_handlers: Any, - variables: Any, - history: Any = None, - ) -> AsyncGenerator[dict[str, Any], None]: - yield {"type": "chunk", "text": "Hi"} - yield {"type": "TOOL_CALL_END", "toolCallId": "c1"} - yield {"type": "done", "output": "Hi"} - - handler = ProviderHandler( - fn=invoke, provides_for=("Acme", "agent"), stream_fn=stream_fn - ) - seen = [ - event - async for event in rich_stream( - config(key="flag", handler=handler), "q", CONTEXT - ) - ] - assert [event["type"] for event in seen] == [ - "RUN_STARTED", - "TEXT_MESSAGE_START", - "TEXT_MESSAGE_CONTENT", - "TOOL_CALL_END", - "TEXT_MESSAGE_END", - "RUN_FINISHED", - ] - assert seen[2]["delta"] == "Hi" - assert seen[-1]["result"] == "Hi" - assert "usage" not in seen[-1] From 127e43959d9b0e17aaf1b6766b44e8a002201301 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 18:55:02 +0000 Subject: [PATCH 24/28] fix(opencode-agents): pass the prompt into the session events receives the config, input, variables, history, and session id. Other sessions are dropped and session.idle ends the run. A judged config must set skip_judges=True. The run opens an invoke_agent span. --- packages/opencode-agents/README.md | 40 ++- packages/opencode-agents/agents.md | 36 ++- packages/opencode-agents/pyproject.toml | 1 + .../handler.py | 227 +++++++++++--- .../launchdarkly_ai_opencode_agents/spans.py | 146 +++++++++ .../opencode-agents/tests/test_handler.py | 278 +++++++++++++++++- .../opencode-agents/tests/test_sdk_usage.py | 91 ++++++ tests/test_cross_handler_parity.py | 1 + uv.lock | 6 +- 9 files changed, 759 insertions(+), 67 deletions(-) create mode 100644 packages/opencode-agents/src/launchdarkly_ai_opencode_agents/spans.py create mode 100644 packages/opencode-agents/tests/test_sdk_usage.py diff --git a/packages/opencode-agents/README.md b/packages/opencode-agents/README.md index fff1a306..4fba15f0 100644 --- a/packages/opencode-agents/README.md +++ b/packages/opencode-agents/README.md @@ -9,11 +9,43 @@ pip install launchdarkly-ai-server launchdarkly-server-sdk launchdarkly-ai-openc ``` ```python +import asyncio +from collections.abc import AsyncIterator + from launchdarkly_ai_opencode_agents import create_opencode_handler -from launchdarkly_ai_server import config +from launchdarkly_ai_server import config, init_client, shutdown + + +async def session_events(config, user_input, variables, history, session_id): + """Subscribe to session_id, send user_input, and stop at session.idle.""" + del config, variables, history + yield {"type": "message.updated", "properties": {"info": {"id": "m1", "role": "assistant", "time": {"created": 1}}}} + yield { + "type": "message.part.delta", + "properties": { + "sessionID": session_id, + "messageID": "m1", + "partID": "p1", + "field": "text", + "delta": "Hello", + }, + } + yield {"type": "session.idle", "properties": {"sessionID": session_id}} + -handler = create_opencode_handler(events=session_events) -config(key="my-agent", handler=handler).stream("Hello", {"kind": "user", "key": "user-1"}) +async def main() -> None: + await init_client() + handler = create_opencode_handler(events=session_events, session_id="ses") + async for event in config( + key="my-agent", handler=handler, skip_judges=True + ).stream("Hello", {"kind": "user", "key": "user-1"}): + print(event) + await shutdown() + + +asyncio.run(main()) ``` -`events` is called on every invocation and must return a fresh async iterable of OpenCode session events. `config().stream()` yields `chunk` then `done`. +`events` is required. Each invocation calls `events(config, user_input, variables, history, session_id)` and the return value must be a fresh async iterable of that session only. The iterable must end: return it after `session.idle`, or close it yourself. `config().stream()` yields `chunk` then `done`. + +Pass `skip_judges=True`. This handler matches every agent-mode provider, including a judge, and it does not run the judge prompt. diff --git a/packages/opencode-agents/agents.md b/packages/opencode-agents/agents.md index 17af5d0b..e82939f4 100644 --- a/packages/opencode-agents/agents.md +++ b/packages/opencode-agents/agents.md @@ -6,6 +6,38 @@ provides_for = ('*', 'agent') The handler reduces OpenCode session events the caller supplies. It does not import or construct `anomalyco/opencode-sdk-python`, and it does not start an OpenCode process. -`create_opencode_handler(*, capture_content=None, events=None)` returns a `ProviderHandler`. `events` is called per invocation and returns an async iterable of bus events (`message.part.delta`, `message.part.updated`, `message.updated`, `session.error`). +This package is Python-only. There is no JS counterpart in this slice. The spec section, API surface list, and `usage-ids.txt` row live in `launchdarkly/ai-sdks-monorepo`, which is not this repository. The usage helper ids are `opencode-agents.createOpenCodeHandler` and `opencode-agents.opencodeAgents`. The package is a normal pre-v1 package, same as the other handlers. -Assistant text is emitted only after `message.updated` marks that message `role == "assistant"`. A part snapshot is the full text of that part so far; a later delta appends. The public stream is `chunk` then `done`. +## File map + +| File | Role | +| --- | --- | +| `handler.py` | Reducer, `create_opencode_handler`, `opencode_agents` | +| `spans.py` | `invoke_agent` root, and the `chat` / `execute_tool` constructors the parity checks call | +| `_version.py` | Package name and version sent with usage events | + +## Dependencies + +`launchdarkly-ai-server` and `opentelemetry-api`. `launchdarkly-server-sdk` is what `init_client()` loads for a normal process. No OpenCode SDK. + +## init_client + +Call `init_client()` before `config()`, the same way as the other handlers. The example in `README.md` does. + +## OTel + +A run opens one `invoke_agent` span and sets `launchdarkly.operation.type`. Tool parts are not forwarded and do not open `execute_tool` spans. `config().stream()` is `chunk` then `done`. + +## Call shape + +`create_opencode_handler(*, events, session_id=None, capture_content=None)` returns a `ProviderHandler`. `events` is required. Each invocation calls `events(config, user_input, variables, history, session_id)` and expects a fresh async iterable. + +Assistant text is emitted only after `message.updated` marks that message `role == "assistant"`. A part snapshot is the full text of that part so far; a later delta appends. A text delta without `partID` is dropped. + +## Common pitfalls + +The AI Config, user input, variables, and history are arguments to `events`. A callable that ignores them never sees the prompt. + +`provides_for` is `('*', 'agent')`, so a judge is routed here too. Pass `skip_judges=True` until this handler runs the prompt it is given. Otherwise `events` runs again on the judge prompt and the score is wrong. + +The reducer keeps one session. Pass `session_id`, or the first `sessionID` on an event wins. Other sessions are dropped, including their `session.error`. `session.idle` for this session ends the run. If the iterable is a server-wide stream and you neither send `session.idle` nor close it, `done` never arrives. Filter and terminate the iterable in `events` when you own the subscription. diff --git a/packages/opencode-agents/pyproject.toml b/packages/opencode-agents/pyproject.toml index 5e55d11d..cd65e988 100644 --- a/packages/opencode-agents/pyproject.toml +++ b/packages/opencode-agents/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" requires-python = ">=3.12" dependencies = [ "launchdarkly-ai-server", + "opentelemetry-api>=1.25", ] description = "OpenCode agent handler for LaunchDarkly AI SDK" readme = "README.md" diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index f61c5970..39b3103f 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -13,20 +13,40 @@ from launchdarkly_ai_server import ( AiConfigRep, ProviderHandler, + SpanMessage, + SpanUsage, _config, _create_handler, + add_cached_tokens_to_input, + end_span_once, + set_input_content_attributes, + set_output_content_attributes, + text_message, ) +from launchdarkly_ai_server.lifecycle import effective_capture_content from launchdarkly_ai_server.sdk_usage import report_usage from launchdarkly_ai_server.utils import number_or_zero from ._version import PACKAGE_NAME, __version__ +from .spans import fail_span, finish_root_span, mark_ok, start_root_span + +Events = Callable[ + [ + AiConfigRep, + str | None, + dict[str, Any] | None, + list[dict[str, Any]] | None, + str | None, + ], + AsyncIterable[Mapping[str, Any]], +] class OpenCodeSessionError(Exception): """The OpenCode session failed. - The provider error body is not included. A failed session does not finish - the run. + The provider error body is not included. The error ``name`` is, when the + event has one. A failed session does not finish the run. """ @@ -35,11 +55,17 @@ class _Reducer: Text is emitted only for messages ``message.updated`` has marked assistant. A part snapshot is that part's full text so far: a later delta still appends, - and a snapshot that repeats text already emitted adds nothing. Tool parts - are not forwarded. + and a snapshot that repeats text already emitted adds nothing. A text delta + without ``partID`` is dropped, because the snapshot of that part cannot be + matched to it. Tool parts are not forwarded. + + Events whose ``sessionID`` is not this session are ignored. The first + ``sessionID`` seen is the session when the caller did not pass one. A + ``session.idle`` for this session ends the iterable. """ - def __init__(self) -> None: + def __init__(self, session_id: str | None) -> None: + self._session_id = session_id self._deltas: list[str] = [] self._roles: dict[str, str] = {} self._held: dict[str, list[tuple[str, str, str]]] = {} @@ -73,8 +99,20 @@ async def reduce( self, events: AsyncIterable[Mapping[str, Any]] ) -> AsyncGenerator[dict[str, Any], None]: async for raw in events: - if isinstance(raw, Mapping) and raw.get("type") == "session.error": - raise OpenCodeSessionError("The OpenCode session failed.") + if not isinstance(raw, Mapping): + continue + kind = raw.get("type") + properties = raw.get("properties") + session_id = _session_id(properties) + if session_id is not None: + if self._session_id is None: + self._session_id = session_id + elif session_id != self._session_id: + continue + if kind == "session.idle": + return + if kind == "session.error": + raise OpenCodeSessionError(_session_error_message(properties)) for event in self._event(raw): yield event @@ -98,27 +136,22 @@ def _flush_held(self, message_id: str) -> list[dict[str, Any]]: def _apply_text(self, part_id: str, kind: str, text: str) -> list[dict[str, Any]]: if kind == "delta": - if part_id: - self._part_text[part_id] = self._part_text.get(part_id, "") + text + self._part_text[part_id] = self._part_text.get(part_id, "") + text return self._chunk(text) already = self._part_text.get(part_id, "") if text.startswith(already): suffix = text[len(already) :] - if part_id: - self._part_text[part_id] = text + self._part_text[part_id] = text elif not already: suffix = text - if part_id: - self._part_text[part_id] = text + self._part_text[part_id] = text else: return [] if not suffix: return [] return self._chunk(suffix) - def _event(self, raw: Mapping[str, Any] | object) -> list[dict[str, Any]]: - if not isinstance(raw, Mapping): - return [] + def _event(self, raw: Mapping[str, Any]) -> list[dict[str, Any]]: kind = raw.get("type") properties = raw.get("properties") if not isinstance(properties, Mapping): @@ -141,12 +174,16 @@ def _delta(self, properties: Mapping[str, Any]) -> list[dict[str, Any]]: return [] delta = properties.get("delta") message_id = properties.get("messageID") - if not isinstance(delta, str) or not delta or not isinstance(message_id, str): - return [] part_id = properties.get("partID") - return self._accept_text( - message_id, part_id if isinstance(part_id, str) else "", "delta", delta - ) + if ( + not isinstance(delta, str) + or not delta + or not isinstance(message_id, str) + or not isinstance(part_id, str) + or not part_id + ): + return [] + return self._accept_text(message_id, part_id, "delta", delta) def _text_snapshot(self, part: Mapping[str, Any]) -> list[dict[str, Any]]: message_id = part.get("messageID") @@ -198,12 +235,54 @@ def _add_usage(self, tokens: Mapping[str, Any]) -> None: self._cache_write += number_or_zero(cache.get("write")) +def _session_id(properties: object) -> str | None: + if not isinstance(properties, Mapping): + return None + session_id = properties.get("sessionID") + if isinstance(session_id, str) and session_id: + return session_id + return None + + +def _session_error_message(properties: object) -> str: + name = None + if isinstance(properties, Mapping): + error = properties.get("error") + if isinstance(error, Mapping): + raw_name = error.get("name") + if isinstance(raw_name, str) and raw_name: + name = raw_name + if name is None: + return "The OpenCode session failed." + return f"The OpenCode session failed ({name})." + + +def _input_messages( + user_input: str | None, + history: list[dict[str, Any]] | None, +) -> list[SpanMessage]: + messages: list[SpanMessage] = [] + for item in history or []: + if not isinstance(item, dict): + continue + role = item.get("role") + content = item.get("content") + if isinstance(role, str) and role and isinstance(content, str): + messages.append(text_message(role, content)) + if isinstance(user_input, str): + messages.append(text_message("user", user_input)) + return messages + + def _open_events( - events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None, + events: Events, + config: AiConfigRep, + user_input: str | None, + variables: dict[str, Any] | None, + history: list[dict[str, Any]] | None, + session_id: str | None, ) -> AsyncIterable[Mapping[str, Any]]: - if events is None: - raise OpenCodeSessionError("The OpenCode handler has no session event source.") - produced = events() + produced = events(config, user_input, variables, history, session_id) if not hasattr(produced, "__aiter__"): raise TypeError("OpenCode event source must return an async iterable.") return produced @@ -211,22 +290,29 @@ def _open_events( def create_opencode_handler( *, + events: Events, + session_id: str | None = None, capture_content: bool | None = None, - events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None = None, ) -> ProviderHandler: """Return a handler that runs an OpenCode session supplied by *events*. ``provides_for`` is ``('*', 'agent')``, so the handler matches an agent-mode - variation for any provider. *events* is called on every invocation and must - return a fresh async iterable of OpenCode session events. This function does - not start OpenCode and does not construct an OpenCode SDK client. - - The stream yields assistant text as ``chunk`` and one ``done``. *capture_content* - follows the other handlers: ``None`` reads the process default from - ``configure`` when content is read, and ``True`` or ``False`` overrides it. + variation for any provider. *events* is called on every invocation as + ``events(config, user_input, variables, history, session_id)`` and must + return a fresh async iterable of that session's bus events. This function + does not start OpenCode and does not construct an OpenCode SDK client. + + The stream yields assistant text as ``chunk`` and one ``done``. Pass + ``skip_judges=True`` to ``config()``: a judge would otherwise be routed + here and *events* would run again on the judge prompt. *capture_content* + follows the other handlers. """ report_usage("opencode-agents.createOpenCodeHandler", PACKAGE_NAME, __version__) - return _create_opencode_handler(capture_content=capture_content, events=events) + return _create_opencode_handler( + events=events, + session_id=session_id, + capture_content=capture_content, + ) def opencode_agents( @@ -237,19 +323,23 @@ def opencode_agents( ) -> Any: """Convenience wrapper: creates a handler and calls ``config(...).invoke()``. - *events* is the session event source, the same callable - :func:`create_opencode_handler` takes. This does not construct an OpenCode - SDK client. + *events* is required and is the same callable :func:`create_opencode_handler` + takes. *variables*, *history*, *session_id*, and *capture_content* configure + that call. This does not construct an OpenCode SDK client. Pass + ``skip_judges=True`` so a judged config does not run *events* again. """ report_usage("opencode-agents.opencodeAgents", PACKAGE_NAME, __version__) variables = kwargs.pop("variables", None) history = kwargs.pop("history", None) capture_content = kwargs.pop("capture_content", None) - events = kwargs.pop("events", None) + session_id = kwargs.pop("session_id", None) + events = kwargs.pop("events") return _config( key=config_key, handler=_create_opencode_handler( - capture_content=capture_content, events=events + events=events, + session_id=session_id, + capture_content=capture_content, ), **kwargs, ).invoke(user_input, context, variables, history) @@ -257,9 +347,13 @@ def opencode_agents( def _create_opencode_handler( *, + events: Events, + session_id: str | None, capture_content: bool | None, - events: Callable[[], AsyncIterable[Mapping[str, Any]]] | None, ) -> ProviderHandler: + content_override = capture_content + bound_session = session_id + async def _stream_impl( config: AiConfigRep, user_input: str | None, @@ -267,14 +361,49 @@ async def _stream_impl( variables: dict[str, Any] | None, history: list[dict[str, Any]] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: - reducer = _Reducer() - async for event in reducer.reduce(_open_events(events)): - yield event - done: dict[str, Any] = {"type": "done", "output": "".join(reducer.deltas)} - usage = reducer.usage_payload() - if usage is not None: - done["usage"] = usage - yield done + capture = effective_capture_content(content_override) + span = start_root_span(config, variables or {}) + ended: set[int] = set() + reducer = _Reducer(bound_session) + try: + instructions = ( + config.get("instructions") if isinstance(config, dict) else None + ) + set_input_content_attributes( + span, + capture, + system_instructions=instructions + if isinstance(instructions, str) + else None, + messages=_input_messages(user_input, history), + ) + async for event in reducer.reduce( + _open_events( + events, config, user_input, variables, history, bound_session + ) + ): + yield event + output = "".join(reducer.deltas) + if output: + set_output_content_attributes( + span, capture, [text_message("assistant", output)] + ) + usage = reducer.usage_payload() + finish_root_span( + span, + config, + add_cached_tokens_to_input(usage) if usage is not None else SpanUsage(), + ) + mark_ok(span) + done: dict[str, Any] = {"type": "done", "output": output} + if usage is not None: + done["usage"] = usage + yield done + except Exception as exc: + fail_span(span, exc, ended) + raise + finally: + end_span_once(span, ended) async def _call_impl( config: AiConfigRep, @@ -299,5 +428,5 @@ async def _call_impl( ("*", "agent"), _call_impl, _stream_impl, - capture_content=capture_content, + capture_content=content_override, ) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/spans.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/spans.py new file mode 100644 index 00000000..b03e9b17 --- /dev/null +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/spans.py @@ -0,0 +1,146 @@ +"""Span construction for the OpenCode handler. + +The public stream is ``chunk`` then ``done``. This module opens the +``invoke_agent`` root the other handlers open, plus the ``chat`` and +``execute_tool`` constructors the shared parity checks call. The reducer does +not emit tool events, so a run only starts the root. +""" + +from __future__ import annotations + +from typing import Any + +from launchdarkly_ai_server import ( + AiConfigRep, + SpanUsage, + set_ld_span_attributes, + set_model_identity_attributes, + set_usage_span_attributes, +) + +try: + from opentelemetry import trace + from opentelemetry.trace import StatusCode as SpanStatusCode + + _HAS_OTEL = True +except ImportError: # pragma: no cover - exercised when the API package is absent + _HAS_OTEL = False + +TRACER_NAME = "@launchdarkly/ai-opencode-agents" + + +def model_name(config: AiConfigRep) -> str: + if not isinstance(config, dict): + return "" + model = config.get("model") + if not isinstance(model, dict): + return "" + name = model.get("name") + return name if isinstance(name, str) else "" + + +def provider_name(config: AiConfigRep) -> str: + """Who served the model, from the variation, or ``opencode`` when it is blank.""" + if isinstance(config, dict): + provider = config.get("provider") + if isinstance(provider, dict): + name = provider.get("name") + if isinstance(name, str) and name.strip(): + return name.strip().lower() + return "opencode" + + +def start_root_span(config: AiConfigRep, variables: dict[str, Any]) -> Any: + """Opens the ``invoke_agent`` root and returns it, or ``None`` when OTel is absent.""" + if not _HAS_OTEL: + return None + span = trace.get_tracer(TRACER_NAME).start_span("invoke_agent") + span.set_attribute("gen_ai.operation.name", "invoke_agent") + set_model_identity_attributes(span, provider_name(config), model_name(config)) + set_ld_span_attributes(span, variables) + return span + + +def parent_context_of(span: Any) -> Any: + """The context a child span should be parented to.""" + if not _HAS_OTEL or span is None: + return None + return trace.set_span_in_context(span) + + +def start_model_span(config: AiConfigRep, parent: Any) -> Any: + """Opens one ``chat {model}`` span for one model turn.""" + if not _HAS_OTEL: + return None + name = model_name(config) + span = trace.get_tracer(TRACER_NAME).start_span(f"chat {name}", context=parent) + span.set_attribute("gen_ai.operation.name", "chat") + set_model_identity_attributes(span, provider_name(config), name) + return span + + +def start_tool_span(tool_name: str, tool_call_id: str, parent: Any) -> Any: + """Opens one ``execute_tool {name}`` span for one tool call.""" + if not _HAS_OTEL: + return None + span = trace.get_tracer(TRACER_NAME).start_span( + f"execute_tool {tool_name}", context=parent + ) + span.set_attribute("gen_ai.operation.name", "execute_tool") + span.set_attribute("gen_ai.tool.name", tool_name) + span.set_attribute("gen_ai.tool.call.id", tool_call_id) + return span + + +def finish_root_span(span: Any, config: AiConfigRep, usage: SpanUsage) -> None: + """Writes the requested model and token totals onto the root. Does not end it.""" + if span is None: + return + span.set_attribute("gen_ai.response.model", model_name(config)) + set_usage_span_attributes(span, usage) + + +def finish_model_span( + span: Any, + config: AiConfigRep, + usage: SpanUsage, + finish_reason: str | None = None, +) -> None: + """Ends one ``chat`` span successfully.""" + if span is None: + return + span.set_attribute("gen_ai.response.model", model_name(config)) + if finish_reason: + span.set_attribute("gen_ai.response.finish_reasons", [finish_reason]) + set_usage_span_attributes(span, usage) + span.set_status(SpanStatusCode.OK) + span.end() + + +def succeed_span(span: Any) -> None: + """Marks a span OK and ends it.""" + if span is None: + return + span.set_status(SpanStatusCode.OK) + span.end() + + +def mark_ok(span: Any) -> None: + """Marks a span OK without ending it. The streaming ``finally`` owns the end.""" + if span is None: + return + span.set_status(SpanStatusCode.OK) + + +def fail_span(span: Any, error: BaseException, tracker: set[int] | None = None) -> None: + """Records the exception, sets ERROR, and ends the span.""" + if span is None: + return + span.record_exception(error) + span.set_status(SpanStatusCode.ERROR, str(error)) + if tracker is not None: + from launchdarkly_ai_server import end_span_once + + end_span_once(span, tracker) + else: + span.end() diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 988ae226..7ffb14e9 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -12,6 +12,7 @@ from launchdarkly_ai_opencode_agents import ( OpenCodeSessionError, create_opencode_handler, + opencode_agents, ) from launchdarkly_ai_server import SDKOptions, config, configure @@ -34,15 +35,22 @@ def bus(kind: str, **properties: Any) -> dict[str, Any]: return {"type": kind, "properties": properties} -def text_delta(message_id: str, part_id: str, delta: str) -> dict[str, Any]: - return bus( - "message.part.delta", - sessionID="ses", - messageID=message_id, - partID=part_id, - field="text", - delta=delta, - ) +def text_delta( + message_id: str, + part_id: str | None, + delta: str, + *, + session_id: str = "ses", +) -> dict[str, Any]: + properties: dict[str, Any] = { + "sessionID": session_id, + "messageID": message_id, + "field": "text", + "delta": delta, + } + if part_id is not None: + properties["partID"] = part_id + return bus("message.part.delta", **properties) def tool_part( @@ -96,7 +104,14 @@ def assistant_completed( def source(events: list[dict[str, Any]]) -> Any: - async def _events() -> AsyncIterator[dict[str, Any]]: + async def _events( + config: Any, + user_input: str | None, + variables: Any, + history: Any, + session_id: str | None, + ) -> AsyncIterator[dict[str, Any]]: + del config, user_input, variables, history, session_id for event in events: yield event @@ -303,7 +318,12 @@ async def test_session_error_raises_without_the_provider_body(self) -> None: text_delta("m1", "p1", "Hi"), { "type": "session.error", - "properties": {"error": {"data": {"message": "secret-body"}}}, + "properties": { + "error": { + "name": "ProviderAuthError", + "data": {"message": "secret-body"}, + } + }, }, ] ), @@ -312,6 +332,7 @@ async def test_session_error_raises_without_the_provider_body(self) -> None: with pytest.raises(OpenCodeSessionError, match="session failed") as raised: async for event in await handler.stream({}, "hi", None, None): seen.append(event["type"]) + assert "ProviderAuthError" in str(raised.value) assert "secret-body" not in str(raised.value) assert "done" not in seen @@ -371,3 +392,238 @@ async def test_session_error_does_not_yield_stable_done( seen.append(event) assert [event["type"] for event in seen] == ["chunk"] assert seen[0]["text"] == "Hi" + + +class TestInvocation: + def test_events_is_required(self) -> None: + with pytest.raises(TypeError): + create_opencode_handler() # type: ignore[call-arg] + + async def test_events_receives_the_prompt_and_the_session(self) -> None: + seen: dict[str, Any] = {} + + async def events( + config: Any, + user_input: str | None, + variables: Any, + history: Any, + session_id: str | None, + ) -> AsyncIterator[dict[str, Any]]: + seen["instructions"] = config.get("instructions") + seen["user_input"] = user_input + seen["variables"] = variables + seen["history"] = history + seen["session_id"] = session_id + yield assistant_open("m1") + yield text_delta("m1", "p1", "Hi") + yield assistant_completed("m1") + + handler = create_opencode_handler(events=events, session_id="ses") + events_out = [ + event + async for event in await handler.stream( + {"instructions": "Be brief."}, + "Hello", + None, + {"name": "Ada"}, + [{"role": "user", "content": "earlier"}], + ) + ] + assert seen == { + "instructions": "Be brief.", + "user_input": "Hello", + "variables": {"name": "Ada"}, + "history": [{"role": "user", "content": "earlier"}], + "session_id": "ses", + } + assert events_out[-1]["output"] == "Hi" + + async def test_other_sessions_are_ignored_and_idle_ends_the_run(self) -> None: + events = await collect( + [ + assistant_open("m1"), + text_delta("m1", "p1", "Hi", session_id="ses"), + text_delta("m9", "p9", " OTHER", session_id="other"), + { + "type": "session.error", + "properties": { + "sessionID": "other", + "error": {"name": "OtherError", "data": {"message": "nope"}}, + }, + }, + text_delta("m1", "p1", "!", session_id="ses"), + bus("session.idle", sessionID="ses"), + text_delta("m1", "p1", "AFTER", session_id="ses"), + ], + ) + assert events[-1]["output"] == "Hi!" + assert "OTHER" not in events[-1]["output"] + assert "AFTER" not in events[-1]["output"] + assert "nope" not in str(events) + + async def test_a_delta_without_part_id_is_not_repeated_by_its_snapshot( + self, + ) -> None: + events = await collect( + [ + assistant_open("m1"), + text_delta("m1", None, "Hi"), + bus( + "message.part.updated", + part={"id": "p1", "messageID": "m1", "type": "text", "text": "Hi"}, + ), + assistant_completed("m1"), + ], + ) + chunks = [event["text"] for event in events if event["type"] == "chunk"] + assert chunks == ["Hi"] + assert events[-1]["output"] == "Hi" + + async def test_skip_judges_runs_events_once( + self, mock_ld_client: MagicMock + ) -> None: + judged = { + **VARIATION, + "judgeConfiguration": { + "judges": [{"key": "judge-flag", "samplingRate": 1}] + }, + } + mock_ld_client.variation = AsyncMock(return_value=judged) + calls: list[str | None] = [] + + async def events( + config: Any, + user_input: str | None, + variables: Any, + history: Any, + session_id: str | None, + ) -> AsyncIterator[dict[str, Any]]: + del config, variables, history, session_id + calls.append(user_input) + yield assistant_open("m1") + yield text_delta("m1", "p1", "Hi") + yield assistant_completed("m1") + + handler = create_opencode_handler(events=events, session_id="ses") + skipped = [ + event + async for event in config( + key="flag", handler=handler, skip_judges=True + ).stream("q", CONTEXT) + ] + assert [event["type"] for event in skipped] == ["chunk", "done"] + assert calls == ["q"] + + calls.clear() + await _drain(config(key="flag", handler=handler).stream("q", CONTEXT)) + assert calls[0] == "q" + assert len(calls) == 2 + assert calls[1] != "q" + + async def test_the_wrapper_forwards_variables_history_and_session( + self, mock_ld_client: MagicMock + ) -> None: + seen: dict[str, Any] = {} + + async def events( + config: Any, + user_input: str | None, + variables: Any, + history: Any, + session_id: str | None, + ) -> AsyncIterator[dict[str, Any]]: + seen["user_input"] = user_input + seen["name"] = ( + variables.get("name") if isinstance(variables, dict) else None + ) + seen["history"] = history + seen["session_id"] = session_id + seen["instructions"] = ( + config.get("instructions") if isinstance(config, dict) else None + ) + yield assistant_open("m1") + yield text_delta("m1", "p1", "Hi") + yield assistant_completed("m1") + + result = await opencode_agents( + "flag", + "Hello", + CONTEXT, + events=events, + session_id="ses", + variables={"name": "Ada"}, + history=[{"role": "user", "content": "earlier"}], + skip_judges=True, + ) + assert result.response == "Hi" + assert seen["user_input"] == "Hello" + assert seen["name"] == "Ada" + assert seen["history"] == [{"role": "user", "content": "earlier"}] + assert seen["session_id"] == "ses" + assert seen["instructions"] == "Be brief." + + async def test_a_run_opens_an_invoke_agent_span( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from launchdarkly_ai_opencode_agents import spans as span_module + + class _Span: + def __init__(self, name: str, context: Any = None) -> None: + self.name = name + self.context = context + self.attributes: dict[str, Any] = {} + self.events: list[str] = [] + + def set_attribute(self, key: str, value: Any) -> None: + self.attributes[key] = value + + def add_event( + self, name: str, attributes: dict[str, Any] | None = None + ) -> None: + self.events.append(name) + + def set_status(self, code: Any, description: str | None = None) -> None: + return None + + def record_exception(self, exc: BaseException) -> None: + return None + + def end(self) -> None: + return None + + class _Tracer: + def __init__(self) -> None: + self.spans: list[_Span] = [] + + def get_tracer(self, name: str) -> _Tracer: + return self + + def start_span(self, name: str, context: Any = None) -> _Span: + span = _Span(name, context) + self.spans.append(span) + return span + + def set_span_in_context(self, span: _Span) -> Any: + return span + + tracer = _Tracer() + monkeypatch.setattr(span_module, "trace", tracer) + handler = create_opencode_handler( + events=source( + [ + assistant_open("m1"), + text_delta("m1", "p1", "Hi"), + assistant_completed("m1"), + ] + ) + ) + seen = [event async for event in await handler.stream({}, "hi", None, None)] + assert seen[-1]["output"] == "Hi" + assert tracer.spans[0].name == "invoke_agent" + assert tracer.spans[0].attributes["gen_ai.operation.name"] == "invoke_agent" + assert tracer.spans[0].attributes["launchdarkly.operation.type"] == "gen_ai" + + +async def _drain(stream: Any) -> None: + async for _event in stream: + pass diff --git a/packages/opencode-agents/tests/test_sdk_usage.py b/packages/opencode-agents/tests/test_sdk_usage.py new file mode 100644 index 00000000..1b88f948 --- /dev/null +++ b/packages/opencode-agents/tests/test_sdk_usage.py @@ -0,0 +1,91 @@ +"""Tests for TESTING.md §3.27 — opencode-agents.""" + +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import launchdarkly_ai_server.lifecycle as lifecycle_module +from launchdarkly_ai_opencode_agents import create_opencode_handler, opencode_agents +from launchdarkly_ai_opencode_agents._version import PACKAGE_NAME +from launchdarkly_ai_opencode_agents._version import __version__ as PACKAGE_VERSION +from launchdarkly_ai_server import SDK_INFO_CONTEXT, __version__, init_client, shutdown + +CONTEXT = {"kind": "user", "key": "user-1"} + + +def _fake() -> MagicMock: + client = MagicMock() + client.track = MagicMock() + client.variation = AsyncMock(return_value=None) + client.flush = AsyncMock() + client.close = AsyncMock() + return client + + +async def _events( + config: Any, + user_input: str | None, + variables: Any, + history: Any, + session_id: str | None, +) -> AsyncIterator[dict[str, Any]]: + del config, user_input, variables, history, session_id + if False: + yield {} + + +async def _settle(value: Any) -> None: + if hasattr(value, "__await__"): + try: + await value + except Exception: + return + + +@pytest.fixture +async def installed() -> Any: + await shutdown() + client = _fake() + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + await init_client(client=client) + client.track.reset_mock() + yield client + await shutdown() + + +def _usage(client: MagicMock) -> list[Any]: + return [ + entry + for entry in client.track.call_args_list + if entry.args[0] == "$ld:ai:sdk:usage" + ] + + +async def test_create_handler_sends_one_usage_event(installed: MagicMock) -> None: + create_opencode_handler(events=_events) + calls = _usage(installed) + assert [entry.args[2].get("helper") for entry in calls] == [ + "opencode-agents.createOpenCodeHandler" + ] + assert calls[0].args[1] == SDK_INFO_CONTEXT + assert calls[0].args[2] == { + "aiSdkName": "launchdarkly-ai-server", + "aiSdkVersion": __version__, + "aiSdkLanguage": "python", + "helper": "opencode-agents.createOpenCodeHandler", + "helperPackageName": PACKAGE_NAME, + "helperPackageVersion": PACKAGE_VERSION, + } + assert calls[0].args[3] == 1 + + +async def test_wrapper_sends_one_usage_event(installed: MagicMock) -> None: + await _settle(opencode_agents("k", "q", CONTEXT, events=_events)) + calls = _usage(installed) + assert [entry.args[2].get("helper") for entry in calls] == [ + "opencode-agents.opencodeAgents" + ] + assert calls[0].args[2]["helperPackageName"] == PACKAGE_NAME + assert calls[0].args[2]["helperPackageVersion"] == PACKAGE_VERSION diff --git a/tests/test_cross_handler_parity.py b/tests/test_cross_handler_parity.py index 2ada483f..d22c66b6 100644 --- a/tests/test_cross_handler_parity.py +++ b/tests/test_cross_handler_parity.py @@ -41,6 +41,7 @@ "openai-agents": "launchdarkly_ai_openai_agents.spans", "langchain-messages": "launchdarkly_ai_langchain_messages.spans", "langchain-agents": "launchdarkly_ai_langchain_agents.spans", + "opencode-agents": "launchdarkly_ai_opencode_agents.spans", } #: `claude-agents` builds its `chat` span inside an inference tracker rather than in a standalone diff --git a/uv.lock b/uv.lock index 860a08fa..8aebcdd1 100644 --- a/uv.lock +++ b/uv.lock @@ -957,10 +957,14 @@ version = "0.1.0" source = { editable = "packages/opencode-agents" } dependencies = [ { name = "launchdarkly-ai-server" }, + { name = "opentelemetry-api" }, ] [package.metadata] -requires-dist = [{ name = "launchdarkly-ai-server", editable = "packages/client" }] +requires-dist = [ + { name = "launchdarkly-ai-server", editable = "packages/client" }, + { name = "opentelemetry-api", specifier = ">=1.25" }, +] [[package]] name = "launchdarkly-ai-python" From c03b5113a59e62cb30aa538287309a1fa115487f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 19:08:28 +0000 Subject: [PATCH 25/28] fix(opencode-agents): read nested OpenCode session ids message.updated carries sessionID on info, and message.part.updated carries it on part. A foreign event no longer latches the run. --- .../handler.py | 34 ++++++++++++--- .../opencode-agents/tests/test_handler.py | 43 +++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 39b3103f..e1a575ce 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -103,7 +103,7 @@ async def reduce( continue kind = raw.get("type") properties = raw.get("properties") - session_id = _session_id(properties) + session_id = _session_id(raw) if session_id is not None: if self._session_id is None: self._session_id = session_id @@ -235,12 +235,34 @@ def _add_usage(self, tokens: Mapping[str, Any]) -> None: self._cache_write += number_or_zero(cache.get("write")) -def _session_id(properties: object) -> str | None: - if not isinstance(properties, Mapping): +def _string_id(value: object) -> str | None: + if isinstance(value, str) and value: + return value + return None + + +def _session_id(raw: object) -> str | None: + """Session id from a bus event. + + Deltas carry it on ``properties``. ``message.updated`` carries it on + ``properties.info``, and ``message.part.updated`` on ``properties.part``. + """ + if not isinstance(raw, Mapping): return None - session_id = properties.get("sessionID") - if isinstance(session_id, str) and session_id: - return session_id + properties = raw.get("properties") + if not isinstance(properties, Mapping): + return _string_id(raw.get("sessionID")) + found = _string_id(properties.get("sessionID")) + if found is not None: + return found + info = properties.get("info") + if isinstance(info, Mapping): + found = _string_id(info.get("sessionID")) + if found is not None: + return found + part = properties.get("part") + if isinstance(part, Mapping): + return _string_id(part.get("sessionID")) return None diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 7ffb14e9..01eee754 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -461,6 +461,49 @@ async def test_other_sessions_are_ignored_and_idle_ends_the_run(self) -> None: assert "AFTER" not in events[-1]["output"] assert "nope" not in str(events) + async def test_nested_session_ids_do_not_lock_the_run(self) -> None: + events = await collect( + [ + bus( + "message.updated", + info={ + "id": "m1", + "role": "assistant", + "sessionID": "ses", + "time": {"created": 1}, + }, + ), + text_delta("m1", "p1", "Hi", session_id="ses"), + bus( + "message.part.updated", + part={ + "id": "px", + "messageID": "mx", + "sessionID": "other", + "type": "text", + "text": "FOREIGN", + }, + ), + bus( + "message.updated", + info={ + "id": "mx", + "role": "assistant", + "sessionID": "other", + "time": {"created": 1, "completed": 2}, + "tokens": {"input": 9, "output": 9}, + }, + ), + text_delta("m1", "p1", "!", session_id="ses"), + bus("session.idle", sessionID="ses"), + text_delta("m1", "p1", "AFTER", session_id="ses"), + ], + ) + assert events[-1]["output"] == "Hi!" + assert "FOREIGN" not in events[-1]["output"] + assert "AFTER" not in events[-1]["output"] + assert "usage" not in events[-1] + async def test_a_delta_without_part_id_is_not_repeated_by_its_snapshot( self, ) -> None: From a06988b0aa7ab556789665a8a635d07c819137c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 18:23:26 +0000 Subject: [PATCH 26/28] fix(opencode-agents): emit a late message after the open text A completion for an earlier message no longer drops its held text and tools. The open message stays whole. Those parts are emitted once that text ends, as their own message. --- .../handler.py | 45 ++++++++++++++++++- .../opencode-agents/tests/test_handler.py | 32 ++++++++++--- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index eaecf8a8..8b088a5c 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -76,6 +76,9 @@ def __init__(self, *, capture_content: bool, session_id: str | None) -> None: self._deltas: list[str] = [] self._roles: dict[str, str] = {} self._held: dict[str, list[tuple[str, str, str]]] = {} + self._parked: list[ + tuple[str, list[tuple[str, str, str]], list[Mapping[str, Any]]] + ] = [] self._part_text: dict[str, str] = {} self._usage_ids: set[str] = set() self._input = 0 @@ -176,6 +179,40 @@ def _end_open_text(self) -> list[dict[str, Any]]: events = self._emitter.end_text() self._text_message_id = None self._text_open = False + events.extend(self._flush_parked()) + return events + + def _flush_parked(self) -> list[dict[str, Any]]: + """Emit messages completed while other text was open, after that text ends.""" + parked = self._parked + self._parked = [] + events: list[dict[str, Any]] = [] + for message_id, held, tools in parked: + events.extend(self._emit_parked(message_id, held, tools)) + return events + + def _emit_parked( + self, + message_id: str, + held: list[tuple[str, str, str]], + tools: list[Mapping[str, Any]], + ) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + if held: + events.extend(self._begin_message(message_id)) + for kind, part_id, text in held: + events.extend(self._apply_text(part_id, kind, text)) + if self._text_open and self._text_message_id == message_id: + for part in tools: + events.extend(self._emit_tool(part)) + events.extend(self._emitter.end_text()) + self._text_message_id = None + self._text_open = False + return events + for part in tools: + events.extend(self._emit_tool(part)) + if self._text_message_id == message_id: + self._text_message_id = None return events def _release_tools(self, message_id: str) -> list[dict[str, Any]]: @@ -326,8 +363,12 @@ def _message(self, info: Mapping[str, Any]) -> list[dict[str, Any]]: finished and self._text_open and message_id != self._text_message_id ) if late_completion: - self._held.pop(message_id, None) - self._held_tools.pop(message_id, None) + # Keep the open message intact. The held text and tools + # still happened; emit them once that text ends. + held = self._held.pop(message_id, []) + tools = self._held_tools.pop(message_id, []) + if held or tools: + self._parked.append((message_id, held, tools)) else: events.extend(self._begin_message(message_id)) events.extend(self._flush_held(message_id)) diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 0987b9e8..3850ffe3 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -319,7 +319,7 @@ async def test_a_late_completion_does_not_split_the_open_message(self) -> None: for event in events if event["type"] == "TEXT_MESSAGE_CONTENT" ] - assert texts == ["A", "B1", "B2"] + assert texts == ["A", "B1", "B2", "stale"] message_ids = [ event["messageId"] for event in events @@ -327,16 +327,34 @@ async def test_a_late_completion_does_not_split_the_open_message(self) -> None: ] assert message_ids[1] == message_ids[2] assert message_ids[0] != message_ids[1] - assert "stale" not in texts - assert not any( - event.get("toolCallId") == "c3" + assert message_ids[3] != message_ids[1] + open_end = next( + event + for event in events + if event["type"] == "TEXT_MESSAGE_END" + and event["messageId"] == message_ids[1] + ) + stale = next( + event for event in events - if event["type"].startswith("TOOL_CALL") + if event["type"] == "TEXT_MESSAGE_CONTENT" and event["delta"] == "stale" ) - assert any( - event["type"] == "TEXT_MESSAGE_END" and event["messageId"] == message_ids[1] + assert events.index(open_end) < events.index(stale) + start = next( + event for event in events + if event["type"] == "TOOL_CALL_START" and event["toolCallId"] == "c3" ) + result = next( + event + for event in events + if event["type"] == "TOOL_CALL_RESULT" and event["toolCallId"] == "c3" + ) + assert result["content"] == "old.txt" + assert "metadata" not in result + assert start.get("parentMessageId") == message_ids[3] + assert events.index(open_end) < events.index(start) + assert events[-1]["output"] == "AB1B2stale" async def test_a_completed_message_is_emitted_when_nothing_is_open(self) -> None: events = await collect( From 079f887b28c9bf99c8f61b0519d84eca60e5e04a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 16:36:48 +0000 Subject: [PATCH 27/28] fix(opencode-agents): hold late text until the open message ends Text for an earlier message no longer closes the message that is streaming. It is emitted after that message ends. Session end emits assistant text that never completed. Drop the leftover configure test. --- packages/client/tests/test_configure.py | 8 --- .../handler.py | 9 +++ .../opencode-agents/tests/test_handler.py | 61 +++++++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/packages/client/tests/test_configure.py b/packages/client/tests/test_configure.py index 102996d5..53f0fe5b 100644 --- a/packages/client/tests/test_configure.py +++ b/packages/client/tests/test_configure.py @@ -68,14 +68,6 @@ def test_explicit_false_overrides_a_configured_true() -> None: assert _handler(None).capture_content is True -def test_judge_gate_uses_the_effective_bool() -> None: - # The same expression judges.py uses. No second content gate. - configure(options=SDKOptions(capture_content=True)) - reasoning = "because" - assert (reasoning if _handler().capture_content else None) == "because" - assert (reasoning if _handler(False).capture_content else None) is None - - def test_assigning_capture_content_sets_the_override() -> None: configure(options=SDKOptions(capture_content=True)) handler = _handler() diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 1167375c..6364fc00 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -133,6 +133,9 @@ def _accept_text( return [] if role != "assistant": return [] + if self._text_open and message_id != self._text_message_id: + self._held.setdefault(message_id, []).append((kind, part_id, text)) + return [] events = self._begin_message(message_id) events.extend(self._apply_text(part_id, kind, text)) if self._text_open and self._text_message_id == message_id: @@ -227,6 +230,12 @@ def close(self) -> list[dict[str, Any]]: # assistant call, including one that completed after the text closed, # before fail_open marks a started call as a failure. events.extend(self._end_open_text()) + for message_id, held in list(self._held.items()): + if self._roles.get(message_id) != "assistant" or not held: + continue + tools = self._held_tools.pop(message_id, []) + events.extend(self._emit_parked(message_id, held, tools)) + self._held.pop(message_id, None) for message_id, parts in list(self._held_tools.items()): role = self._roles.get(message_id) if role is None or role == "assistant": diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 350a08a6..78fdcc96 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -360,6 +360,67 @@ async def test_a_late_completion_does_not_split_the_open_message(self) -> None: assert events.index(open_end) < events.index(start) assert events[-1]["output"] == "AB1B2stale" + async def test_late_text_does_not_split_the_open_message(self) -> None: + events = await collect( + [ + assistant_open("m1"), + text_delta("m1", "p1", "A"), + assistant_open("m2"), + text_delta("m2", "p2", "B1"), + bus( + "message.part.updated", + part={"id": "p1", "messageID": "m1", "type": "text", "text": "A"}, + ), + text_delta("m1", "p1", "X"), + text_delta("m2", "p2", "B2"), + assistant_completed("m2"), + ] + ) + b_ids = [ + event["messageId"] + for event in events + if event["type"] == "TEXT_MESSAGE_CONTENT" + and event["delta"] in ("B1", "B2") + ] + assert b_ids == [b_ids[0], b_ids[0]] + late = next( + event + for event in events + if event["type"] == "TEXT_MESSAGE_CONTENT" and event["delta"] == "X" + ) + assert late["messageId"] != b_ids[0] + assert events[-1]["output"] == "AB1B2X" + + async def test_unfinished_assistant_text_is_emitted_at_session_end(self) -> None: + events = await collect( + [ + assistant_open("m1"), + text_delta("m1", "p1", "A"), + assistant_open("m2"), + text_delta("m2", "p2", "B1"), + text_delta("m1", "p1", " later"), + ] + ) + b = next( + event + for event in events + if event["type"] == "TEXT_MESSAGE_CONTENT" and event["delta"] == "B1" + ) + later = next( + event + for event in events + if event["type"] == "TEXT_MESSAGE_CONTENT" and event["delta"] == " later" + ) + ended = next( + event + for event in events + if event["type"] == "TEXT_MESSAGE_END" + and event["messageId"] == b["messageId"] + ) + assert later["messageId"] != b["messageId"] + assert events.index(ended) < events.index(later) + assert events[-1]["output"] == "AB1 later" + async def test_a_completed_message_is_emitted_when_nothing_is_open(self) -> None: events = await collect( [ From c39e9181a45b08504bd81b8a248fe41960d5cc44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 16:58:13 +0000 Subject: [PATCH 28/28] fix(opencode-agents): flush held text before the next part A part that arrives after the blocking message ends applies the buffered delta first. A later snapshot or close no longer emits it again. --- .../handler.py | 1 + .../opencode-agents/tests/test_handler.py | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 6364fc00..a0aca9ce 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -137,6 +137,7 @@ def _accept_text( self._held.setdefault(message_id, []).append((kind, part_id, text)) return [] events = self._begin_message(message_id) + events.extend(self._flush_held(message_id)) events.extend(self._apply_text(part_id, kind, text)) if self._text_open and self._text_message_id == message_id: events.extend(self._release_tools(message_id)) diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 78fdcc96..4e806c23 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -391,6 +391,30 @@ async def test_late_text_does_not_split_the_open_message(self) -> None: assert late["messageId"] != b_ids[0] assert events[-1]["output"] == "AB1B2X" + async def test_a_later_snapshot_does_not_repeat_held_text(self) -> None: + events = await collect( + [ + assistant_open("m1"), + text_delta("m1", "p1", "A"), + assistant_open("m2"), + text_delta("m2", "p2", "B1"), + text_delta("m1", "p1", "X"), + assistant_completed("m2"), + bus( + "message.part.updated", + part={"id": "p1", "messageID": "m1", "type": "text", "text": "AX"}, + ), + assistant_completed("m1"), + ] + ) + deltas = [ + event["delta"] + for event in events + if event["type"] == "TEXT_MESSAGE_CONTENT" + ] + assert deltas == ["A", "B1", "X"] + assert events[-1]["output"] == "AB1X" + async def test_unfinished_assistant_text_is_emitted_at_session_end(self) -> None: events = await collect( [