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 dac8348d..d022c77f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/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..4fba15f0 --- /dev/null +++ b/packages/opencode-agents/README.md @@ -0,0 +1,51 @@ +# `launchdarkly-ai-opencode-agents` + +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. + +```bash +pip install launchdarkly-ai-server launchdarkly-server-sdk launchdarkly-ai-opencode-agents +``` + +```python +import asyncio +from collections.abc import AsyncIterator + +from launchdarkly_ai_opencode_agents import create_opencode_handler +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}} + + +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 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 new file mode 100644 index 00000000..44d4597c --- /dev/null +++ b/packages/opencode-agents/agents.md @@ -0,0 +1,43 @@ +# 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. + +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. + +## 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, 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. + +## 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. + +`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/pyproject.toml b/packages/opencode-agents/pyproject.toml new file mode 100644 index 00000000..cd65e988 --- /dev/null +++ b/packages/opencode-agents/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "launchdarkly-ai-opencode-agents" +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" +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/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py new file mode 100644 index 00000000..dcd4a8d9 --- /dev/null +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -0,0 +1,459 @@ +"""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 and does not construct an OpenCode SDK client. +The public stream is assistant text as ``chunk``, then one ``done``. +""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, AsyncIterable, Callable, Mapping +from typing import Any + +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. The error ``name`` is, when the + event has one. A failed session does not finish the run. + """ + + +class _Reducer: + """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. 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 *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: + self._session_id = session_id + 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._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 not isinstance(raw, Mapping): + continue + kind = raw.get("type") + properties = raw.get("properties") + session_id = _session_id(raw) + if session_id is not None and 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 + + 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(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(part_id, kind, text)) + return events + + def _apply_text(self, part_id: str, kind: str, text: str) -> list[dict[str, Any]]: + if kind == "delta": + 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) :] + self._part_text[part_id] = text + elif not already: + suffix = text + self._part_text[part_id] = text + else: + return [] + if not suffix: + return [] + return self._chunk(suffix) + + def _event(self, raw: Mapping[str, Any]) -> list[dict[str, Any]]: + 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) or part.get("type") != "text": + return [] + return self._text_snapshot(part) + 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") + part_id = properties.get("partID") + 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") + 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 [] + return self._accept_text(message_id, part_id, "snapshot", text) + + 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") + role = info.get("role") + events: list[dict[str, Any]] = [] + 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 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 _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 + 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 + + +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: 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]]: + 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 + + +def create_opencode_handler( + *, + events: Events, + session_id: str, + capture_content: bool | 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 as + ``events(config, user_input, variables, history, session_id)`` and must + 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 + 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( + events=events, + session_id=_require_session_id(session_id), + capture_content=capture_content, + ) + + +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 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) + session_id = _require_session_id(kwargs.pop("session_id", None)) + events = kwargs.pop("events") + return _config( + key=config_key, + handler=_create_opencode_handler( + events=events, + session_id=session_id, + capture_content=capture_content, + ), + **kwargs, + ).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, + capture_content: bool | None, +) -> ProviderHandler: + content_override = capture_content + bound_session = session_id + + 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 = 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, + 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/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/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 new file mode 100644 index 00000000..9ee80aaf --- /dev/null +++ b/packages/opencode-agents/tests/test_handler.py @@ -0,0 +1,718 @@ +"""OpenCode handler: synthetic session events, stable chunk then done stream.""" + +from __future__ import annotations + +from collections.abc import 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_opencode_agents import ( + OpenCodeSessionError, + create_opencode_handler, + opencode_agents, +) +from launchdarkly_ai_server import SDKOptions, config, configure + +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 | 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( + 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_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, + *, + 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( + 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 + + return _events + + +async def collect(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + 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] + + +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([]), session_id="ses") + assert handler.provides_for == ("*", "agent") + + def test_package_exports_the_handler(self) -> None: + import launchdarkly_ai_opencode_agents as package + + assert set(package.__all__) == { + "OpenCodeSessionError", + "create_opencode_handler", + "opencode_agents", + } + 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([]), 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 + + +class TestText: + async def test_text_around_a_tool_is_chunks_then_done(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"}), + 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) == ["chunk", "chunk", "done"] + assert [event["text"] for event in events if event["type"] == "chunk"] == [ + "Looking up", + "Done.", + ] + 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_does_not_leak_into_the_stream(self) -> None: + events = await collect( + [ + tool_part( + "c1", + "error", + raw_input={"command": "rm"}, + output="disk on fire", + ) + ], + ) + assert types_of(events) == ["done"] + assert events[-1]["output"] == "" + assert "disk on fire" not in str(events) + + 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"] + + 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), + ], + ) + assert events[-1]["usage"] == { + "input_tokens": 3, + "output_tokens": 4, + "cache_read_input_tokens": 1, + "cache_creation_input_tokens": 2, + } + + 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", + part={ + "id": "p1", + "messageID": "m1", + "type": "text", + "text": "Hi", + }, + ), + assistant_completed("m1"), + ], + ) + 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( + [ + 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"), + ], + ) + 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: + 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, + session_id="ses", + events=source( + [ + assistant_open("m1"), + text_delta("m1", "p1", "Hi"), + { + "type": "session.error", + "properties": { + "error": { + "name": "ProviderAuthError", + "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 "ProviderAuthError" in str(raised.value) + assert "secret-body" not in str(raised.value) + assert "done" not in seen + + +class TestStableStream: + 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"), + 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"), + assistant_open("m2"), + 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( + session_id="ses", + events=source( + [ + assistant_open("m1"), + 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" + + +class TestInvocation: + 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] = {} + + 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_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: + 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( + 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" + 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..64d946e4 --- /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, session_id="ses") + 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, session_id="ses")) + 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/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 130d93b0..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": [ 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 d8fba767..8aebcdd1 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,24 @@ 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" }, + { name = "opentelemetry-api" }, +] + +[package.metadata] +requires-dist = [ + { name = "launchdarkly-ai-server", editable = "packages/client" }, + { name = "opentelemetry-api", specifier = ">=1.25" }, +] + [[package]] name = "launchdarkly-ai-python" -version = "0.1.7" +version = "0.2.0" source = { editable = "packages/ai" } dependencies = [ { name = "launchdarkly-ai-server" }, @@ -972,7 +988,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" },