Skip to content

fix: prevent provider 400 errors from broken tool message payloads - #18

Merged
nshi merged 2 commits into
masterfrom
fix/provider-tool-call-errors
May 9, 2026
Merged

fix: prevent provider 400 errors from broken tool message payloads#18
nshi merged 2 commits into
masterfrom
fix/provider-tool-call-errors

Conversation

@nshi

@nshi nshi commented May 9, 2026

Copy link
Copy Markdown
Owner

Summary

  • file_read binary rejection: Hard-reject binary files (.db, .sqlite, etc.) with a clear error steering the LLM to memory_recall, instead of returning lossy UTF-8 that floods context with heavily-escaped content and triggers downstream truncation bugs
  • truncate_tool_message_content fix: Replace broken envelope_overhead calculation that used raw inner.len() (wrong for JSON-escaped content like \u0000) with zero-allocation json_escaped_len() arithmetic and a capped retry loop, preserving the JSON wrapper and tool_call_id in all cases
  • convert_messages defense-in-depth: Salvage tool_call_id via substring search when serde_json::from_str fails on truncation-broken JSON, preventing Gemini "Tool message must have either name or tool_call_id" 400s
  • Router streaming fix: Change ModelRouter::supports_streaming_tool_events() from any() to all() to prevent stream→fallback cycle on every tool-bearing call when the resolved provider doesn't support streaming tool events

Test plan

  • cargo test --lib — 4279 passed, 0 failed
  • New test truncate_tool_message_content_preserves_json_with_heavy_escaping — verifies binary content (null bytes) truncation preserves valid JSON and tool_call_id
  • New test convert_messages_salvages_tool_call_id_from_broken_json — verifies ID extraction from truncation-broken JSON
  • Updated tests file_read_rejects_binary_file, e2e_agent_file_read_rejects_binary — verify binary rejection path
  • New test file_read_rejects_pdf_without_feature — verifies PDF-specific error without rag-pdf feature
  • Deploy and monitor runtime-trace.jsonl for absence of llm_stream_fallback and channel_message_error events with "tool_call_id" errors

Three root causes were producing "Tool message must have either name or
tool_call_id" 400 errors from Google AI Studio via OpenRouter, plus
"Corrupted thought signature" errors on Gemini models:

1. file_read returned lossy UTF-8 for binary files (e.g. brain.db),
   flooding the context with heavily-escaped binary content. Now rejects
   binary files with a clear error steering the LLM to memory_recall.

2. truncate_tool_message_content used raw inner.len() to estimate JSON
   envelope overhead, which is wildly wrong for heavily-escaped content
   (\0 = 1 byte raw but 6 chars as \u0000). This caused the function to
   fall through to truncate_plain_text on the outer JSON, breaking the
   wrapper and severing tool_call_id. Fixed with zero-alloc
   json_escaped_len() arithmetic and a capped retry loop.

3. convert_messages in OpenRouter provider silently dropped tool_call_id
   when serde_json::from_str failed on truncation-broken JSON. Now
   salvages the ID via substring search as a defense-in-depth fallback.

Additionally fixes ModelRouter::supports_streaming_tool_events() using
any() instead of all(), which caused a stream-then-fallback cycle on
every tool-bearing call (~190 events in recent trace window).
@nshi
nshi merged commit 5a64251 into master May 9, 2026
15 of 18 checks passed
@nshi
nshi deleted the fix/provider-tool-call-errors branch May 9, 2026 16:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant