Skip to content

A tool call written into message.content is never recovered, and NativeDialect's fallback for it is unreachable #143

Description

@sanil-23

When a model writes its tool call into message.content as text instead of emitting message.tool_calls, the call is silently lost: the loop ends the turn and the raw JSON becomes the assistant's visible answer. The machinery that would recover it exists in this crate and is unreachable from it.

Observed in production against a model that honours native tool calling only intermittently — the same agent, calling the same tool, one turn apart:

Let me proceed with querying the tasks ledger. function_call:{"id":"call_3rY","call":"read_ledger","arguments":{"ledger":"tasks","query":"2026-09-01"}}

The wire payload carries that in choices[0].message.content with no choices[0].message.tool_calls. The user-visible effect is an agent that says it is going to look something up, does not, and shows you the JSON it meant to send.

Pinned at 954b3dc.

Where the turn gives up

agent_loop/run_loop.rs:494:

let tool_calls = response.tool_calls().to_vec();

ModelResponse::tool_calls is populated by providers/openai/convert.rs::parse_chat_response, which reads choice.message.tool_calls and nothing else. If the field is absent the vec is empty, real_tool_calls.is_empty() holds, and the turn ends with the text as the reply. Nothing on the path reads content looking for a call.

The recovery already exists, and nothing can reach it

NativeDialect::parse_response (tool_calling/dialect/native.rs) does exactly the right thing — when structured calls are empty it falls back to XmlDialect::parse_text on the response text. Its own doc says so:

It still falls back to text parsing, because a model with a structured channel available sometimes narrates a <tool_call> tag anyway, and dropping that call burns an iteration for no reason.

But nothing inside this crate calls it. The dialect module has no non-test consumer here:

$ grep -rn "tool_calling::dialect\|use .*dialect::" src/ --include='*.rs' | grep -v "dialect/" | grep -v test
(no output)

The dialects are reachable only from OpenHuman's agent::dispatcher::ToolDispatcher wrappers — and downstream that seam has itself gone quiet: since openhuman #4249 removed the legacy engine, ToolDispatcher::parse_response has one live caller, the end-of-turn wrap-up check in session/turn/session_io.rs. The whole turn runs through this crate's loop, which never consults a dialect.

So the fallback looks like coverage and is dead code. That is why a gap this plain survived: everyone reading native.rs reasonably concludes the case is handled.

Related: the dispatcher config knob selects nothing

ToolConfig::dispatcher (config/types.rs:216, the auto / native / xml / pformat enum) is written, serialized, defaulted and unit-tested, but never read to select any behaviour:

$ grep -rn "\.dispatcher" src/ --include='*.rs' | grep -v "_test\|config/test"
(no output)

OpenHuman maps its agent.tool_dispatcher string onto this enum (agent/tinyagents/config.rs::dispatcher_from), so a host setting tool_dispatcher = "xml" today gets no XML dispatching. Same root cause as above, and probably the same fix.

What a fix needs

A text-recovery pass is only safe if a candidate's name is checked against the tools that were actually offered. Without that, {"name":"Alice","input":"hi"} is indistinguishable from a call — which is precisely why parse.rs is deliberately narrow today (allow_arg_aliases: false on the whole-response bare-object path, per the comment citing CodeRabbit #2683). That narrowness is correct for a free function and is not the thing to loosen.

The loop, unlike the parser, has the registry in hand — self.tools.schemas() at run_loop.rs:153, a few hundred lines above the read that gives up at :494. Two placements look reasonable:

  1. In the loop, at :494: if the response carries no structured calls but does carry text, attempt a recovery validated against self.tools.schemas().
  2. In the provider, in parse_chat_response, with the request's tool schemas threaded in. This is where the crate already tolerates model defects at this layer — synthesizing absent ids as tacall-{epoch}-{slot}, repairing malformed argument JSON, stripping <tool_call> delimiters leaked into an arguments blob, marking invalid rather than failing the call. Recovering a call written into content is the same kind of tolerance, one field over.

Two details worth carrying over from our downstream implementation, both learned the hard way:

  • Synthesize ids. The loop pairs each tool result back to its opener by id. A recovered call without one runs the tool and is billed for it while the model never learns the result, and re-narrates the same intention next iteration — a worse failure than not recovering at all. The tool_call_id epoch scheme here already solves this for absent provider ids.
  • Do not recover out of substituted content. Anything that replaces the visible message before the recovery runs — a refusal, a finish_reason: "failed" clear, promoted reasoning — must block it. A planning trace that merely mentions a call in JSON shape has not requested it, and dispatching it executes a thought.

Downstream stopgap

We shipped a host-level compensation in OpenCompany — tinyhumansai/opencompany#2011 — validating against the turn's own request.tools at the provider boundary. It carries a note to delete it if this is fixed here, since two implementations of one rule will drift. Happy to port that work upstream as a PR here if the placement above sounds right; would rather agree the seam first.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority: p1Next. Wrong behaviour a user will hit, or a security weakness behind a condition.

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions