[Rust Frontend] Tolerate whitespace before the outer brace in JSON tool-call parsers (#48034)

Signed-off-by: Tahsin Tunan <tahsintunan@gmail.com>

Tahsin Tunan committed Jul 15, 2026 at 17:03 UTC 4e04bcbce6f0852017d26660dd2d027de9bed7d5
3 files changed +105 -2
rust/src/chat/src/output/default/unified.rs
+51
@@ -451,6 +451,57 @@ mod tests {
451 output
452 }
453
454 + #[tokio::test]
455 + async fn unified_stream_parses_formatted_tool_call_without_latch() {
456 + use vllm_parser::tool::{HermesToolParser, ToolParser as _};
457 +
458 + let hermes = HermesToolParser::create(&[]).unwrap();
459 + let parser = vllm_parser::unified::CombinedParser::new(None, Some(hermes));
460 +
461 + // Regression guard: a formatted call (space before the outer `}`) must not
462 + // trip the parse-error latch that turns it and every later call into text.
463 + let d1 = decoded_delta(
464 + r#"<tool_call>{"name":"get_weather","arguments":{"location":"Paris"} }</tool_call>"#,
465 + );
466 + let d2 = finished_delta(
467 + r#"<tool_call>{"name":"get_time","arguments":{"tz":"UTC"}}</tool_call>"#,
468 + );
469 +
470 + let stream = stream::iter(vec![d1, d2].into_iter().map(Ok));
471 + let events = unified_event_stream(stream, Box::new(parser))
472 + .collect::<Vec<_>>()
473 + .await
474 + .into_iter()
475 + .collect::<crate::Result<Vec<_>>>()
476 + .unwrap();
477 +
478 + let names: Vec<&str> = events
479 + .iter()
480 + .filter_map(|event| match event {
481 + AssistantEvent::ToolCallStart { name, .. } => Some(name.as_str()),
482 + _ => None,
483 + })
484 + .collect();
485 + assert_eq!(names, ["get_weather", "get_time"]);
486 +
487 + let text_leaks = events
488 + .iter()
489 + .filter(|event| {
490 + matches!(
491 + event,
492 + AssistantEvent::TextDelta {
493 + kind: AssistantBlockKind::Text,
494 + ..
495 + }
496 + )
497 + })
498 + .count();
499 + assert_eq!(
500 + text_leaks, 0,
501 + "formatted tool call leaked into text: {events:#?}"
502 + );
503 + }
504 +
505 #[tokio::test]
506 async fn unified_stream_emits_reasoning_only_deltas() {
507 let events = collect(
rust/src/parser/src/tool/json/llama.rs
+25 -1
@@ -238,7 +238,9 @@ fn parse_llama_arguments_event(
238
239 /// Parse the outer closing brace for one Llama JSON tool call.
240 fn tool_call_close_event(input: &mut JsonToolInput<'_>) -> ModalResult<LlamaJsonEvent> {
241 - literal("}").value(LlamaJsonEvent::ToolCallClose).parse_next(input)
241 + seq!(_: ws0, _: literal("}"))
242 + .value(LlamaJsonEvent::ToolCallClose)
243 + .parse_next(input)
244 }
245
246 /// Parse a semicolon separator after one Llama JSON tool call.
@@ -266,6 +268,28 @@ mod tests {
268 format!(r#"{{"name":"{function_name}","parameters":{parameters}}}"#)
269 }
270
271 + #[test]
272 + fn llama_tolerates_whitespace_before_outer_brace() {
273 + // Whitespace between the parameters object's `}` and the outer `}` must
274 + // still parse (json.loads / raw_decode parity).
275 + let mut whole = Llama3JsonToolParser::new(&test_tools());
276 + let whole_output = whole.parse_complete(r#"{"name":"f","parameters":{"x":1} }"#).unwrap();
277 + assert_eq!(whole_output.calls().len(), 1);
278 + assert_eq!(whole_output.calls()[0].name.as_deref(), Some("f"));
279 + assert_eq!(whole_output.calls()[0].arguments, r#"{"x":1}"#);
280 +
281 + // Same input, whitespace before the outer `}` split across a chunk boundary.
282 + let mut chunked = Llama3JsonToolParser::new(&test_tools());
283 + let mut output = ToolParserOutput::default();
284 + for chunk in [r#"{"name":"f","parameters":{"x":1}"#, " ", "}"] {
285 + output.append(chunked.parse_chunk(chunk).unwrap());
286 + }
287 + output.append(chunked.finish().unwrap());
288 + let output = output.coalesce();
289 + assert_eq!(output.calls().len(), 1);
290 + assert_eq!(output.calls()[0].arguments, r#"{"x":1}"#);
291 + }
292 +
293 #[test]
294 fn llama_json_parse_complete_without_tool_call_keeps_text() {
295 let mut parser = Llama3JsonToolParser::new(&test_tools());
rust/src/parser/src/tool/json/mod.rs
+29 -1
@@ -317,7 +317,7 @@ fn tool_call_close_event(
317 input: &mut JsonToolInput<'_>,
318 config: JsonToolCallConfig,
319 ) -> ModalResult<JsonToolCallEvent> {
320 - let _ = literal("}").parse_next(input)?;
320 + seq!(_: ws0, _: literal("}")).parse_next(input)?;
321
322 match config.delimiter {
323 Some(delimiter) => alt((
@@ -406,6 +406,34 @@ mod tests {
406 output.coalesce()
407 }
408
409 + #[test]
410 + fn json_tool_call_tolerates_whitespace_before_outer_brace() {
411 + // Pretty-printed JSON puts whitespace between the arguments object's `}`
412 + // and the outer object's `}`; it must still parse (json.loads parity).
413 + let mut whole = JsonToolCallParser::new(DELIMITED_CONFIG);
414 + let whole_output = collect_chunks(
415 + &mut whole,
416 + &[r#"<tool_calls>{"function":"f","parameters":{"x":1} }</tool_calls>"#],
417 + );
418 + assert_eq!(whole_output.calls().len(), 1);
419 + assert_eq!(whole_output.calls()[0].name.as_deref(), Some("f"));
420 + assert_eq!(whole_output.calls()[0].arguments, r#"{"x":1}"#);
421 +
422 + // Same input, but the whitespace before the outer `}` is split across a
423 + // chunk boundary (exercises `ws0` returning Incomplete on `Partial`).
424 + let mut chunked = JsonToolCallParser::new(DELIMITED_CONFIG);
425 + let chunked_output = collect_chunks(
426 + &mut chunked,
427 + &[
428 + r#"<tool_calls>{"function":"f","parameters":{"x":1}"#,
429 + " ",
430 + "}</tool_calls>",
431 + ],
432 + );
433 + assert_eq!(chunked_output.calls().len(), 1);
434 + assert_eq!(chunked_output.calls()[0].arguments, r#"{"x":1}"#);
435 + }
436 +
437 #[test]
438 fn json_tool_call_delimiter_extracts_multiple_calls_in_one_block() {
439 let input = build_tool_calls(&[