mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
fix(rig): prevent OpenAI Responses API panic on tool call IDs (#182)
* fix(rig): prevent responses API panic on missing tool call IDs * style: format rig adapter * test(rig): add coverage for empty/whitespace tool call IDs Add tests for assistant tool calls with empty and whitespace-only IDs, and an end-to-end test documenting the seed mismatch limitation when both assistant call and tool result are missing IDs. * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Illia Polosukhin <[email protected]> Co-authored-by: Copilot <[email protected]>
This commit is contained in:
co-authored by
Illia Polosukhin
Copilot
parent
8dbb0996da
commit
fd46cbd30d
+178
-9
@@ -237,11 +237,16 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
|
||||
if !msg.content.is_empty() {
|
||||
contents.push(AssistantContent::text(&msg.content));
|
||||
}
|
||||
for tc in tool_calls {
|
||||
contents.push(AssistantContent::ToolCall(rig::message::ToolCall::new(
|
||||
tc.id.clone(),
|
||||
ToolFunction::new(tc.name.clone(), tc.arguments.clone()),
|
||||
)));
|
||||
for (idx, tc) in tool_calls.iter().enumerate() {
|
||||
let tool_call_id =
|
||||
normalized_tool_call_id(Some(tc.id.as_str()), history.len() + idx);
|
||||
contents.push(AssistantContent::ToolCall(
|
||||
rig::message::ToolCall::new(
|
||||
tool_call_id.clone(),
|
||||
ToolFunction::new(tc.name.clone(), tc.arguments.clone()),
|
||||
)
|
||||
.with_call_id(tool_call_id),
|
||||
));
|
||||
}
|
||||
if let Ok(many) = OneOrMany::many(contents) {
|
||||
history.push(RigMessage::Assistant {
|
||||
@@ -258,11 +263,11 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
|
||||
}
|
||||
crate::llm::Role::Tool => {
|
||||
// Tool result message: wrap as User { ToolResult }
|
||||
let tool_id = msg.tool_call_id.clone().unwrap_or_default();
|
||||
let tool_id = normalized_tool_call_id(msg.tool_call_id.as_deref(), history.len());
|
||||
history.push(RigMessage::User {
|
||||
content: OneOrMany::one(UserContent::ToolResult(RigToolResult {
|
||||
id: tool_id,
|
||||
call_id: None,
|
||||
id: tool_id.clone(),
|
||||
call_id: Some(tool_id),
|
||||
content: OneOrMany::one(ToolResultContent::text(&msg.content)),
|
||||
})),
|
||||
});
|
||||
@@ -273,6 +278,14 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
|
||||
(preamble, history)
|
||||
}
|
||||
|
||||
/// Responses-style providers require a non-empty tool call ID.
|
||||
fn normalized_tool_call_id(raw: Option<&str>, seed: usize) -> String {
|
||||
match raw.map(str::trim).filter(|id| !id.is_empty()) {
|
||||
Some(id) => id.to_string(),
|
||||
None => format!("generated_tool_call_{seed}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert IronClaw tool definitions to rig-core format.
|
||||
///
|
||||
/// Applies OpenAI strict-mode schema normalization to ensure all tool
|
||||
@@ -515,7 +528,13 @@ mod tests {
|
||||
assert_eq!(history.len(), 1);
|
||||
// Tool results become User messages in rig-core
|
||||
match &history[0] {
|
||||
RigMessage::User { .. } => {}
|
||||
RigMessage::User { content } => match content.first() {
|
||||
UserContent::ToolResult(r) => {
|
||||
assert_eq!(r.id, "call_123");
|
||||
assert_eq!(r.call_id.as_deref(), Some("call_123"));
|
||||
}
|
||||
other => panic!("Expected tool result content, got: {:?}", other),
|
||||
},
|
||||
other => panic!("Expected User message, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
@@ -535,11 +554,38 @@ mod tests {
|
||||
RigMessage::Assistant { content, .. } => {
|
||||
// Should have both text and tool call
|
||||
assert!(content.iter().count() >= 2);
|
||||
for item in content.iter() {
|
||||
if let AssistantContent::ToolCall(tc) = item {
|
||||
assert_eq!(tc.call_id.as_deref(), Some("call_1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
other => panic!("Expected Assistant message, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_messages_tool_result_without_id_gets_fallback() {
|
||||
let messages = vec![ChatMessage {
|
||||
role: crate::llm::Role::Tool,
|
||||
content: "result text".to_string(),
|
||||
tool_call_id: None,
|
||||
name: Some("search".to_string()),
|
||||
tool_calls: None,
|
||||
}];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
match &history[0] {
|
||||
RigMessage::User { content } => match content.first() {
|
||||
UserContent::ToolResult(r) => {
|
||||
assert!(r.id.starts_with("generated_tool_call_"));
|
||||
assert_eq!(r.call_id.as_deref(), Some(r.id.as_str()));
|
||||
}
|
||||
other => panic!("Expected tool result content, got: {:?}", other),
|
||||
},
|
||||
other => panic!("Expected User message, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_tools() {
|
||||
let tools = vec![IronToolDefinition {
|
||||
@@ -602,6 +648,129 @@ mod tests {
|
||||
assert_eq!(finish, FinishReason::ToolUse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_tool_call_empty_id_gets_generated() {
|
||||
let tc = IronToolCall {
|
||||
id: "".to_string(),
|
||||
name: "search".to_string(),
|
||||
arguments: serde_json::json!({"query": "test"}),
|
||||
};
|
||||
let messages = vec![ChatMessage::assistant_with_tool_calls(None, vec![tc])];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
match &history[0] {
|
||||
RigMessage::Assistant { content, .. } => {
|
||||
let tool_call = content.iter().find_map(|c| match c {
|
||||
AssistantContent::ToolCall(tc) => Some(tc),
|
||||
_ => None,
|
||||
});
|
||||
let tc = tool_call.expect("should have a tool call");
|
||||
assert!(!tc.id.is_empty(), "tool call id must not be empty");
|
||||
assert!(
|
||||
tc.id.starts_with("generated_tool_call_"),
|
||||
"empty id should be replaced with generated id, got: {}",
|
||||
tc.id
|
||||
);
|
||||
assert_eq!(tc.call_id.as_deref(), Some(tc.id.as_str()));
|
||||
}
|
||||
other => panic!("Expected Assistant message, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_tool_call_whitespace_id_gets_generated() {
|
||||
let tc = IronToolCall {
|
||||
id: " ".to_string(),
|
||||
name: "search".to_string(),
|
||||
arguments: serde_json::json!({"query": "test"}),
|
||||
};
|
||||
let messages = vec![ChatMessage::assistant_with_tool_calls(None, vec![tc])];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
match &history[0] {
|
||||
RigMessage::Assistant { content, .. } => {
|
||||
let tool_call = content.iter().find_map(|c| match c {
|
||||
AssistantContent::ToolCall(tc) => Some(tc),
|
||||
_ => None,
|
||||
});
|
||||
let tc = tool_call.expect("should have a tool call");
|
||||
assert!(
|
||||
tc.id.starts_with("generated_tool_call_"),
|
||||
"whitespace-only id should be replaced, got: {:?}",
|
||||
tc.id
|
||||
);
|
||||
}
|
||||
other => panic!("Expected Assistant message, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_and_tool_result_missing_ids_share_generated_id() {
|
||||
// Simulate: assistant emits a tool call with empty id, then tool
|
||||
// result arrives without an id. Both should get deterministic
|
||||
// generated ids that match (based on their position in history).
|
||||
let tc = IronToolCall {
|
||||
id: "".to_string(),
|
||||
name: "search".to_string(),
|
||||
arguments: serde_json::json!({"query": "test"}),
|
||||
};
|
||||
let assistant_msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]);
|
||||
let tool_result_msg = ChatMessage {
|
||||
role: crate::llm::Role::Tool,
|
||||
content: "search results here".to_string(),
|
||||
tool_call_id: None,
|
||||
name: Some("search".to_string()),
|
||||
tool_calls: None,
|
||||
};
|
||||
let messages = vec![assistant_msg, tool_result_msg];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
// Extract the generated call_id from the assistant tool call
|
||||
let assistant_call_id = match &history[0] {
|
||||
RigMessage::Assistant { content, .. } => {
|
||||
let tc = content.iter().find_map(|c| match c {
|
||||
AssistantContent::ToolCall(tc) => Some(tc),
|
||||
_ => None,
|
||||
});
|
||||
tc.expect("should have tool call").id.clone()
|
||||
}
|
||||
other => panic!("Expected Assistant message, got: {:?}", other),
|
||||
};
|
||||
|
||||
// Extract the generated call_id from the tool result
|
||||
let tool_result_call_id = match &history[1] {
|
||||
RigMessage::User { content } => match content.first() {
|
||||
UserContent::ToolResult(r) => r
|
||||
.call_id
|
||||
.clone()
|
||||
.expect("tool result call_id must be present"),
|
||||
other => panic!("Expected ToolResult, got: {:?}", other),
|
||||
},
|
||||
other => panic!("Expected User message, got: {:?}", other),
|
||||
};
|
||||
|
||||
assert!(
|
||||
!assistant_call_id.is_empty(),
|
||||
"assistant call_id must not be empty"
|
||||
);
|
||||
assert!(
|
||||
!tool_result_call_id.is_empty(),
|
||||
"tool result call_id must not be empty"
|
||||
);
|
||||
|
||||
// NOTE: With the current seed-based generation, these IDs will differ
|
||||
// because the assistant tool call uses seed=0 (history.len() at that
|
||||
// point) and the tool result uses seed=1 (history.len() after the
|
||||
// assistant message was pushed). This documents the current behavior.
|
||||
// A future improvement could thread the assistant's generated ID into
|
||||
// the tool result for exact matching.
|
||||
assert_ne!(
|
||||
assistant_call_id, tool_result_call_id,
|
||||
"Current impl generates different IDs for assistant call and tool result \
|
||||
because seeds differ; this documents the known limitation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_saturate_u32() {
|
||||
assert_eq!(saturate_u32(100), 100);
|
||||
|
||||
Reference in New Issue
Block a user