agent: Fix Preserve tool call ID/name across DashScope empty delta chunks (#54872)

Release Notes:

- Fixed: DashScope (Aliyun) tool calls now preserve id and name across
streaming delta chunks
   

---------------------------------------------------------------------------------------------------
    Aliyun (DashScope) SSE streaming sends id="" and name="" in
    subsequent tool_calls delta chunks after the first chunk. Previously
    these empty strings would unconditionally overwrite the accumulated
    id and name values, causing tool calls to lose identity and fail.
    
    Add is_empty() guards so id and name are only updated when the
    delta provides a non-empty value (falsy guard pattern), matching how
    Hermes Agent and OpenAI SDK handle this provider edge case.
    
    Test stream_maps_preserves_tool_id_and_name_across_empty_deltas
    simulates DashScope's actual streaming behavior and asserts that
    the completed ToolUse retains the correct id, name, and arguments.
    
    Files changed: 1 (+148/-2)
    - crates/open_ai/src/completion.rs
    
    CLA signed.
    
- [x] I've reviewed my own diff for quality, security, and reliability
    - [x] Tests cover the new/changed behavior
    - [x] Performance impact has been considered and is acceptable
    
 
<img width="980" height="1392" alt="CleanShot 2026-04-26 at 00 03 20@2x"
src="https://github.com/user-attachments/assets/428a845b-82a0-44eb-9e43-1a351de6ca6a"
/>

After FIx
<img width="900" height="1398" alt="CleanShot 2026-04-26 at 00 02 15@2x"
src="https://github.com/user-attachments/assets/604e36fd-bf90-4549-9e60-8a927033d3e9"
/>

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
This commit is contained in:
kangxlandBennet Bo Fenner authored and GitHub committed 2026-05-11 13:47:30 +00:00
1 parent cb715fb374
commit febc3ebcb4
1 file changed
+148 -2
+148 -2
View File
@@ -461,12 +461,16 @@ impl OpenAiEventMapper {
for tool_call in tool_calls {
let entry = self.tool_calls_by_index.entry(tool_call.index).or_default();
if let Some(tool_id) = tool_call.id.clone() {
if let Some(tool_id) = tool_call.id.clone()
&& !tool_id.is_empty()
{
entry.id = tool_id;
}
if let Some(function) = tool_call.function.as_ref() {
if let Some(name) = function.name.clone() {
if let Some(name) = function.name.clone()
&& !name.is_empty()
{
entry.name = name;
}
@@ -848,6 +852,9 @@ mod tests {
use serde_json::json;
use super::*;
use crate::{
ChoiceDelta, FunctionChunk, ResponseMessageDelta, ResponseStreamEvent, ToolCallChunk,
};
fn map_response_events(events: Vec<ResponsesStreamEvent>) -> Vec<LanguageModelCompletionEvent> {
block_on(async {
@@ -861,6 +868,17 @@ mod tests {
})
}
fn map_completion_events(
events: Vec<ResponseStreamEvent>,
) -> Vec<LanguageModelCompletionEvent> {
let mut mapper = OpenAiEventMapper::new();
let mut all_events = Vec::new();
for event in events {
all_events.extend(mapper.map_event(event));
}
all_events.into_iter().filter_map(|e| e.ok()).collect()
}
fn response_item_message(id: &str) -> ResponseOutputItem {
ResponseOutputItem::Message(ResponseOutputMessage {
id: Some(id.to_string()),
@@ -1722,4 +1740,132 @@ mod tests {
])
);
}
#[test]
fn stream_maps_preserves_tool_id_and_name_across_empty_deltas() {
// DashScope sends id="" and name="" in subsequent tool_calls delta
// chunks after the first chunk. OpenAiEventMapper must not overwrite
// the accumulated id and name with these empty strings.
let events = vec![
// First chunk: id and name are present
ResponseStreamEvent {
choices: vec![ChoiceDelta {
index: 0,
delta: Some(ResponseMessageDelta {
role: None,
content: None,
tool_calls: Some(vec![ToolCallChunk {
index: 0,
id: Some("call_dashscope_test".into()),
function: Some(FunctionChunk {
name: Some("list_directory".into()),
arguments: Some("".into()),
}),
}]),
reasoning_content: None,
}),
finish_reason: None,
}],
usage: None,
},
// Subsequent chunks: DashScope sends id="" and name=""
ResponseStreamEvent {
choices: vec![ChoiceDelta {
index: 0,
delta: Some(ResponseMessageDelta {
role: None,
content: None,
tool_calls: Some(vec![ToolCallChunk {
index: 0,
id: Some("".into()),
function: Some(FunctionChunk {
name: Some("".into()),
arguments: Some("{\"path\": \"".into()),
}),
}]),
reasoning_content: None,
}),
finish_reason: None,
}],
usage: None,
},
ResponseStreamEvent {
choices: vec![ChoiceDelta {
index: 0,
delta: Some(ResponseMessageDelta {
role: None,
content: None,
tool_calls: Some(vec![ToolCallChunk {
index: 0,
id: Some("".into()),
function: Some(FunctionChunk {
name: Some("".into()),
arguments: Some("blog-scraper\"}".into()),
}),
}]),
reasoning_content: None,
}),
finish_reason: None,
}],
usage: None,
},
// Final chunk: finish_reason = "tool_calls"
ResponseStreamEvent {
choices: vec![ChoiceDelta {
index: 0,
delta: None,
finish_reason: Some("tool_calls".into()),
}],
usage: None,
},
];
let mapped = map_completion_events(events);
// Events emitted:
// 1. Partial ToolUse from chunk 1 (fix_json("") → "{}", parseable)
// 2. Partial ToolUse from chunk 3 (arguments fully assembled)
// 3. Complete ToolUse from finish_reason="tool_calls" drain
// 4. Stop(ToolUse)
assert_eq!(mapped.len(), 4);
// Verify the complete ToolUse event (from finish_reason drain)
// has the correct id, name, and accumulated arguments.
let complete_tool_use = mapped.iter().find_map(|event| {
if let LanguageModelCompletionEvent::ToolUse(tool_use) = event {
if tool_use.is_input_complete {
return Some(tool_use);
}
}
None
});
assert!(
complete_tool_use.is_some(),
"expected a completed ToolUse event"
);
let tool_use = complete_tool_use.unwrap();
assert_eq!(
tool_use.id.to_string(),
"call_dashscope_test",
"id must survive empty-string overwrites"
);
assert_eq!(
tool_use.name.as_ref(),
"list_directory",
"name must survive empty-string overwrites"
);
assert_eq!(
tool_use.raw_input, "{\"path\": \"blog-scraper\"}",
"arguments should accumulate across chunks"
);
// Verify the Stop event
assert!(mapped.iter().any(|event| {
matches!(
event,
LanguageModelCompletionEvent::Stop(StopReason::ToolUse)
)
}));
}
}