Unwrap lingua-mangled tool-call arguments before dispatch - #104
Unwrap lingua-mangled tool-call arguments before dispatch#104akrentsel wants to merge 3 commits into
Conversation
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>
|
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:
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 So I think this TS shim is a useful guard, but the upstream Rust fix should be: unwrap |
(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>
|
nice fix. one gap on the chat-completions side: 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 happy to send this as a small PR onto this branch if useful. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
exo | c99f7eb | Jul 08 2026, 06:38 AM |
Symptom
After rebuilding
exofor the first time since June 8, every agent turn that calls a tool (e.g.shell) fails withexecute_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-callargumentsthrough its internal serde representation, leaking two shapes into thefunction_callarguments JSON:{"type":"valid","value":"{\"command\":\"ls\"}"}serde_json::Valuenumbers 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
unwrapLinguaToolArgumentsinresponseToolCallResultsrecursively 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 (
shellenvelope,web_fetchwith 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
10f863cfis the alternative but risks whatever #69 needed.🤖 Generated with Claude Code