feat(sdk): port the explicit OpenCode harness config overlay to Go and TypeScript - #1064
Merged
Merged
Conversation
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>
Contributor
Performance
⚠ Regression detected:
|
Contributor
📊 Coverage gateThresholds from
✅ Gate passedNo surface regressed past the allowed threshold and the aggregate stayed above the floor. |
Contributor
📐 Patch coverage gateThreshold: 80% on lines this PR touches vs
✅ Patch gate passedEvery surface whose lines were touched by this PR has patch coverage at or above the threshold. |
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Python OpenCode provider stopped folding the harness
system_promptinto thepositional
opencode runprompt in #1023/#1024 — it now builds a per-runOPENCODE_CONFIG_CONTENToverlay that defines a fixedagentfield-harnessagent andselects it with
--agent. The Go and TypeScript providers never got that; they still dothe old thing:
with no
--agent, no overlay, and (on the Go side) an attribution overlay that is skippedentirely whenever a caller has already set
OPENCODE_CONFIG_CONTENT. This PR ports thePython 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. Adeployment's
mcp,plugin,providerand other agents survive; AgentField's generatedfields 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 theSYSTEM INSTRUCTIONS:prompt and stripspromptfrom the merged agent, while keeping--agent, the permissions and the model/variant flags — so a caller pushing a very longsystem 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"}.toolsandpermission_modeare accepted for provider-neutral API compatibility andignored, 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.
stepsis now a named constant with anAGENTFIELD_OPENCODE_STEPSoverride in all threeSDKs (it was a bare
500literal in Python);max_turnsis still never serialized assteps.One fix beyond the port: the TypeScript provider read
options.system_prompt, butHarnessRunnerpassessystemPrompt, so a system prompt set through the publicTypeScript API never reached OpenCode at all. It now accepts both, like
aforge.tsdoes.Fixes #960
Validation
No OpenCode credentials on the test machine, so no paid model call — the run path was
exercised with a recording
opencodestub, and the generated configuration was thenreplayed 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
affrom this branch, started it onan isolated
HOME, registered a Go SDK agent built against this branch whosereviewreasoner 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 overPOST /api/v1/execute/<node>.review.Before this change the child received no
--agent, the untouched caller config, and thesystem prompt inlined into the positional argument. After it, the same dispatch produced:
The caller's
mcpblock anddeployment-agentare still there. Re-running the samereasoner with
AGENTFIELD_OPENCODE_INLINE_SYSTEM_PROMPT=1put theSYSTEM INSTRUCTIONS:block back in the positional prompt and dropped
promptfrom the merged agent, with--agentand the permissions unchanged.Against the real binary. Feeding that exact
OPENCODE_CONFIG_CONTENTtoopencode run --agent agentfield-harnesson opencode 1.14.33 passes its strict configvalidator and resolves the agent — the same command without the overlay prints
! agent "agentfield-harness" not found. Falling back to default agent, which is theobservable difference. A deployment
opencode.jsonplaced inXDG_CONFIG_HOMEis stillloaded and validated while the overlay is set, confirming file-based deployment config is
merged rather than replaced.
TypeScript. Same scenario through the real
HarnessRunnerand a real child process (nomocked
runCli), producing the same argv and merged configuration.Gates:
sdk/gobuild + vet +go test ./harness/...;sdk/typescriptnpm run lint+npm test;sdk/pythonruff 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 onencoding/jsonsorting 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 sinceOpenCode 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 thischange; the harness docs now say so instead of claiming all three adapters behave the
same.
🤖 Generated with Claude Code