Skip to content

feat(opencode-agents): honor AI Config tools - #216

Open
apucacao wants to merge 2 commits into
mainfrom
cursor/opencode-handler-ai-config-tools-1b7f
Open

apucacao wants to merge 2 commits into
mainfrom
cursor/opencode-handler-ai-config-tools-1b7f

Conversation

@apucacao

Copy link
Copy Markdown
Contributor

What

The OpenCode handler now honors config["tools"] and tool_handlers, matching the contract every other handler follows (claude-agents, openai-agents, ...). Previously _stream_impl did del tool_handlers and never read config["tools"], so the AI Config's tool catalog did nothing and every caller — including a judge, which always calls with tool_handlers=None — silently inherited whatever opencode.json already configured on the running server.

Design doc (internal): "OpenCode handler: the tool contract", Option C.

How

  • Offered set. The intersection of config["tools"] and the keys present in tool_handlers — same request-scoped authorization rule the JS SDK states for its own handlers. A NativeTool is allowed on the session by its own provider name. A plain callable has no route into OpenCode otherwise (the message route's tools field is a deprecated boolean permission map, not a place to describe a tool), so it's served from a per-call loopback MCP bridge the handler registers on the running opencode serve with POST /mcp and disconnects with POST /mcp/{name}/disconnect when the call ends.
  • Permission. The session's permission ruleset (POST /session) always starts with a deny-all rule, then allows only the offered names. Permission.merge appends a session's ruleset after the agent's static opencode.json permission, and OpenCode resolves the last matching rule, so this always wins. An empty offered set still denies everything — a variation with no tools (or a judge) gets a model with no tools, not the host's default catalog.
  • Spans. chat and execute_tool (constructors already existed, unused) are now opened and closed from the same bus events the AG-UI stream already derives from — one chat per assistant message, one execute_tool per tool call id — named after the AI Config key, not OpenCode's own tool id or the bridge's composed MCP name. close_open_spans/abandon_open_spans give this package the same abandon/failure split the hook-based handlers have; it's now registered in test_cross_handler_parity.py's HOOK_BASED_TOOL_HANDLERS.
  • New public sentinels. OpenCodeBash, OpenCodeRead, OpenCodeEdit, OpenCodeWrite, OpenCodeGlob, OpenCodeGrep, OpenCodeList, OpenCodeTask, OpenCodeSkill, OpenCodeTodoWrite, OpenCodeWebFetch, OpenCodeWebSearch, OpenCodeApplyPatch — NativeTool sentinels for OpenCode's own built-ins, the same pattern as claude-agents's Claude* exports. skill is not auto-allowed; a caller that wants it lists OpenCodeSkill explicitly.
  • No new dependency. The bridge runs on a background http.server thread rather than an asyncio/ASGI stack, answering in MCP streamable-HTTP "JSON response" mode (no SSE) — this package still depends on only httpx and opentelemetry-api.

What this is not

No change to claude-agents (it still leaves Claude Code's built-ins on for a tool-less judge; that inconsistency is called out in agents.md as open, not fixed here). No new AI Config field for attaching an MCP server — a remote MCP tool is still NativeTool("<server>_<tool>"), configured host-side.

Verification

Nothing here has been run against a real OpenCode MCP client (none is available). The bridge's own JSON-RPC behavior is unit-tested directly (test_tool_bridge.py); the handler's side of the contract — ruleset sent, POST /mcp registration body, teardown, and one test that calls the registered bridge exactly as a real client would, using the URL/token the fake opencode serve recorded — is tested against the existing fake (test_tool_contract.py).

uv run pytest packages/opencode-agents -q          # 89 passed
uv run pytest tests/test_cross_handler_parity.py -q # 214 passed, 17 skipped (repo-wide)
uv run pytest -q                                    # 3321 passed, 17 skipped (full repo suite)
uv run mypy packages/opencode-agents/src            # Success: no issues found in 8 source files
uv run ruff check . && uv run ruff format --check . # clean
uv run python scripts/api_report.py --check         # 15 API reports are current
Open in Web Open in Cursor 

The OpenCode handler previously deleted tool_handlers and never read
config["tools"], so the AI Config's tool catalog did nothing and every
caller silently inherited whatever opencode.json already configured.
That broke the tool-handling contract every other handler follows and
gave a judge (tool_handlers=None) a full, unsandboxed OpenCode agent.

The offered set is now the intersection of config["tools"] and the
caller's tool_handlers, same as the other handlers. A NativeTool (see
the new OpenCode* built-in sentinels) is allowed on the session by its
own provider name. A plain callable is served from a per-call loopback
MCP bridge the handler registers on the running opencode serve with
POST /mcp and disconnects when the call ends -- there is no OpenCode
route that accepts a tool definition directly on a message.

The session's permission ruleset now always denies everything, then
allows only the offered names, so a variation with no tools gets a
model with no tools (including every judge). chat and execute_tool
spans are opened and closed from the same bus events the AG-UI stream
already derives from, named after the AI Config key.

Nothing here has been run against a real OpenCode MCP client; the
bridge's own protocol behavior is unit-tested directly, and the
handler's side of the contract (ruleset, registration, teardown) is
tested against the existing fake opencode server.

Co-authored-by: alexis <alexis@launchdarkly.com>
@apucacao
apucacao requested a review from a team as a code owner October 24, 2026 13:19

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3e1e7c2. Configure here.

"""
ruleset = [{"permission": "*", "action": "deny", "pattern": "*"}]
for name in offered_names:
ruleset.append({"permission": name, "action": "allow", "pattern": "*"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit-family tools use wrong permission

High Severity

Allow rules use each native tool's own id, but OpenCode's Permission.disabled remaps write and apply_patch to the edit permission before matching. OpenCodeWrite and OpenCodeApplyPatch therefore stay denied by the leading * rule, and OpenCodeEdit also leaves write and apply_patch enabled.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3e1e7c2. Configure here.

tools.clear()
for span in list(models.values()):
end_span_once(span, ended, abandoned=True)
models.clear()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timeouts mark spans abandoned

Medium Severity

abandon_open_spans always sets launchdarkly.stream.abandoned and has no cancelled argument. asyncio.CancelledError skips except Exception, so a timeout around config().stream() tags open chat and execute_tool spans as abandoned instead of launchdarkly.run.cancelled.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3e1e7c2. Configure here.

@cursor

cursor Bot commented Oct 24, 2026

Copy link
Copy Markdown

Heads up for review: while wiring observability's Vega runtime to this commit (git pin, packages/client + packages/opencode-agents), I hit a pre-existing, unrelated version-floor conflict that blocks any Poetry- or pip-strict-resolver-based git pin to current main:

So poetry lock / pip's resolver refuses to install launchdarkly-ai-server @ git+...#subdirectory=packages/client alongside launchdarkly-ai-opencode-agents @ git+...#subdirectory=packages/opencode-agents from the same commit, since the declared floor (>=0.4.1) is never satisfied by the resolved version string (0.4.0) — even though the actual code is obviously compatible (same commit). I worked around it locally for testing with pip install --no-deps, and documented the real blocker in launchdarkly/observability#2693 (which depends on this PR), but I did not touch packages/client's version or the release-please manifest here — that's a release-versioning call for this repo's own maintainers, out of scope for what I was asked to do.

This will affect any consumer trying to git-pin main (or this branch) with a normal resolver until launchdarkly-ai-server actually releases ≥0.4.1.

packages/client is still version 0.4.0. Requiring >=0.4.1 made a git pin
of this commit uninstallable: Poetry and pip resolve the sibling client
package as 0.4.0 and reject it. The handler is written against the client
source in the same commit, so the floor matches that declared version.

Co-authored-by: alexis <alexis@launchdarkly.com>
@cursor

cursor Bot commented Oct 24, 2026

Copy link
Copy Markdown

Follow-up on the install failure: 569d3a1 lowers this package's requirement from launchdarkly-ai-server>=0.4.1 to >=0.4.0, which is the version packages/client still declares on this commit. A git pin of both packages from this SHA now resolves. No client version bump and no PyPI release. Vega's lock is regenerated against this commit in launchdarkly/observability#2693.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants