fix: strip orphaned tool_calls from preserved assistant message during compaction

After context compaction, the preserved last assistant message retained
its structured tool_calls field, but the corresponding tool_result was
summarized away. This created orphaned tool_use blocks that violated
the Anthropic API constraint: 'Each tool_use block must have a
corresponding tool_result block in the next message', causing 400 errors.

Primary fix: clear tool_calls from the preserved assistant message in
extract_preserved_messages(). The tool call was already executed and
its result is captured in the summary.

Defense-in-depth: added strip_orphaned_tool_use() post-processing in
Anthropic convert_messages() to detect and strip any orphaned tool_use
blocks before they reach the API.

Added 7 tests: 3 unit tests for compaction stripping, 3 unit tests for
Anthropic orphan detection, 1 integration test reproducing the exact
bug scenario from the h3 session.
This commit is contained in:
Dhanji R. Prasanna
2026-02-11 15:22:03 +11:00
parent d3f0112f46
commit d61be719c2
4 changed files with 506 additions and 4 deletions

View File

@@ -360,9 +360,80 @@ impl AnthropicProvider {
}
}
// Defense-in-depth: strip orphaned tool_use blocks that have no matching tool_result
Self::strip_orphaned_tool_use(&mut anthropic_messages);
Ok((system_message, anthropic_messages))
}
/// Strip orphaned tool_use blocks from assistant messages that have no matching
/// tool_result in the immediately following user message.
///
/// Anthropic API requires: "Each tool_use block must have a corresponding tool_result
/// block in the next message." This can happen after context compaction when the
/// last assistant message had tool_calls but the tool_result was summarized away.
fn strip_orphaned_tool_use(messages: &mut Vec<AnthropicMessage>) {
// Collect tool_result IDs from each user message, indexed by position
let tool_result_ids_by_pos: Vec<Option<Vec<String>>> = messages
.iter()
.map(|msg| {
if msg.role == "user" {
let ids: Vec<String> = msg
.content
.iter()
.filter_map(|c| match c {
AnthropicContent::ToolResult { tool_use_id, .. } => {
Some(tool_use_id.clone())
}
_ => None,
})
.collect();
if ids.is_empty() { None } else { Some(ids) }
} else {
None
}
})
.collect();
for i in 0..messages.len() {
if messages[i].role != "assistant" {
continue;
}
let has_tool_use = messages[i].content.iter().any(|c| matches!(c, AnthropicContent::ToolUse { .. }));
if !has_tool_use {
continue;
}
// Check if next message is a user message with tool_result blocks
let next_has_results = i + 1 < messages.len()
&& tool_result_ids_by_pos.get(i + 1).and_then(|v| v.as_ref()).is_some();
if !next_has_results {
let tool_use_ids: Vec<String> = messages[i]
.content
.iter()
.filter_map(|c| match c {
AnthropicContent::ToolUse { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
tracing::warn!(
"Stripping {} orphaned tool_use block(s) from assistant message {}: {:?}",
tool_use_ids.len(), i, tool_use_ids
);
messages[i].content.retain(|c| !matches!(c, AnthropicContent::ToolUse { .. }));
// If stripping left the message empty, add placeholder text
if messages[i].content.is_empty() {
messages[i].content.push(AnthropicContent::Text {
text: "(continued)".to_string(),
cache_control: None,
});
}
}
}
}
fn create_request_body(
&self,
messages: &[Message],
@@ -1310,4 +1381,146 @@ mod tests {
assert_eq!(text_content.len(), 1);
assert_eq!(text_content[0], "Here is my response.");
}
// ====================================================================
// Orphaned tool_use stripping tests
// ====================================================================
#[test]
fn test_strip_orphaned_tool_use_removes_orphaned_blocks() {
// Simulate: assistant with tool_use, followed by regular user message (no tool_result)
let mut messages = vec![
AnthropicMessage {
role: "user".to_string(),
content: vec![AnthropicContent::Text {
text: "Read the file".to_string(),
cache_control: None,
}],
},
AnthropicMessage {
role: "assistant".to_string(),
content: vec![
AnthropicContent::Text {
text: "Let me read that.".to_string(),
cache_control: None,
},
AnthropicContent::ToolUse {
id: "toolu_orphaned".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"file_path": "test.rs"}),
},
],
},
// Next message is a regular user message, NOT a tool_result
AnthropicMessage {
role: "user".to_string(),
content: vec![AnthropicContent::Text {
text: "Do something else".to_string(),
cache_control: None,
}],
},
];
AnthropicProvider::strip_orphaned_tool_use(&mut messages);
// The tool_use should be stripped from the assistant message
let assistant = &messages[1];
assert!(
!assistant.content.iter().any(|c| matches!(c, AnthropicContent::ToolUse { .. })),
"Orphaned tool_use should be stripped"
);
// Text content should remain
assert!(
assistant.content.iter().any(|c| matches!(c, AnthropicContent::Text { .. })),
"Text content should be preserved"
);
}
#[test]
fn test_strip_orphaned_tool_use_preserves_valid_sequence() {
// Valid: assistant with tool_use, followed by user with matching tool_result
let mut messages = vec![
AnthropicMessage {
role: "user".to_string(),
content: vec![AnthropicContent::Text {
text: "Read the file".to_string(),
cache_control: None,
}],
},
AnthropicMessage {
role: "assistant".to_string(),
content: vec![
AnthropicContent::Text {
text: "Reading...".to_string(),
cache_control: None,
},
AnthropicContent::ToolUse {
id: "toolu_valid".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"file_path": "test.rs"}),
},
],
},
AnthropicMessage {
role: "user".to_string(),
content: vec![AnthropicContent::ToolResult {
tool_use_id: "toolu_valid".to_string(),
content: "file contents".to_string(),
cache_control: None,
}],
},
];
AnthropicProvider::strip_orphaned_tool_use(&mut messages);
// tool_use should NOT be stripped
let assistant = &messages[1];
assert!(
assistant.content.iter().any(|c| matches!(c, AnthropicContent::ToolUse { .. })),
"Valid tool_use should be preserved"
);
}
#[test]
fn test_strip_orphaned_tool_use_adds_placeholder_for_empty_message() {
// Assistant message with ONLY a tool_use block (no text)
let mut messages = vec![
AnthropicMessage {
role: "user".to_string(),
content: vec![AnthropicContent::Text {
text: "Do something".to_string(),
cache_control: None,
}],
},
AnthropicMessage {
role: "assistant".to_string(),
content: vec![AnthropicContent::ToolUse {
id: "toolu_only".to_string(),
name: "shell".to_string(),
input: serde_json::json!({"command": "ls"}),
}],
},
AnthropicMessage {
role: "user".to_string(),
content: vec![AnthropicContent::Text {
text: "Never mind".to_string(),
cache_control: None,
}],
},
];
AnthropicProvider::strip_orphaned_tool_use(&mut messages);
// Should have placeholder text instead of empty content
let assistant = &messages[1];
assert!(!assistant.content.is_empty(), "Should not have empty content");
assert!(
assistant.content.iter().any(|c| matches!(c, AnthropicContent::Text { .. })),
"Should have placeholder text"
);
assert!(
!assistant.content.iter().any(|c| matches!(c, AnthropicContent::ToolUse { .. })),
"tool_use should be stripped"
);
}
}