Skip to content

Unwrap lingua-mangled tool-call arguments before dispatch - #104

Open
akrentsel wants to merge 3 commits into
mainfrom
fix-lingua-tool-arg-envelope
Open

Unwrap lingua-mangled tool-call arguments before dispatch#104
akrentsel wants to merge 3 commits into
mainfrom
fix-lingua-tool-arg-envelope

Conversation

@akrentsel

Copy link
Copy Markdown
Collaborator

Symptom

After rebuilding exo for the first time since June 8, every agent turn that calls a tool (e.g. shell) fails with execute_tool failed: missing field \command`` and the model retries the identical call forever — a token-burning infinite loop.

Cause

The lingua/braintrust model router bumped in #69 (June 16, rev 3ab9bcc9) serializes tool-call arguments through its internal serde representation, leaking two shapes into the function_call arguments JSON:

  1. A Result envelope wrapping the real args as a string: {"type":"valid","value":"{\"command\":\"ls\"}"}
  2. serde_json::Value numbers as a tagged object: {"$serde_json::private::Number":"3000"}

Either shape makes the real arguments unreachable — the tool sees no command/url/etc. field, errors, and the model loops. This had been latent in the tree since June 16; it only surfaced when a fresh build finally ran the newer router.

Fix

unwrapLinguaToolArguments in responseToolCallResults recursively undoes both shapes before dispatch, so tools receive the arguments the model actually produced. Anything not matching these shapes passes through unchanged. Model-agnostic and applies to every tool and harness.

Tests cover the two exact corrupt payloads captured from a live loop (shell envelope, web_fetch with a nested serde number) plus a well-formed-arguments passthrough.

Root cause is upstream in lingua (a vendored git dep); this is the pragmatic guard at the TS boundary. Reverting to the pre-#69 rev 10f863cf is the alternative but risks whatever #69 needed.

🤖 Generated with Claude Code

The braintrust/lingua model router (rev 3ab9bcc9) can serialize tool-call
arguments through its internal serde representation, leaking two shapes into
the function_call arguments JSON:

  1. a Result envelope wrapping the real args as a string:
       {"type":"valid","value":"{\"command\":\"ls\"}"}
  2. serde_json::Value numbers as a tagged object:
       {"$serde_json::private::Number":"3000"}

Either shape makes the real arguments unreachable: the shell tool sees no
`command` field, fails, and the model retries the identical mangled call in an
endless loop. Recursively undo both in responseToolCallResults before dispatch
so tools receive the arguments the model actually produced; anything not
matching these shapes passes through unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ankrgyl

ankrgyl commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Thanks for tracking this down. I think the durable fix should be at the Rust/Lingua boundary rather than as a recursive TS unwrap.

There are two separate leaks here:

  1. $serde_json::private::Number happens when lingua::serde_json::Value / arbitrary-precision serde JSON is exposed through a generic serializer. The pattern we use in Braintrust is to serialize at the edge with lingua::serde_json::to_string(...) and then parse into the target representation (JSON.parse in wasm, or normal serde_json::from_str in Rust). In other words, avoid handing a lingua::serde_json::Value directly to serde_wasm_bindgen / generic to_value at a JS/plain-JSON boundary.

  2. { "type": "valid", "value": ... } is different: that is the tagged serde representation of Lingua ToolCallArguments enum leaking. The OpenAI Responses function_call.arguments field should not serialize the enum directly. It should match the enum and emit only the provider-expected payload:

match args {
    ToolCallArguments::Valid(map) => lingua::serde_json::to_string(map)?,
    ToolCallArguments::Invalid(raw) => raw.clone(),
}

If the target field wants a JSON value rather than a string, use Value::Object(map.clone()) for valid args and Value::String(raw.clone()) for invalid args.

So I think this TS shim is a useful guard, but the upstream Rust fix should be: unwrap ToolCallArguments before constructing OpenAI Responses function_call.arguments, and cross the Lingua arbitrary-precision JSON boundary via JSON text/bytes or Lingua custom wasm serializer.

@ankrgyl

ankrgyl commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Thanks for tracking this down. I think the durable fix should be at the Rust/Lingua boundary rather than as a recursive TS unwrap.

There are two separate leaks here:

  1. $serde_json::private::Number happens when lingua::serde_json::Value / arbitrary-precision serde JSON is exposed through a generic serializer. The pattern we use in Braintrust is to serialize at the edge with lingua::serde_json::to_string(...) and then parse into the target representation (JSON.parse in wasm, or normal serde_json::from_str in Rust). In other words, avoid handing a lingua::serde_json::Value directly to serde_wasm_bindgen / generic to_value at a JS/plain-JSON boundary.
  2. { "type": "valid", "value": ... } is different: that is the tagged serde representation of Lingua ToolCallArguments enum leaking. The OpenAI Responses function_call.arguments field should not serialize the enum directly. It should match the enum and emit only the provider-expected payload:
match args {
    ToolCallArguments::Valid(map) => lingua::serde_json::to_string(map)?,
    ToolCallArguments::Invalid(raw) => raw.clone(),
}

If the target field wants a JSON value rather than a string, use Value::Object(map.clone()) for valid args and Value::String(raw.clone()) for invalid args.

So I think this TS shim is a useful guard, but the upstream Rust fix should be: unwrap ToolCallArguments before constructing OpenAI Responses function_call.arguments, and cross the Lingua arbitrary-precision JSON boundary via JSON text/bytes or Lingua custom wasm serializer.

(FYI Codex wrote this for me)

Follow-up to the review feedback: the recursive TS unwrap stays as a
guard, but the two corruption shapes are now fixed where they originate
in exo's own code.

1. Number leak: lingua vendors a serde_json fork with arbitrary_precision
   enabled, so serializing lingua Messages/stream chunks with std
   serde_json (event storage, the JSONL/HTTP protocol) emitted literal
   {"$serde_json::private::Number":"3000"} maps. EventData::Messages,
   EventData::LinguaStreamChunk, and BeginTurnRequest.input now cross
   that boundary via lingua's own JSON text (serialize with
   lingua::serde_json, re-parse with std serde_json, and the reverse on
   read, which also heals events written by older builds).

2. Envelope leak: the hand-rolled chat-completions history replay
   (messagesToChatMessages -> assistantToolCalls) JSON.stringify'd the
   stored ToolCallArguments enum, so replayed function calls carried
   {"type":"valid","value":{...}} as their arguments. Models mimic the
   argument shape they see in prior calls, which reproduces the corrupt
   envelope in fresh tool calls and starts the retry loop. The enum is
   now matched and unwrapped (valid -> the arguments object, invalid ->
   the raw string), mirroring what lingua's wasm converters do on the
   Responses/Anthropic paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Alexsun1one

Copy link
Copy Markdown
Contributor

nice fix. one gap on the chat-completions side: assistantToolCalls (responses.ts:1068) re-serializes part.arguments straight back into the tool_calls history, so a model that emitted the {"type":"valid","value":...} envelope keeps seeing it in its own replayed history and keeps emitting it. i hit this driving fork with a deepseek model via --base-url: slug/purpose went through the tool call but the handler read undefined, and children fell back to fork-parent-fork-00N / purpose:null.

unwrapping in the history converter too closes the loop:

// assistantToolCalls, before serializing part.arguments:
const unwrappedArguments = unwrapLinguaToolArguments(part.arguments);
// ...JSON.stringify(isRecord(unwrappedArguments) ? unwrappedArguments : {})

one catch: here the envelope's value arrives already as an object (it came from stored history, not a raw JSON string), so unwrapLinguaToolArguments also needs to handle value being an object/array, not just a string.

happy to send this as a small PR onto this branch if useful.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 8, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
exo c99f7eb Jul 08 2026, 06:38 AM

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants