Blocks: #293 (its implementation requires starting a DAG through this tool).
Problem Statement
The workflow tool cannot be invoked at all when the session model is qwen-family (verified local-proxy-compatible/qwen3.8-max): every call — including the trivial {"params": {"action": "list"}} — fails schema validation with the params value arriving as a JSON string of the intended object instead of the object:
SchemaError(Expected {action: "start", ...} | ... , got "{\"action\": \"list\"}" at ["params"])
Reproduces across restarts, across sessions, and on first call with zero prior context. Other tools with nested-but-flat parameters (compress, question, task) are unaffected.
Evidence
All evidence gathered read-only; chain is complete from persisted state down to wire bytes.
-
Persisted tool parts (~/.local/share/opencode/opencode.db, part table) store state.input.params already as a string, e.g. {"params":"{\"action\": \"validate\", \"spec_path\": \"spec293.yaml\"}"} — the corruption predates opencode's decode layer.
-
Schema generation is innocent. The JSON Schema actually sent on the wire was captured via fetch interception: correct object root, union nested as anyOf under the params property (exactly the shape this codebase mandates for discriminated unions):
{"type":"object","properties":{"params":{"anyOf":[
{"type":"object","properties":{"action":{"enum":["list"]}},"required":["action"]},
{"type":"object","properties":{"action":{"enum":["validate"]},"spec_path":{"type":"string"}},"required":["action","spec_path"]}
]}},"required":["params"]}
-
Parse pipeline is innocent. session/llm.ts and the AI SDK adapter pass provider-parsed input through unchanged; tool/tool.ts decodes and rejects correctly.
-
Wire capture is decisive. SSE chunks from the provider contain the model's emission verbatim: tool_calls … "arguments":"{\"params\": \"{\\\"action\\\": \\\"list\\\"}\"}". The model itself emits the double-encoded string.
-
Controlled A/B (same proxy, same wire, minimal schema):
| model |
schema shape of params |
emitted params |
| qwen3.8-max |
union (anyOf) |
string ❌ (reproducible) |
| qwen3.8-max |
flat object |
object ✅ |
| deepseek-v4-flash |
union (anyOf) |
object ✅ |
-
In the original session the very first call succeeded and every later call failed; after restart, first call fails too — the failure does not require prior error feedback, but the feedback text (which prints the stringified shape) can reinforce it.
Root Cause
A model-side generation bias: qwen-family models, presented with a tool parameter property whose schema is a nested anyOf union, emit that property's value as a JSON-encoded string of the object. This joins the provider-quirk family already documented at the assertObjectRootedParameters site — "OpenAI tolerates root-level combinators, DeepSeek rejects them, GLM answers with empty tool arguments" — as a fourth member: qwen stringifies nested-union property values.
Implementation Decisions (proposed)
- Primary fix — tolerant normalization in the tool decode wrapper, model-agnostic and future-proof: before schema decoding, opportunistically JSON-parse any string-typed argument value sitting where the tool parameter schema expects an object or array (parse only when it succeeds and yields the expected container type). Location: the decode wrapper in
tool/tool.ts. This repairs every affected tool × model combination, including future unknown quirks, without touching provider code.
- Optional prevention — provider-side flattening: the established per-provider schema-sanitize seam in
provider/transform.ts (schema(); existing branches for openai/azure, moonshot, gemini) gains a qwen-family branch that flattens union-valued properties into one merged object schema (action field as the union of literals, per-branch fields optional), so the model never sees the triggering shape. Keyed by model-id heuristic; accepted as lossy strictness. May be skipped if 1 alone proves sufficient in practice.
- Extend the quirk-family comment at the assertObjectRootedParameters site with the qwen case, so the documented provider contract stays truthful.
parseOptions.onExcessProperty: "error" and the action-union strictness remain untouched — normalization only fixes encoding, never semantics.
Testing Decisions
- Unit tests at the existing tool-decode seam (prior art:
test/tool/tool-define.test.ts): (a) string-of-object at a union property round-trips to the decoded object; (b) a genuinely invalid string still fails validation; (c) strings where the schema expects a string pass through unparsed.
- Reproduction harness (fetch-intercepting probe against
LOCAL_PROXY_URL with the workflow-shaped schema) is cheap to rerun and can be kept as a manual check, not a CI test (needs network + provider).
- Existing workflow-tool tests stay green; no runtime DAG behaviour is touched.
Out of Scope
- Changing the nested-union tool design itself (it is the correct OpenAI-contract shape).
- Proxy-side fixes (the proxy forwards the model's emission faithfully; verified).
- Other model quirks beyond this one.
Further Notes
Blocks: #293 (its implementation requires starting a DAG through this tool).
Problem Statement
The
workflowtool cannot be invoked at all when the session model is qwen-family (verifiedlocal-proxy-compatible/qwen3.8-max): every call — including the trivial{"params": {"action": "list"}}— fails schema validation with the params value arriving as a JSON string of the intended object instead of the object:Reproduces across restarts, across sessions, and on first call with zero prior context. Other tools with nested-but-flat parameters (compress, question, task) are unaffected.
Evidence
All evidence gathered read-only; chain is complete from persisted state down to wire bytes.
Persisted tool parts (
~/.local/share/opencode/opencode.db,parttable) storestate.input.paramsalready as a string, e.g.{"params":"{\"action\": \"validate\", \"spec_path\": \"spec293.yaml\"}"}— the corruption predates opencode's decode layer.Schema generation is innocent. The JSON Schema actually sent on the wire was captured via fetch interception: correct object root, union nested as
anyOfunder theparamsproperty (exactly the shape this codebase mandates for discriminated unions):{"type":"object","properties":{"params":{"anyOf":[ {"type":"object","properties":{"action":{"enum":["list"]}},"required":["action"]}, {"type":"object","properties":{"action":{"enum":["validate"]},"spec_path":{"type":"string"}},"required":["action","spec_path"]} ]}},"required":["params"]}Parse pipeline is innocent.
session/llm.tsand the AI SDK adapter pass provider-parsed input through unchanged;tool/tool.tsdecodes and rejects correctly.Wire capture is decisive. SSE chunks from the provider contain the model's emission verbatim:
tool_calls … "arguments":"{\"params\": \"{\\\"action\\\": \\\"list\\\"}\"}". The model itself emits the double-encoded string.Controlled A/B (same proxy, same wire, minimal schema):
In the original session the very first call succeeded and every later call failed; after restart, first call fails too — the failure does not require prior error feedback, but the feedback text (which prints the stringified shape) can reinforce it.
Root Cause
A model-side generation bias: qwen-family models, presented with a tool parameter property whose schema is a nested
anyOfunion, emit that property's value as a JSON-encoded string of the object. This joins the provider-quirk family already documented at the assertObjectRootedParameters site — "OpenAI tolerates root-level combinators, DeepSeek rejects them, GLM answers with empty tool arguments" — as a fourth member: qwen stringifies nested-union property values.Implementation Decisions (proposed)
tool/tool.ts. This repairs every affected tool × model combination, including future unknown quirks, without touching provider code.provider/transform.ts(schema(); existing branches for openai/azure, moonshot, gemini) gains a qwen-family branch that flattens union-valued properties into one merged object schema (action field as the union of literals, per-branch fields optional), so the model never sees the triggering shape. Keyed by model-id heuristic; accepted as lossy strictness. May be skipped if 1 alone proves sufficient in practice.parseOptions.onExcessProperty: "error"and the action-union strictness remain untouched — normalization only fixes encoding, never semantics.Testing Decisions
test/tool/tool-define.test.ts): (a) string-of-object at a union property round-trips to the decoded object; (b) a genuinely invalid string still fails validation; (c) strings where the schema expects a string pass through unparsed.LOCAL_PROXY_URLwith the workflow-shaped schema) is cheap to rerun and can be kept as a manual check, not a CI test (needs network + provider).Out of Scope
Further Notes
docs/harness-review-2026-08-16.mdcontext (branchfeat/parallel-writers).spec293.yaml(repo root, onfeat/parallel-writers) is ready forvalidate+start.