From d72e29936c188a7d84645126d757894b1782f339 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Oct 2026 23:11:14 +0000 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 c934e2df6a661143ceee833a71b3a5a87dcd7aa9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 23:55:56 +0000 Subject: [PATCH 8/8] fix(opencode-agents): require a session id A shared OpenCode event stream must not latch onto the first session it sees. session_id is required, and every other session is ignored. --- packages/opencode-agents/agents.md | 4 +- .../handler.py | 35 +++++++----- .../opencode-agents/tests/test_handler.py | 56 +++++++++++++++++-- .../opencode-agents/tests/test_sdk_usage.py | 4 +- 4 files changed, 75 insertions(+), 24 deletions(-) diff --git a/packages/opencode-agents/agents.md b/packages/opencode-agents/agents.md index e82939f4..44d4597c 100644 --- a/packages/opencode-agents/agents.md +++ b/packages/opencode-agents/agents.md @@ -30,7 +30,7 @@ A run opens one `invoke_agent` span and sets `launchdarkly.operation.type`. Tool ## 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. +`create_opencode_handler(*, events, session_id, capture_content=None)` returns a `ProviderHandler`. `events` and `session_id` are 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. @@ -40,4 +40,4 @@ The AI Config, user input, variables, and history are arguments to `events`. A c `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. +`session_id` is required. The reducer does not adopt the first session on a shared stream. 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. 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 e1a575ce..dcd4a8d9 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -59,12 +59,12 @@ class _Reducer: 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. + Events whose ``sessionID`` is not *session_id* are ignored. A missing + ``sessionID`` is kept. The reducer does not adopt the first session it + sees. A ``session.idle`` for this session ends the iterable. """ - def __init__(self, session_id: str | None) -> None: + def __init__(self, session_id: str) -> None: self._session_id = session_id self._deltas: list[str] = [] self._roles: dict[str, str] = {} @@ -104,11 +104,8 @@ async def reduce( kind = raw.get("type") properties = raw.get("properties") session_id = _session_id(raw) - 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 session_id is not None and session_id != self._session_id: + continue if kind == "session.idle": return if kind == "session.error": @@ -313,7 +310,7 @@ def _open_events( def create_opencode_handler( *, events: Events, - session_id: str | None = None, + session_id: str, capture_content: bool | None = None, ) -> ProviderHandler: """Return a handler that runs an OpenCode session supplied by *events*. @@ -321,8 +318,10 @@ 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 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. + return a fresh async iterable of that session's bus events. *session_id* + is required. The reducer ignores every other session and does not adopt + the first one it sees. 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 @@ -332,7 +331,7 @@ def create_opencode_handler( report_usage("opencode-agents.createOpenCodeHandler", PACKAGE_NAME, __version__) return _create_opencode_handler( events=events, - session_id=session_id, + session_id=_require_session_id(session_id), capture_content=capture_content, ) @@ -354,7 +353,7 @@ def opencode_agents( variables = kwargs.pop("variables", None) history = kwargs.pop("history", None) capture_content = kwargs.pop("capture_content", None) - session_id = kwargs.pop("session_id", None) + session_id = _require_session_id(kwargs.pop("session_id", None)) events = kwargs.pop("events") return _config( key=config_key, @@ -367,10 +366,16 @@ def opencode_agents( ).invoke(user_input, context, variables, history) +def _require_session_id(session_id: object) -> str: + if not isinstance(session_id, str) or not session_id: + raise TypeError("The OpenCode handler requires a session_id.") + return session_id + + def _create_opencode_handler( *, events: Events, - session_id: str | None, + session_id: str, capture_content: bool | None, ) -> ProviderHandler: content_override = capture_content diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 01eee754..9ee80aaf 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -119,7 +119,9 @@ async def _events( async def collect(events: list[dict[str, Any]]) -> list[dict[str, Any]]: - built = create_opencode_handler(capture_content=True, events=source(events)) + built = create_opencode_handler( + capture_content=True, events=source(events), session_id="ses" + ) gen = await built.stream({}, "hi", None, None) return [event async for event in gen] @@ -149,7 +151,7 @@ def mock_ld_client() -> Iterator[MagicMock]: class TestSurface: def test_provides_for_is_any_provider_in_agent_mode(self) -> None: - handler = create_opencode_handler(events=source([])) + handler = create_opencode_handler(events=source([]), session_id="ses") assert handler.provides_for == ("*", "agent") def test_package_exports_the_handler(self) -> None: @@ -165,8 +167,10 @@ def test_package_exports_the_handler(self) -> None: 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([])) + omitted = create_opencode_handler(events=source([]), session_id="ses") + explicit = create_opencode_handler( + capture_content=False, events=source([]), session_id="ses" + ) assert omitted.capture_content is True assert explicit.capture_content is False @@ -312,6 +316,7 @@ async def test_user_text_is_not_the_assistant_reply(self) -> None: async def test_session_error_raises_without_the_provider_body(self) -> None: handler = create_opencode_handler( capture_content=True, + session_id="ses", events=source( [ assistant_open("m1"), @@ -342,6 +347,7 @@ async def test_a_tool_does_not_close_the_stream( self, mock_ld_client: MagicMock ) -> None: handler = create_opencode_handler( + session_id="ses", events=source( [ assistant_open("m1"), @@ -378,6 +384,7 @@ async def test_session_error_does_not_yield_stable_done( self, mock_ld_client: MagicMock ) -> None: handler = create_opencode_handler( + session_id="ses", events=source( [ assistant_open("m1"), @@ -399,6 +406,44 @@ def test_events_is_required(self) -> None: with pytest.raises(TypeError): create_opencode_handler() # type: ignore[call-arg] + def test_session_id_is_required(self) -> None: + with pytest.raises(TypeError, match="session_id"): + create_opencode_handler(events=source([])) # type: ignore[call-arg] + with pytest.raises(TypeError, match="session_id"): + create_opencode_handler(events=source([]), session_id="") + with pytest.raises(TypeError, match="session_id"): + create_opencode_handler(events=source([]), session_id=None) # type: ignore[arg-type] + + async def test_the_first_session_on_the_stream_is_not_adopted(self) -> None: + events = await collect( + [ + bus( + "message.updated", + info={ + "id": "other", + "role": "assistant", + "sessionID": "other", + "time": {"created": 1}, + }, + ), + text_delta("other", "po", "their answer", session_id="other"), + assistant_completed("other"), + bus( + "message.updated", + info={ + "id": "m1", + "role": "assistant", + "sessionID": "ses", + "time": {"created": 1}, + }, + ), + text_delta("m1", "p1", "Hi", session_id="ses"), + assistant_completed("m1"), + ] + ) + assert events[-1]["output"] == "Hi" + assert "their answer" not in str(events) + async def test_events_receives_the_prompt_and_the_session(self) -> None: seen: dict[str, Any] = {} @@ -652,13 +697,14 @@ def set_span_in_context(self, span: _Span) -> Any: tracer = _Tracer() monkeypatch.setattr(span_module, "trace", tracer) handler = create_opencode_handler( + session_id="ses", 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" diff --git a/packages/opencode-agents/tests/test_sdk_usage.py b/packages/opencode-agents/tests/test_sdk_usage.py index 1b88f948..64d946e4 100644 --- a/packages/opencode-agents/tests/test_sdk_usage.py +++ b/packages/opencode-agents/tests/test_sdk_usage.py @@ -64,7 +64,7 @@ def _usage(client: MagicMock) -> list[Any]: async def test_create_handler_sends_one_usage_event(installed: MagicMock) -> None: - create_opencode_handler(events=_events) + create_opencode_handler(events=_events, session_id="ses") calls = _usage(installed) assert [entry.args[2].get("helper") for entry in calls] == [ "opencode-agents.createOpenCodeHandler" @@ -82,7 +82,7 @@ async def test_create_handler_sends_one_usage_event(installed: MagicMock) -> Non async def test_wrapper_sends_one_usage_event(installed: MagicMock) -> None: - await _settle(opencode_agents("k", "q", CONTEXT, events=_events)) + await _settle(opencode_agents("k", "q", CONTEXT, events=_events, session_id="ses")) calls = _usage(installed) assert [entry.args[2].get("helper") for entry in calls] == [ "opencode-agents.opencodeAgents"