Skip to content

feat(sdk): port the explicit OpenCode harness config overlay to Go and TypeScript - #1064

Merged
AbirAbbas merged 6 commits into
mainfrom
fix/960-opencode-overlay-go-ts
Sep 21, 2026
Merged

AbirAbbas merged 6 commits into
mainfrom
fix/960-opencode-overlay-go-ts

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

The Python OpenCode provider stopped folding the harness system_prompt into the
positional opencode run prompt in #1023/#1024 — it now builds a per-run
OPENCODE_CONFIG_CONTENT overlay that defines a fixed agentfield-harness agent and
selects it with --agent. The Go and TypeScript providers never got that; they still do
the old thing:

opencode run --format json --dir <dir> -m <model> --variant <v> \
  "SYSTEM INSTRUCTIONS:\n<system prompt>\n\n---\n\nUSER REQUEST:\n<task>"

with no --agent, no overlay, and (on the Go side) an attribution overlay that is skipped
entirely whenever a caller has already set OPENCODE_CONFIG_CONTENT. This PR ports the
Python behaviour to both, under the three conditions from the #960 thread.

Merge, not clobber. The generated overlay is deep-merged into the caller's per-call
OPENCODE_CONFIG_CONTENT, or into the ambient value when there is no per-call one. A
deployment's mcp, plugin, provider and other agents survive; AgentField's generated
fields win only where they overlap. The OpenRouter attribution overlay and the harness
agent now coexist in one merged document instead of one destroying the other. A caller
value that is not a JSON object is reported, never silently dropped, and the child is not
launched.

The inline path stays available for one release.
AGENTFIELD_OPENCODE_INLINE_SYSTEM_PROMPT=1 (per-call env or ambient) restores the
SYSTEM INSTRUCTIONS: prompt and strips prompt from the merged agent, while keeping
--agent, the permissions and the model/variant flags — so a caller pushing a very long
system prompt can A/B and roll back without pinning an old SDK.

No tool→permission mapping. The permission baseline is fixed and headless:
{"*": "allow", "skill": {"agentfield*": "deny"}, "question": "deny", "task": "deny"}.
tools and permission_mode are accepted for provider-neutral API compatibility and
ignored, and the docs now say that rather than implying they are wired. The agentfield*
skill denial is the enforcement layer for the recursion problem ddbaron found — it is what
stops an AgentField-launched OpenCode session from loading the AgentField skills and
dispatching back into the control plane.

steps is now a named constant with an AGENTFIELD_OPENCODE_STEPS override in all three
SDKs (it was a bare 500 literal in Python); max_turns is still never serialized as
steps.

One fix beyond the port: the TypeScript provider read options.system_prompt, but
HarnessRunner passes systemPrompt, so a system prompt set through the public
TypeScript API never reached OpenCode at all. It now accepts both, like aforge.ts does.

Fixes #960

Validation

No OpenCode credentials on the test machine, so no paid model call — the run path was
exercised with a recording opencode stub, and the generated configuration was then
replayed against the real opencode 1.14.33 binary, which is what actually proves the
overlay takes effect.

Real reasoner, real control plane (Go SDK). Built af from this branch, started it on
an isolated HOME, registered a Go SDK agent built against this branch whose review
reasoner calls a.Harness(..., Provider: opencode, SystemPrompt: ..., Model: openrouter/z-ai/glm-5.2, Variant: high, Env: {OPENCODE_CONFIG_CONTENT: <a deployment config with an mcp server and another agent>}), and dispatched it over
POST /api/v1/execute/<node>.review.

Before this change the child received no --agent, the untouched caller config, and the
system prompt inlined into the positional argument. After it, the same dispatch produced:

ARGV: run --format json --agent agentfield-harness --dir <dir> -m openrouter/z-ai/glm-5.2 --variant high
      "Summarise the repository layout in one sentence."      <- task only
OPENCODE_CONFIG_CONTENT:
{"$schema":"https://opencode.ai/config.json","agent":{
   "agentfield-harness":{"mode":"primary","model":"openrouter/z-ai/glm-5.2",
     "permission":{"*":"allow","skill":{"agentfield*":"deny"},"question":"deny","task":"deny"},
     "prompt":"You are a meticulous senior reviewer. ...\n\nYou are an AgentField-launched worker. ...",
     "reasoningEffort":"high","steps":500},
   "deployment-agent":{"mode":"subagent","prompt":"deployment owned"}},
 "default_agent":"agentfield-harness",
 "mcp":{"deployment-mcp":{"command":["echo","hi"],"enabled":true,"type":"local"}}}

The caller's mcp block and deployment-agent are still there. Re-running the same
reasoner with AGENTFIELD_OPENCODE_INLINE_SYSTEM_PROMPT=1 put the SYSTEM INSTRUCTIONS:
block back in the positional prompt and dropped prompt from the merged agent, with
--agent and the permissions unchanged.

Against the real binary. Feeding that exact OPENCODE_CONFIG_CONTENT to
opencode run --agent agentfield-harness on opencode 1.14.33 passes its strict config
validator and resolves the agent — the same command without the overlay prints
! agent "agentfield-harness" not found. Falling back to default agent, which is the
observable difference. A deployment opencode.json placed in XDG_CONFIG_HOME is still
loaded and validated while the overlay is set, confirming file-based deployment config is
merged rather than replaced.

TypeScript. Same scenario through the real HarnessRunner and a real child process (no
mocked runCli), producing the same argv and merged configuration.

Gates: sdk/go build + vet + go test ./harness/...; sdk/typescript npm run lint +
npm test; sdk/python ruff check . + the OpenCode provider suite.

Review round

An adversarial pass over the first version found one real defect, fixed in
fix(sdk/go): keep the agentfield* skill denial last: Go was relying on encoding/json
sorting map keys to put the wildcard before the denials, which is only accidentally
correct. A caller supplying permission.skill = {"agentfield-*": "allow"} serialized as
{"agentfield*":"deny","agentfield-*":"allow"} — * sorts before - — and since
OpenCode applies the last matching rule, the caller's allow won and the recursion guard
was off. Go now serializes the permission object through an explicit ordered type in the
same order Python and TypeScript use. The two new tests assert on the serialized JSON
(a decoded map cannot express order) and both fail against the previous implementation.

Two things deliberately left alone: the TypeScript adapter still has no Windows stdin
transport (Python and Go do) and still does not pass --format json. Both predate this
change; the harness docs now say so instead of claiming all three adapters behave the
same.

🤖 Generated with Claude Code

AbirAbbas and others added 4 commits September 21, 2026 13:06
The Go OpenCode provider folded the harness system prompt into the positional
`opencode run` prompt and passed no agent, so a run depended on whatever agent
and permissions the ambient OpenCode config happened to define. Python moved off
that in #1023; this brings Go to the same behaviour.

Each run now selects the fixed `agentfield-harness` agent with `--agent` and
supplies it through OPENCODE_CONFIG_CONTENT: system prompt, model,
reasoningEffort, mode primary, a fixed steps budget, and a headless permission
baseline that denies `question`, `task` and the `agentfield*` skills so an
AgentField-launched worker cannot dispatch back into the control plane.

The overlay is deep-merged into the caller's per-call value, or the ambient one
when there is no per-call value, so a deployment's mcp servers, plugins,
providers and other agents survive and the OpenRouter attribution overlay and
the harness agent coexist. A caller value that is not a JSON object fails the
run before the concurrency slot is taken and before the child is launched.

AGENTFIELD_OPENCODE_INLINE_SYSTEM_PROMPT restores the inline prompt transport
and strips the agent's configured prompt, keeping the agent selection and
permissions, so a caller with a very long system prompt can roll back without
pinning an older SDK. `tools` and `permission_mode` stay untranslated: with the
wildcard allow in place a tool mapping would only write allow on top of allow.
`steps` is a named constant with an AGENTFIELD_OPENCODE_STEPS override and is
never fed from `max_turns`.

Refs #960

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…arness run

Mirrors the Go and Python OpenCode providers: each run selects the fixed
`agentfield-harness` agent with `--agent` and defines it through a per-run
OPENCODE_CONFIG_CONTENT overlay (system prompt, model, reasoningEffort, mode
primary, fixed steps, and the headless permission baseline that denies
`question`, `task` and the `agentfield*` skills). The task is the only
positional prompt.

The overlay is deep-merged into the caller's per-call value or the ambient one,
so deployment-owned mcp servers, plugins and agents survive and the OpenRouter
attribution overlay is no longer the only thing that can occupy the variable.
Object key order is preserved deliberately, with the wildcard first and
AgentField's denials last, because OpenCode applies the last matching rule.
A caller value that is not a JSON object throws before runCli is called.

AGENTFIELD_OPENCODE_INLINE_SYSTEM_PROMPT restores the inline prompt transport,
and `tools` / `permission_mode` remain accepted but untranslated.

This also fixes a key-name bug the overlay would otherwise inherit: the
provider read `options.system_prompt`, but HarnessRunner forwards HarnessOptions
verbatim, so a system prompt set through the public TypeScript API arrived as
`systemPrompt` and never reached opencode at all. It now accepts both spellings,
as the aforge provider already does.

Refs #960

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…able constant

The OpenCode agent overlay hard-coded `steps: 500` as a bare literal. Give it a
name and an AGENTFIELD_OPENCODE_STEPS override (per-call environment first, then
ambient; non-numeric, zero and negative values fall back to the default), so all
three SDKs expose the same knob. The default is unchanged and `max_turns` is
still never serialized as `steps`.

Refs #960

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The standalone-runs section was written when only Python had the per-run agent
overlay, and the provider-parity table still said OpenCode receives only the
model, directory and prompt. Both are now true of Go and TypeScript too.

Also documents AGENTFIELD_OPENCODE_STEPS and states plainly that `tools` and
`permission_mode` are accepted and ignored, rather than leaving readers to infer
they are wired to OpenCode permissions.

Refs #960

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AbirAbbas
AbirAbbas requested a review from a team as a code owner September 21, 2026 17:06
@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Python 9.0 KB - 0.23 µs -34% ✓ ✓
Go 235 B -16% 0.68 µs -32% ✓ ✓
TS 464 B +33% 1.98 µs -1% ✓ ✗

⚠ Regression detected:

  • TypeScript memory: 350 B → 464 B (+33%)

@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.80% 87.40% ↑ +0.40 pp 🟡
sdk-go 93.20% 92.00% ↑ +1.20 pp 🟢
sdk-python 94.72% 93.73% ↑ +0.99 pp 🟢
sdk-typescript 91.84% 90.42% ↑ +1.42 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.88% 85.75% ↑ +0.13 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 0 — ➖ no changes
sdk-go 211 89.00% ✅
sdk-python 0 — ➖ no changes
sdk-typescript 91 94.00% ✅
web-ui 0 — ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

Comment thread sdk/go/harness/opencode.go Fixed
Comment thread sdk/go/harness/opencode.go Fixed
AbirAbbas and others added 2 commits September 21, 2026 13:23
…verlay

OpenCode applies the last matching permission rule, so the Python and
TypeScript providers deliberately emit AgentField's skill/question/task denials
after any caller rules. Go relied on `encoding/json` sorting map keys, which is
only accidentally correct: a caller supplying

    {"agent":{"agentfield-harness":{"permission":{"skill":{"agentfield-*":"allow"}}}}}

serialized as {"agentfield*":"deny","agentfield-*":"allow"} — `*` sorts before
`-` — so the caller's allow was the last match and the recursion guard was off.

Serialize the permission object through a small ordered JSON type instead, in
the same order Python uses: wildcard, caller rules, then AgentField's denials,
with `agentfield*` last inside `skill`. Both the merged and the generated-only
paths now go through it, so one mechanism governs the order.

Also sizes the deep-merge map from the base alone; summing both lengths is what
CodeQL's allocation-size-overflow rule flags.

The two new tests assert on the serialized JSON rather than a decoded map,
because a decoded map cannot express order; both fail against the previous
implementation.

Refs #960

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nored too

HarnessRunner forwards HarnessOptions verbatim, so a caller sets
`permissionMode` and `maxTurns`; the test only passed the snake_case aliases, so
"tools and permission_mode add nothing to the overlay" was not actually checked
against the keys the public API sends. Pass both spellings.

Also scopes the Windows stdin sentence in the harness docs: Python and Go send
the prompt over stdin there, the TypeScript adapter always uses the positional
argument. That difference is pre-existing and stays out of this change.

Refs #960

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AbirAbbas
AbirAbbas merged commit f7bae4b into main Sep 21, 2026
42 checks passed
@AbirAbbas
AbirAbbas deleted the fix/960-opencode-overlay-go-ts branch September 21, 2026 20:03
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.

proposal: make opencode harness calls explicit

2 participants