fix(agent): 修复工具调用消息序列的完整性问题
- 在历史压缩时,移除保留消息中不完整的工具调用序列,防止API 400错误 - 压缩后对当前历史消息再次进行工具调用序列清理,确保消息序列有效性 - OpenAI请求构建中,增加位置感知的工具调用和结果配对验证 - 跳过含有不完整工具调用序列的助手消息,避免发送非法请求 - 剥离不完整助手消息的工具调用字段,作为普通助手消息序列化 - 跟踪并警告不完整工具调用序列和被跳过的消息索引
This commit is contained in:
parent
3be9c1e646
commit
95cf152ab9
@ -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(),
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user