[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(&[