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:
parent
6257f4f6b8
commit
1d1f50d1b1
|
|
@ -276,15 +276,33 @@ impl ProviderEventReducer {
|
|||
out
|
||||
}
|
||||
|
||||
/// 在 `index`(provider 局部索引,映射到一个新内容槽)处打开工具调用块。
|
||||
/// 在 `index`(provider 局部索引,映射到一个新内容槽)处打开工具调用块,
|
||||
/// 使用指定的调用 id(API 提供的真实 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).
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue