diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 36cdfc8..b409f0f 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -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) -> 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 = 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)]