Skip to content

Tool calls fail with SchemaError when the Anthropic (native) provider returns a nested array argument as a JSON string (e.g. todowrite todos) #34652

Closed as not planned

Description

@edikid

TL;DR

todowrite (and any built-in tool with an array/object parameter) fails with a hard SchemaError when the model returns a nested argument as a JSON-encoded string instead of a real array. This happens specifically with the native Anthropic provider (@ai-sdk/anthropic); OpenAI / function-calling models do not trigger it. It is intermittent and not size-gated — even an empty array [] has been observed arriving as "[]".

I'm keeping this separate from #10813 / #29142 because it adds three things those threads lack: (1) the exact failing code path, (2) instrumented proof that the widely-shared tool.execute.before workaround no longer works on current opencode (validation migrated Zod → Effect Schema and now runs before the hook), and (3) instrumented proof that no plugin hook can currently work around it.

Impact

todowrite is the primary progress-tracking tool. For anyone running a Claude model on the native Anthropic provider it fails frequently and unrecoverably (the model retries and re-fails, and the todo panel cannot be updated). There is no working user-side mitigation on current opencode.

Environment

  • opencode: reproduced on 1.17.11; binary is now 1.17.12 and the offending validation path is unchanged (verified in the 1.17.12 compiled binary — see Root cause).
  • Provider: native anthropic — no baseURL override, so it uses the built-in @ai-sdk/anthropic against api.anthropic.com (this is not an OpenAI-compatible gateway).
  • Model: claude-opus-4-8 (Opus class). OS: macOS (arm64).

Symptom (verbatim)

The todowrite tool was called with invalid arguments:
SchemaError(Expected array, got "[{\"content\":...,\"status\":...,\"priority\":...}]").
Please rewrite the input so it satisfies the expected schema.

The value after got is a stringified array (note the escaped quotes) — i.e. todos arrived as a string, not an array.

Root cause

opencode validates tool-call arguments by Effect-decoding the tool's parameters. From the compiled 1.17.12 binary (tool-execute wrapper):

// build a decoder from the tool's parameters schema, then wrap execute with it
let r = typeof s === "function" ? { ...yield* s() } : { ...s },
    c = t.decodeUnknownEffect(r.parameters),   // <-- Effect Schema decode of `parameters`
    S = r.execute;

and the error surface:

`The ${this.tool} tool was called with invalid arguments: ${this.detail}. Please rewrite the input so it satisfies the expected schema.`

Claude (native tool_use) occasionally JSON-encodes the nested todos array as a string inside its tool_use input. decodeUnknownEffect then fails because the schema expects an array. This validation happens before any plugin hook can touch the arguments.

Provider specificity (why this is a distinct variant, not #29142)

  • Claude via native Anthropic delivers the array pre-stringified in the tool_use input → fails.
  • OpenAI / function-calling models send the whole arguments object as one JSON string that opencode parses once into { todos: [...] }, so the nested array is a real array → works.

So this is not "the model sent wrong keys/types" — it is the narrower case of a correctly-shaped value delivered JSON-stringified one extra level, and it is provider-specific.

Why the popular tool.execute.before workaround (from #10813) no longer works

The workaround shared in #10813 (a tool.execute.before hook that JSON.parses todos) silently stopped working. I instrumented a bare version of it (only that hook, nothing else) and confirmed with markers that for a stringified todos the hook never fires:

EXECUTE_BEFORE todosType=string : 0   # stringified calls NEVER reach execute.before
EXECUTE_BEFORE todosType=object : 1   # only native-array calls reach it
COERCED_OK : 0

Why it used to work and now doesn't: the error format in #10813 (2026-01) is Zod (code: "invalid_type", "Invalid input: expected array, received string"), whereas current opencode emits Effect SchemaError(...). Tool-arg validation migrated Zod → Effect Schema and now runs ahead of tool.execute.before, so the hook can no longer repair the args.

Proof that no plugin can currently work around this (1.17.11 / 1.17.12)

All four plugin avenues were tested with an instrumented plugin:

  1. tool.definition override of a built-in tool's parameters/jsonSchema is not used for validation — the override's validate is never invoked (TOOLDEF_FIRED: 5, VALIDATE_CALLED: 0). The original Effect schema still rejects the string.
  2. tool.execute.before runs after validation (markers above) — too late.
  3. Shadow / custom tool cannot replace todowrite: the SDK exposes only GET /session/{id}/todo (no write endpoint), so a plugin cannot update todo state.
  4. (Minor, worth documenting) global ~/.config/opencode/plugin/ is not auto-loaded; only project .opencode/plugin/ is.

Reproduction

  1. Configure a Claude model (Opus class) on the native anthropic provider as the agent.
  2. Have it call todowrite with a todos array (verbose / large items raise the odds, but small ones fail too).
  3. Intermittently: SchemaError(Expected array, got "<stringified array>"). A single-item or even empty array can trigger it.

Relationship to #10813 and #29142

Happy to consolidate if maintainers prefer a single tracking issue — the actionable fix below is the same either way.

Suggested fix

At tool-argument decoding, if a field whose schema expects an array/object arrives as a string that JSON.parses to a value satisfying the schema, coerce it before/within decodeUnknownEffect. Scope this strictly to tool-call argument decoding so it cannot mask unrelated type errors. (Alternatively / additionally: let plugin tool.definition parameters overrides actually drive validation for built-in tools, so provider quirks can be patched from userland.)

Happy to test a patch build.

Activity

  1. github-actions commented on Jun 30, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If your issue is specifically about the Anthropic native provider delivering pre-stringified nested values (rather than wrong key names), it may be a distinct variant worth keeping open — but please review those issues first and comment if yours is genuinely different.

  2. edikid commented on Jul 1, 2026

    @edikid
    Author

    Confirmed distinct from the flagged issues, and keeping this open. I've rewritten the body with the full root-cause trace + instrumented proof. Two things that are not covered by #10813 or #29142:

    1. It is the native Anthropic provider delivering a correctly-shaped value JSON-stringified one extra level — not wrong keys or wrong types (OpenAI-compatible models can call write/edit with invalid schema arguments #29142), and not model-agnostic. OpenAI / function-calling models don't hit it.
    2. The popular tool.execute.before JSON.parse workaround from todowrite:tool call parameter error #10813 no longer works. Tool-arg validation migrated Zod → Effect Schema and now runs before tool.execute.before, so the hook never sees the stringified value. Verified with an instrumented plugin: EXECUTE_BEFORE todosType=string : 0 (stringified calls never reach the hook), VALIDATE_CALLED : 0 for a tool.definition parameters override (overrides aren't used for built-in validation).

    Still reproduces on 1.17.12 — the decodeUnknownEffect(r.parameters) path is unchanged in the current binary. Fix suggestion + full evidence are in the updated body.

  3. edikid commented on Jul 1, 2026

    @edikid
    Author

    Update: still reproduces on the latest opencode (1.17.12) with a newer Claude model generation (claude-sonnet-5), ruling out two hypotheses.

    Retested today on a genuinely fresh setup:

    • opencode auto-updated to 1.17.12 — confirmed the backend process started after the new binary was installed (not a stale long-running process from before the upgrade).
    • Main agent switched from claude-opus-4-8 to claude-sonnet-5 (still native Anthropic provider, no gateway).

    Three consecutive todowrite calls in the same fresh session:

    # payload result
    1 7 items, long content ✅ success
    2 8 items, longer content ❌ SchemaError(Expected array, got "[…]")
    3 1 item ❌ same SchemaError

    Same signature as the original report: intermittent, not size-gated (a single item still fails), no application-level error logged (rejected at tool-arg validation before reaching opencode's own error paths).

    This rules out:

    • "Opus-4-8-specific quirk" — Sonnet 5 hits it too, so it's a Claude tool_use serialization behavior, not tied to one model.
    • "Already fixed in the latest opencode" — 1.17.12 still reproduces; the decodeUnknownEffect(r.parameters) validation path is unchanged from 1.17.11 in the compiled binary.

    No change to the root cause / fix suggestion in the issue body — this is just fresh confirmation that the bug is current and not model- or version-specific.

  4. edikid commented on Jul 2, 2026

    @edikid
    Author

    Final forensics: exhaustive client-side elimination completed — the emission is model-level, and the validation boundary is the only place this can be fixed

    Since my last update I ran a staged elimination of every client-side variable on 1.17.12 (Claude sonnet-5 / fable-5, native Anthropic provider), with per-layer instrumentation (marker logs from plugins). Result: all six layers active simultaneously, the failure still reproduces.

    # Controlled variable Mechanism Verified active Outcome
    1 Fine-grained tool streaming OFF chat.params → options.toolStreaming=false (same mechanism opencode itself uses for github-copilot) marker logged per request still fails
    2 Failed calls' invalid input redacted from replayed history experimental.chat.messages.transform marker: 16–18 error parts cleaned per request still fails
    3 Error text sanitized (no verbatim malformed value quoted) same transform confirmed end-to-end (the model receives the rewritten text) still fails
    4 Successful todowrite results rewritten (JSON dump → markdown) so no "array as text" sample remains in context same transform marker: outputs=3 rewritten still fails
    5 All prose/reasoning parts scrubbed of stringified-array shapes (regex, incl. compaction summaries) same transform marker: texts=12 scrubbed in one request still fails
    6 Same-turn reasoning deliberately kept free of any bad-shape examples procedural n/a still fails

    Additional data points:

    Why no plugin/user-side fix is possible (all verified on 1.17.11/1.17.12)

    • tool.definition overrides of parameters/jsonSchema are not used for validation (instrumented: override's validate never called).
    • tool.execute.before runs after validation (instrumented: stringified calls never reach it).
    • A shadow tool can't replace todowrite (SDK exposes only GET /session/{id}/todo, no write).
    • Context sanitization via experimental.chat.messages.transform works (verified) but doesn't prevent the emission.

    Recommended fixes (in order of robustness)

    1. Coerce at the tool-arg decode boundary: if a field's schema expects an array/object and a string arrives that JSON.parses to a schema-conforming value, accept the parsed value. One localized change; neutralizes the model defect permanently for every tool, every Claude model.
    2. Enable Anthropic strict tool use (grammar-constrained sampling) for built-in tools where supported — makes the malformed emission impossible at the API level rather than repairing it after the fact.
    3. Minor but cheap: stop quoting the malformed value verbatim in the validation error fed back to the model — it demonstrably fuels a self-imitation retry cascade (claude-code#61367).
    4. Consistency note: opencode's bundled github-copilot hook already sets toolStreaming=false for @ai-sdk/anthropic models, but the native anthropic provider path doesn't. Empirically insufficient alone (see table), but worth aligning.

    Happy to share the instrumentation plugins or test a patch build.

  5. mrjw717 commented on Jul 17, 2026

    @mrjw717

    Corroborating evidence + 1.17.20 hook-ordering finding (different from 1.17.11/1.17.12)

    Forensic audit of a production opencode deployment confirms this bug and adds a new data point: on opencode 1.17.20 (Jul 13 build), tool.execute.before appears to fire BEFORE schema validation — contradicting the instrumentation results from 1.17.11/1.17.12 in this issue. A plugin-side JSON.parse recovery hook may now work again.

    Regression timeline (from opencode.db SQLite store)

    The bug was not present before Jul 7 and appeared immediately after an opencode binary reinstall on Jul 6 23:55:

    Window todowrite calls SchemaError(Expected array) Rate
    Jun 10 – Jul 6 (pre-reinstall) ~1,300+ 0 0%
    Jul 7 – Jul 17 (post-reinstall) ~600+ 54 ~9%

    Same model (glm-5.2), same provider (ollama-cloud): Jul 6 = 3/3 success, Jul 7 = 44/56 (10 schema failures). The model did not change; the binary did.

    Multi-model/multi-provider scope

    This is not Anthropic-specific in our environment. The 54 failures span:

    Provider Model Failures
    ollama-cloud glm-5.2 41
    zai-coding-plan glm-5.2 7
    opencode deepseek-v4-flash-free 5
    opencode mimo-v2.5-free 1

    7 agent types affected: orc-task-worker (18), plan (13), dojomojo-builder (13), build (9), dojomojo-architect (3), savant (2), orc-auditor (1).

    Malformed-JSON variants observed

    The model does not always emit valid JSON inside the stringified array. From the 54 failing payloads:

    1. Valid JSON string: "[{\"content\":\"...\",\"priority\":\"high\",\"status\":\"completed\"}]" — JSON.parse succeeds directly.
    2. Unquoted key: ...,priority\":\"high\"... — the opening " on the key is missing.
    3. Unquoted value: \"content\":WS1: Delete broken symlinks...\",\"priority\" — the opening " on the value is missing.
    4. Unquoted enum value: \"priority\":high, — the value high has no quotes at all (both " missing).

    Hook ordering on 1.17.20

    Extracted from the 1.17.20 compiled binary (SessionTools.resolve, built-in tool path):

    o[u.id]=no({
      execute(b,w){
        yield*a.trigger("tool.execute.before",{tool:u.id,sessionID:F.sessionID,callID:F.callID},{args:b});
        let i=yield*u.execute(b,F),  // validation + execute runs AFTER hook
      }
    })

    The hook fires with {args:b} (raw model args) before u.execute (which wraps validation via decodeUnknownEffect). Since output.args is the same reference as b, a tool.execute.before hook that mutates output.args.todos from string→array changes b before validation sees it.

    This contradicts the 1.17.11/1.17.12 finding in this issue (EXECUTE_BEFORE todosType=string : 0). Either the ordering was reverted in a version between 1.17.12 and 1.17.20, or the built-in tool path (o[u.id]) has a different ordering than the path the previous instrumentation measured.

    Our workaround

    We implemented a tool.execute.before recovery hook (open-source, dj-guardrails plugin) that:

    1. Detects typeof args.todos === "string"
    2. Attempts JSON.parse; on failure, applies targeted regex repairs for the known todo-item schema (unquoted keys, unquoted values, unquoted enum values)
    3. Validates the parsed result is a plausible todos array (content is string, status/priority are known enums)
    4. Mutates output.args.todos to the recovered array on success; no-ops on failure (lets the original SchemaError fire)

    Tested against all 4 malformed-JSON variants from our failure corpus — all recover successfully. Pending empirical verification that the hook fires pre-validation on 1.17.20 (our running opencode session cached the pre-rebuild plugin; next restart will load the hook).

    Suggested fix (same as issue body)

    Coerce at the tool-arg decode boundary: if a field whose schema expects an array/object arrives as a string that JSON.parses to a schema-conforming value, accept the parsed value. This is the only robust fix since the model-level emission cannot be controlled by the client. Our plugin-side hook is a workaround for todowrite specifically, but the same defect affects any tool with array/object parameters.

    Happy to share the recovery hook code or test a patch build.

  6. github-actions commented on Sep 20, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions