diff --git a/rust/src/chat/src/output/default/unified.rs b/rust/src/chat/src/output/default/unified.rs
index 038b907a26a..f865e5a6ae0 100644
--- a/rust/src/chat/src/output/default/unified.rs
+++ b/rust/src/chat/src/output/default/unified.rs
@@ -451,6 +451,57 @@ mod tests {
output
}
+ #[tokio::test]
+ async fn unified_stream_parses_formatted_tool_call_without_latch() {
+ use vllm_parser::tool::{HermesToolParser, ToolParser as _};
+
+ let hermes = HermesToolParser::create(&[]).unwrap();
+ let parser = vllm_parser::unified::CombinedParser::new(None, Some(hermes));
+
+ // Regression guard: a formatted call (space before the outer `}`) must not
+ // trip the parse-error latch that turns it and every later call into text.
+ let d1 = decoded_delta(
+ r#"{"name":"get_weather","arguments":{"location":"Paris"} }"#,
+ );
+ let d2 = finished_delta(
+ r#"{"name":"get_time","arguments":{"tz":"UTC"}}"#,
+ );
+
+ let stream = stream::iter(vec![d1, d2].into_iter().map(Ok));
+ let events = unified_event_stream(stream, Box::new(parser))
+ .collect::>()
+ .await
+ .into_iter()
+ .collect::>>()
+ .unwrap();
+
+ let names: Vec<&str> = events
+ .iter()
+ .filter_map(|event| match event {
+ AssistantEvent::ToolCallStart { name, .. } => Some(name.as_str()),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(names, ["get_weather", "get_time"]);
+
+ let text_leaks = events
+ .iter()
+ .filter(|event| {
+ matches!(
+ event,
+ AssistantEvent::TextDelta {
+ kind: AssistantBlockKind::Text,
+ ..
+ }
+ )
+ })
+ .count();
+ assert_eq!(
+ text_leaks, 0,
+ "formatted tool call leaked into text: {events:#?}"
+ );
+ }
+
#[tokio::test]
async fn unified_stream_emits_reasoning_only_deltas() {
let events = collect(
diff --git a/rust/src/parser/src/tool/json/llama.rs b/rust/src/parser/src/tool/json/llama.rs
index e898d07d863..23e49724f07 100644
--- a/rust/src/parser/src/tool/json/llama.rs
+++ b/rust/src/parser/src/tool/json/llama.rs
@@ -238,7 +238,9 @@ fn parse_llama_arguments_event(
/// Parse the outer closing brace for one Llama JSON tool call.
fn tool_call_close_event(input: &mut JsonToolInput<'_>) -> ModalResult {
- literal("}").value(LlamaJsonEvent::ToolCallClose).parse_next(input)
+ seq!(_: ws0, _: literal("}"))
+ .value(LlamaJsonEvent::ToolCallClose)
+ .parse_next(input)
}
/// Parse a semicolon separator after one Llama JSON tool call.
@@ -266,6 +268,28 @@ mod tests {
format!(r#"{{"name":"{function_name}","parameters":{parameters}}}"#)
}
+ #[test]
+ fn llama_tolerates_whitespace_before_outer_brace() {
+ // Whitespace between the parameters object's `}` and the outer `}` must
+ // still parse (json.loads / raw_decode parity).
+ let mut whole = Llama3JsonToolParser::new(&test_tools());
+ let whole_output = whole.parse_complete(r#"{"name":"f","parameters":{"x":1} }"#).unwrap();
+ assert_eq!(whole_output.calls().len(), 1);
+ assert_eq!(whole_output.calls()[0].name.as_deref(), Some("f"));
+ assert_eq!(whole_output.calls()[0].arguments, r#"{"x":1}"#);
+
+ // Same input, whitespace before the outer `}` split across a chunk boundary.
+ let mut chunked = Llama3JsonToolParser::new(&test_tools());
+ let mut output = ToolParserOutput::default();
+ for chunk in [r#"{"name":"f","parameters":{"x":1}"#, " ", "}"] {
+ output.append(chunked.parse_chunk(chunk).unwrap());
+ }
+ output.append(chunked.finish().unwrap());
+ let output = output.coalesce();
+ assert_eq!(output.calls().len(), 1);
+ assert_eq!(output.calls()[0].arguments, r#"{"x":1}"#);
+ }
+
#[test]
fn llama_json_parse_complete_without_tool_call_keeps_text() {
let mut parser = Llama3JsonToolParser::new(&test_tools());
diff --git a/rust/src/parser/src/tool/json/mod.rs b/rust/src/parser/src/tool/json/mod.rs
index ee85ddf356f..b707bff6fd1 100644
--- a/rust/src/parser/src/tool/json/mod.rs
+++ b/rust/src/parser/src/tool/json/mod.rs
@@ -317,7 +317,7 @@ fn tool_call_close_event(
input: &mut JsonToolInput<'_>,
config: JsonToolCallConfig,
) -> ModalResult {
- let _ = literal("}").parse_next(input)?;
+ seq!(_: ws0, _: literal("}")).parse_next(input)?;
match config.delimiter {
Some(delimiter) => alt((
@@ -406,6 +406,34 @@ mod tests {
output.coalesce()
}
+ #[test]
+ fn json_tool_call_tolerates_whitespace_before_outer_brace() {
+ // Pretty-printed JSON puts whitespace between the arguments object's `}`
+ // and the outer object's `}`; it must still parse (json.loads parity).
+ let mut whole = JsonToolCallParser::new(DELIMITED_CONFIG);
+ let whole_output = collect_chunks(
+ &mut whole,
+ &[r#"{"function":"f","parameters":{"x":1} }"#],
+ );
+ assert_eq!(whole_output.calls().len(), 1);
+ assert_eq!(whole_output.calls()[0].name.as_deref(), Some("f"));
+ assert_eq!(whole_output.calls()[0].arguments, r#"{"x":1}"#);
+
+ // Same input, but the whitespace before the outer `}` is split across a
+ // chunk boundary (exercises `ws0` returning Incomplete on `Partial`).
+ let mut chunked = JsonToolCallParser::new(DELIMITED_CONFIG);
+ let chunked_output = collect_chunks(
+ &mut chunked,
+ &[
+ r#"{"function":"f","parameters":{"x":1}"#,
+ " ",
+ "}",
+ ],
+ );
+ assert_eq!(chunked_output.calls().len(), 1);
+ assert_eq!(chunked_output.calls()[0].arguments, r#"{"x":1}"#);
+ }
+
#[test]
fn json_tool_call_delimiter_extracts_multiple_calls_in_one_block() {
let input = build_tool_calls(&[