feat: 清理消息历史中不完整的 tool call 序列

新增 sanitize_incomplete_tool_call_sequences() 方法,移除末尾
缺少对应 tool result 的 assistant tool_calls 消息。解决进程中断
导致部分 tool call 序列残留、进而引发 API 报错的问题。

同时清理因父消息被移除而残留的孤儿 tool result 消息。
This commit is contained in:
oudecheng 2026-06-02 20:40:05 +08:00
parent 9f2eedf313
commit cb58d9f3f0

View File

@ -838,6 +838,123 @@ impl AgentLoop {
&self.tools
}
/// Sanitize message history by removing trailing assistant messages with
/// tool_calls that don't have corresponding tool result messages.
///
/// This can happen if the process was interrupted mid-execution: the
/// assistant message with tool_calls was persisted but the tool results
/// were not. Sending such incomplete sequences to the API causes errors
/// like "insufficient tool messages following tool_calls message".
///
/// Returns the number of messages removed.
fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMessage>) -> usize {
let mut removed = 0;
// Phase 1: Remove trailing assistant messages with tool_calls that lack
// corresponding tool result messages. We loop because removing one may
// expose another incomplete sequence.
loop {
let last_assistant_idx = match messages.iter().rposition(|m| {
m.role == "assistant"
&& m.tool_calls
.as_ref()
.map_or(false, |calls| !calls.is_empty())
}) {
Some(idx) => idx,
None => break,
};
// Collect all tool_call_ids from this assistant message
let tool_call_ids: Vec<&str> = messages[last_assistant_idx]
.tool_calls
.as_ref()
.unwrap()
.iter()
.map(|tc| tc.id.as_str())
.collect();
// Check if ALL tool_call_ids have corresponding tool messages
// appearing AFTER this assistant message
let all_have_results = tool_call_ids.iter().all(|&tc_id| {
messages[last_assistant_idx + 1..]
.iter()
.any(|m| m.role == "tool" && m.tool_call_id.as_deref() == Some(tc_id))
});
if all_have_results {
// Complete sequence found, stop trimming
break;
}
let tool_call_count = tool_call_ids.len();
let missing_count = tool_call_ids
.iter()
.filter(|&&tc_id| {
!messages[last_assistant_idx + 1..]
.iter()
.any(|m| m.role == "tool" && m.tool_call_id.as_deref() == Some(tc_id))
})
.count();
tracing::warn!(
tool_call_count,
missing_tool_results = missing_count,
message_id = %messages[last_assistant_idx].id,
"Removing assistant message with incomplete tool call sequence — \
tool results were never persisted (likely due to process interruption)"
);
messages.remove(last_assistant_idx);
removed += 1;
}
// Phase 2: Remove orphaned trailing tool messages that no longer have a
// corresponding assistant tool_calls message before them. These are left
// over from Phase 1 removals.
//
// We work backwards: a tool message at the end of the sequence is
// orphaned if none of the preceding messages is an assistant with a
// matching tool_call in its tool_calls.
while let Some(last_idx) = messages.last().map(|_| messages.len() - 1) {
let last = &messages[last_idx];
if last.role != "tool" {
break;
}
let tool_id = match &last.tool_call_id {
Some(id) => id.as_str(),
None => break, // tool message without tool_call_id — shouldn't happen, but safe
};
// Check if any preceding assistant message has this tool_call_id in
// its tool_calls
let has_parent = messages[..last_idx].iter().any(|m| {
m.role == "assistant"
&& m.tool_calls
.as_ref()
.map_or(false, |calls| {
calls.iter().any(|tc| tc.id == tool_id)
})
});
if has_parent {
break; // This tool message has a valid parent, stop
}
tracing::warn!(
tool_call_id = %tool_id,
message_id = %last.id,
"Removing orphaned tool result message — its parent assistant \
tool_calls message was removed or never persisted"
);
messages.remove(last_idx);
removed += 1;
}
removed
}
/// Process a message using the provided conversation history.
/// History management is handled externally by SessionManager.
///
@ -861,6 +978,10 @@ impl AgentLoop {
"Starting agent process"
);
// Sanitize: remove any trailing incomplete tool call sequences
// that may have been persisted before a process interruption.
Self::sanitize_incomplete_tool_call_sequences(&mut messages);
// Track tool calls for loop detection
let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default());
let mut emitted_messages = Vec::new();
@ -1763,6 +1884,218 @@ mod tests {
assert_eq!(filtered[0].media_refs.len(), 0, "age=19 的消息图片应被过滤");
assert!(filtered[0].content.contains("超出 10 条消息范围"));
}
// ====================
// sanitize_incomplete_tool_call_sequences tests
// ====================
#[test]
fn test_sanitize_removes_trailing_incomplete_tool_call_sequence() {
let mut messages = vec![
ChatMessage::user("hello"),
ChatMessage::assistant_with_tool_calls(
"calling tool",
vec![ToolCall {
id: "call_1".to_string(),
name: "calculator".to_string(),
arguments: serde_json::json!({"expression": "1+1"}),
}],
),
// Tool result for call_1 is MISSING — incomplete sequence
];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 1);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, "user");
}
#[test]
fn test_sanitize_preserves_complete_tool_call_sequence() {
let mut messages = vec![
ChatMessage::user("hello"),
ChatMessage::assistant_with_tool_calls(
"calling tool",
vec![ToolCall {
id: "call_1".to_string(),
name: "calculator".to_string(),
arguments: serde_json::json!({"expression": "1+1"}),
}],
),
ChatMessage::tool("call_1", "calculator", "2"),
];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 0);
assert_eq!(messages.len(), 3);
}
#[test]
fn test_sanitize_removes_multiple_incomplete_sequences() {
let mut messages = vec![
ChatMessage::user("hello"),
ChatMessage::assistant_with_tool_calls(
"first tool call",
vec![ToolCall {
id: "call_1".to_string(),
name: "calculator".to_string(),
arguments: serde_json::json!({"expression": "1+1"}),
}],
),
// Missing tool result for call_1
ChatMessage::user("second question"),
ChatMessage::assistant_with_tool_calls(
"second tool call",
vec![ToolCall {
id: "call_2".to_string(),
name: "read".to_string(),
arguments: serde_json::json!({"path": "README.md"}),
}],
),
// Also missing tool result for call_2
];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
// Should remove both trailing assistant messages with incomplete tool calls
assert_eq!(removed, 2);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].role, "user");
assert_eq!(messages[0].content, "hello");
assert_eq!(messages[1].role, "user");
assert_eq!(messages[1].content, "second question");
}
#[test]
fn test_sanitize_removes_assistant_when_partial_tool_results() {
// Assistant makes 2 tool calls, but only 1 tool result exists
let mut messages = vec![
ChatMessage::user("hello"),
ChatMessage::assistant_with_tool_calls(
"calling two tools",
vec![
ToolCall {
id: "call_1".to_string(),
name: "calculator".to_string(),
arguments: serde_json::json!({"expression": "1+1"}),
},
ToolCall {
id: "call_2".to_string(),
name: "read".to_string(),
arguments: serde_json::json!({"path": "README.md"}),
},
],
),
ChatMessage::tool("call_1", "calculator", "2"),
// Missing tool result for call_2
];
let removed_count = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
// Phase 1 removes the assistant message (call_2 has no result).
// Phase 2 removes the orphaned tool result for call_1 (its parent
// assistant was removed).
assert_eq!(removed_count, 2);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, "user");
}
#[test]
fn test_sanitize_preserves_messages_without_tool_calls() {
let mut messages = vec![
ChatMessage::user("hello"),
ChatMessage::assistant("hi there"),
ChatMessage::user("how are you"),
];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 0);
assert_eq!(messages.len(), 3);
}
#[test]
fn test_sanitize_handles_empty_messages() {
let mut messages: Vec<ChatMessage> = vec![];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 0);
}
#[test]
fn test_sanitize_removes_orphaned_tool_messages() {
// A lone tool message without a preceding assistant tool_calls
// is orphaned and should be removed.
let mut messages = vec![
ChatMessage::tool("call_1", "calculator", "2"),
];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 1);
assert!(messages.is_empty());
}
#[test]
fn test_sanitize_preserves_complete_sequence_with_multiple_tool_calls() {
let mut messages = vec![
ChatMessage::user("do two things"),
ChatMessage::assistant_with_tool_calls(
"calling two tools",
vec![
ToolCall {
id: "call_1".to_string(),
name: "calculator".to_string(),
arguments: serde_json::json!({"expression": "1+1"}),
},
ToolCall {
id: "call_2".to_string(),
name: "read".to_string(),
arguments: serde_json::json!({"path": "README.md"}),
},
],
),
ChatMessage::tool("call_1", "calculator", "2"),
ChatMessage::tool("call_2", "read", "contents of README"),
];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 0);
assert_eq!(messages.len(), 4);
}
#[test]
fn test_sanitize_only_trims_trailing_incomplete_sequence() {
// Complete sequence followed by an incomplete one — only the
// trailing incomplete one should be removed
let mut messages = vec![
ChatMessage::user("first question"),
ChatMessage::assistant_with_tool_calls(
"first tool call",
vec![ToolCall {
id: "call_1".to_string(),
name: "calculator".to_string(),
arguments: serde_json::json!({"expression": "1+1"}),
}],
),
ChatMessage::tool("call_1", "calculator", "2"),
ChatMessage::assistant("the answer is 2"),
ChatMessage::user("second question"),
ChatMessage::assistant_with_tool_calls(
"second tool call",
vec![ToolCall {
id: "call_2".to_string(),
name: "read".to_string(),
arguments: serde_json::json!({"path": "README.md"}),
}],
),
// Missing tool result for call_2 — only THIS sequence should be trimmed
];
let removed = AgentLoop::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 1);
// First complete sequence preserved (5 messages), user message for second
// question preserved
assert_eq!(messages.len(), 5);
assert_eq!(messages[0].content, "first question");
assert_eq!(messages[3].content, "the answer is 2");
assert_eq!(messages[4].content, "second question");
}
}
#[derive(Debug)]