From c6719eec55ce41bbdac88744756cdd578d2e2450 Mon Sep 17 00:00:00 2001 From: jischeng <49861575+jischeng@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:52:43 +0800 Subject: [PATCH] fix(ai): preserve tool name when OpenAI-compatible provider re-sends empty id (#2196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GLM-5.2 (and potentially other OpenAI-compatible providers) send id="" on every tool-call delta after the first, while only the first delta carries function.name. The streaming parser treated Some("") as a valid id and emitted ToolCallStart for every chunk; the accumulator's insert then overwrote the previously-correct name with "", producing "Unknown tool:" errors and trapping the agent in a retry loop until the 30-turn limit. Two fixes, mutually reinforcing: 1. OpenAI parser: only treat a non-empty id as a genuine tool-call start (filter out id=""), so subsequent deltas no longer trigger ToolCallStart. 2. Accumulator: merge ToolCallStart into an existing entry instead of blind insert — keep the known name when the new one is empty, and preserve accumulated arguments. Defense in depth for any other provider that re-sends id or splits name across chunks. Added a regression test that feeds a re-sent ToolCallStart with an empty name and asserts the name and arguments survive. Verified end-to-end with the real GLM-5.2 provider: Unknown tool count dropped from 12+ to 0, and the agent completed in 3 turns instead of hitting the 30-turn safety limit. --- crates/dbx-core/src/ai.rs | 56 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index 521c7a6ad..7387782f8 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -1692,7 +1692,22 @@ impl StreamingToolCallAccumulator { match event { StreamToolEvent::Chunk(chunk) => on_chunk(chunk), StreamToolEvent::ToolCallStart { index, id, name } => { - self.calls.insert(index, PartialToolCall { id, name, arguments: String::new() }); + // Merge with any existing entry for this index instead of + // overwriting it. Some OpenAI-compatible providers (e.g. GLM) + // re-send `id` (as an empty string) or omit `name` on + // subsequent delta chunks; a blind insert would wipe a + // previously-correct name and reset accumulated arguments, + // producing "Unknown tool:" errors. + if let Some(existing) = self.calls.get_mut(&index) { + if !id.is_empty() { + existing.id = id; + } + if !name.is_empty() { + existing.name = name; + } + } else { + self.calls.insert(index, PartialToolCall { id, name, arguments: String::new() }); + } if !self.ordered_indices.contains(&index) { self.ordered_indices.push(index); } @@ -2025,8 +2040,11 @@ async fn stream_openai_with_tools( if let Some(tool_calls) = event["choices"].get(0).and_then(|c| c["delta"]["tool_calls"].as_array()) { for tc in tool_calls { let idx = tc["index"].as_u64().unwrap_or(0) as u32; - // First chunk for this tool call has id and name - if let Some(id) = tc["id"].as_str() { + // The first delta carries the tool call id and + // function name. Some OpenAI-compatible providers + // (e.g. GLM) send id="" on subsequent deltas, so + // only a non-empty id marks a genuine start. + if let Some(id) = tc["id"].as_str().filter(|s| !s.is_empty()) { let name = tc["function"]["name"].as_str().unwrap_or_default().to_string(); on_event(StreamToolEvent::ToolCallStart { index: idx, id: id.to_string(), name }); } @@ -2448,6 +2466,38 @@ mod tests { AUTHORIZATION, CLAUDE_DEFAULT_SYSTEM, TEST_PROMPT, }; + /// Reproduce the "Unknown tool:" bug: some OpenAI-compatible providers + /// (e.g. GLM via proxy) re-send the `id` field in every tool-call delta. + /// The second delta carries `id` but omits `function.name`, so the OpenAI + /// parser emits a second ToolCallStart with an empty name. The + /// accumulator's `insert` then overwrites the previously-correct name. + #[test] + fn accumulator_preserves_name_when_provider_resends_id() { + let mut acc = StreamingToolCallAccumulator::new(); + let noop = |_chunk| {}; + + // First chunk: id + name present (standard OpenAI first delta) + acc.process( + StreamToolEvent::ToolCallStart { index: 0, id: "call_1".to_string(), name: "get_columns".to_string() }, + &noop, + ); + acc.process(StreamToolEvent::ToolCallDelta { index: 0, fragment: "{\"table\":".to_string() }, &noop); + + // Second chunk: provider re-sends `id` but omits `function.name`. + // The OpenAI parser sees `id` is Some and emits ToolCallStart with + // name = "" (from unwrap_or_default()). + acc.process(StreamToolEvent::ToolCallStart { index: 0, id: "call_1".to_string(), name: String::new() }, &noop); + acc.process(StreamToolEvent::ToolCallDelta { index: 0, fragment: "\"record_trip_id_t\"}".to_string() }, &noop); + + let calls = acc.finalize(); + assert_eq!(calls.len(), 1, "expected exactly one accumulated tool call"); + assert_eq!( + calls[0].name, "get_columns", + "tool name was wiped to empty by a re-sent ToolCallStart — this is the \"Unknown tool:\" bug" + ); + assert_eq!(calls[0].arguments["table"], "record_trip_id_t", "arguments were reset by a re-sent ToolCallStart"); + } + #[test] fn stream_line_decoder_preserves_split_multibyte_utf8() { let text = "\u{8bf4}\u{660e}";