fix(ai): preserve tool name when OpenAI-compatible provider re-sends empty id (#2196)
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.
This commit is contained in:
parent
d04463ebeb
commit
c6719eec55
|
|
@ -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}";
|
||||
|
|
|
|||
Loading…
Reference in New Issue