fix(providers): keep the API's real tool-call ids across turns

Multi-turn tool calls collided on replay: the reducer regenerated call_0,
call_1, ... every turn while ignoring the API's real call ids, so replayed
history had duplicate function_call/function_call_output ids that the API
cannot pair (visible once a conversation has two tool-using turns).

- ProviderEventReducer::tool_call_start_with_id(index, name, id) keeps the
  API's id; tool_call_start delegates with an auto-generated fallback
- Responses API: use item.call_id; Chat Completions: use tool_calls[].id
- regressions: real-log test asserts the captured ids, agent replay test
  asserts ids stay unique across three turns; reducer id-override test
This commit is contained in:
DaiChaoXiong 2026-08-09 22:07:18 +08:00
parent 6257f4f6b8
commit 1d1f50d1b1
4 changed files with 89 additions and 8 deletions

View File

@ -276,15 +276,33 @@ impl ProviderEventReducer {
out
}
/// 在 `index`provider 局部索引,映射到一个新内容槽)处打开工具调用块。
/// 在 `index`provider 局部索引,映射到一个新内容槽)处打开工具调用块,
/// 使用指定的调用 idAPI 提供的真实 id
/// Open a tool-call block at `index` (provider-local index, mapped to a
/// new content slot).
pub fn tool_call_start(&mut self, index: usize, name: &str) -> Vec<StreamEvent> {
/// new content slot), using the given call id (the API's real id).
///
/// 使用 API 的真实 id 至关重要:跨多轮工具调用时,若每轮都从 `call_0`
/// 重新生成,回放历史时 id 会冲突,导致 API 无法配对 function_call 与
/// function_call_output。空 id 时退回自动生成。
/// Using the API's real id matters: across multiple tool-using turns,
/// re-generating `call_0` every turn collides on replay, breaking the
/// function_call ↔ function_call_output pairing. Empty ids fall back to
/// auto-generated ones.
pub fn tool_call_start_with_id(
&mut self,
index: usize,
name: &str,
id: &str,
) -> Vec<StreamEvent> {
let mut out = Vec::new();
if let Some(ev) = self.ensure_started() {
out.push(ev);
}
let call_id = format!("call_{}", self.tool_call_pos.len());
let call_id = if id.is_empty() {
format!("call_{}", self.tool_call_pos.len())
} else {
id.to_string()
};
self.content.push(ContentBlock::ToolCall(ToolCall {
id: call_id,
name: name.to_string(),
@ -300,6 +318,14 @@ impl ProviderEventReducer {
out
}
/// 在 `index`provider 局部索引,映射到一个新内容槽)处打开工具调用块,
/// 自动生成调用 id。
/// Open a tool-call block at `index` (provider-local index, mapped to a
/// new content slot) with an auto-generated call id.
pub fn tool_call_start(&mut self, index: usize, name: &str) -> Vec<StreamEvent> {
self.tool_call_start_with_id(index, name, "")
}
/// 向 `index` 处的工具调用追加部分 JSON 参数(按 provider 局部索引寻址)。
/// Append partial JSON arguments to the tool call at `index` (addressed by
/// the provider-local index).

View File

@ -92,3 +92,18 @@ fn reducer_thinking_then_text() {
.iter()
.any(|e| matches!(e, StreamEvent::ThinkingEnd { .. })));
}
/// 工具调用 id 可显式指定API 真实 id空时退回自动生成。
/// Tool-call ids can be set explicitly (the API's real id); empty falls back
/// to auto-generation.
#[test]
fn reducer_tool_call_id_override() {
let mut r = ProviderEventReducer::new("m");
let _ = r.tool_call_start_with_id(0, "shell", "call_00_realId");
let _ = r.tool_call_start_with_id(1, "read", "");
let msg = r.finish().expect("final message");
let tc0 = msg.content[0].as_tool_call().expect("call 0");
let tc1 = msg.content[1].as_tool_call().expect("call 1");
assert_eq!(tc0.id, "call_00_realId");
assert_eq!(tc1.id, "call_1");
}

View File

@ -650,7 +650,13 @@ impl OpenAiTurn {
if let Some(f) = tc.get("function") {
if let Some(name) = f.get_str("name") {
if !name.is_empty() && self.seen_calls.insert(idx) {
for ev in self.reducer.tool_call_start(idx, name) {
// 用 API 的真实 id跨回合唯一
// Use the API's real id (unique across
// turns).
let call_id = tc.get_str("id").unwrap_or("");
for ev in
self.reducer.tool_call_start_with_id(idx, name, call_id)
{
if !common::send(tx, ev) {
return Ok(false);
}
@ -709,8 +715,12 @@ impl OpenAiTurn {
"function_call" => {
let idx = json.get_num("output_index").unwrap_or(0.0) as usize;
let name = item.get_str("name").unwrap_or("").to_string();
// 用 API 的真实 call_id跨回合唯一回放时避免冲突
// Use the API's real call_id (unique across turns,
// avoiding collisions on replay).
let call_id = item.get_str("call_id").unwrap_or("");
if !name.is_empty() {
for e in self.reducer.tool_call_start(idx, &name) {
for e in self.reducer.tool_call_start_with_id(idx, &name, call_id) {
if !common::send(tx, e) {
return Ok(false);
}

View File

@ -686,6 +686,12 @@ fn real_log_captures_reasoning_and_tools() {
let cmd2 = tc2.arguments.get_str("command").unwrap_or("");
assert!(cmd2.contains("find"), "got: {}", cmd2);
// 调用 id 必须保留 API 的真实 id跨回合唯一回放不冲突
// Call ids must keep the API's real ids (unique across turns, no replay
// collisions).
assert_eq!(tc1.id, "call_00_wyQ");
assert_eq!(tc2.id, "call_01_wUr");
// usage 映射input / output / cache-read。
// Usage mapping: input / output / cache-read.
assert_eq!(msg.usage.input_tokens, 850);
@ -825,8 +831,9 @@ fn agent_multi_turn_replays_reasoning() {
"got: {}",
text
);
// function_call 与 function_call_output 的 call_id 一致。
// The function_call and function_call_output call ids match.
// function_call 与 function_call_output 的 call_id 一致,且为 API 的真实 id。
// The function_call and function_call_output call ids match, using the
// API's real ids.
let fc = input
.iter()
.find(|i| i.get_str("type") == Some("function_call"))
@ -836,4 +843,27 @@ fn agent_multi_turn_replays_reasoning() {
.find(|i| i.get_str("type") == Some("function_call_output"))
.expect("function_call_output item");
assert_eq!(fc.get_str("call_id"), fco.get_str("call_id"));
assert_eq!(fc.get_str("call_id"), Some("call_00_wyQ"));
// 第 3 个请求(回放两轮历史):两轮的 call_id 不得冲突(回归:此前每轮
// 都从 call_0 重新生成导致跨回合撞号)。
// The third request (replaying two turns): call ids across turns must not
// collide (regression: ids used to restart at call_0 every turn).
let body3: focus_json::JsonValue =
focus_json::parse(&String::from_utf8_lossy(&reqs[2].body)).unwrap();
let input3 = body3.get_arr("input").expect("input items");
let call_ids: Vec<&str> = input3
.iter()
.filter(|i| i.get_str("type") == Some("function_call"))
.filter_map(|i| i.get_str("call_id"))
.collect();
let mut unique: Vec<&str> = call_ids.clone();
unique.sort();
unique.dedup();
assert_eq!(
unique.len(),
call_ids.len(),
"call ids must be unique across turns, got {:?}",
call_ids
);
}