Skip to content

fix(tool): qwen-family models stringify nested-union tool parameters — workflow tool unusable #297

Description

@LeXwDeX

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.

  1. 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.

  2. 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"]}
  3. 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.

  4. 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.

  5. 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 ✅
  6. 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)

  1. 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.
  2. 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.
  3. Extend the quirk-family comment at the assertObjectRootedParameters site with the qwen case, so the documented provider contract stays truthful.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingready-for-agentFully specified and ready for an agent

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions