From 4e04bcbce6f0852017d26660dd2d027de9bed7d5 Mon Sep 17 00:00:00 2001 From: Tahsin Tunan Date: Wed, 15 Jul 2026 17:03:37 +0600 Subject: [PATCH] [Rust Frontend] Tolerate whitespace before the outer brace in JSON tool-call parsers (#48034) Signed-off-by: Tahsin Tunan --- rust/src/chat/src/output/default/unified.rs | 51 +++++++++++++++++++++ rust/src/parser/src/tool/json/llama.rs | 26 ++++++++++- rust/src/parser/src/tool/json/mod.rs | 30 +++++++++++- 3 files changed, 105 insertions(+), 2 deletions(-) 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(&[