fix(agent): 修复工具调用消息序列的完整性问题

- 在历史压缩时,移除保留消息中不完整的工具调用序列,防止API 400错误
- 压缩后对当前历史消息再次进行工具调用序列清理,确保消息序列有效性
- OpenAI请求构建中,增加位置感知的工具调用和结果配对验证
- 跳过含有不完整工具调用序列的助手消息,避免发送非法请求
- 剥离不完整助手消息的工具调用字段,作为普通助手消息序列化
- 跟踪并警告不完整工具调用序列和被跳过的消息索引
This commit is contained in:
oudecheng 2026-06-23 08:44:48 +08:00
parent 3be9c1e646
commit 95cf152ab9
2 changed files with 76 additions and 19 deletions

View File

@ -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(&current_history),
final_msg_count = current_history.len(),

View File

@ -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<usize> = 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()))
{
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 {
valid_tool_call_parent_ids.insert(tc.id.as_str());
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 {