Skip to content

Korean/non-ASCII tool calls are rejected instead of resampled: escapedNonAsciiArguments has no recovery path #4489

Description

@probepark

Symptom

Any tool call carrying non-ASCII text (Korean, em dash, curly quotes, arrows) is frequently rejected before execution with:

Tool call "bash" spelled non-ASCII text as \uXXXX escapes instead of literal UTF-8.
Escaped text cannot be verified - a single wrong hex digit silently becomes a different
character - so the call was not executed. Re-issue it writing every non-ASCII character literally.

The guard itself (findUnnecessaryUnicodeEscape in packages/ai/src/utils/json-parse.ts -> escapedNonAsciiArguments -> rejection in packages/agent/src/agent-loop.ts) is correct: hand-spelled hex parses cleanly but one mistyped nibble decodes to a different, equally valid character, and nothing can repair that after parsing.

The problem is the recovery path, not the detection.

Measured impact

Local session logs, most recent 60 sessions, 7910 tool calls:

tool calls non-ASCII present rejected
all 7910 175 116 (66%)
Hangul-carrying - 85 66 (78%)

Per model: claude-opus-5 53/54 Hangul calls rejected, claude-sonnet-5 13/13, gpt-5.6-sol/gpt-5.6-terra 0/18. Only the anthropic-messages path is affected.

Not a transport defect

Direct probes against the same proxy and model could not reproduce it: 25+ curl runs varying payload length, thinking on/off, fine-grained-tool-streaming beta on/off, 30 extra tools, cloaked mcp__... tool names, 40-turn filler context, and a primed history containing the rejection error itself - all returned literal UTF-8, zero escapes. Six real gjc -p runs with wire capture (45-76 tools, ~200KB requests, one 929-Hangul-character write call) also produced zero escapes.

It is an intermittent sampling accident that shows up in long sessions. In session logs the flag clusters in runs with 700-1000 assistant messages and fires in consecutive bursts (F@144 F@145 F@146 F@147).

Why the current handling makes it worse

  1. A wire-format defect is promoted to a tool error, spending the entire turn.
  2. The rejection text re-injects the literal \uXXXX syntax into the context the model samples from next.
  3. There is no bounded resample, and no config switch (0 related keys in config.schema.json).

The anthropic.ts thinking-replay repair already establishes the right shape for this class of fault: when the defect is visible before the turn is usable, drop it and re-request within a bounded budget instead of burning the turn.

Fix

Bounded turn resample in the non-managed session path, alongside the existing invalid_prompt and reasoning-content repairs: drop the defective assistant turn from history and re-request, up to twice per turn. The terminal per-call rejection stays as the answer once the budget is spent, and MAX_CONSECUTIVE_MALFORMED_TURNS remains reachable as the loop backstop.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions