Skip to content

openai_compat runs tools on default arguments when the stream truncates; Anthropic refuses to #167

Description

@yuanhao

Found while adding SSE tool-call coverage in #166, and pinned there as a test rather than changed — closing it is a behavioural decision, not a test fix.

The divergence

Two providers disagree about what to do when a tool call's arguments cannot be parsed.

anthropic.rs fails the turn, and states why at the sweep:

a tool handed {"__partial_json": ...} runs on its defaults instead of what the model asked for

So the turn returns StopReason::Error, the agent loop returns before extracting tool calls, and the tool never runs.

openai_compat.rs falls back to an empty object:

let args = serde_json::from_str(&buf.arguments).unwrap_or_else(|e| {
    if !buf.arguments.is_empty() {
        warn!(tool = %buf.name, len = buf.arguments.len(),
              "tool-call arguments failed to parse ({e}); using empty object");
    }
    serde_json::Value::Object(Default::default())
});

The call proceeds with {} — which is exactly the hazard Anthropic's path exists to prevent, and it is the default on 15+ providers: OpenAI, Groq, Together, DeepSeek, Fireworks, Mistral, xAI.

Pinned by truncated_tool_arguments_fall_back_to_empty_unlike_anthropic in tests/openai_compat_stream_test.rs.

Why it matters

A truncated stream is not exotic — a dropped connection mid-arguments is the ordinary case. The failure is silent apart from a warn!, and a library cannot assume its host installed a subscriber.

Concretely: delete_files({"paths": ["/tmp/scratch"]}) truncated after {"paths": becomes delete_files({}). What that does depends entirely on the tool's own defaults, and the model's actual intent is gone. The agent then sees a plausible-looking result and continues.

The empty case is not affected and should stay as-is: a zero-argument tool legitimately streams "", and {} is the right answer there. Both are now pinned, so any fix must keep them distinct — which is the same empty-vs-malformed distinction that shipped the Anthropic bug.

Options

  1. Match Anthropic — fail the turn on a non-empty, unparseable buffer. Consistent, and the loop already handles StopReason::Error. Behavioural change for 15+ providers.
  2. Surface it as an error tool result — let the model see "your arguments did not parse" and retry. Fits the crate's "tools return stderr so the LLM can self-correct" convention, and is less abrupt than failing the turn.
  3. Leave it, document it. Defensible if the truncation rate is genuinely negligible, but nothing measures that today.

Option 2 looks best on the crate's own conventions, but it is a real design call rather than a bug fix.

Acceptance

  • Decide deliberately between the three.
  • Whatever is chosen, the empty-argument case keeps working — that distinction is load-bearing and has already been got wrong once.
  • truncated_tool_arguments_fall_back_to_empty_unlike_anthropic is updated rather than deleted, so the change is visible in the diff.
  • Check whether google.rs, bedrock.rs, openai_responses.rs and azure_openai.rs have a third behaviour — only Anthropic and openai_compat were examined.

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