From 4d3b4b9b01efbca77872e3d4a568b273c7a245a7 Mon Sep 17 00:00:00 2001 From: Bugen Zhao Date: Thu, 25 Jun 2026 14:27:07 +0800 Subject: [PATCH] [Rust Frontend] Make `ToolParserOutput` a seq of `ToolParserEvent` to preserve order (#46584) Signed-off-by: Bugen Zhao --- rust/src/chat/src/output/default/unified.rs | 44 ++++++ rust/src/parser/benches/utils/mod.rs | 2 +- rust/src/parser/python/src/lib.rs | 28 ++-- .../src/tool/deepseek_dsml/deepseek_v32.rs | 88 ++++++------ .../src/tool/deepseek_dsml/deepseek_v4.rs | 16 +-- rust/src/parser/src/tool/deepseek_dsml/mod.rs | 6 +- .../src/tool/deepseek_json/deepseek_v3.rs | 65 +++++---- .../src/tool/deepseek_json/deepseek_v31.rs | 71 ++++----- rust/src/parser/src/tool/deepseek_json/mod.rs | 8 +- rust/src/parser/src/tool/gemma4.rs | 28 ++-- rust/src/parser/src/tool/glm_xml/glm47_moe.rs | 26 ++-- rust/src/parser/src/tool/glm_xml/mod.rs | 52 +++---- rust/src/parser/src/tool/hy_v3.rs | 85 +++++------ rust/src/parser/src/tool/json/granite4.rs | 136 ++++++++++-------- rust/src/parser/src/tool/json/hermes.rs | 67 ++++----- rust/src/parser/src/tool/json/internlm2.rs | 79 +++++----- rust/src/parser/src/tool/json/llama.rs | 87 +++++------ rust/src/parser/src/tool/json/mistral.rs | 70 ++++----- rust/src/parser/src/tool/json/mod.rs | 102 +++++++------ rust/src/parser/src/tool/json/phi4mini.rs | 95 ++++++------ rust/src/parser/src/tool/json/qwen.rs | 71 ++++----- rust/src/parser/src/tool/kimi_k2.rs | 87 +++++------ rust/src/parser/src/tool/minimax_m2.rs | 86 +++++------ rust/src/parser/src/tool/minimax_m3.rs | 116 +++++++++------ rust/src/parser/src/tool/mod.rs | 102 ++++++++++--- rust/src/parser/src/tool/qwen_coder.rs | 130 ++++++++--------- rust/src/parser/src/tool/test_utils.rs | 6 +- rust/src/parser/src/tool/tests.rs | 102 ++++++++++--- rust/src/parser/src/unified/combined.rs | 6 +- rust/src/parser/src/unified/mod.rs | 110 ++++++++++++-- tests/tool_parsers/test_rust_tool_parser.py | 2 +- vllm/tool_parsers/rust_tool_parser.py | 2 +- 32 files changed, 1155 insertions(+), 820 deletions(-) diff --git a/rust/src/chat/src/output/default/unified.rs b/rust/src/chat/src/output/default/unified.rs index 78320e796e9..66d2c5a204e 100644 --- a/rust/src/chat/src/output/default/unified.rs +++ b/rust/src/chat/src/output/default/unified.rs @@ -425,6 +425,18 @@ mod tests { } } + fn tool_call_arguments(arguments: &str) -> UnifiedParserOutput { + UnifiedParserOutput { + events: vec![vllm_parser::unified::UnifiedParserEvent::ToolCall( + ToolCallDelta { + tool_index: 0, + name: None, + arguments: arguments.to_string(), + }, + )], + } + } + fn combined(first: UnifiedParserOutput, second: UnifiedParserOutput) -> UnifiedParserOutput { let mut output = first; output.append(second); @@ -531,6 +543,38 @@ mod tests { ); } + #[tokio::test] + async fn unified_stream_emits_tool_arguments_before_trailing_text() { + let events = collect( + ScriptedParser::new([ + ScriptedStep::Output(tool_call("get_weather", "")), + ScriptedStep::Output(combined( + tool_call_arguments(r#"{"location":"Paris"}"#), + text(" done"), + )), + ]), + vec![decoded_delta("start"), decoded_delta("finish")], + ) + .await; + + assert_eq!( + events, + vec![ + AssistantEvent::ToolCallStart { + id: "call_test".to_string(), + name: "get_weather".to_string(), + }, + AssistantEvent::ToolCallArgumentsDelta { + delta: r#"{"location":"Paris"}"#.to_string(), + }, + AssistantEvent::TextDelta { + kind: AssistantBlockKind::Text, + delta: " done".to_string(), + }, + ] + ); + } + #[tokio::test] async fn unified_stream_fallback_keeps_committed_output_and_disables_later_parsing() { let events = collect( diff --git a/rust/src/parser/benches/utils/mod.rs b/rust/src/parser/benches/utils/mod.rs index 1acd1e51c0f..914766a79aa 100644 --- a/rust/src/parser/benches/utils/mod.rs +++ b/rust/src/parser/benches/utils/mod.rs @@ -23,7 +23,7 @@ pub(super) fn openai_tools(tools: &[Tool]) -> Vec { pub(super) fn feed_parser(parser: &mut dyn ToolParser, chunks: &[&str]) -> (String, usize) { let result = collect_stream(parser, chunks); - (result.normal_text, result.calls.len()) + (result.normal_text(), result.calls().len()) } pub(super) fn feed_external_parser( diff --git a/rust/src/parser/python/src/lib.rs b/rust/src/parser/python/src/lib.rs index e988ff3442b..4567348bcd9 100644 --- a/rust/src/parser/python/src/lib.rs +++ b/rust/src/parser/python/src/lib.rs @@ -146,30 +146,30 @@ impl PyToolParserOutput { #[new] #[pyo3(signature = (normal_text="", calls=None))] fn new(py: Python<'_>, normal_text: &str, calls: Option>>) -> Self { - let calls = - calls.unwrap_or_default().iter().map(|call| call.borrow(py).0.clone()).collect(); - Self(ToolParserOutput { - normal_text: normal_text.to_owned(), - calls, - }) + let mut output = ToolParserOutput::default(); + output.push_text(normal_text); + for call in calls.unwrap_or_default() { + output.push_call(call.borrow(py).0.clone()); + } + Self(output) } #[getter] - fn normal_text(&self) -> &str { - &self.0.normal_text + fn normal_text(&self) -> String { + self.0.normal_text() } #[getter] fn calls(&self) -> Vec { - self.0.calls.iter().cloned().map(PyToolCallDelta).collect() + self.0.calls().into_iter().cloned().map(PyToolCallDelta).collect() } fn append(&mut self, other: PyRef<'_, PyToolParserOutput>) { self.0.append(other.0.clone()); } - fn coalesce_calls(&self) -> Self { - Self(self.0.clone().coalesce_calls()) + fn coalesce(&self) -> Self { + Self(self.0.clone().coalesce()) } } @@ -300,7 +300,7 @@ mod tests { } #[test] - fn output_append_and_coalesce_calls() { + fn output_append_and_coalesce() { with_python(|py| { let first = Py::new( py, @@ -311,7 +311,7 @@ mod tests { let other = Py::new(py, PyToolParserOutput::new(py, "", Some(vec![second])))?; output.append(other.borrow(py)); - let coalesced = output.coalesce_calls(); + let coalesced = output.coalesce(); assert_eq!(coalesced.normal_text(), "text"); let calls = coalesced.calls(); assert_eq!(calls.len(), 1); @@ -334,7 +334,7 @@ mod tests { parser.parse_into_output(&build_call(), &mut output)?; let finish = Py::new(py, parser.finish()?)?; output.append(finish.borrow(py)); - let output = output.coalesce_calls(); + let output = output.coalesce(); assert_eq!(output.normal_text(), ""); let calls = output.calls(); diff --git a/rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs index 201b9dcba8b..7d3432f9e5c 100644 --- a/rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs +++ b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs @@ -90,8 +90,8 @@ mod tests { let mut parser = DeepSeekV32ToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -104,11 +104,11 @@ mod tests { )) .unwrap(); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF", "date": "2024-01-16" @@ -125,8 +125,8 @@ mod tests { ); let output = parser.parse_complete(&output).unwrap(); - assert_eq!(output.normal_text, "Thinking... "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Thinking... "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -146,9 +146,9 @@ mod tests { ) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "whole": 5.0, "flag": true, @@ -176,9 +176,9 @@ mod tests { ) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "whole": "5.0", "flag": "true", @@ -206,7 +206,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "Hangzhou </|DSML|parameter></|DSML|invoke></|DSML|function_calls>", "date": "2026-05-08", @@ -228,11 +228,11 @@ mod tests { ], ); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF" }) ); } @@ -252,8 +252,8 @@ mod tests { ], ); - assert_eq!(output.normal_text, "Thinking... "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Thinking... "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -261,8 +261,8 @@ mod tests { let mut parser = DeepSeekV32ToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &["Hello, ", "world!"]); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -278,17 +278,17 @@ mod tests { )], ); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[1].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[1].tool_index, 1); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[1].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[1].tool_index, 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF" }) ); assert_eq!( - serde_json::from_str::(&output.calls[1].arguments).unwrap(), + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), json!({ "location": "NYC" }) ); } @@ -300,9 +300,9 @@ mod tests { let mut parser = DeepSeekV32ToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF" }) ); } @@ -337,11 +337,11 @@ mod tests { ], ); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "Beijing" }) ); } @@ -373,9 +373,9 @@ mod tests { ], ); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); } #[test] @@ -393,8 +393,8 @@ mod tests { ], ); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); } #[test] @@ -421,10 +421,10 @@ mod tests { .parse_complete(&build_tool_call("get_weather", &[("location", "NYC")])) .unwrap(); - assert_eq!(first.calls.len(), 1); - assert_eq!(second.calls.len(), 1); + assert_eq!(first.calls().len(), 1); + assert_eq!(second.calls().len(), 1); assert_eq!( - serde_json::from_str::(&second.calls[0].arguments).unwrap(), + serde_json::from_str::(&second.calls()[0].arguments).unwrap(), json!({ "location": "NYC" }) ); } @@ -439,7 +439,7 @@ mod tests { let mut parser = DeepSeekV32ToolParser::new(&test_tools()); let complete = parser.parse_complete(&full_text).unwrap(); - assert_eq!(streamed.normal_text, complete.normal_text); - assert_eq!(streamed.calls, complete.calls); + assert_eq!(streamed.normal_text(), complete.normal_text()); + assert_eq!(streamed.calls(), complete.calls()); } } diff --git a/rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs index 344dfff5542..a0493932766 100644 --- a/rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs +++ b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs @@ -107,11 +107,11 @@ mod tests { )) .unwrap(); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF", "date": "2024-01-16" @@ -137,11 +137,11 @@ mod tests { ], ); - assert_eq!(output.normal_text, "Thinking... "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.normal_text(), "Thinking... "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "Beijing" }) ); } diff --git a/rust/src/parser/src/tool/deepseek_dsml/mod.rs b/rust/src/parser/src/tool/deepseek_dsml/mod.rs index add70c8d5a9..b49fb1de8b5 100644 --- a/rust/src/parser/src/tool/deepseek_dsml/mod.rs +++ b/rust/src/parser/src/tool/deepseek_dsml/mod.rs @@ -92,7 +92,7 @@ impl DeepSeekDsmlToolParser { fn apply_event(&mut self, event: DsmlEvent, output: &mut ToolParserOutput) -> Result<()> { match event { DsmlEvent::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); } DsmlEvent::ToolCallsStart => { self.mode = DsmlMode::ToolBlock { @@ -116,7 +116,7 @@ impl DeepSeekDsmlToolParser { let arguments = serde_json::to_string(&arguments) .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index: self.emitted_invoke_count, name: Some(name), arguments, @@ -156,7 +156,7 @@ impl DeepSeekDsmlToolParser { fn finish(&mut self) -> Result { let mut output = ToolParserOutput::default(); match self.mode { - DsmlMode::Text => output.normal_text.push_str(&self.buffer), + DsmlMode::Text => output.push_text(&self.buffer), DsmlMode::Done => {} DsmlMode::ToolBlock { .. } => { return Err(parsing_failed!("incomplete DeepSeek DSML tool call")); diff --git a/rust/src/parser/src/tool/deepseek_json/deepseek_v3.rs b/rust/src/parser/src/tool/deepseek_json/deepseek_v3.rs index 5b5147450c1..ea1a660ccec 100644 --- a/rust/src/parser/src/tool/deepseek_json/deepseek_v3.rs +++ b/rust/src/parser/src/tool/deepseek_json/deepseek_v3.rs @@ -77,8 +77,8 @@ mod tests { let mut parser = DeepSeekV3ToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -92,11 +92,11 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Let me check.\n"); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.normal_text(), "Let me check.\n"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -107,7 +107,7 @@ mod tests { .parse_complete(&tool_section(&[v3_tool_call("get_weather", arguments)])) .unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -132,7 +132,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -143,7 +143,7 @@ mod tests { assert_eq!(observed_arguments, ["{\"location\":", "\"Beijing\"", "}"]); assert_eq!( - output.coalesce_calls().calls[0].arguments, + output.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -159,9 +159,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -172,8 +172,8 @@ mod tests { let output = parser.parse_complete(&input).unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -189,22 +189,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] diff --git a/rust/src/parser/src/tool/deepseek_json/deepseek_v31.rs b/rust/src/parser/src/tool/deepseek_json/deepseek_v31.rs index bf89fb4e841..cf2ea196282 100644 --- a/rust/src/parser/src/tool/deepseek_json/deepseek_v31.rs +++ b/rust/src/parser/src/tool/deepseek_json/deepseek_v31.rs @@ -70,8 +70,8 @@ mod tests { let mut parser = DeepSeekV31ToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -85,11 +85,11 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Let me check."); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.normal_text(), "Let me check."); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -100,7 +100,7 @@ mod tests { .parse_complete(&tool_section(&[v31_tool_call("get_weather", arguments)])) .unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -123,7 +123,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -134,7 +134,7 @@ mod tests { assert_eq!(observed_arguments, ["{\"location\":", "\"Beijing\"", "}"]); assert_eq!( - output.coalesce_calls().calls[0].arguments, + output.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -150,9 +150,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -163,8 +163,8 @@ mod tests { let output = parser.parse_complete(&input).unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -180,22 +180,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -212,9 +215,9 @@ mod tests { let output = collect_stream(&mut parser, &[&input]); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] diff --git a/rust/src/parser/src/tool/deepseek_json/mod.rs b/rust/src/parser/src/tool/deepseek_json/mod.rs index 0f0d04f0428..c6fce9fec67 100644 --- a/rust/src/parser/src/tool/deepseek_json/mod.rs +++ b/rust/src/parser/src/tool/deepseek_json/mod.rs @@ -96,7 +96,7 @@ impl DeepSeekJsonToolParser { ) -> Result<()> { match event { DeepSeekJsonEvent::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); } DeepSeekJsonEvent::ToolCallsStart => self.mode = DeepSeekJsonMode::ToolBlock, DeepSeekJsonEvent::ToolCallStart => self.mode = DeepSeekJsonMode::Header, @@ -107,7 +107,7 @@ impl DeepSeekJsonToolParser { self.mode = DeepSeekJsonMode::Arguments { json_scan: JsonObjectScanState::default(), }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: Some(function_name), arguments: String::new(), @@ -120,7 +120,7 @@ impl DeepSeekJsonToolParser { self.format.parser_name() )); }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: None, arguments: self.buffer[..consumed_len].to_string(), @@ -155,7 +155,7 @@ impl DeepSeekJsonToolParser { fn finish(&mut self) -> Result { let mut output = ToolParserOutput::default(); match &self.mode { - DeepSeekJsonMode::Text => output.normal_text.push_str(&self.buffer), + DeepSeekJsonMode::Text => output.push_text(&self.buffer), DeepSeekJsonMode::ToolBlock | DeepSeekJsonMode::Done => {} DeepSeekJsonMode::Header | DeepSeekJsonMode::Arguments { .. } => { return Err(parsing_failed!( diff --git a/rust/src/parser/src/tool/gemma4.rs b/rust/src/parser/src/tool/gemma4.rs index 09d79fd8bcf..e5a95485ce8 100644 --- a/rust/src/parser/src/tool/gemma4.rs +++ b/rust/src/parser/src/tool/gemma4.rs @@ -70,7 +70,7 @@ impl Gemma4ToolParser { fn apply_event(&mut self, event: Gemma4Event, output: &mut ToolParserOutput) -> Result<()> { match event { Gemma4Event::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); } Gemma4Event::ToolCallStart => self.mode = Gemma4Mode::Header, Gemma4Event::ToolCallHeader { name } => { @@ -89,7 +89,7 @@ impl Gemma4ToolParser { let arguments = serde_json::to_string(&args) .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index: self.emitted_tool_count, name: Some(name), arguments, @@ -153,7 +153,7 @@ impl ToolParser for Gemma4ToolParser { let mut output = ToolParserOutput::default(); match &self.mode { - Gemma4Mode::Text => output.normal_text.push_str(&self.buffer), + Gemma4Mode::Text => output.push_text(&self.buffer), Gemma4Mode::Header | Gemma4Mode::ToolCall { .. } => { return Err(parsing_failed!("incomplete Gemma4 tool call")); } @@ -501,11 +501,11 @@ mod tests { output.append(parser.parse_chunk(chunk).unwrap()); } output.append(parser.finish().unwrap()); - output.coalesce_calls() + output.coalesce() } - fn first_call(output: &ToolParserOutput) -> &ToolCallDelta { - output.calls.first().expect("expected one tool call") + fn first_call(output: &ToolParserOutput) -> ToolCallDelta { + (*output.calls().first().expect("expected one tool call")).clone() } #[test] @@ -547,8 +547,8 @@ mod tests { .parse_complete("<|tool_call>call:get_weather{location:<|\"|>London<|\"|>}") .unwrap(); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); assert_eq!(first_call(&output).name.as_deref(), Some("get_weather")); assert_eq!( serde_json::from_str::(&first_call(&output).arguments).unwrap(), @@ -577,7 +577,7 @@ mod tests { "", ]); - assert!(output.normal_text.is_empty()); + assert!(output.normal_text().is_empty()); assert_eq!(first_call(&output).name.as_deref(), Some("get_weather")); assert_eq!( serde_json::from_str::(&first_call(&output).arguments).unwrap(), @@ -597,7 +597,7 @@ mod tests { "div>", ]); - assert_eq!(output.normal_text, "Let me check the weather.
"); + assert_eq!(output.normal_text(), "Let me check the weather.
"); assert_eq!(first_call(&output).name.as_deref(), Some("get_weather")); assert_eq!( serde_json::from_str::(&first_call(&output).arguments).unwrap(), @@ -616,11 +616,11 @@ mod tests { "location:<|\"|>Paris<|\"|>}", ] { output.append(parser.parse_chunk(chunk).unwrap()); - assert!(output.calls.is_empty()); + assert!(output.calls().is_empty()); } output.append(parser.parse_chunk("").unwrap()); - let output = output.coalesce_calls(); + let output = output.coalesce(); assert_eq!(first_call(&output).name.as_deref(), Some("get_weather")); assert_eq!( @@ -777,8 +777,8 @@ mod tests { let mut output = parser.parse_chunk("<").unwrap(); output.append(parser.finish().unwrap()); - assert_eq!(output.normal_text, "<"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "<"); + assert!(output.calls().is_empty()); } #[test] diff --git a/rust/src/parser/src/tool/glm_xml/glm47_moe.rs b/rust/src/parser/src/tool/glm_xml/glm47_moe.rs index 0e8135fdc52..ac1a9d6ac6d 100644 --- a/rust/src/parser/src/tool/glm_xml/glm47_moe.rs +++ b/rust/src/parser/src/tool/glm_xml/glm47_moe.rs @@ -69,11 +69,11 @@ mod tests { let output = parser.parse_complete(&output).unwrap(); - assert_eq!(output.normal_text, "Let me search for that.\n"); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.normal_text(), "Let me search for that.\n"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({"city": "Beijing", "date": "2024-12-25"}) ); } @@ -90,12 +90,12 @@ mod tests { let chunks = split_by_chars(&output, 7); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, ""); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[1].name.as_deref(), Some("add")); + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[1].name.as_deref(), Some("add")); assert_eq!( - serde_json::from_str::(&output.calls[1].arguments).unwrap(), + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), json!({"x": 1, "y": 2}) ); } @@ -117,7 +117,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "whole": 42, "flag": true, @@ -134,10 +134,10 @@ mod tests { let output = parser.parse_complete("add").unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("add")); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("add")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({}) ); } diff --git a/rust/src/parser/src/tool/glm_xml/mod.rs b/rust/src/parser/src/tool/glm_xml/mod.rs index 7b4cacbb99e..cc175aeb641 100644 --- a/rust/src/parser/src/tool/glm_xml/mod.rs +++ b/rust/src/parser/src/tool/glm_xml/mod.rs @@ -79,7 +79,7 @@ impl GlmXmlToolParser { fn apply_event(&mut self, event: GlmEvent, output: &mut ToolParserOutput) -> Result<()> { match event { GlmEvent::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); } GlmEvent::ToolCallStart => { self.mode = GlmMode::ToolCall { @@ -92,7 +92,7 @@ impl GlmXmlToolParser { let arguments = serde_json::to_string(&arguments) .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index: self.emitted_tool_count, name: Some(name), arguments, @@ -127,7 +127,7 @@ impl GlmXmlToolParser { let mut output = ToolParserOutput::default(); if !self.buffer.is_empty() { match self.mode { - GlmMode::Text => output.normal_text.push_str(&self.buffer), + GlmMode::Text => output.push_text(&self.buffer), GlmMode::ToolCall { .. } => { return Err(parsing_failed!("incomplete GLM MoE tool call")); } @@ -283,8 +283,8 @@ mod tests { let mut parser = Glm45MoeToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -300,11 +300,11 @@ mod tests { let output = parser.parse_complete(&output).unwrap(); - assert_eq!(output.normal_text, "Let me search for that.\n"); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.normal_text(), "Let me search for that.\n"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({"city": "Beijing", "date": "2024-12-25"}) ); } @@ -321,12 +321,12 @@ mod tests { let chunks = split_by_chars(&output, 11); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, ""); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[1].name.as_deref(), Some("add")); + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[1].name.as_deref(), Some("add")); assert_eq!( - serde_json::from_str::(&output.calls[1].arguments).unwrap(), + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), json!({"x": 1, "y": 2}) ); } @@ -345,7 +345,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Paris </arg_value></tool_call>", "date": "2026-05-08", @@ -359,8 +359,8 @@ mod tests { let output = collect_stream(&mut parser, &["hello ", "world"]); - assert_eq!(output.normal_text, "hello world"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "hello world"); + assert!(output.calls().is_empty()); } #[test] @@ -375,8 +375,8 @@ mod tests { ], ); - assert_eq!(output.normal_text, "Prefix "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Prefix "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -391,9 +391,9 @@ mod tests { ], ); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); } #[test] @@ -402,8 +402,8 @@ mod tests { let output = parser.parse_chunk("get_weather\ncity").unwrap(); - assert_eq!(output.normal_text, ""); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), ""); + assert!(output.calls().is_empty()); } #[test] @@ -437,7 +437,7 @@ mod tests { )], ); - assert_eq!(output.normal_text, ""); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 1); } } diff --git a/rust/src/parser/src/tool/hy_v3.rs b/rust/src/parser/src/tool/hy_v3.rs index 94b0c3a9308..566df28d320 100644 --- a/rust/src/parser/src/tool/hy_v3.rs +++ b/rust/src/parser/src/tool/hy_v3.rs @@ -79,7 +79,7 @@ impl HyV3ToolParser { fn apply_event(&mut self, event: HyV3Event, output: &mut ToolParserOutput) -> Result<()> { match event { HyV3Event::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); } HyV3Event::ToolBlockStart => { self.mode = HyV3Mode::ToolBlock { @@ -91,7 +91,7 @@ impl HyV3ToolParser { let arguments = serde_json::to_string(&arguments) .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index: self.emitted_tool_count, name: Some(name), arguments, @@ -133,7 +133,7 @@ impl ToolParser for HyV3ToolParser { fn finish(&mut self) -> Result { let mut output = ToolParserOutput::default(); match self.mode { - HyV3Mode::Text => output.normal_text.push_str(&self.buffer), + HyV3Mode::Text => output.push_text(&self.buffer), HyV3Mode::ToolBlock { .. } => return Err(parsing_failed!("incomplete HY3 tool call")), HyV3Mode::Done => {} } @@ -266,7 +266,7 @@ mod tests { } fn parsed_arguments(output: &ToolParserOutput, index: usize) -> Value { - serde_json::from_str(&output.calls[index].arguments).unwrap() + serde_json::from_str(&output.calls()[index].arguments).unwrap() } #[test] @@ -281,8 +281,8 @@ mod tests { let mut parser = HyV3ToolParser::new(&test_tools()); let output = parser.parse_complete("This is a plain response.").unwrap(); - assert_eq!(output.normal_text, "This is a plain response."); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "This is a plain response."); + assert!(output.calls().is_empty()); } #[test] @@ -294,9 +294,9 @@ mod tests { ) .unwrap(); - assert_eq!(output.normal_text, ""); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date")); + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_current_date")); assert_eq!(parsed_arguments(&output, 0), json!({})); } @@ -309,7 +309,7 @@ mod tests { ) .unwrap(); - assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date")); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_current_date")); assert_eq!(parsed_arguments(&output, 0), json!({})); } @@ -354,8 +354,8 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Checking."); - assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date")); + assert_eq!(output.normal_text(), "Checking."); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_current_date")); } #[test] @@ -376,22 +376,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"city\":\"Beijing\",\"date\":\"2026-03-30\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "get_weather", - ), - arguments: "{\"city\":\"Hangzhou\",\"date\":\"2026-03-30\"}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"city\":\"Beijing\",\"date\":\"2026-03-30\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "get_weather", + ), + arguments: "{\"city\":\"Hangzhou\",\"date\":\"2026-03-30\"}", + }, + ), ], } "#]] @@ -434,8 +437,8 @@ mod tests { output.append(parser.parse_chunk("response.").unwrap()); output.append(parser.finish().unwrap()); - assert_eq!(output.normal_text, "This is a plain response."); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "This is a plain response."); + assert!(output.calls().is_empty()); } #[test] @@ -452,8 +455,8 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date")); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_current_date")); assert_eq!(parsed_arguments(&output, 0), json!({})); } @@ -475,8 +478,8 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( parsed_arguments(&output, 0), json!({ "city": "Beijing", "date": "2026-03-30" }) @@ -498,8 +501,8 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "Checking."); - assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date")); + assert_eq!(output.normal_text(), "Checking."); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_current_date")); } #[test] @@ -519,7 +522,7 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 2); + assert_eq!(output.calls().len(), 2); assert_eq!(parsed_arguments(&output, 0)["city"], json!("Beijing")); assert_eq!(parsed_arguments(&output, 1)["city"], json!("Hangzhou")); } @@ -535,8 +538,8 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); assert_eq!(parsed_arguments(&output, 0), json!({ "city": "Beijing" })); } @@ -552,8 +555,8 @@ mod tests { ) .unwrap(); - assert_eq!(output.normal_text, ""); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), ""); + assert!(output.calls().is_empty()); } #[test] diff --git a/rust/src/parser/src/tool/json/granite4.rs b/rust/src/parser/src/tool/json/granite4.rs index 112bd5660e5..fe1cf190225 100644 --- a/rust/src/parser/src/tool/json/granite4.rs +++ b/rust/src/parser/src/tool/json/granite4.rs @@ -86,14 +86,14 @@ impl Granite4ToolParser { /// Apply one parsed Granite 4 event to parser state and output. fn apply_event(&mut self, event: Granite4Event, output: &mut ToolParserOutput) -> Result<()> { match event { - Granite4Event::Text { len } => output.normal_text.push_str(&self.buffer[..len]), + Granite4Event::Text { len } => output.push_text(&self.buffer[..len]), Granite4Event::ToolCallStart => self.mode = Granite4Mode::Header, Granite4Event::ToolCallHeader { function_name } => { let tool_index = self.emitted_tool_count; self.emitted_tool_count += 1; self.active_tool_index = Some(tool_index); self.mode = Granite4Mode::Args { json_scan: None }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: Some(function_name), arguments: String::new(), @@ -125,7 +125,7 @@ impl Granite4ToolParser { "Granite4 arguments without an active tool call" )); }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: None, arguments, @@ -165,7 +165,7 @@ impl ToolParser for Granite4ToolParser { fn finish(&mut self) -> Result { let mut output = ToolParserOutput::default(); match &self.mode { - Granite4Mode::Text => output.normal_text.push_str(&self.buffer), + Granite4Mode::Text => output.push_text(&self.buffer), Granite4Mode::Header | Granite4Mode::Args { .. } | Granite4Mode::Close => { return Err(parsing_failed!("incomplete Granite4 tool call")); } @@ -287,8 +287,8 @@ mod tests { let mut parser = Granite4ToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -300,10 +300,10 @@ mod tests { ) .unwrap(); - assert_eq!(output.normal_text, ""); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, r#"{"city":"Boston"}"#); + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, r#"{"city":"Boston"}"#); } #[test] @@ -317,9 +317,9 @@ mod tests { ) .unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, r#"{"city":"Boston"}"#); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, r#"{"city":"Boston"}"#); } #[test] @@ -333,22 +333,28 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "before middle after", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "find_bbox", - ), - arguments: "{\"x\":1}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "get_weather", - ), - arguments: "{\"city\":\"Boston\"}", - }, + events: [ + Text( + "before middle after", + ), + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "find_bbox", + ), + arguments: "{\"x\":1}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "get_weather", + ), + arguments: "{\"city\":\"Boston\"}", + }, + ), ], } "#]] @@ -363,10 +369,10 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello bye"); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, r#"{"city":"Tokyo"}"#); + assert_eq!(output.normal_text(), "hello bye"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, r#"{"city":"Tokyo"}"#); } #[test] @@ -385,7 +391,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -396,7 +402,7 @@ mod tests { assert_eq!(observed_arguments, [r#"{"city":"#, r#""Beijing""#, r#"}"#]); assert_eq!( - output.coalesce_calls().calls[0].arguments, + output.coalesce().calls()[0].arguments, r#"{"city":"Beijing"}"# ); } @@ -409,9 +415,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("f")); - assert_eq!(output.calls[0].arguments, r#"{"a":1}"#); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("f")); + assert_eq!(output.calls()[0].arguments, r#"{"a":1}"#); } #[test] @@ -435,29 +441,37 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "Here goes the bbox call: \n Now the stock price call: \n Now another bbox call: \n See? I'm a helpful assistant.", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "find_bbox", - ), - arguments: "{\"coordinates\": [[23.54, 43.1], [-12.2, 54.3], [4, 5]], \"coordinate_type\": \"latlong\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "get_stock_price", - ), - arguments: "{\"symbol\": \"AAPL\", \"start_date\": \"2021-01-01\", \"end_date\": \"2021-12-31\"}", - }, - ToolCallDelta { - tool_index: 2, - name: Some( - "find_bbox", - ), - arguments: "{\"coordinates\": [[23.54, 43.1], [-12.2, 54.3], [4, 5]], \"coordinate_type\": \"latlong\"}", - }, + events: [ + Text( + "Here goes the bbox call: \n Now the stock price call: \n Now another bbox call: \n See? I'm a helpful assistant.", + ), + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "find_bbox", + ), + arguments: "{\"coordinates\": [[23.54, 43.1], [-12.2, 54.3], [4, 5]], \"coordinate_type\": \"latlong\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "get_stock_price", + ), + arguments: "{\"symbol\": \"AAPL\", \"start_date\": \"2021-01-01\", \"end_date\": \"2021-12-31\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 2, + name: Some( + "find_bbox", + ), + arguments: "{\"coordinates\": [[23.54, 43.1], [-12.2, 54.3], [4, 5]], \"coordinate_type\": \"latlong\"}", + }, + ), ], } "#]].assert_debug_eq(&output); diff --git a/rust/src/parser/src/tool/json/hermes.rs b/rust/src/parser/src/tool/json/hermes.rs index 227c0fec16a..817eaee91f1 100644 --- a/rust/src/parser/src/tool/json/hermes.rs +++ b/rust/src/parser/src/tool/json/hermes.rs @@ -80,8 +80,8 @@ mod tests { let mut parser = HermesToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -95,11 +95,11 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Let me check.\n"); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.normal_text(), "Let me check.\n"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -112,8 +112,8 @@ mod tests { ) .unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); } #[test] @@ -122,7 +122,7 @@ mod tests { let arguments = r#"{"location":"Tokyo",}"#; let output = parser.parse_complete(&build_tool_call("get_weather", arguments)).unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -142,7 +142,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -152,9 +152,9 @@ mod tests { output.append(parser.finish().unwrap()); assert_eq!(observed_arguments, ["{\"location\":", "\"Beijing\"", "}"]); - assert_eq!(output.normal_text, "preface suffix"); + assert_eq!(output.normal_text(), "preface suffix"); assert_eq!( - output.coalesce_calls().calls[0].arguments, + output.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -170,9 +170,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -189,22 +189,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] diff --git a/rust/src/parser/src/tool/json/internlm2.rs b/rust/src/parser/src/tool/json/internlm2.rs index da957fd0614..aae3b9f6a02 100644 --- a/rust/src/parser/src/tool/json/internlm2.rs +++ b/rust/src/parser/src/tool/json/internlm2.rs @@ -140,8 +140,8 @@ mod tests { let mut parser = Internlm2ToolParser::new(&test_tools()); let result = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(result.normal_text, "Hello, world!"); - assert!(result.calls.is_empty()); + assert_eq!(result.normal_text(), "Hello, world!"); + assert!(result.calls().is_empty()); } #[test] @@ -155,11 +155,11 @@ mod tests { )) .unwrap(); - assert_eq!(result.normal_text, "Let me check.\n"); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].tool_index, 0); - assert_eq!(result.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(result.calls[0].arguments, arguments); + assert_eq!(result.normal_text(), "Let me check.\n"); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].tool_index, 0); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[0].arguments, arguments); } #[test] @@ -170,9 +170,9 @@ mod tests { .parse_complete(&build_tool_call("get_weather", "arguments", arguments)) .unwrap(); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(result.calls[0].arguments, arguments); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[0].arguments, arguments); } #[test] @@ -185,8 +185,8 @@ mod tests { )) .unwrap(); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); } #[test] @@ -197,7 +197,7 @@ mod tests { .parse_complete(&build_tool_call("get_weather", "parameters", arguments)) .unwrap(); - assert_eq!(result.calls[0].arguments, arguments); + assert_eq!(result.calls()[0].arguments, arguments); } #[test] @@ -218,7 +218,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -231,9 +231,9 @@ mod tests { observed_arguments, [r#"{"location":"#, r#""Beijing""#, r#"}"#] ); - assert_eq!(result.normal_text, "preface suffix"); + assert_eq!(result.normal_text(), "preface suffix"); assert_eq!( - result.coalesce_calls().calls[0].arguments, + result.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -249,9 +249,9 @@ mod tests { let result = collect_stream(&mut parser, &chunks); - assert_eq!(result.normal_text, "hello "); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert_eq!(result.normal_text(), "hello "); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -268,22 +268,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -298,8 +301,8 @@ mod tests { let result = parser.parse_complete(&input).unwrap(); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].arguments, arguments); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].arguments, arguments); } #[test] @@ -313,7 +316,7 @@ mod tests { let error = parser.finish().unwrap_err(); assert_eq!( - pre_finish.calls[0].name.as_deref(), + pre_finish.calls()[0].name.as_deref(), Some("get_weather"), "name delta is still emitted from parse_chunk() before truncation", ); diff --git a/rust/src/parser/src/tool/json/llama.rs b/rust/src/parser/src/tool/json/llama.rs index 7bfcb8ac1c9..b736f27e306 100644 --- a/rust/src/parser/src/tool/json/llama.rs +++ b/rust/src/parser/src/tool/json/llama.rs @@ -87,7 +87,7 @@ impl Llama3JsonToolParser { self.mode = LlamaJsonMode::Arguments { json_scan: JsonObjectScanState::default(), }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: Some(function_name), arguments: String::new(), @@ -99,7 +99,7 @@ impl Llama3JsonToolParser { "Llama JSON arguments without an active tool call" )); }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: None, arguments: self.buffer[..consumed_len].to_string(), @@ -145,7 +145,7 @@ impl ToolParser for Llama3JsonToolParser { } if matches!(self.mode, LlamaJsonMode::Passthrough) { - output.normal_text.push_str(&self.buffer); + output.push_text(&self.buffer); self.buffer.clear(); return Ok(()); } @@ -164,7 +164,7 @@ impl ToolParser for Llama3JsonToolParser { let mut output = ToolParserOutput::default(); match &self.mode { LlamaJsonMode::Start | LlamaJsonMode::Passthrough => { - output.normal_text.push_str(&self.buffer); + output.push_text(&self.buffer); } LlamaJsonMode::AfterCall if self.buffer.trim().is_empty() => {} LlamaJsonMode::Header | LlamaJsonMode::Arguments { .. } => { @@ -268,8 +268,8 @@ mod tests { let mut parser = Llama3JsonToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -284,10 +284,10 @@ mod tests { output.append(parser.finish().unwrap()); assert_eq!( - output.normal_text, + output.normal_text(), r#"plain text first {"name":"get_weather","parameters":{"location":"Tokyo"}}"# ); - assert!(output.calls.is_empty()); + assert!(output.calls().is_empty()); } #[test] @@ -299,8 +299,8 @@ mod tests { ); let output = parser.parse_complete(&input).unwrap(); - assert_eq!(output.normal_text, input); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), input); + assert!(output.calls().is_empty()); } #[test] @@ -312,8 +312,8 @@ mod tests { ); let output = parser.parse_complete(&input).unwrap(); - assert_eq!(output.normal_text, input); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), input); + assert!(output.calls().is_empty()); } #[test] @@ -322,10 +322,10 @@ mod tests { let arguments = r#"{ "location": "Tokyo", "days": 3 }"#; let output = parser.parse_complete(&build_tool_call("get_weather", arguments)).unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -353,22 +353,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -390,7 +393,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -401,7 +404,7 @@ mod tests { assert_eq!(observed_arguments, ["{\"location\":", "\"Beijing\"", "}"]); assert_eq!( - output.coalesce_calls().calls[0].arguments, + output.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -418,14 +421,14 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, ""); - assert_eq!(output.calls.len(), 2); + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 2); assert_eq!( - output.calls[0].arguments, + output.calls()[0].arguments, r#"{"location":"Dallas","state":"TX"}"# ); - assert_eq!(output.calls[1].name.as_deref(), Some("add")); - assert_eq!(output.calls[1].arguments, r#"{"x":4,"y":5}"#); + assert_eq!(output.calls()[1].name.as_deref(), Some("add")); + assert_eq!(output.calls()[1].arguments, r#"{"x":4,"y":5}"#); } #[test] @@ -437,7 +440,7 @@ mod tests { }"#; let output = parser.parse_complete(&build_tool_call("convert", arguments)).unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -450,8 +453,8 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, ""); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 1); } #[test] diff --git a/rust/src/parser/src/tool/json/mistral.rs b/rust/src/parser/src/tool/json/mistral.rs index c8d1f51ff71..8a20b4db7b8 100644 --- a/rust/src/parser/src/tool/json/mistral.rs +++ b/rust/src/parser/src/tool/json/mistral.rs @@ -77,8 +77,8 @@ mod tests { let mut parser = MistralToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -92,11 +92,11 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Let me check.\n"); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.normal_text(), "Let me check.\n"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -115,22 +115,28 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "I'll help.\n", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"city\": \"Tokyo\", \"units\": \"celsius\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\": 1, \"y\": 2}", - }, + events: [ + Text( + "I'll help.\n", + ), + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"city\": \"Tokyo\", \"units\": \"celsius\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\": 1, \"y\": 2}", + }, + ), ], } "#]] @@ -148,7 +154,7 @@ mod tests { )])) .unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -168,7 +174,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -178,9 +184,9 @@ mod tests { output.append(parser.finish().unwrap()); assert_eq!(observed_arguments, ["{\"location\":", "\"Beijing\"", "}"]); - assert_eq!(output.normal_text, "preface suffix"); + assert_eq!(output.normal_text(), "preface suffix"); assert_eq!( - output.coalesce_calls().calls[0].arguments, + output.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -196,9 +202,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -209,8 +215,8 @@ mod tests { .parse_complete(&build_tool_calls(&[build_tool_call("echo", arguments)])) .unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] diff --git a/rust/src/parser/src/tool/json/mod.rs b/rust/src/parser/src/tool/json/mod.rs index d7d42c0cecf..6a701de435e 100644 --- a/rust/src/parser/src/tool/json/mod.rs +++ b/rust/src/parser/src/tool/json/mod.rs @@ -106,7 +106,7 @@ impl JsonToolCallParser { fn finish(&mut self) -> Result { let mut output = ToolParserOutput::default(); match &self.mode { - JsonToolCallMode::Text => output.normal_text.push_str(&self.buffer), + JsonToolCallMode::Text => output.push_text(&self.buffer), JsonToolCallMode::Header | JsonToolCallMode::Arguments { .. } => { return Err(parsing_failed!( "incomplete {} tool call", @@ -126,7 +126,7 @@ impl JsonToolCallParser { ) -> Result<()> { match event { JsonToolCallEvent::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); } JsonToolCallEvent::ToolCallStart => self.mode = JsonToolCallMode::Header, JsonToolCallEvent::ToolCallHeader { function_name } => { @@ -136,7 +136,7 @@ impl JsonToolCallParser { self.mode = JsonToolCallMode::Arguments { json_scan: JsonObjectScanState::default(), }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: Some(function_name), arguments: String::new(), @@ -149,7 +149,7 @@ impl JsonToolCallParser { self.config.parser_name )); }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: None, arguments: self.buffer[..consumed_len].to_string(), @@ -400,7 +400,7 @@ mod tests { parser.parse_into(chunk, &mut output).unwrap(); } output.append(parser.finish().unwrap()); - output.coalesce_calls() + output.coalesce() } #[test] @@ -415,22 +415,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -451,22 +454,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -486,15 +492,19 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: " trailing text", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, + events: [ + Text( + " trailing text", + ), + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), ], } "#]] diff --git a/rust/src/parser/src/tool/json/phi4mini.rs b/rust/src/parser/src/tool/json/phi4mini.rs index 6e83d4374bf..3f259c2d7fe 100644 --- a/rust/src/parser/src/tool/json/phi4mini.rs +++ b/rust/src/parser/src/tool/json/phi4mini.rs @@ -87,8 +87,8 @@ mod tests { let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); let result = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(result.normal_text, "Hello, world!"); - assert!(result.calls.is_empty()); + assert_eq!(result.normal_text(), "Hello, world!"); + assert!(result.calls().is_empty()); } #[test] @@ -99,10 +99,10 @@ mod tests { .parse_complete(&wrap(&[build_call("get_weather", "arguments", arguments)])) .unwrap(); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].tool_index, 0); - assert_eq!(result.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(result.calls[0].arguments, arguments); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].tool_index, 0); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[0].arguments, arguments); } #[test] @@ -113,9 +113,9 @@ mod tests { .parse_complete(&wrap(&[build_call("get_weather", "parameters", arguments)])) .unwrap(); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(result.calls[0].arguments, arguments); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[0].arguments, arguments); } #[test] @@ -130,22 +130,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -162,8 +165,8 @@ mod tests { .parse_complete(&wrap(&[build_call("convert", "arguments", arguments)])) .unwrap(); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].arguments, arguments); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].arguments, arguments); } /// Preface text before a tool call is preserved as normal_text, consistent @@ -182,8 +185,8 @@ mod tests { let result = parser.parse_complete(&input).unwrap(); - assert_eq!(result.normal_text, "Let me check.\n"); - assert_eq!(result.calls.len(), 1); + assert_eq!(result.normal_text(), "Let me check.\n"); + assert_eq!(result.calls().len(), 1); } #[test] @@ -194,7 +197,7 @@ mod tests { .parse_complete(&wrap(&[build_call("get_weather", "arguments", arguments)])) .unwrap(); - assert_eq!(result.calls[0].arguments, arguments); + assert_eq!(result.calls()[0].arguments, arguments); } /// The bundled `tool_chat_template_phi4_mini.jinja` emits objects with @@ -208,9 +211,9 @@ mod tests { let result = parser.parse_complete(input).unwrap(); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(result.calls[0].arguments, r#"{"location": "Tokyo"}"#); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[0].arguments, r#"{"location": "Tokyo"}"#); } /// Argument deltas are streamed through the shared JSON core. @@ -230,10 +233,10 @@ mod tests { let result = collect_stream(&mut parser, &chunks); - assert_eq!(result.normal_text, "preface suffix"); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(result.calls[0].arguments, r#"{"location":"Beijing"}"#); + assert_eq!(result.normal_text(), "preface suffix"); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[0].arguments, r#"{"location":"Beijing"}"#); } #[test] @@ -251,9 +254,9 @@ mod tests { let result = collect_stream(&mut parser, &chunks); - assert_eq!(result.normal_text, "hello "); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert_eq!(result.normal_text(), "hello "); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -287,9 +290,9 @@ mod tests { .parse_complete(&wrap(&[build_call("convert", "arguments", arguments)])) .unwrap(); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].name.as_deref(), Some("convert")); - assert_eq!(result.calls[0].arguments, arguments); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].name.as_deref(), Some("convert")); + assert_eq!(result.calls()[0].arguments, arguments); } /// The chat template emits parallel calls as `},\n {` (comma + newline + @@ -307,9 +310,9 @@ mod tests { let result = parser.parse_complete(input).unwrap(); - assert_eq!(result.calls.len(), 2); - assert_eq!(result.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(result.calls[1].name.as_deref(), Some("add")); + assert_eq!(result.calls().len(), 2); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[1].name.as_deref(), Some("add")); } /// The shared core requires an object after the start marker. diff --git a/rust/src/parser/src/tool/json/qwen.rs b/rust/src/parser/src/tool/json/qwen.rs index 2339cf69fa0..7fa53c9007e 100644 --- a/rust/src/parser/src/tool/json/qwen.rs +++ b/rust/src/parser/src/tool/json/qwen.rs @@ -84,8 +84,8 @@ mod tests { let mut parser = Qwen3XmlToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -99,11 +99,11 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Let me check.\n"); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.normal_text(), "Let me check.\n"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -112,7 +112,7 @@ mod tests { let arguments = r#"{"location":"Tokyo",}"#; let output = parser.parse_complete(&build_tool_call("get_weather", arguments)).unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -132,7 +132,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -143,7 +143,7 @@ mod tests { assert_eq!(observed_arguments, ["{\"location\":", "\"Beijing\"", "}"]); assert_eq!( - output.coalesce_calls().calls[0].arguments, + output.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -159,9 +159,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -170,8 +170,8 @@ mod tests { let arguments = r#"{"text":"literal inside"}"#; let output = parser.parse_complete(&build_tool_call("echo", arguments)).unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -185,7 +185,7 @@ mod tests { ) .unwrap(); - assert_eq!(output.calls[0].name.as_deref(), Some("say_\"hi")); + assert_eq!(output.calls()[0].name.as_deref(), Some("say_\"hi")); } #[test] @@ -196,8 +196,8 @@ mod tests { let output = parser.parse_complete(input).unwrap(); - assert_eq!(output.normal_text, input); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), input); + assert!(output.calls().is_empty()); } #[test] @@ -227,22 +227,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] diff --git a/rust/src/parser/src/tool/kimi_k2.rs b/rust/src/parser/src/tool/kimi_k2.rs index 14a185011eb..b692c1ec598 100644 --- a/rust/src/parser/src/tool/kimi_k2.rs +++ b/rust/src/parser/src/tool/kimi_k2.rs @@ -81,7 +81,7 @@ impl KimiK2ToolParser { fn apply_event(&mut self, event: KimiK2Event, output: &mut ToolParserOutput) -> Result<()> { match event { KimiK2Event::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); } KimiK2Event::ToolCallsStart => self.mode = KimiK2Mode::ToolBlock, KimiK2Event::ToolCallStart => self.mode = KimiK2Mode::Header, @@ -96,7 +96,7 @@ impl KimiK2ToolParser { json_scan: JsonObjectScanState::default(), }; self.call_ids.insert(tool_index, tool_call_id); - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: Some(function_name), arguments: String::new(), @@ -108,7 +108,7 @@ impl KimiK2ToolParser { "Kimi K2 arguments without an active tool call" )); }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: None, arguments: self.buffer[..consumed_len].to_string(), @@ -171,7 +171,7 @@ impl ToolParser for KimiK2ToolParser { fn finish(&mut self) -> Result { let mut output = ToolParserOutput::default(); match &self.mode { - KimiK2Mode::Text => output.normal_text.push_str(&self.buffer), + KimiK2Mode::Text => output.push_text(&self.buffer), KimiK2Mode::ToolBlock | KimiK2Mode::Done => {} KimiK2Mode::Header | KimiK2Mode::Arguments { .. } => { return Err(parsing_failed!("incomplete Kimi K2 tool call")); @@ -357,8 +357,8 @@ mod tests { let mut parser = KimiK2ToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -372,11 +372,11 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Checking. "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.normal_text(), "Checking. "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -391,7 +391,7 @@ mod tests { )])) .unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -414,7 +414,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -424,8 +424,8 @@ mod tests { output.append(parser.finish().unwrap()); assert_eq!(observed_arguments, ["{\"location\":", "\"Paris\"", "}"]); - let output = output.coalesce_calls(); - assert_eq!(output.calls[0].arguments, r#"{"location":"Paris"}"#); + let output = output.coalesce(); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Paris"}"#); } #[test] @@ -445,9 +445,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"NYC"}"#); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"NYC"}"#); } #[test] @@ -458,8 +458,8 @@ mod tests { let output = parser.parse_complete(&input).unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -478,9 +478,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - output.calls[0].arguments, + output.calls()[0].arguments, r#"{"text":"literal <|tool_call_end|> inside"}"# ); } @@ -498,22 +498,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -546,12 +549,12 @@ mod tests { "{TOOL_CALLS_START}{TOOL_CALL_START}api.tools.search:42{TOOL_CALL_ARGUMENT_START}{{}}{TOOL_CALL_END}{TOOL_CALLS_END}" ); - let output = parser.parse_chunk(&input).unwrap().coalesce_calls(); + let output = parser.parse_chunk(&input).unwrap().coalesce(); - assert_eq!(output.calls[0].tool_index, 42); + assert_eq!(output.calls()[0].tool_index, 42); assert_eq!(parser.tool_call_id(42), Some("api.tools.search:42")); - assert_eq!(output.calls[0].name.as_deref(), Some("search")); - assert_eq!(output.calls[0].arguments, "{}"); + assert_eq!(output.calls()[0].name.as_deref(), Some("search")); + assert_eq!(output.calls()[0].arguments, "{}"); } #[test] diff --git a/rust/src/parser/src/tool/minimax_m2.rs b/rust/src/parser/src/tool/minimax_m2.rs index 27519176b2c..5c5411775a9 100644 --- a/rust/src/parser/src/tool/minimax_m2.rs +++ b/rust/src/parser/src/tool/minimax_m2.rs @@ -72,7 +72,7 @@ impl MinimaxM2ToolParser { fn apply_event(&mut self, event: MinimaxM2Event, output: &mut ToolParserOutput) -> Result<()> { match event { MinimaxM2Event::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); } MinimaxM2Event::ToolBlockStart => { self.mode = MinimaxM2Mode::ToolBlock { @@ -84,7 +84,7 @@ impl MinimaxM2ToolParser { let arguments = serde_json::to_string(&arguments) .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index: self.emitted_tool_count, name: Some(name), arguments, @@ -133,7 +133,7 @@ impl ToolParser for MinimaxM2ToolParser { let mut output = ToolParserOutput::default(); match self.mode { MinimaxM2Mode::Text => { - output.normal_text.push_str(&self.buffer); + output.push_text(&self.buffer); } MinimaxM2Mode::ToolBlock { .. } => { return Err(parsing_failed!("incomplete MiniMax M2 tool call")); @@ -295,8 +295,8 @@ mod tests { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -309,11 +309,11 @@ mod tests { )])) .unwrap(); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Seattle", "days": 5 }) ); } @@ -327,8 +327,8 @@ mod tests { ); let output = parser.parse_complete(&output).unwrap(); - assert_eq!(output.normal_text, "Let me check. "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Let me check. "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -341,15 +341,15 @@ mod tests { ])) .unwrap(); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[1].tool_index, 1); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[1].tool_index, 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Seattle" }) ); assert_eq!( - serde_json::from_str::(&output.calls[1].arguments).unwrap(), + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), json!({ "city": "NYC" }) ); } @@ -371,7 +371,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "whole": 5.0, "flag": true, @@ -395,7 +395,7 @@ mod tests { vec![("city", "Tom & Jerry <3")], )])) .unwrap(); - let args: Value = serde_json::from_str(&output.calls[0].arguments).unwrap(); + let args: Value = serde_json::from_str(&output.calls()[0].arguments).unwrap(); assert_eq!(args["city"], json!("Tom & Jerry <3")); } @@ -416,7 +416,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Seattle </parameter></invoke></minimax:tool_call>", "days": 5, @@ -440,7 +440,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "shape": "\nrectangle\n", "dimensions": { "width": 10, "height": 20 }, @@ -462,11 +462,11 @@ mod tests { ], ); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Seattle" }) ); } @@ -484,8 +484,8 @@ mod tests { ], ); - assert_eq!(output.normal_text, "Let me check. "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Let me check. "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -493,8 +493,8 @@ mod tests { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &["Hello, ", "world!"]); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -504,8 +504,8 @@ mod tests { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); - assert!(output.normal_text.is_empty()); + assert_eq!(output.calls().len(), 1); + assert!(output.normal_text().is_empty()); } #[test] @@ -518,9 +518,9 @@ mod tests { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[1].tool_index, 1); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[1].tool_index, 1); } #[test] @@ -540,12 +540,12 @@ mod tests { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let result = collect_stream(&mut parser, &chunks); - assert_eq!(result.normal_text, "I will call the tools.\n"); - assert_eq!(result.calls.len(), 2); - assert_eq!(result.calls[0].tool_index, 0); - assert_eq!(result.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(result.calls[1].tool_index, 1); - assert_eq!(result.calls[1].name.as_deref(), Some("get_weather")); + assert_eq!(result.normal_text(), "I will call the tools.\n"); + assert_eq!(result.calls().len(), 2); + assert_eq!(result.calls()[0].tool_index, 0); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[1].tool_index, 1); + assert_eq!(result.calls()[1].name.as_deref(), Some("get_weather")); } #[test] @@ -558,8 +558,8 @@ mod tests { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &chunks); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); } #[test] @@ -568,8 +568,8 @@ mod tests { let output = parser.parse_chunk(r#""#).unwrap(); - assert!(output.normal_text.is_empty()); - assert!(output.calls.is_empty()); + assert!(output.normal_text().is_empty()); + assert!(output.calls().is_empty()); } #[test] diff --git a/rust/src/parser/src/tool/minimax_m3.rs b/rust/src/parser/src/tool/minimax_m3.rs index f6800790723..a1ab375b731 100644 --- a/rust/src/parser/src/tool/minimax_m3.rs +++ b/rust/src/parser/src/tool/minimax_m3.rs @@ -107,7 +107,7 @@ impl MinimaxM3ToolParser { fn apply_event(&mut self, event: MinimaxM3Event, output: &mut ToolParserOutput) -> Result<()> { match event { MinimaxM3Event::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); } MinimaxM3Event::ToolBlockStart => { self.mode = MinimaxM3Mode::ToolBlock { @@ -119,7 +119,7 @@ impl MinimaxM3ToolParser { let arguments = serde_json::to_string(&arguments) .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index: self.emitted_tool_count, name: Some(name), arguments, @@ -158,7 +158,7 @@ impl ToolParser for MinimaxM3ToolParser { let mut output = ToolParserOutput::default(); match self.mode { MinimaxM3Mode::Text => { - output.normal_text.push_str(&self.buffer); + output.push_text(&self.buffer); } MinimaxM3Mode::ToolBlock { .. } => { if !self.buffer.trim_start().is_empty() { @@ -389,7 +389,7 @@ mod tests { TOOL_CALL_END, TOOL_CALL_START, ToolParser, }; use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; - use crate::tool::{Tool, ToolParserTestExt as _}; + use crate::tool::{Tool, ToolParserEvent, ToolParserTestExt as _}; fn element(name: &str, body: &str) -> String { format!("{ELEMENT_START}{name}>{body}{ELEMENT_END_START}{name}>") @@ -510,8 +510,8 @@ mod tests { let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -524,11 +524,11 @@ mod tests { )])) .unwrap(); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Seattle", "days": 5 }) ); } @@ -542,8 +542,8 @@ mod tests { ); let output = parser.parse_complete(&output).unwrap(); - assert_eq!(output.normal_text, "Let me check. "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Let me check. "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -556,15 +556,15 @@ mod tests { ])) .unwrap(); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[1].tool_index, 1); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[1].tool_index, 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Seattle" }) ); assert_eq!( - serde_json::from_str::(&output.calls[1].arguments).unwrap(), + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), json!({ "city": "NYC" }) ); } @@ -585,7 +585,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Seattle" }) ); } @@ -608,7 +608,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "whole": 5.0, "flag": true, @@ -627,7 +627,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "user_id": 42, "urgent": true, @@ -677,7 +677,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "shape": "\nrectangle\n", "dimensions": { "width": 10, "height": 20 }, @@ -698,11 +698,11 @@ mod tests { ], ); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Seattle" }) ); } @@ -720,8 +720,36 @@ mod tests { ], ); - assert_eq!(output.normal_text, "Let me check. "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Let me check. "); + assert_eq!(output.calls().len(), 1); + } + + #[test] + fn minimax_m3_streaming_preserves_ordered_events() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = collect_stream( + &mut parser, + &[ + "Let me check. ", + TOOL_CALL_START, + &invoke("get_weather", &element("city", "Seattle")), + TOOL_CALL_END, + ], + ); + + assert_eq!(output.events.len(), 2); + assert_eq!( + output.events[0], + ToolParserEvent::Text("Let me check. ".to_string()) + ); + let ToolParserEvent::ToolCall(call) = &output.events[1] else { + panic!("expected tool-call event"); + }; + assert_eq!(call.name.as_deref(), Some("get_weather")); + assert_eq!( + serde_json::from_str::(&call.arguments).unwrap(), + json!({ "city": "Seattle" }) + ); } #[test] @@ -729,8 +757,8 @@ mod tests { let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); let output = collect_stream(&mut parser, &["Hello, ", "world!"]); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -740,8 +768,8 @@ mod tests { let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); - assert!(output.normal_text.is_empty()); + assert_eq!(output.calls().len(), 1); + assert!(output.normal_text().is_empty()); } #[test] @@ -754,9 +782,9 @@ mod tests { let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[1].tool_index, 1); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[1].tool_index, 1); } #[test] @@ -768,8 +796,8 @@ mod tests { )) .unwrap(); - assert!(output.normal_text.is_empty()); - assert!(output.calls.is_empty()); + assert!(output.normal_text().is_empty()); + assert!(output.calls().is_empty()); } #[test] @@ -782,8 +810,8 @@ mod tests { let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); let output = collect_stream(&mut parser, &chunks); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); } #[test] @@ -804,8 +832,8 @@ mod tests { parser.parse_chunk(TOOL_CALL_START).unwrap(); let output = parser.finish().unwrap(); - assert!(output.normal_text.is_empty()); - assert!(output.calls.is_empty()); + assert!(output.normal_text().is_empty()); + assert!(output.calls().is_empty()); } #[test] @@ -819,9 +847,9 @@ mod tests { )) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Seattle" }) ); } @@ -863,7 +891,7 @@ mod tests { let output = parser.parse_complete(&build_tool_block(&[("convert", body)])).unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "payload": { "child": "value", @@ -887,7 +915,7 @@ mod tests { let output = parser.parse_complete(&build_tool_block(&[("convert", body)])).unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "payload": { "$text": "child text", diff --git a/rust/src/parser/src/tool/mod.rs b/rust/src/parser/src/tool/mod.rs index 8c067f169ed..a27e202e660 100644 --- a/rust/src/parser/src/tool/mod.rs +++ b/rust/src/parser/src/tool/mod.rs @@ -57,55 +57,115 @@ pub struct ToolCallDelta { pub arguments: String, } +/// One ordered event emitted while parsing assistant text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToolParserEvent { + /// Plain assistant text that is not part of any tool call. + Text(String), + /// A tool-call update extracted from assistant text. + ToolCall(ToolCallDelta), +} + /// Result of advancing tool parsing with one assistant-text input. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ToolParserOutput { - /// Plain assistant text that is not part of any tool call. - pub normal_text: String, - /// Tool-call updates extracted from this input. - pub calls: Vec, + /// Ordered parser events committed by this input. + pub events: Vec, } impl ToolParserOutput { - /// Append another parser output onto this one. - /// - /// Note that this does not attempt to merge multiple deltas for the same - /// tool call into one complete item. Call `coalesce_calls()` after if - /// that behavior is desired. - pub fn append(&mut self, mut other: Self) { - self.normal_text.push_str(&other.normal_text); - self.calls.append(&mut other.calls); + /// Append one visible text event if `text` is non-empty. + pub fn push_text(&mut self, text: impl AsRef + Into) { + if text.as_ref().is_empty() { + return; + } + if let Some(ToolParserEvent::Text(last_text)) = self.events.last_mut() { + last_text.push_str(text.as_ref()); + return; + } + self.events.push(ToolParserEvent::Text(text.into())); } - /// Merge multiple deltas for the same tool call into one complete item. + /// Append one tool-call update event. + pub fn push_call(&mut self, call: ToolCallDelta) { + self.events.push(ToolParserEvent::ToolCall(call)); + } + + /// Return all plain assistant text committed by this output. + /// + /// Texts before and after tool calls will be concatenated into a single string. To preserve + /// the original order of the text and tool-call events, directly access `events` instead. + pub fn normal_text(&self) -> String { + self.events + .iter() + .filter_map(|event| match event { + ToolParserEvent::Text(text) => Some(text.as_str()), + ToolParserEvent::ToolCall(_) => None, + }) + .collect() + } + + /// Return all tool-call updates committed by this output. + pub fn calls(&self) -> Vec<&ToolCallDelta> { + self.events + .iter() + .filter_map(|event| match event { + ToolParserEvent::Text(_) => None, + ToolParserEvent::ToolCall(call) => Some(call), + }) + .collect() + } + + /// Append another parser output onto this one. + /// + /// Note that this keeps events exactly as they arrive. Call `coalesce()` + /// after if final text and tool-call fragments should be flattened. + pub fn append(&mut self, other: Self) { + for event in other.events { + match event { + ToolParserEvent::Text(text) => self.push_text(text), + ToolParserEvent::ToolCall(call) => self.push_call(call), + } + } + } + + /// Flatten text and merge deltas for the same tool call. + /// + /// All text events are concatenated into one leading text event. Tool-call + /// events follow that text event in first-seen tool index order, with + /// argument fragments for the same tool call concatenated together. /// /// This is primarily used by the default `parse_complete()` implementation, /// which delegates through the incremental parser lifecycle and then /// needs to collapse streaming-style argument fragments into one final /// tool call. - pub fn coalesce_calls(mut self) -> Self { + pub fn coalesce(self) -> Self { let mut merged = BTreeMap::::new(); let mut order = Vec::new(); + let normal_text = self.normal_text(); - for call in self.calls { + for call in self.calls() { match merged.entry(call.tool_index) { btree_map::Entry::Vacant(entry) => { order.push(call.tool_index); - entry.insert(call); + entry.insert(call.clone()); } btree_map::Entry::Occupied(mut entry) => { let existing = entry.get_mut(); if existing.name.is_none() { - existing.name = call.name; + existing.name = call.name.clone(); } existing.arguments.push_str(&call.arguments); } } } - self.calls = - order.into_iter().filter_map(|tool_index| merged.remove(&tool_index)).collect(); - self + let mut output = Self::default(); + output.push_text(normal_text); + for call in order.into_iter().filter_map(|tool_index| merged.remove(&tool_index)) { + output.push_call(call); + } + output } } @@ -183,7 +243,7 @@ impl T { pub fn parse_complete(&mut self, text: &str) -> Result { let mut output = self.parse_chunk(text)?; output.append(self.finish()?); - Ok(output.coalesce_calls()) + Ok(output.coalesce()) } } diff --git a/rust/src/parser/src/tool/qwen_coder.rs b/rust/src/parser/src/tool/qwen_coder.rs index c3c792d3d66..d67a5c42f1c 100644 --- a/rust/src/parser/src/tool/qwen_coder.rs +++ b/rust/src/parser/src/tool/qwen_coder.rs @@ -74,7 +74,7 @@ impl Qwen3CoderToolParser { fn apply_event(&mut self, event: QwenCoderEvent, output: &mut ToolParserOutput) -> Result<()> { match event { QwenCoderEvent::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); } QwenCoderEvent::ToolCallStart => { self.mode = QwenCoderMode::ToolCall { @@ -87,7 +87,7 @@ impl Qwen3CoderToolParser { let arguments = serde_json::to_string(&arguments) .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index: self.emitted_tool_count, name: Some(name), arguments, @@ -138,7 +138,7 @@ impl ToolParser for Qwen3CoderToolParser { { return Err(parsing_failed!("incomplete Qwen Coder tool call")); } - output.normal_text.push_str(&self.buffer); + output.push_text(&self.buffer); } let _ = self.reset(); Ok(output) @@ -268,8 +268,8 @@ mod tests { let mut parser = Qwen3CoderToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -282,11 +282,11 @@ mod tests { )) .unwrap(); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF", "date": "2026-04-29" @@ -303,8 +303,8 @@ mod tests { ); let output = parser.parse_complete(&output).unwrap(); - assert_eq!(output.normal_text, "Thinking... "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Thinking... "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -323,9 +323,9 @@ mod tests { )) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "whole": 5.0, "flag": true, @@ -341,10 +341,10 @@ mod tests { let mut parser = Qwen3CoderToolParser::new(&test_tools()); let output = parser.parse_complete(&build_tool_call("get_weather", &[])).unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({}) ); } @@ -371,10 +371,10 @@ mod tests { ) .unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("calculate_area")); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("calculate_area")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "shape": "rectangle", "dimensions": { "width": 10, "height": 20 }, @@ -396,9 +396,9 @@ mod tests { )) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "payload": { "nested": { @@ -426,9 +426,9 @@ mod tests { )) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "html_content": r#"
Hello
"#, "xml_snippet": r#""#, @@ -452,9 +452,9 @@ mod tests { )) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "杭州 </parameter></function></tool_call>", "date": "2026-05-08", @@ -472,9 +472,9 @@ mod tests { )) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "data": { "key": "value", "count": 42 }, }) @@ -495,11 +495,11 @@ mod tests { ], ); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF" }) ); } @@ -519,8 +519,8 @@ mod tests { ], ); - assert_eq!(output.normal_text, "Thinking... "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Thinking... "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -528,8 +528,8 @@ mod tests { let mut parser = Qwen3CoderToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &["Hello, ", "world!"]); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -542,17 +542,17 @@ mod tests { let mut parser = Qwen3CoderToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &[&text]); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[1].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[1].tool_index, 1); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[1].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[1].tool_index, 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF" }) ); assert_eq!( - serde_json::from_str::(&output.calls[1].arguments).unwrap(), + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), json!({ "location": "NYC" }) ); } @@ -569,16 +569,16 @@ mod tests { let output = collect_stream(&mut parser, &chunks); assert_eq!( - output.normal_text, + output.normal_text(), "I'll check two cities.Between calls.Done." ); - assert_eq!(output.calls.len(), 2); + assert_eq!(output.calls().len(), 2); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Dallas", "state": "TX" }) ); assert_eq!( - serde_json::from_str::(&output.calls[1].arguments).unwrap(), + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), json!({ "city": "Orlando", "state": "FL" }) ); } @@ -590,9 +590,9 @@ mod tests { let mut parser = Qwen3CoderToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF" }) ); } @@ -610,19 +610,19 @@ mod tests { ) .unwrap(); - assert!(output.normal_text.is_empty()); - assert!(output.calls.is_empty()); + assert!(output.normal_text().is_empty()); + assert!(output.calls().is_empty()); let mut output = output; output.append(parser.parse_chunk("_call>").unwrap()); output.append(parser.finish().unwrap()); - let output = output.coalesce_calls(); + let output = output.coalesce(); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF" }) ); } @@ -641,20 +641,20 @@ mod tests { for chunk in chunks { let chunk_output = parser.parse_chunk(chunk).unwrap(); - assert!(chunk_output.normal_text.is_empty()); - assert!(chunk_output.calls.is_empty()); + assert!(chunk_output.normal_text().is_empty()); + assert!(chunk_output.calls().is_empty()); output.append(chunk_output); } output.append(parser.parse_chunk(end_suffix).unwrap()); output.append(parser.finish().unwrap()); - let output = output.coalesce_calls(); + let output = output.coalesce(); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": long_location }) ); } @@ -666,8 +666,8 @@ mod tests { .parse_chunk("\n\nSF") .unwrap(); - assert!(output.normal_text.is_empty()); - assert!(output.calls.is_empty()); + assert!(output.normal_text().is_empty()); + assert!(output.calls().is_empty()); } #[test] @@ -710,7 +710,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "Hangzhou" }) ); } diff --git a/rust/src/parser/src/tool/test_utils.rs b/rust/src/parser/src/tool/test_utils.rs index b16ef144a33..c160977479c 100644 --- a/rust/src/parser/src/tool/test_utils.rs +++ b/rust/src/parser/src/tool/test_utils.rs @@ -1,7 +1,7 @@ use serde_json::json; use super::{ToolParser, ToolParserOutput}; -use crate::tool::{Tool, ToolParserTestExt as _}; +use crate::tool::Tool; /// Build a reusable set of function tools for parser unit tests. pub fn test_tools() -> Vec { @@ -87,10 +87,10 @@ pub fn test_tools() -> Vec { pub fn collect_stream(parser: &mut T, chunks: &[&str]) -> ToolParserOutput { let mut output = ToolParserOutput::default(); for chunk in chunks { - output.append(parser.parse_chunk(chunk).unwrap()); + parser.parse_into(chunk, &mut output).unwrap(); } output.append(parser.finish().unwrap()); - output.coalesce_calls() + output.coalesce() } /// Split text into chunks containing at most `chunk_chars` Unicode scalar diff --git a/rust/src/parser/src/tool/tests.rs b/rust/src/parser/src/tool/tests.rs index db7d0721c87..5a79e764203 100644 --- a/rust/src/parser/src/tool/tests.rs +++ b/rust/src/parser/src/tool/tests.rs @@ -1,4 +1,4 @@ -use super::{Result, Tool, ToolCallDelta, ToolParser, ToolParserOutput}; +use super::{Result, Tool, ToolCallDelta, ToolParser, ToolParserEvent, ToolParserOutput}; use crate::tool::ToolParserTestExt as _; struct DefaultParser; @@ -31,6 +31,66 @@ fn tool_parser_does_not_preserve_special_tokens_by_default() { assert!(!parser.preserve_special_tokens()); } +#[test] +fn tool_parser_output_coalesces_adjacent_text_events() { + let mut output = ToolParserOutput::default(); + output.push_text("hello"); + output.push_text(" "); + output.push_text("world"); + output.push_call(ToolCallDelta { + tool_index: 0, + name: Some("lookup".to_string()), + arguments: "{}".to_string(), + }); + output.push_text("!"); + + assert_eq!( + output.events, + vec![ + ToolParserEvent::Text("hello world".to_string()), + ToolParserEvent::ToolCall(ToolCallDelta { + tool_index: 0, + name: Some("lookup".to_string()), + arguments: "{}".to_string(), + }), + ToolParserEvent::Text("!".to_string()), + ] + ); +} + +#[test] +fn tool_parser_output_append_coalesces_adjacent_text_events() { + let mut output = ToolParserOutput::default(); + output.push_text("hello"); + + let mut other = ToolParserOutput::default(); + other.push_text(" "); + other.push_text("world"); + output.append(other); + + let mut after_call = ToolParserOutput::default(); + after_call.push_call(ToolCallDelta { + tool_index: 0, + name: Some("lookup".to_string()), + arguments: "{}".to_string(), + }); + after_call.push_text("!"); + output.append(after_call); + + assert_eq!( + output.events, + vec![ + ToolParserEvent::Text("hello world".to_string()), + ToolParserEvent::ToolCall(ToolCallDelta { + tool_index: 0, + name: Some("lookup".to_string()), + arguments: "{}".to_string(), + }), + ToolParserEvent::Text("!".to_string()), + ] + ); +} + #[test] fn default_parse_complete_delegates_through_parse_chunk_and_finish() { struct StreamingParser; @@ -44,8 +104,8 @@ fn default_parse_complete_delegates_through_parse_chunk_and_finish() { } fn parse_into(&mut self, _chunk: &str, output: &mut ToolParserOutput) -> Result<()> { - output.normal_text.push_str("prefix "); - output.calls.extend([ + output.push_text("prefix "); + for call in [ ToolCallDelta { tool_index: 0, name: Some("weather".to_string()), @@ -61,26 +121,26 @@ fn default_parse_complete_delegates_through_parse_chunk_and_finish() { name: Some("time".to_string()), arguments: "{\"timezone\":".to_string(), }, - ]); + ] { + output.push_call(call); + } Ok(()) } fn finish(&mut self) -> Result { - Ok(ToolParserOutput { - normal_text: "suffix".to_string(), - calls: vec![ - ToolCallDelta { - tool_index: 0, - name: None, - arguments: "}".to_string(), - }, - ToolCallDelta { - tool_index: 1, - name: None, - arguments: "\"UTC\"}".to_string(), - }, - ], - }) + let mut output = ToolParserOutput::default(); + output.push_text("suffix"); + output.push_call(ToolCallDelta { + tool_index: 0, + name: None, + arguments: "}".to_string(), + }); + output.push_call(ToolCallDelta { + tool_index: 1, + name: None, + arguments: "\"UTC\"}".to_string(), + }); + Ok(output) } fn reset(&mut self) -> String { @@ -90,9 +150,9 @@ fn default_parse_complete_delegates_through_parse_chunk_and_finish() { let mut parser = StreamingParser; let output = parser.parse_complete("ignored").unwrap(); - assert_eq!(output.normal_text, "prefix suffix"); + assert_eq!(output.normal_text(), "prefix suffix"); assert_eq!( - output.calls, + output.calls().into_iter().cloned().collect::>(), vec![ ToolCallDelta { tool_index: 0, diff --git a/rust/src/parser/src/unified/combined.rs b/rust/src/parser/src/unified/combined.rs index 549753edbae..3f1c669013d 100644 --- a/rust/src/parser/src/unified/combined.rs +++ b/rust/src/parser/src/unified/combined.rs @@ -32,7 +32,7 @@ impl CombinedParser { fn parse_tool(&mut self, content: &str, output: &mut UnifiedParserOutput) -> Result<()> { let Some(tool) = self.tool.as_mut() else { - output.push_text(content.to_string()); + output.push_text(content); return Ok(()); }; @@ -228,7 +228,7 @@ mod tests { chunk: &str, output: &mut crate::tool::ToolParserOutput, ) -> crate::tool::Result<()> { - output.normal_text.push_str(chunk); + output.push_text(chunk); Ok(()) } @@ -256,7 +256,7 @@ mod tests { _chunk: &str, output: &mut crate::tool::ToolParserOutput, ) -> crate::tool::Result<()> { - output.normal_text.push_str("committed"); + output.push_text("committed"); Err(crate::tool::ToolParserError::ParsingFailed { message: "synthetic failure".to_string(), }) diff --git a/rust/src/parser/src/unified/mod.rs b/rust/src/parser/src/unified/mod.rs index a24ad6952cd..49955b4d818 100644 --- a/rust/src/parser/src/unified/mod.rs +++ b/rust/src/parser/src/unified/mod.rs @@ -8,7 +8,9 @@ use vllm_tokenizer::DynTokenizer; pub use combined::CombinedParser; use crate::reasoning::ReasoningError; -use crate::tool::{StructuralTagModel, Tool, ToolCallDelta, ToolParserError, ToolParserOutput}; +use crate::tool::{ + StructuralTagModel, Tool, ToolCallDelta, ToolParserError, ToolParserEvent, ToolParserOutput, +}; /// Result alias for unified parser operations. pub type Result = std::result::Result; @@ -33,31 +35,115 @@ pub struct UnifiedParserOutput { impl UnifiedParserOutput { /// Append one visible text event if `delta` is non-empty. - pub fn push_text(&mut self, delta: String) { - if delta.is_empty() { + pub fn push_text(&mut self, delta: impl AsRef + Into) { + if delta.as_ref().is_empty() { return; } - self.events.push(UnifiedParserEvent::Text(delta)); + if let Some(UnifiedParserEvent::Text(last_text)) = self.events.last_mut() { + last_text.push_str(delta.as_ref()); + return; + } + self.events.push(UnifiedParserEvent::Text(delta.into())); } /// Append one reasoning text event if `delta` is non-empty. - pub fn push_reasoning(&mut self, delta: String) { - if delta.is_empty() { + pub fn push_reasoning(&mut self, delta: impl AsRef + Into) { + if delta.as_ref().is_empty() { return; } - self.events.push(UnifiedParserEvent::Reasoning(delta)); + if let Some(UnifiedParserEvent::Reasoning(last_text)) = self.events.last_mut() { + last_text.push_str(delta.as_ref()); + return; + } + self.events.push(UnifiedParserEvent::Reasoning(delta.into())); + } + + /// Append one tool-call event. + pub fn push_call(&mut self, call: ToolCallDelta) { + self.events.push(UnifiedParserEvent::ToolCall(call)); } /// Append parsed tool parser output as unified events. pub fn append_tool_output(&mut self, output: ToolParserOutput) { - // TODO: make ToolParserOutput carry ordered events and remove this text-first flattening. - self.push_text(output.normal_text); - self.events.extend(output.calls.into_iter().map(UnifiedParserEvent::ToolCall)); + for event in output.events { + match event { + ToolParserEvent::Text(text) => self.push_text(text), + ToolParserEvent::ToolCall(call) => self.push_call(call), + } + } } /// Append another parser output onto this one. - pub fn append(&mut self, mut other: Self) { - self.events.append(&mut other.events); + pub fn append(&mut self, other: Self) { + for event in other.events { + match event { + UnifiedParserEvent::Text(text) => self.push_text(text), + UnifiedParserEvent::Reasoning(reasoning) => self.push_reasoning(reasoning), + UnifiedParserEvent::ToolCall(call) => self.push_call(call), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{UnifiedParserEvent, UnifiedParserOutput}; + use crate::tool::ToolCallDelta; + + #[test] + fn unified_parser_output_coalesces_adjacent_text_events() { + let mut output = UnifiedParserOutput::default(); + output.push_text("hello"); + output.push_text(" "); + output.push_text("world"); + output.push_reasoning("think"); + output.push_reasoning("ing"); + output.push_call(ToolCallDelta { + tool_index: 0, + name: Some("lookup".to_string()), + arguments: "{}".to_string(), + }); + output.push_text("!"); + + assert_eq!( + output.events, + vec![ + UnifiedParserEvent::Text("hello world".to_string()), + UnifiedParserEvent::Reasoning("thinking".to_string()), + UnifiedParserEvent::ToolCall(ToolCallDelta { + tool_index: 0, + name: Some("lookup".to_string()), + arguments: "{}".to_string(), + }), + UnifiedParserEvent::Text("!".to_string()), + ] + ); + } + + #[test] + fn unified_parser_output_append_coalesces_adjacent_events() { + let mut output = UnifiedParserOutput::default(); + output.push_text("hello"); + + let mut other = UnifiedParserOutput::default(); + other.push_text(" "); + other.push_text("world"); + other.push_reasoning("think"); + output.append(other); + + let mut after_reasoning = UnifiedParserOutput::default(); + after_reasoning.push_reasoning("ing"); + after_reasoning.push_text("!"); + output.append(after_reasoning); + + assert_eq!( + output.events, + vec![ + UnifiedParserEvent::Text("hello world".to_string()), + UnifiedParserEvent::Reasoning("thinking".to_string()), + UnifiedParserEvent::Text("!".to_string()), + ] + ); } } diff --git a/tests/tool_parsers/test_rust_tool_parser.py b/tests/tool_parsers/test_rust_tool_parser.py index 75468487783..2349d4d292a 100644 --- a/tests/tool_parsers/test_rust_tool_parser.py +++ b/tests/tool_parsers/test_rust_tool_parser.py @@ -171,7 +171,7 @@ def test_rust_tool_parser_extension_typed_api() -> None: parser.parse_into(build_tool_call(), output) output.append(parser.finish()) - output = output.coalesce_calls() + output = output.coalesce() assert parser.preserve_special_tokens() assert output.normal_text == "" diff --git a/vllm/tool_parsers/rust_tool_parser.py b/vllm/tool_parsers/rust_tool_parser.py index 493f765a2c2..05f015369f8 100644 --- a/vllm/tool_parsers/rust_tool_parser.py +++ b/vllm/tool_parsers/rust_tool_parser.py @@ -224,7 +224,7 @@ class RustToolParser(ToolParser): "Error parsing %s tool call output.", self.rust_parser_name ) return None - return output.coalesce_calls(), tool_call_ids + return output.coalesce(), tool_call_ids def extract_tool_calls( self,