Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion packages/opencode-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,26 @@ asyncio.run(main())

`cwd` is the working directory, `config` is the OpenCode config file, and `env` is the child environment. The handler sets `OPENCODE_CONFIG` from `config`, replaces any `OPENCODE_SERVER_USERNAME` in `env` with `opencode`, replaces any `OPENCODE_SERVER_PASSWORD` with a generated secret, and binds `opencode serve` to loopback. Requests to that server use that username and secret as basic auth. A caller does not pass a URL, an HTTP client, or a transport. `command` overrides the `opencode` executable; a test passes a fake executable there.

Entering the handler starts `opencode serve` and returns once it is healthy. The process stops when the context exits, after `config().stream()` returns. Put a wall-clock limit around `config().stream()`, not around the context. A judge routed here runs as a full OpenCode agent: the server's tools, in `cwd`, apply to the judge prompt. There is no tool-less judge mode. The handler yields AG-UI text and tool events. `config().stream()` yields `chunk` then `done`.
Entering the handler starts `opencode serve` and returns once it is healthy. The process stops when the context exits, after `config().stream()` returns. Put a wall-clock limit around `config().stream()`, not around the context. The handler yields AG-UI text and tool events. `config().stream()` yields `chunk` then `done`.

## Tools

The AI Config's `tools`, filtered to the keys also present in `tool_handlers`, is the only catalog the model sees for one call — every other tool on the running `opencode serve`, including every built-in, is denied. A variation with no tools (and every judge, which always calls with `tool_handlers=None`) gets a model with no tools at all.

```python
from launchdarkly_ai_opencode_agents import OpenCodeBash, OpenCodeRead

response = await config(
key="my-agent",
handler=handler,
tool_handlers={
"run-shell": OpenCodeBash, # an OpenCode built-in, allowed by name
"read-file": OpenCodeRead,
"get-weather": get_weather, # a plain callable, served from a per-call bridge
},
).invoke(user_input, context)
```

A `NativeTool` (see `OpenCodeBash`, `OpenCodeRead`, and the rest of this package's sentinels) names an OpenCode built-in or a server-configured MCP tool directly — OpenCode already has it, so the handler only allows it for this call's session. A plain callable has no route into OpenCode otherwise: `POST /session/{id}/message`'s own `tools` field is a deprecated boolean permission map, not a place to describe one. Each call with at least one callable starts its own loopback MCP server, registers it on the running `opencode serve` with `POST /mcp`, and disconnects it when the call ends. This is unit-tested directly (`test_tool_bridge.py`) and through the handler against a fake `opencode serve` (`test_tool_contract.py`); it has not been exercised against a real OpenCode MCP client.

`chat` and `execute_tool` spans are opened and closed from the same bus events the AG-UI stream is derived from, named after the AI Config key (not OpenCode's own tool id or the bridge's composed MCP name).
22 changes: 18 additions & 4 deletions packages/opencode-agents/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ This package is Python-only. There is no JS counterpart in this slice. The spec

| File | Role |
| --- | --- |
| `handler.py` | Reducer, `create_opencode_handler`, `opencode_agents` |
| `handler.py` | Reducer, `create_opencode_handler`, `opencode_agents`, tool planning (`_plan_tools`, `_build_tool_ruleset`) |
| `process.py` | Starts and stops `opencode serve` for one call |
| `server.py` | The HTTP session this process just started |
| `spans.py` | `invoke_agent` root, and the `chat` / `execute_tool` constructors the parity checks call |
| `server.py` | The HTTP session this process just started; `register_mcp_bridge`/`disconnect_mcp_bridge` |
| `spans.py` | `invoke_agent` root, the `chat` / `execute_tool` constructors, and `close_open_spans`/`abandon_open_spans` |
| `tool_bridge.py` | The per-call loopback MCP server a plain callable tool is served from |
| `builtins.py` | `NativeTool` sentinels for OpenCode's own built-ins (`OpenCodeBash`, etc.) |
| `_version.py` | Package name and version sent with usage events |

## Dependencies
Expand All @@ -38,9 +40,21 @@ A run opens one `invoke_agent` span and sets `launchdarkly.operation.type`. The

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.

## Tool contract (Option C)

`_plan_tools(config, tool_handlers)` is the single place that decides what one call offers: the intersection of `config["tools"]` and the keys present in `tool_handlers` (already wrapped by `tracking.py`, so a `NativeTool` entry carries `NATIVE_TOOL_KEY`). A `NativeTool` is allowed by its own `tool_name`. A plain callable is queued as a `_CallableEntry` and only gets a model-visible name once `_stream_impl` has started that call's `ToolBridge` and knows its generated MCP server name (`ld_<run id>`); `bridge_tool_name` composes the name OpenCode will actually report, matching `mcp/catalog.ts`'s own `sanitize` + `toolName`.

`_build_tool_ruleset(offered_names)` is sent as `POST /session`'s `permission`: a leading `{"*": deny}` rule, then one `{name: allow}` rule per offered name. `Permission.merge` (OpenCode's own code) appends a session's `permission` after the agent's static `opencode.json` permission, and `Permission.disabled` resolves the *last* matching rule — so this ruleset always wins, and an empty `offered_names` still denies everything rather than falling back to the host's static catalog.

The reducer (`_Reducer`) opens and closes `chat`/`execute_tool` spans from the same tool-part and message-info transitions the AG-UI events already come from, independent of what was actually offered — a span reports what OpenCode said happened, not what was requested. `tool_display_names` maps the name OpenCode reports (a native id or a bridge's composed name) back to the AI Config key for the span name; an unmapped name falls back to itself. A `NativeTool`'s own `$ld:ai:tool_call` tracking stub fires once, when its span opens (mirroring `claude-agents`'s `PreToolUse` hook); a bridged callable's tracked wrapper already fires it when the bridge invokes it, so the reducer does not fire it again.

`open_tool_spans`/`open_model_spans` are owned by `_stream_impl`, not the reducer, specifically so `close_open_spans`/`abandon_open_spans` (registered in `test_cross_handler_parity.py`'s `HOOK_BASED_TOOL_HANDLERS`, this package's closest analogue to a vendor hook object) can find and end whatever is still open from the streaming `finally`, even if the reducer was never constructed (no raw bus event ever arrived). Keep both span-teardown signatures on one physical line — the parity suite's body extractor is indentation-based and mis-reads a wrapped signature whose closing `) -> None:` lands back at column 0, which is exactly what `ruff format` produces once a signature exceeds the line-length limit.

## Common pitfalls

`provides_for` is `('*', 'agent')`, so a judge is routed here too. Each call starts its own process, so the judge prompt does not enter the agent's session and does not need `skip_judges=True`. The judge still runs as a full OpenCode agent, with the server's tools, in `cwd`. This package does not take a tool-less judge agent.
`provides_for` is `('*', 'agent')`, so a judge is routed here too. Each call starts its own process, so the judge prompt does not enter the agent's session and does not need `skip_judges=True`. Judges always call with `tool_handlers=None` (`judges.py`), so the offered set is empty and the judge gets a tool-less OpenCode agent — unlike `claude-agents`, which leaves Claude Code's built-ins on for a tool-less judge today; that inconsistency is open, not fixed here.

The bridge (`tool_bridge.py`) answers in MCP streamable-HTTP "JSON response" mode only: no SSE, because this bridge never needs to push an unsolicited message to OpenCode. It runs on a background thread (`http.server`), not an asyncio server, to avoid taking on an ASGI dependency for one loopback endpoint; a `tools/call` crosses back onto the caller's event loop with `asyncio.run_coroutine_threadsafe`. None of this has been exercised against OpenCode's real MCP client (`@modelcontextprotocol/sdk`); `fake_opencode.py` only records the `POST /mcp` body, it never actually calls back into a registered bridge, except the one test in `test_tool_contract.py` that does so directly with a raw `httpx` request built from that recorded body.

The reducer does not adopt the first session on the bus. Other sessions are dropped, including their `session.error`. `session.idle` for this session ends the run.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
# Public API of launchdarkly_ai_opencode_agents. Generated by `make api-report`; do not edit.
create_opencode_handler(*, cwd: str | os.PathLike[str], config: str | os.PathLike[str], env: Mapping[str, str], capture_content: bool | None = None, model: ModelResolver | None = None, agent: str | None = None, command: Command = None) -> ProviderHandler
opencode_agents(config_key: str, user_input: str, context: dict[str, Any], **kwargs: Any) -> Any
OpenCodeApplyPatch = NativeTool('apply_patch')
OpenCodeBash = NativeTool('bash')
OpenCodeEdit = NativeTool('edit')
OpenCodeGlob = NativeTool('glob')
OpenCodeGrep = NativeTool('grep')
OpenCodeList = NativeTool('list')
OpenCodeRead = NativeTool('read')
OpenCodeSessionError(Exception) [class]
OpenCodeSkill = NativeTool('skill')
OpenCodeTask = NativeTool('task')
OpenCodeTodoWrite = NativeTool('todowrite')
OpenCodeWebFetch = NativeTool('webfetch')
OpenCodeWebSearch = NativeTool('websearch')
OpenCodeWrite = NativeTool('write')
2 changes: 1 addition & 1 deletion packages/opencode-agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "launchdarkly-ai-opencode-agents"
version = "0.2.0"
requires-python = ">=3.12"
dependencies = [
"launchdarkly-ai-server>=0.4.1",
"launchdarkly-ai-server>=0.4.0",
"httpx>=0.27",
"opentelemetry-api>=1.25",
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,38 @@
from launchdarkly_ai_server import register_ai_sdk_package

from ._version import PACKAGE_NAME, __version__
from .builtins import (
OpenCodeApplyPatch,
OpenCodeBash,
OpenCodeEdit,
OpenCodeGlob,
OpenCodeGrep,
OpenCodeList,
OpenCodeRead,
OpenCodeSkill,
OpenCodeTask,
OpenCodeTodoWrite,
OpenCodeWebFetch,
OpenCodeWebSearch,
OpenCodeWrite,
)
from .handler import OpenCodeSessionError, create_opencode_handler, opencode_agents

__all__ = [
"OpenCodeApplyPatch",
"OpenCodeBash",
"OpenCodeEdit",
"OpenCodeGlob",
"OpenCodeGrep",
"OpenCodeList",
"OpenCodeRead",
"OpenCodeSessionError",
"OpenCodeSkill",
"OpenCodeTask",
"OpenCodeTodoWrite",
"OpenCodeWebFetch",
"OpenCodeWebSearch",
"OpenCodeWrite",
"create_opencode_handler",
"opencode_agents",
]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""OpenCode built-in tool sentinels.

Place any of these as a value in your ``tool_handlers`` map to allow the
corresponding OpenCode built-in capability for one call. Every other
built-in, including one not named here, is denied for that call: an AI
Config variation with no tools gets a model with no tools at all.

Ids verified against the OpenCode source at tag ``v1.18.4``
(``packages/opencode/src/tool/*.ts``). ``OpenCodeList`` is the one
exception: it mirrors Vega's own ``_PINNABLE_BUILTIN_TOOLS`` pin
(``ai/vega-agentcore/vega_agentcore/opencode_config.py``) even though that
tag ships no ``list`` tool file. Passing a name OpenCode does not currently
serve is harmless — it simply never matches a tool the model is offered.

Example::

from launchdarkly_ai_opencode_agents import OpenCodeBash, OpenCodeRead
from launchdarkly_ai_server import config

response = await config(
key="my-agent",
handler=create_opencode_handler(cwd=..., config=..., env=...),
tool_handlers={"run-bash": OpenCodeBash, "read-file": OpenCodeRead},
).invoke(user_input, context)
"""

from __future__ import annotations

from launchdarkly_ai_server import NativeTool

OpenCodeBash = NativeTool("bash")
OpenCodeRead = NativeTool("read")
OpenCodeEdit = NativeTool("edit")
OpenCodeWrite = NativeTool("write")
OpenCodeGlob = NativeTool("glob")
OpenCodeGrep = NativeTool("grep")
OpenCodeList = NativeTool("list")
OpenCodeTask = NativeTool("task")
OpenCodeSkill = NativeTool("skill")
OpenCodeTodoWrite = NativeTool("todowrite")
OpenCodeWebFetch = NativeTool("webfetch")
OpenCodeWebSearch = NativeTool("websearch")
OpenCodeApplyPatch = NativeTool("apply_patch")
Loading
Loading