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

@@ -334,13 +334,24 @@ Format this as a detailed but concise summary that can be used to resume the con
}
});
// Find the last assistant message in the conversation
// Find the last assistant message in the conversation.
// IMPORTANT: Strip tool_calls from the preserved message. After compaction,
// the tool_result messages are summarized away, so keeping tool_calls would
// create orphaned tool_use blocks that violate the Anthropic API constraint:
// "Each tool_use block must have a corresponding tool_result block in the next message."
let last_assistant_message = self
.conversation_history
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::Assistant))
.cloned();
.map(|m| {
let mut msg = m.clone();
if !msg.tool_calls.is_empty() {
debug!("Stripping {} tool_calls from preserved assistant message during compaction", msg.tool_calls.len());
msg.tool_calls.clear();
}
msg
});
PreservedMessages {
system_prompt,
@@ -767,6 +778,7 @@ impl ThinResult {
#[cfg(test)]
mod tests {
use super::*;
use g3_providers::MessageToolCall;
#[test]
fn test_new_context_window() {
@@ -858,4 +870,136 @@ mod tests {
assert_eq!(ThinScope::FirstThird.error_action(), "thinning");
assert_eq!(ThinScope::All.error_action(), "skinnifying");
}
// ====================================================================
// Compaction: tool_call stripping tests
// ====================================================================
/// Helper to create a Message with tool_calls
fn assistant_msg_with_tool_calls(content: &str, tool_call_ids: &[&str]) -> Message {
let mut msg = Message::new(MessageRole::Assistant, content.to_string());
msg.tool_calls = tool_call_ids
.iter()
.map(|id| MessageToolCall {
id: id.to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"file_path": "/tmp/test.rs"}),
})
.collect();
msg
}
#[test]
fn test_compaction_strips_tool_calls_from_last_assistant() {
// Reproduce the exact bug: assistant message with tool_calls gets preserved
// across compaction, creating orphaned tool_use blocks.
let mut cw = ContextWindow::new(100_000);
// Build a conversation: system, user, assistant(with tool_call), user(tool_result), user(new input)
cw.add_message(Message::new(MessageRole::System, "You are a helpful assistant.".to_string()));
cw.add_message(Message::new(MessageRole::User, "Read the file please.".to_string()));
cw.add_message(assistant_msg_with_tool_calls(
"Let me read that file for you.",
&["toolu_01QRFL8vGKDjZZkfHR586Srb"],
));
let mut tool_result = Message::new(MessageRole::User, "Tool result: file contents here...".to_string());
tool_result.tool_result_id = Some("toolu_01QRFL8vGKDjZZkfHR586Srb".to_string());
cw.add_message(tool_result);
// Now compact
cw.reset_with_summary(
"Summary: user asked to read a file, assistant read it.".to_string(),
Some("Now do something else.".to_string()),
);
// Find the preserved assistant message
let assistant_msgs: Vec<&Message> = cw
.conversation_history
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.collect();
assert_eq!(assistant_msgs.len(), 1, "Should have exactly one assistant message");
let preserved = assistant_msgs[0];
// The key assertion: tool_calls must be stripped
assert!(
preserved.tool_calls.is_empty(),
"tool_calls should be stripped from preserved assistant message, but found: {:?}",
preserved.tool_calls
);
// Text content should be preserved
assert!(preserved.content.contains("Let me read that file"));
}
#[test]
fn test_compaction_drops_assistant_with_only_tool_calls_no_text() {
// Edge case: assistant message has tool_calls but empty content.
// After stripping tool_calls, the message is empty and should be dropped.
let mut cw = ContextWindow::new(100_000);
cw.add_message(Message::new(MessageRole::System, "You are a helpful assistant.".to_string()));
cw.add_message(Message::new(MessageRole::User, "Do something.".to_string()));
// Assistant message with tool_calls but empty text content
let mut assistant = Message::new(MessageRole::Assistant, "".to_string());
assistant.tool_calls = vec![MessageToolCall {
id: "toolu_abc123".to_string(),
name: "shell".to_string(),
input: serde_json::json!({"command": "ls"}),
}];
// Force-add it (bypassing the empty check since it has tool_calls)
cw.conversation_history.push(assistant);
let mut tool_result = Message::new(MessageRole::User, "Tool result: file1 file2".to_string());
tool_result.tool_result_id = Some("toolu_abc123".to_string());
cw.add_message(tool_result);
// Compact
cw.reset_with_summary(
"Summary: ran ls command.".to_string(),
Some("What next?".to_string()),
);
// The empty assistant message (after tool_call stripping) should be dropped
let assistant_msgs: Vec<&Message> = cw
.conversation_history
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.collect();
assert_eq!(
assistant_msgs.len(), 0,
"Empty assistant message (after tool_call stripping) should be dropped"
);
}
#[test]
fn test_compaction_preserves_normal_assistant_message() {
// Normal case: assistant message without tool_calls should be preserved as-is.
let mut cw = ContextWindow::new(100_000);
cw.add_message(Message::new(MessageRole::System, "You are a helpful assistant.".to_string()));
cw.add_message(Message::new(MessageRole::User, "Hello!".to_string()));
cw.add_message(Message::new(
MessageRole::Assistant,
"Hello! How can I help you today?".to_string(),
));
cw.reset_with_summary(
"Summary: greeting exchange.".to_string(),
Some("Tell me a joke.".to_string()),
);
let assistant_msgs: Vec<&Message> = cw
.conversation_history
.iter()
.filter(|m| matches!(m.role, MessageRole::Assistant))
.collect();
assert_eq!(assistant_msgs.len(), 1);
assert!(assistant_msgs[0].tool_calls.is_empty());
assert!(assistant_msgs[0].content.contains("Hello! How can I help you today?"));
}
}