Skip to content

Commit 649badd

Browse files
authored
fix: chat multiple tool calls (#8556)
Fix this: #8479 The issue is that chat completion API expect all the tool calls in a single assistant message and then all the tool call output in a single response message
1 parent a8e0fe8 commit 649badd

File tree

1 file changed

+150
-44
lines changed
  • codex-rs/codex-api/src/requests

1 file changed

+150
-44
lines changed

codex-rs/codex-api/src/requests/chat.rs

Lines changed: 150 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -204,47 +204,31 @@ impl<'a> ChatRequestBuilder<'a> {
204204
call_id,
205205
..
206206
} => {
207-
let mut msg = json!({
208-
"role": "assistant",
209-
"content": null,
210-
"tool_calls": [{
211-
"id": call_id,
212-
"type": "function",
213-
"function": {
214-
"name": name,
215-
"arguments": arguments,
216-
}
217-
}]
207+
let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
208+
let tool_call = json!({
209+
"id": call_id,
210+
"type": "function",
211+
"function": {
212+
"name": name,
213+
"arguments": arguments,
214+
}
218215
});
219-
if let Some(reasoning) = reasoning_by_anchor_index.get(&idx)
220-
&& let Some(obj) = msg.as_object_mut()
221-
{
222-
obj.insert("reasoning".to_string(), json!(reasoning));
223-
}
224-
messages.push(msg);
216+
push_tool_call_message(&mut messages, tool_call, reasoning);
225217
}
226218
ResponseItem::LocalShellCall {
227219
id,
228220
call_id: _,
229221
status,
230222
action,
231223
} => {
232-
let mut msg = json!({
233-
"role": "assistant",
234-
"content": null,
235-
"tool_calls": [{
236-
"id": id.clone().unwrap_or_default(),
237-
"type": "local_shell_call",
238-
"status": status,
239-
"action": action,
240-
}]
224+
let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
225+
let tool_call = json!({
226+
"id": id.clone().unwrap_or_default(),
227+
"type": "local_shell_call",
228+
"status": status,
229+
"action": action,
241230
});
242-
if let Some(reasoning) = reasoning_by_anchor_index.get(&idx)
243-
&& let Some(obj) = msg.as_object_mut()
244-
{
245-
obj.insert("reasoning".to_string(), json!(reasoning));
246-
}
247-
messages.push(msg);
231+
push_tool_call_message(&mut messages, tool_call, reasoning);
248232
}
249233
ResponseItem::FunctionCallOutput { call_id, output } => {
250234
let content_value = if let Some(items) = &output.content_items {
@@ -277,18 +261,16 @@ impl<'a> ChatRequestBuilder<'a> {
277261
input,
278262
status: _,
279263
} => {
280-
messages.push(json!({
281-
"role": "assistant",
282-
"content": null,
283-
"tool_calls": [{
284-
"id": id,
285-
"type": "custom",
286-
"custom": {
287-
"name": name,
288-
"input": input,
289-
}
290-
}]
291-
}));
264+
let tool_call = json!({
265+
"id": id,
266+
"type": "custom",
267+
"custom": {
268+
"name": name,
269+
"input": input,
270+
}
271+
});
272+
let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
273+
push_tool_call_message(&mut messages, tool_call, reasoning);
292274
}
293275
ResponseItem::CustomToolCallOutput { call_id, output } => {
294276
messages.push(json!({
@@ -328,11 +310,50 @@ impl<'a> ChatRequestBuilder<'a> {
328310
}
329311
}
330312

313+
fn push_tool_call_message(messages: &mut Vec<Value>, tool_call: Value, reasoning: Option<&str>) {
314+
// Chat Completions requires that tool calls are grouped into a single assistant message
315+
// (with `tool_calls: [...]`) followed by tool role responses.
316+
if let Some(Value::Object(obj)) = messages.last_mut()
317+
&& obj.get("role").and_then(Value::as_str) == Some("assistant")
318+
&& obj.get("content").is_some_and(Value::is_null)
319+
&& let Some(tool_calls) = obj.get_mut("tool_calls").and_then(Value::as_array_mut)
320+
{
321+
tool_calls.push(tool_call);
322+
if let Some(reasoning) = reasoning {
323+
if let Some(Value::String(existing)) = obj.get_mut("reasoning") {
324+
if !existing.is_empty() {
325+
existing.push('\n');
326+
}
327+
existing.push_str(reasoning);
328+
} else {
329+
obj.insert(
330+
"reasoning".to_string(),
331+
Value::String(reasoning.to_string()),
332+
);
333+
}
334+
}
335+
return;
336+
}
337+
338+
let mut msg = json!({
339+
"role": "assistant",
340+
"content": null,
341+
"tool_calls": [tool_call],
342+
});
343+
if let Some(reasoning) = reasoning
344+
&& let Some(obj) = msg.as_object_mut()
345+
{
346+
obj.insert("reasoning".to_string(), json!(reasoning));
347+
}
348+
messages.push(msg);
349+
}
350+
331351
#[cfg(test)]
332352
mod tests {
333353
use super::*;
334354
use crate::provider::RetryConfig;
335355
use crate::provider::WireApi;
356+
use codex_protocol::models::FunctionCallOutputPayload;
336357
use codex_protocol::protocol::SessionSource;
337358
use codex_protocol::protocol::SubAgentSource;
338359
use http::HeaderValue;
@@ -385,4 +406,89 @@ mod tests {
385406
Some(&HeaderValue::from_static("review"))
386407
);
387408
}
409+
410+
#[test]
411+
fn groups_consecutive_tool_calls_into_a_single_assistant_message() {
412+
let prompt_input = vec![
413+
ResponseItem::Message {
414+
id: None,
415+
role: "user".to_string(),
416+
content: vec![ContentItem::InputText {
417+
text: "read these".to_string(),
418+
}],
419+
},
420+
ResponseItem::FunctionCall {
421+
id: None,
422+
name: "read_file".to_string(),
423+
arguments: r#"{"path":"a.txt"}"#.to_string(),
424+
call_id: "call-a".to_string(),
425+
},
426+
ResponseItem::FunctionCall {
427+
id: None,
428+
name: "read_file".to_string(),
429+
arguments: r#"{"path":"b.txt"}"#.to_string(),
430+
call_id: "call-b".to_string(),
431+
},
432+
ResponseItem::FunctionCall {
433+
id: None,
434+
name: "read_file".to_string(),
435+
arguments: r#"{"path":"c.txt"}"#.to_string(),
436+
call_id: "call-c".to_string(),
437+
},
438+
ResponseItem::FunctionCallOutput {
439+
call_id: "call-a".to_string(),
440+
output: FunctionCallOutputPayload {
441+
content: "A".to_string(),
442+
..Default::default()
443+
},
444+
},
445+
ResponseItem::FunctionCallOutput {
446+
call_id: "call-b".to_string(),
447+
output: FunctionCallOutputPayload {
448+
content: "B".to_string(),
449+
..Default::default()
450+
},
451+
},
452+
ResponseItem::FunctionCallOutput {
453+
call_id: "call-c".to_string(),
454+
output: FunctionCallOutputPayload {
455+
content: "C".to_string(),
456+
..Default::default()
457+
},
458+
},
459+
];
460+
461+
let req = ChatRequestBuilder::new("gpt-test", "inst", &prompt_input, &[])
462+
.build(&provider())
463+
.expect("request");
464+
465+
let messages = req
466+
.body
467+
.get("messages")
468+
.and_then(|v| v.as_array())
469+
.expect("messages array");
470+
// system + user + assistant(tool_calls=[...]) + 3 tool outputs
471+
assert_eq!(messages.len(), 6);
472+
473+
assert_eq!(messages[0]["role"], "system");
474+
assert_eq!(messages[1]["role"], "user");
475+
476+
let tool_calls_msg = &messages[2];
477+
assert_eq!(tool_calls_msg["role"], "assistant");
478+
assert_eq!(tool_calls_msg["content"], serde_json::Value::Null);
479+
let tool_calls = tool_calls_msg["tool_calls"]
480+
.as_array()
481+
.expect("tool_calls array");
482+
assert_eq!(tool_calls.len(), 3);
483+
assert_eq!(tool_calls[0]["id"], "call-a");
484+
assert_eq!(tool_calls[1]["id"], "call-b");
485+
assert_eq!(tool_calls[2]["id"], "call-c");
486+
487+
assert_eq!(messages[3]["role"], "tool");
488+
assert_eq!(messages[3]["tool_call_id"], "call-a");
489+
assert_eq!(messages[4]["role"], "tool");
490+
assert_eq!(messages[4]["tool_call_id"], "call-b");
491+
assert_eq!(messages[5]["role"], "tool");
492+
assert_eq!(messages[5]["tool_call_id"], "call-c");
493+
}
388494
}

0 commit comments

Comments
 (0)