From 95cf152ab9e271b58b172b57275e65a24a65c9b7 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Tue, 23 Jun 2026 08:44:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=E4=BF=AE=E5=A4=8D=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=B0=83=E7=94=A8=E6=B6=88=E6=81=AF=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E7=9A=84=E5=AE=8C=E6=95=B4=E6=80=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在历史压缩时,移除保留消息中不完整的工具调用序列,防止API 400错误 - 压缩后对当前历史消息再次进行工具调用序列清理,确保消息序列有效性 - OpenAI请求构建中,增加位置感知的工具调用和结果配对验证 - 跳过含有不完整工具调用序列的助手消息,避免发送非法请求 - 剥离不完整助手消息的工具调用字段,作为普通助手消息序列化 - 跟踪并警告不完整工具调用序列和被跳过的消息索引 --- src/agent/context_compressor.rs | 34 ++++++++++++++++-- src/providers/openai.rs | 61 ++++++++++++++++++++++++--------- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/agent/context_compressor.rs b/src/agent/context_compressor.rs index 2d21028..6f90e6a 100644 --- a/src/agent/context_compressor.rs +++ b/src/agent/context_compressor.rs @@ -408,13 +408,28 @@ Be concise, aim for {} characters or less. .summarize_segment(&summary_source, provider_config) .await?; + // Sanitize preserved messages: the boundary between the summarized + // and preserved sections can split an assistant tool_calls message + // from its tool result messages, creating orphaned sequences that + // would cause API 400 errors. + let mut preserved_messages = history[preserved_turn_start..].to_vec(); + let removed = + crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut preserved_messages); + if removed > 0 { + tracing::warn!( + removed_count = removed, + preserved_turn_start, + "Compaction plan: removed incomplete tool call sequences from preserved messages" + ); + } + Ok(Some(HistoryCompactionPlan { preserved_system_messages, summary_message: ChatMessage::system_with_context( format!("[Compressed History]\n\n{}", summary), Some(SYSTEM_CONTEXT_HISTORY_COMPACTION.to_string()), ), - preserved_messages: history[preserved_turn_start..].to_vec(), + preserved_messages, compressed_turns: turn_ranges.len() - self.config.retain_last_user_turns, preserved_turns: self.config.retain_last_user_turns, })) @@ -445,7 +460,7 @@ Be concise, aim for {} characters or less. "Starting context compression" ); - let current_history = match self + let mut current_history = match self .build_compaction_plan(&history, provider_config) .await? { @@ -461,6 +476,21 @@ Be concise, aim for {} characters or less. None => history, }; + // Post-compression sanitization: compression can split an assistant + // tool_calls message from its tool result messages at the boundary + // between the summarized and preserved sections. This pass removes + // any orphaned tool_calls or tool results to ensure the message + // sequence is always valid for the LLM API. + let removed = + crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut current_history); + if removed > 0 { + tracing::warn!( + removed_count = removed, + remaining_messages = current_history.len(), + "Post-compression sanitization removed incomplete tool call sequences" + ); + } + tracing::info!( final_tokens = estimate_tokens(¤t_history), final_msg_count = current_history.len(), diff --git a/src/providers/openai.rs b/src/providers/openai.rs index ee80827..9469b46 100644 --- a/src/providers/openai.rs +++ b/src/providers/openai.rs @@ -632,41 +632,68 @@ impl OpenAIProvider { fn build_request_body(&self, request: &ChatCompletionRequest) -> Value { let supports_images = self.supports_images(); - // --- Final defense: validate tool_call / tool result pairing --- - // Collect all tool_call_ids that have a corresponding tool result message. - // Any assistant tool_call NOT in this set is orphaned and must be stripped - // to avoid API 400 errors ("insufficient tool messages following tool_calls"). + // --- Final defense: position-aware tool_call / tool result validation --- + // + // Scan right-to-left (matching sanitize_incomplete_tool_call_sequences) + // so that only tool results appearing AFTER an assistant message count + // as "resolved". A simple global scan would incorrectly accept a tool + // result that precedes its parent assistant (e.g. after compaction + // boundary splits), leading to API 400 errors: + // "insufficient tool messages following tool_calls message". let mut resolved_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new(); - for m in &request.messages { + let mut with_parent: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut skip_assistant_indices: std::collections::HashSet = std::collections::HashSet::new(); + + for (i, m) in request.messages.iter().enumerate().rev() { if m.role == "tool" { if let Some(ref tc_id) = m.tool_call_id { resolved_tool_ids.insert(tc_id.as_str()); } } - } - // Build the set of assistant tool_call_ids that are fully valid - // (ALL tool_calls in the message have corresponding results). - // If an assistant has partial or no valid tool_calls, we strip the - // tool_calls field and serialize it as a plain assistant message. - let mut valid_tool_call_parent_ids: std::collections::HashSet<&str> = std::collections::HashSet::new(); - for m in &request.messages { if m.role == "assistant" { if let Some(ref calls) = m.tool_calls { - if !calls.is_empty() - && calls.iter().all(|tc| resolved_tool_ids.contains(tc.id.as_str())) - { - for tc in calls { - valid_tool_call_parent_ids.insert(tc.id.as_str()); + if !calls.is_empty() { + let all_resolved = + calls.iter().all(|tc| resolved_tool_ids.contains(tc.id.as_str())); + if all_resolved { + for tc in calls { + with_parent.insert(tc.id.as_str()); + } + } else { + skip_assistant_indices.insert(i); } } } } } + // valid_tool_call_parent_ids = with_parent (assistant tool_call_ids + // whose parent assistant has ALL results after it) + let valid_tool_call_parent_ids = &with_parent; + let mut body = json!({ "model": self.model_id, "messages": request.messages.iter().enumerate().filter_map(|(i, m)| { + // Skip assistant messages identified as having incomplete tool_calls + // (position-aware scan found missing tool results after this assistant). + if skip_assistant_indices.contains(&i) { + tracing::warn!( + message_index = i, + "build_request_body: skipping assistant with incomplete tool call sequence \ + (tool results missing after this position)" + ); + // Serialize as plain assistant message (strip tool_calls) + let mut message = json!({ + "role": m.role, + "content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i) + }); + if let Some(reasoning_content) = &m.reasoning_content { + message["reasoning_content"] = Value::String(reasoning_content.clone()); + } + return Some(message); + } + if m.role == "tool" { // Skip orphaned tool results (no matching assistant tool_call) let is_orphaned = match &m.tool_call_id {