Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions src/agent/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ pub(crate) fn floor_char_boundary(s: &str, i: usize) -> usize {
pos
}

/// Count the number of bytes a string would occupy when JSON-serialized
/// (excluding the surrounding quotes). This avoids allocating a throwaway
/// `serde_json::to_string` just to measure length.
fn json_escaped_len(s: &str) -> usize {
s.bytes()
.map(|b| match b {
b'"' | b'\\' | b'\n' | b'\r' | b'\t' => 2,
b if b < 0x20 => 6, // \u00XX
_ => 1,
})
.sum()
}

/// Truncate a tool result to `max_chars`, keeping head (2/3) + tail (1/3)
/// with a marker in the middle. Returns input unchanged if within limit or
/// `max_chars == 0` (disabled).
Expand Down Expand Up @@ -53,20 +66,51 @@ pub(crate) fn truncate_tool_message_content(content: &str, max_chars: usize) ->
if value.get("tool_call_id").is_none() {
return truncate_plain_text(content, max_chars);
}
let Some(inner) = value.get("content").and_then(|v| v.as_str()) else {
// Clone inner content to release the immutable borrow on `value`,
// which we need to mutate below.
let Some(inner) = value
.get("content")
.and_then(|v| v.as_str())
.map(String::from)
else {
return truncate_plain_text(content, max_chars);
};

// inner.len() is raw bytes; content.len() is JSON-serialized (escaping may
// make the serialized form shorter than the raw value), so use saturating_sub.
let envelope_overhead = content.len().saturating_sub(inner.len());
let inner_max = max_chars.saturating_sub(envelope_overhead);
if inner_max == 0 || inner.len() <= inner_max {
return truncate_plain_text(content, max_chars);
// Compute serialized length of the inner string without allocating:
// each byte that needs JSON escaping expands, plus 2 for the quotes.
let inner_serialized_len = json_escaped_len(&inner) + 2;
let envelope_overhead = content.len().saturating_sub(inner_serialized_len);

let mut inner_max = max_chars.saturating_sub(envelope_overhead);
// When escaping makes the envelope estimate unreliable, use a
// conservative fraction of the budget as a starting point.
if inner_max == 0 {
inner_max = max_chars / 4;
}
if inner.len() <= inner_max {
return content.to_string();
}

// Shrink inner content until the re-serialized JSON fits the budget.
// Capped to prevent pathological inputs from hot-looping.
for _ in 0..8 {
let truncated = truncate_plain_text(&inner, inner_max);
value["content"] = serde_json::Value::String(truncated);
match serde_json::to_string(&value) {
Ok(result) if result.len() <= max_chars => return result,
Ok(result) => {
let overshoot = result.len() - max_chars;
inner_max = inner_max.saturating_sub(overshoot + 1);
if inner_max == 0 {
break;
}
}
Err(_) => return truncate_plain_text(content, max_chars),
}
}

let truncated = truncate_plain_text(inner, inner_max);
value["content"] = serde_json::Value::String(truncated);
// Envelope alone exceeds budget or iterations exhausted; drop inner.
value["content"] = serde_json::Value::String("[content truncated]".into());
serde_json::to_string(&value).unwrap_or_else(|_| truncate_plain_text(content, max_chars))
}

Expand Down
35 changes: 34 additions & 1 deletion src/agent/loop_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4774,6 +4774,35 @@ mod tests {
assert!(inner_content.contains("characters truncated"));
}

#[test]
fn truncate_tool_message_content_preserves_json_with_heavy_escaping() {
// Simulate binary content (null bytes) that expands heavily under JSON escaping.
// Each \0 becomes \u0000 (6 chars) in JSON, so 5000 null bytes ≈ 30000 JSON chars.
let inner = "\0".repeat(5000);
let content = serde_json::json!({
"tool_call_id": "call_binary_test",
"content": inner,
})
.to_string();

// The JSON-serialized content is ~30k+ chars. Truncate to 2000.
let result = truncate_tool_message_content(&content, 2000);

// Must remain valid JSON with tool_call_id preserved
let parsed: serde_json::Value = serde_json::from_str(&result)
.expect("truncated binary tool message must remain valid JSON");
assert_eq!(
parsed.get("tool_call_id").and_then(|v| v.as_str()),
Some("call_binary_test"),
"tool_call_id must be preserved even with heavily-escaped content"
);
assert!(
result.len() <= 2000,
"result len {} must be within budget 2000",
result.len()
);
}

#[test]
fn truncate_tool_message_content_plain_text_fallback() {
let content = "x".repeat(5000);
Expand Down Expand Up @@ -6652,7 +6681,11 @@ mod tests {
.find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
.expect("tool results message should be present");
assert!(tool_results.content.contains("hello"));
assert!(!tool_results.content.contains("The operation was NOT performed."));
assert!(
!tool_results
.content
.contains("The operation was NOT performed.")
);
}

#[tokio::test]
Expand Down
52 changes: 52 additions & 0 deletions src/providers/openrouter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,19 @@ impl OpenRouterProvider {
reasoning_details: None,
};
}
// JSON parse failed (e.g. truncation broke the wrapper).
// Try to salvage tool_call_id via substring search so
// providers that require it (Gemini) don't reject the
// entire request.
let tool_call_id = Self::extract_tool_call_id_from_broken_json(&m.content);
return NativeMessage {
role: "tool".to_string(),
content: Some(MessageContent::Text(m.content.clone())),
tool_call_id,
tool_calls: None,
reasoning_content: None,
reasoning_details: None,
};
}

NativeMessage {
Expand Down Expand Up @@ -387,6 +400,22 @@ impl OpenRouterProvider {
}
}

/// Best-effort extraction of `tool_call_id` from a tool message whose
/// JSON wrapper was broken by truncation. Falls back to `None` if the
/// field cannot be found.
fn extract_tool_call_id_from_broken_json(content: &str) -> Option<String> {
// Look for "tool_call_id":"<value>" pattern
let marker = "\"tool_call_id\":\"";
let start = content.find(marker)? + marker.len();
let end = content[start..].find('"')? + start;
let id = &content[start..end];
if id.is_empty() {
None
} else {
Some(id.to_string())
}
}

fn compact_sanitized_body_snippet(body: &str) -> String {
super::sanitize_api_error(body)
.split_whitespace()
Expand Down Expand Up @@ -1099,6 +1128,29 @@ mod tests {
assert!(converted[0].tool_calls.is_none());
}

#[test]
fn convert_messages_salvages_tool_call_id_from_broken_json() {
// Simulate a tool message whose JSON wrapper was broken by truncation
let broken = r#"{"content":"SQLite\u0000\u0

[... 139762 characters truncated ...]

\u0001","tool_call_id":"tool_file_read_abc123"}"#;
let messages = vec![ChatMessage {
role: "tool".into(),
content: broken.into(),
}];

let converted = OpenRouterProvider::convert_messages(&messages);
assert_eq!(converted.len(), 1);
assert_eq!(converted[0].role, "tool");
assert_eq!(
converted[0].tool_call_id.as_deref(),
Some("tool_file_read_abc123"),
"must salvage tool_call_id from broken JSON"
);
}

#[test]
fn to_message_content_converts_image_markers_to_openai_parts() {
let content = "Describe this\n\n[IMAGE:data:image/png;base64,abcd]";
Expand Down
7 changes: 6 additions & 1 deletion src/providers/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,9 +288,14 @@ impl Provider for RouterProvider {
}

fn supports_streaming_tool_events(&self) -> bool {
// Use `all()` not `any()`: the router doesn't know which provider
// will handle a given request, so it can only claim support when
// every routed provider supports it. With `any()`, the agent loop
// sees "supported", attempts streaming, but the resolved provider
// may not support it — causing a fallback on every tool-bearing call.
self.providers
.iter()
.any(|(_, provider)| provider.supports_streaming_tool_events())
.all(|(_, provider)| provider.supports_streaming_tool_events())
}

fn stream_chat_with_history(
Expand Down
Loading
Loading