main
py 1,013 lines 37.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for the streaming parser engine core pipeline."""
4
5 from dataclasses import replace
6 from unittest.mock import MagicMock
7
8 import pytest
9
10 from tests.parser.engine.conftest import make_mock_tokenizer
11 from vllm.parser.engine.events import EventType, SemanticEvent
12 from vllm.parser.engine.incremental_lexer import (
13 LexerShape,
14 TerminalDef,
15 terminals_from_literals,
16 )
17 from vllm.parser.engine.parser_engine_config import (
18 ParserEngineConfig,
19 ParserState,
20 Transition,
21 )
22 from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
23
24
25 def _hermes_config() -> ParserEngineConfig:
26 """Simple Hermes-style config: <tool_call>JSON</tool_call>."""
27 return ParserEngineConfig(
28 name="hermes_test",
29 terminals={
30 "TOOL_START": "<tool_call>",
31 "TOOL_END": "</tool_call>",
32 },
33 token_id_terminals={
34 "TOOL_START": "<tool_call>",
35 "TOOL_END": "</tool_call>",
36 },
37 transitions={
38 (ParserState.CONTENT, "TOOL_START"): Transition(
39 ParserState.TOOL_ARGS,
40 (EventType.TOOL_CALL_START,),
41 ),
42 (ParserState.TOOL_ARGS, "TOOL_END"): Transition(
43 ParserState.CONTENT,
44 (EventType.TOOL_CALL_END,),
45 ),
46 },
47 content_events={
48 ParserState.CONTENT: EventType.TEXT_CHUNK,
49 ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
50 },
51 )
52
53
54 def _think_config() -> ParserEngineConfig:
55 """Simple think-tag reasoning config: <think>...</think>."""
56 return ParserEngineConfig(
57 name="think_test",
58 terminals={
59 "THINK_START": "<think>",
60 "THINK_END": "</think>",
61 },
62 transitions={
63 (ParserState.CONTENT, "THINK_START"): Transition(
64 ParserState.REASONING,
65 (EventType.REASONING_START,),
66 ),
67 (ParserState.REASONING, "THINK_END"): Transition(
68 ParserState.CONTENT,
69 (EventType.REASONING_END,),
70 ),
71 },
72 )
73
74
75 def _token_think_config() -> ParserEngineConfig:
76 return replace(
77 _think_config(),
78 token_id_terminals={
79 "THINK_START": "<think>",
80 "THINK_END": "</think>",
81 },
82 )
83
84
85 class TestNonStreaming:
86 def test_plain_text(self):
87 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
88 events = engine.parse_complete("Hello, world!")
89 assert len(events) == 1
90 assert events[0].type == EventType.TEXT_CHUNK
91 assert events[0].value == "Hello, world!"
92
93 def test_single_tool_call(self):
94 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
95 text = (
96 '<tool_call>{"name": "get_weather",'
97 ' "arguments": {"city": "SF"}}'
98 "</tool_call>"
99 )
100 events = engine.parse_complete(text)
101
102 types = [e.type for e in events]
103 assert EventType.TOOL_CALL_START in types
104 assert EventType.TOOL_CALL_END in types
105 assert EventType.ARG_VALUE_CHUNK in types
106
107 arg_text = "".join(
108 e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK
109 )
110 assert '"name": "get_weather"' in arg_text
111 assert '"city": "SF"' in arg_text
112
113 def test_text_then_tool_call(self):
114 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
115 text = 'Sure!<tool_call>{"name": "add"}</tool_call>'
116 events = engine.parse_complete(text)
117
118 types = [e.type for e in events]
119 assert types[0] == EventType.TEXT_CHUNK
120 assert events[0].value == "Sure!"
121 assert EventType.TOOL_CALL_START in types
122 assert EventType.TOOL_CALL_END in types
123
124 def test_multiple_tool_calls(self):
125 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
126 text = (
127 '<tool_call>{"name": "a"}</tool_call><tool_call>{"name": "b"}</tool_call>'
128 )
129 events = engine.parse_complete(text)
130
131 starts = [e for e in events if e.type == EventType.TOOL_CALL_START]
132 ends = [e for e in events if e.type == EventType.TOOL_CALL_END]
133 assert len(starts) == 2
134 assert len(ends) == 2
135 assert starts[0].tool_index == 0
136 assert starts[1].tool_index == 1
137
138 def test_reasoning(self):
139 engine = StreamingParserEngine(_think_config(), tokenizer=None)
140 text = "<think>Let me think...</think>The answer is 42."
141 events = engine.parse_complete(text)
142
143 types = [e.type for e in events]
144 assert types[0] == EventType.REASONING_START
145 assert EventType.REASONING_CHUNK in types
146 assert EventType.REASONING_END in types
147 assert EventType.TEXT_CHUNK in types
148
149 reasoning = "".join(
150 e.value for e in events if e.type == EventType.REASONING_CHUNK
151 )
152 assert "Let me think..." in reasoning
153
154 content = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK)
155 assert "The answer is 42." in content
156
157
158 class TestStreaming:
159 @staticmethod
160 def _feed_chars(
161 engine: StreamingParserEngine,
162 text: str,
163 ) -> list[SemanticEvent]:
164 """Feed text one character at a time."""
165 all_events = []
166 for ch in text:
167 all_events.extend(engine.feed(ch, []))
168 all_events.extend(engine.finish())
169 return all_events
170
171 @staticmethod
172 def _feed_chunks(
173 engine: StreamingParserEngine,
174 text: str,
175 chunk_size: int,
176 ) -> list[SemanticEvent]:
177 """Feed text in fixed-size chunks."""
178 all_events = []
179 for i in range(0, len(text), chunk_size):
180 chunk = text[i : i + chunk_size]
181 all_events.extend(engine.feed(chunk, []))
182 all_events.extend(engine.finish())
183 return all_events
184
185 def test_char_by_char_tool_call(self):
186 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
187 text = '<tool_call>{"name": "add", "arguments": {"a": 1}}</tool_call>'
188 events = self._feed_chars(engine, text)
189
190 types = [e.type for e in events]
191 assert EventType.TOOL_CALL_START in types
192 assert EventType.TOOL_CALL_END in types
193 assert EventType.ARG_VALUE_CHUNK in types
194
195 arg_text = "".join(
196 e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK
197 )
198 assert '"name": "add"' in arg_text
199
200 @pytest.mark.parametrize(
201 "text",
202 [
203 '<tool_call>{"name": "get", "arguments": {"x": "hello"}}</tool_call>',
204 '<tool_call>{"name": "f", "arguments": '
205 '{"items": [1, [2, 3]], "obj": {"k": "v"}}}'
206 "</tool_call>",
207 ],
208 ids=["flat_args", "nested_arrays"],
209 )
210 def test_chunk_sizes_produce_same_content(self, text):
211 """Different chunk sizes must produce identical concatenated content."""
212 results = {}
213 for chunk_size in [1, 2, 3, 5, 7, len(text)]:
214 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
215 events = self._feed_chunks(engine, text, chunk_size)
216 arg_text = "".join(
217 e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK
218 )
219 results[chunk_size] = arg_text
220
221 values = list(results.values())
222 for v in values[1:]:
223 assert v == values[0], f"Mismatch: {results}"
224
225 def test_prefix_buffering_prevents_premature_emit(self):
226 """Text like '<tool_' should be buffered, not emitted as content."""
227 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
228
229 events1 = engine.feed("<tool_", [])
230 content_events = [e for e in events1 if e.type == EventType.TEXT_CHUNK]
231 assert len(content_events) == 0, "Should buffer partial tag"
232
233 events2 = engine.feed("call>", [])
234 starts = [e for e in events2 if e.type == EventType.TOOL_CALL_START]
235 assert len(starts) == 1
236
237 def test_prefix_buffering_flush_on_mismatch(self):
238 """Text like '<tool_box' should eventually flush as content."""
239 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
240
241 events1 = engine.feed("<tool_", [])
242 assert len([e for e in events1 if e.type == EventType.TEXT_CHUNK]) == 0
243
244 events2 = engine.feed("box>rest", [])
245 events2.extend(engine.finish())
246 content = "".join(e.value for e in events2 if e.type == EventType.TEXT_CHUNK)
247 assert content == "<tool_box>rest"
248
249 def test_reasoning_streaming(self):
250 engine = StreamingParserEngine(_think_config(), tokenizer=None)
251 events = self._feed_chars(engine, "<think>hmm</think>answer")
252
253 reasoning = "".join(
254 e.value for e in events if e.type == EventType.REASONING_CHUNK
255 )
256 content = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK)
257 assert "hmm" in reasoning
258 assert "answer" in content
259
260 def test_text_between_tool_calls(self):
261 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
262 text = (
263 'Hi<tool_call>{"name":"a"}</tool_call>'
264 'mid<tool_call>{"name":"b"}</tool_call>end'
265 )
266 events = self._feed_chunks(engine, text, 3)
267
268 texts = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK)
269 assert "Hi" in texts
270 assert "mid" in texts
271 assert "end" in texts
272
273 starts = [e for e in events if e.type == EventType.TOOL_CALL_START]
274 assert len(starts) == 2
275
276 def test_unmatched_close_brace_does_not_poison_depth(self):
277 """A stray } in malformed JSON must not kill streaming for
278 all subsequent content."""
279 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
280 engine.feed("<tool_call>", [])
281
282 malformed = '}{{"a": 1}}'
283 events = self._feed_chars(engine, malformed + "</tool_call>")
284
285 arg_chunks = [e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK]
286 assert len(arg_chunks) > 1, (
287 "Content after stray } should still stream incrementally"
288 )
289 arg_text = "".join(arg_chunks)
290 assert '"a": 1' in arg_text
291
292 def test_json_args_no_premature_close_brace(self):
293 """Closing braces of the top-level JSON shouldn't be streamed
294 until confirmed by the end tag."""
295 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
296
297 engine.feed("<tool_call>", [])
298 events = engine.feed('{"name": "f"}', [])
299
300 arg_text = "".join(
301 e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK
302 )
303 assert "}" not in arg_text, "Top-level } should be held back"
304
305 events2 = engine.feed("</tool_call>", [])
306 arg_text2 = "".join(
307 e.value for e in events2 if e.type == EventType.ARG_VALUE_CHUNK
308 )
309 assert "}" in arg_text2, "} should flush on end tag"
310
311
312 _START_ID = 50
313 _END_ID = 51
314 _TOOL_START_ID = 60
315 _TOOL_END_ID = 61
316
317
318 def _make_think_tokenizer():
319 tok = MagicMock()
320 tok.encode.return_value = [1, 2, 3]
321 tok.get_vocab.return_value = {"<think>": _START_ID, "</think>": _END_ID}
322 tok.decode.side_effect = lambda ids: {
323 _START_ID: "<think>",
324 _END_ID: "</think>",
325 }.get(ids[0], f"tok{ids[0]}")
326 return tok
327
328
329 def _make_hermes_tokenizer():
330 """Tokenizer that resolves tool_call tags to special IDs."""
331 _special = {_TOOL_START_ID: "<tool_call>", _TOOL_END_ID: "</tool_call>"}
332 tok = MagicMock()
333 tok.encode.return_value = [1, 2, 3]
334 tok.get_vocab.return_value = {
335 "<tool_call>": _TOOL_START_ID,
336 "</tool_call>": _TOOL_END_ID,
337 }
338 tok.decode.side_effect = lambda ids: "".join(
339 _special.get(i, chr(i) if i < 128 else f"<{i}>") for i in ids
340 )
341 return tok
342
343
344 class TestLexerBufferFlush:
345 """Lexer buffer must be flushed before PreLexedTerminal transitions."""
346
347 def test_buffered_prefix_emitted_in_current_state(self):
348 """Text buffered by the lexer (e.g. '<') must be emitted as
349 REASONING_CHUNK before THINK_END transitions to CONTENT."""
350 engine = StreamingParserEngine(_think_config(), _make_think_tokenizer())
351
352 events = engine.feed("<think>", [_START_ID])
353 assert any(e.type == EventType.REASONING_START for e in events)
354
355 events = engine.feed("reasoning text<", [])
356 reasoning_text = "".join(
357 e.value for e in events if e.type == EventType.REASONING_CHUNK
358 )
359 assert "reasoning text" in reasoning_text
360
361 events = engine.feed("</think>", [_END_ID])
362 event_types = [e.type for e in events]
363 if EventType.REASONING_CHUNK in event_types:
364 rc_idx = event_types.index(EventType.REASONING_CHUNK)
365 re_idx = event_types.index(EventType.REASONING_END)
366 assert rc_idx < re_idx, (
367 "'<' must be emitted as REASONING_CHUNK before REASONING_END"
368 )
369 flushed = events[rc_idx].value
370 assert "<" in flushed
371
372 def test_empty_buffer_no_extra_events(self):
373 """When the lexer buffer is empty, flushing is a no-op."""
374 engine = StreamingParserEngine(_think_config(), _make_think_tokenizer())
375
376 engine.feed("<think>", [_START_ID])
377 engine.feed("clean text", [])
378
379 events = engine.feed("</think>", [_END_ID])
380 assert any(e.type == EventType.REASONING_END for e in events)
381 chunk_events = [e for e in events if e.type == EventType.REASONING_CHUNK]
382 assert all(e.value for e in chunk_events)
383
384
385 class TestTokenIdFiltering:
386 """When token IDs are available, lex-matched terminals that also
387 have token_id_terminal entries should be demoted to content."""
388
389 def test_lex_matched_terminal_demoted_after_token_ids_seen(self):
390 """After receiving token IDs, text that matches a token-ID
391 terminal should be treated as content, not trigger a transition."""
392 engine = StreamingParserEngine(_hermes_config(), _make_hermes_tokenizer())
393
394 # First feed with a non-special token ID to set _ever_had_token_ids
395 engine.feed("prefix ", [1])
396
397 # Now feed text containing <tool_call> as literal text
398 events = engine.feed(
399 "Use <tool_call> to invoke tools.</tool_call>", [2, 3, 4, 5]
400 )
401 events.extend(engine.finish())
402
403 types = [e.type for e in events]
404 assert EventType.TOOL_CALL_START not in types
405 assert EventType.TEXT_CHUNK in types
406
407 text = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK)
408 assert "<tool_call>" in text
409
410 def test_scanner_matched_terminal_bypasses_filter(self):
411 """PreLexedTerminals from the scanner bypass the filter and
412 still trigger state transitions."""
413 engine = StreamingParserEngine(_hermes_config(), _make_hermes_tokenizer())
414
415 events = engine.feed("<tool_call>", [_TOOL_START_ID])
416 assert any(e.type == EventType.TOOL_CALL_START for e in events)
417
418 events = engine.feed('{"name": "f"}', [2, 3])
419 events.extend(engine.feed("</tool_call>", [_TOOL_END_ID]))
420 events.extend(engine.finish())
421 assert any(e.type == EventType.TOOL_CALL_END for e in events)
422
423 def test_no_filtering_without_token_ids(self):
424 """When no token IDs are ever provided (non-streaming),
425 text matching still triggers transitions."""
426 engine = StreamingParserEngine(_hermes_config(), _make_hermes_tokenizer())
427
428 events = engine.feed('<tool_call>{"name": "f"}</tool_call>', [])
429 events.extend(engine.finish())
430
431 types = [e.type for e in events]
432 assert EventType.TOOL_CALL_START in types
433 assert EventType.TOOL_CALL_END in types
434
435 def test_mixed_text_then_real_tool_call(self):
436 """Text mentioning tool syntax followed by a real special-token
437 tool call."""
438 engine = StreamingParserEngine(_hermes_config(), _make_hermes_tokenizer())
439
440 events1 = engine.feed("Mention <tool_call> in text. ", [1, 2, 3, 4])
441 events2 = engine.feed("<tool_call>", [_TOOL_START_ID])
442 events3 = engine.feed('{"name": "a"}', [5, 6])
443 events4 = engine.feed("</tool_call>", [_TOOL_END_ID])
444 events4.extend(engine.finish())
445
446 all_events = events1 + events2 + events3 + events4
447
448 content = "".join(e.value for e in all_events if e.type == EventType.TEXT_CHUNK)
449 assert "<tool_call>" in content
450
451 assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_START) == 1
452 assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_END) == 1
453
454
455 class TestReasoningTokenCounts:
456 def test_counts_reasoning_and_excludes_boundaries_and_content(self):
457 engine = StreamingParserEngine(_token_think_config(), _make_think_tokenizer())
458
459 events = engine.feed(
460 "<think>tok1tok2</think>tok3",
461 [_START_ID, 1, 2, _END_ID, 3],
462 )
463 events.extend(engine.finish())
464
465 reasoning = [e for e in events if e.type == EventType.REASONING_CHUNK]
466 assert "".join(e.value for e in reasoning) == "tok1tok2"
467 assert sum(e.token_count for e in reasoning) == 2
468 assert engine.reasoning_token_count == 2
469
470 def test_counts_tokens_after_final_deferred_start_terminal(self):
471 engine = StreamingParserEngine(_token_think_config(), _make_think_tokenizer())
472
473 assert engine.feed("", [_START_ID, 1, 2]) == []
474 events = engine.feed("<think>tok1tok2", [])
475
476 reasoning = [e for e in events if e.type == EventType.REASONING_CHUNK]
477 assert "".join(e.value for e in reasoning) == "tok1tok2"
478 assert sum(e.token_count for e in reasoning) == 2
479 assert engine.reasoning_token_count == 2
480
481 def test_deferred_reasoning_tokens_stay_before_next_end_terminal(self):
482 engine = StreamingParserEngine(_token_think_config(), _make_think_tokenizer())
483
484 assert engine.feed("", [_START_ID, 1, 2]) == []
485 events = engine.feed("<think>tok1tok2</think>tok3", [_END_ID, 3])
486
487 assert (
488 sum(e.token_count for e in events if e.type == EventType.REASONING_CHUNK)
489 == 2
490 )
491 assert sum(e.token_count for e in events if e.type == EventType.TEXT_CHUNK) == 1
492 assert engine.reasoning_token_count == 2
493
494
495 def _func_prefix_config() -> ParserEngineConfig:
496 """Config mixing token-ID terminals (TOOL_START/END) with
497 text-only terminals (FUNC_PREFIX) and fallback transitions."""
498 return ParserEngineConfig(
499 name="func_prefix_test",
500 terminals={
501 "TOOL_START": "<tool_call>",
502 "TOOL_END": "</tool_call>",
503 "FUNC_PREFIX": "<function=",
504 "FUNC_END": "</function>",
505 "CLOSE_ANGLE": ">",
506 },
507 token_id_terminals={
508 "TOOL_START": "<tool_call>",
509 "TOOL_END": "</tool_call>",
510 },
511 transitions={
512 (ParserState.CONTENT, "TOOL_START"): Transition(
513 ParserState.TOOL_PREAMBLE,
514 (EventType.TOOL_CALL_START,),
515 ),
516 (ParserState.CONTENT, "FUNC_PREFIX"): Transition(
517 ParserState.TOOL_NAME,
518 (EventType.TOOL_CALL_START,),
519 skip_in_token_id_mode=True,
520 ),
521 (ParserState.TOOL_PREAMBLE, "FUNC_PREFIX"): Transition(
522 ParserState.TOOL_NAME,
523 (),
524 ),
525 (ParserState.TOOL_NAME, "CLOSE_ANGLE"): Transition(
526 ParserState.TOOL_ARGS,
527 (),
528 ),
529 (ParserState.TOOL_ARGS, "FUNC_END"): Transition(
530 ParserState.TOOL_BETWEEN,
531 (EventType.TOOL_CALL_END,),
532 ),
533 (ParserState.TOOL_BETWEEN, "TOOL_END"): Transition(
534 ParserState.CONTENT,
535 (),
536 ),
537 (ParserState.TOOL_BETWEEN, "TOOL_START"): Transition(
538 ParserState.TOOL_PREAMBLE,
539 (EventType.TOOL_CALL_START,),
540 ),
541 (ParserState.TOOL_BETWEEN, "FUNC_PREFIX"): Transition(
542 ParserState.TOOL_NAME,
543 (EventType.TOOL_CALL_START,),
544 skip_in_token_id_mode=True,
545 ),
546 },
547 content_events={
548 ParserState.CONTENT: EventType.TEXT_CHUNK,
549 ParserState.TOOL_NAME: EventType.TOOL_NAME,
550 ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
551 },
552 )
553
554
555 def _make_func_prefix_tokenizer():
556 return make_mock_tokenizer(
557 {
558 "<tool_call>": _TOOL_START_ID,
559 "</tool_call>": _TOOL_END_ID,
560 }
561 )
562
563
564 class TestTextOnlyFallbackFiltering:
565 """When token IDs are available, transitions marked
566 skip_in_token_id_mode should be skipped."""
567
568 def test_func_prefix_in_prose_demoted_in_strict_mode(self):
569 """<function=get_time> in prose should NOT trigger a tool call
570 when strict mode is active."""
571 engine = StreamingParserEngine(
572 _func_prefix_config(), _make_func_prefix_tokenizer()
573 )
574 engine.feed("prefix ", [1])
575
576 events = engine.feed("Use <function=get_time> to check.", [2, 3, 4, 5])
577 events.extend(engine.finish())
578
579 types = [e.type for e in events]
580 assert EventType.TOOL_CALL_START not in types
581 assert EventType.TEXT_CHUNK in types
582 text = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK)
583 assert "<function=" in text
584
585 def test_normal_flow_after_tool_start_still_works(self):
586 """TOOL_START (special token) -> FUNC_PREFIX (text) should
587 still parse a tool call normally in strict mode."""
588 engine = StreamingParserEngine(
589 _func_prefix_config(), _make_func_prefix_tokenizer()
590 )
591
592 events1 = engine.feed("<tool_call>", [_TOOL_START_ID])
593 assert any(e.type == EventType.TOOL_CALL_START for e in events1)
594
595 events2 = engine.feed("<function=get_weather>", [2, 3])
596 events3 = engine.feed("args", [4])
597 events4 = engine.feed("</function>", [5, 6])
598 events4.extend(engine.feed("</tool_call>", [_TOOL_END_ID]))
599 events4.extend(engine.finish())
600
601 all_events = events1 + events2 + events3 + events4
602 assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_START) == 1
603 assert sum(1 for e in all_events if e.type == EventType.TOOL_CALL_END) == 1
604
605 def test_fallback_fires_without_token_ids(self):
606 """When no token IDs are provided, fallback transitions should
607 still fire normally."""
608 engine = StreamingParserEngine(
609 _func_prefix_config(), _make_func_prefix_tokenizer()
610 )
611
612 events = engine.feed("<function=get_time>args</function>", [])
613 events.extend(engine.finish())
614
615 types = [e.type for e in events]
616 assert EventType.TOOL_CALL_START in types
617 assert EventType.TOOL_CALL_END in types
618
619 def test_tool_between_fallback_blocked_in_strict_mode(self):
620 """The (TOOL_BETWEEN, FUNC_PREFIX) fallback should also be
621 blocked in strict mode."""
622 engine = StreamingParserEngine(
623 _func_prefix_config(), _make_func_prefix_tokenizer()
624 )
625
626 engine.feed("<tool_call>", [_TOOL_START_ID])
627 engine.feed("<function=a>", [2, 3])
628 engine.feed("args", [4])
629 engine.feed("</function>", [5, 6])
630 engine.feed("</tool_call>", [_TOOL_END_ID])
631
632 events = engine.feed("<function=b>more</function>", [7, 8, 9])
633 events.extend(engine.finish())
634
635 types = [e.type for e in events]
636 assert EventType.TOOL_CALL_START not in types
637
638
639 class TestNoUnusedTokenizerAttr:
640 """StreamingParserEngine no longer stores a redundant _tokenizer."""
641
642 def test_no_tokenizer_attribute(self):
643 config = ParserEngineConfig(name="test")
644 engine = StreamingParserEngine(config, tokenizer=None)
645 assert not hasattr(engine, "_tokenizer")
646
647
648 class TestArgsResetOnReentry:
649 """When leaving TOOL_ARGS and later re-entering (e.g. two tool
650 calls), the entering-TOOL_ARGS block resets args tracking. The
651 redundant reset on exit was removed."""
652
653 @staticmethod
654 def _multi_tool_config() -> ParserEngineConfig:
655 return ParserEngineConfig(
656 name="multi_tool",
657 terminals={
658 "TOOL_START": "<tool_call>",
659 "TOOL_END": "</tool_call>",
660 "TOOL_SEP": "<tool_sep>",
661 },
662 transitions={
663 (ParserState.CONTENT, "TOOL_START"): Transition(
664 ParserState.TOOL_ARGS,
665 (EventType.TOOL_CALL_START,),
666 ),
667 (ParserState.TOOL_ARGS, "TOOL_END"): Transition(
668 ParserState.TOOL_BETWEEN,
669 (EventType.TOOL_CALL_END,),
670 ),
671 (ParserState.TOOL_BETWEEN, "TOOL_SEP"): Transition(
672 ParserState.TOOL_ARGS,
673 (EventType.TOOL_CALL_START,),
674 ),
675 },
676 content_events={
677 ParserState.CONTENT: EventType.TEXT_CHUNK,
678 ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
679 },
680 )
681
682 def test_args_tracking_across_reentry(self):
683 engine = StreamingParserEngine(self._multi_tool_config(), tokenizer=None)
684
685 events = engine.feed(
686 '<tool_call>{"city": "SF"}</tool_call>'
687 "<tool_sep>"
688 '{"name": "bar"}</tool_call>',
689 [],
690 )
691
692 tool_starts = [e for e in events if e.type == EventType.TOOL_CALL_START]
693 tool_ends = [e for e in events if e.type == EventType.TOOL_CALL_END]
694 arg_chunks = [e for e in events if e.type == EventType.ARG_VALUE_CHUNK]
695
696 assert len(tool_starts) == 2
697 assert len(tool_ends) == 2
698 assert tool_starts[0].tool_index == 0
699 assert tool_starts[1].tool_index == 1
700
701 first_args = "".join(e.value for e in arg_chunks if e.tool_index == 0)
702 second_args = "".join(e.value for e in arg_chunks if e.tool_index == 1)
703 assert '"city"' in first_args
704 assert '"name"' in second_args
705
706 def test_brace_depth_resets_on_reentry(self):
707 """Verify _args_brace_depth resets when re-entering TOOL_ARGS."""
708 engine = StreamingParserEngine(self._multi_tool_config(), tokenizer=None)
709 engine.feed("<tool_call>", [])
710 assert engine.state == ParserState.TOOL_ARGS
711 assert engine._args_brace_depth == 0
712
713 engine.feed('{"a": 1}', [])
714 engine.feed("</tool_call>", [])
715 assert engine.state == ParserState.TOOL_BETWEEN
716
717 engine.feed("<tool_sep>", [])
718 assert engine.state == ParserState.TOOL_ARGS
719 assert engine._args_brace_depth == 0
720 assert engine._args_in_string is False
721 assert engine._args_escape_next is False
722
723
724 class TestToolPreambleFinish:
725 """finish() in TOOL_PREAMBLE state emits TOOL_CALL_END when a tool
726 call was started (tool_index >= 0), but not when tool_index is -1."""
727
728 @staticmethod
729 def _preamble_with_tool_call_start_config() -> ParserEngineConfig:
730 return ParserEngineConfig(
731 name="preamble_tcs",
732 terminals={"TOOL_START": "<tool_call>"},
733 transitions={
734 (ParserState.CONTENT, "TOOL_START"): Transition(
735 ParserState.TOOL_PREAMBLE,
736 (EventType.TOOL_CALL_START,),
737 ),
738 },
739 content_events={ParserState.CONTENT: EventType.TEXT_CHUNK},
740 )
741
742 @staticmethod
743 def _preamble_without_tool_call_start_config() -> ParserEngineConfig:
744 return ParserEngineConfig(
745 name="preamble_no_tcs",
746 terminals={"TOOL_CALLS_START": "<tool_calls>"},
747 transitions={
748 (ParserState.CONTENT, "TOOL_CALLS_START"): Transition(
749 ParserState.TOOL_PREAMBLE,
750 (),
751 ),
752 },
753 content_events={ParserState.CONTENT: EventType.TEXT_CHUNK},
754 )
755
756 def test_finish_emits_tool_call_end_with_tool_index(self):
757 config = self._preamble_with_tool_call_start_config()
758 engine = StreamingParserEngine(config, tokenizer=None)
759
760 engine.feed("<tool_call>", [])
761 assert engine.state == ParserState.TOOL_PREAMBLE
762 assert engine.tool_index == 0
763
764 finish_events = engine.finish()
765 end_events = [e for e in finish_events if e.type == EventType.TOOL_CALL_END]
766 assert len(end_events) == 1
767 assert end_events[0].tool_index == 0
768
769 def test_finish_no_tool_call_end_without_tool_index(self):
770 config = self._preamble_without_tool_call_start_config()
771 engine = StreamingParserEngine(config, tokenizer=None)
772
773 engine.feed("<tool_calls>", [])
774 assert engine.state == ParserState.TOOL_PREAMBLE
775 assert engine.tool_index == -1
776
777 finish_events = engine.finish()
778 end_events = [e for e in finish_events if e.type == EventType.TOOL_CALL_END]
779 assert len(end_events) == 0
780 assert engine.state == ParserState.CONTENT
781
782
783 class TestRegexTerminalInfraRemoved:
784 """TerminalDef.priority, LexerShape.regex_terminals, and the regex
785 matching loop were removed."""
786
787 def test_terminal_def_no_priority(self):
788 import regex as re
789
790 td = TerminalDef(name="X", pattern=re.compile("x"))
791 assert not hasattr(td, "priority")
792
793 def test_lexer_shape_no_regex_terminals(self):
794 shape = LexerShape([])
795 assert not hasattr(shape, "regex_terminals")
796
797 def test_terminals_from_literals_still_works(self):
798 literals = {"TOOL_START": "<tool_call>", "TOOL_END": "</tool_call>"}
799 defs = terminals_from_literals(literals)
800 assert len(defs) == 2
801 names = {d.name for d in defs}
802 assert names == {"TOOL_START", "TOOL_END"}
803 for d in defs:
804 assert d.is_literal
805 assert d.literal in ("<tool_call>", "</tool_call>")
806
807
808 class TestMultiCharTerminalInArgs:
809 """Regression: multi-char terminals falling through in TOOL_ARGS
810 must be fed char-by-char via _feed_args_text, not _feed_args_char."""
811
812 @staticmethod
813 def _newline_config() -> ParserEngineConfig:
814 return ParserEngineConfig(
815 name="newline_test",
816 terminals={
817 "TOOL_START": "<tool_call>",
818 "TOOL_END": "</tool_call>",
819 "NEWLINE": "\n",
820 },
821 transitions={
822 (ParserState.CONTENT, "TOOL_START"): Transition(
823 ParserState.TOOL_ARGS,
824 (EventType.TOOL_CALL_START,),
825 ),
826 (ParserState.TOOL_ARGS, "TOOL_END"): Transition(
827 ParserState.CONTENT,
828 (EventType.TOOL_CALL_END,),
829 ),
830 },
831 content_events={
832 ParserState.CONTENT: EventType.TEXT_CHUNK,
833 ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
834 },
835 )
836
837 def test_newline_in_args_parsed_correctly(self):
838 engine = StreamingParserEngine(self._newline_config(), tokenizer=None)
839 text = '<tool_call>{"name": "f",\n"arguments": {"a": 1}}</tool_call>'
840 events = engine.parse_complete(text)
841
842 arg_text = "".join(
843 e.value for e in events if e.type == EventType.ARG_VALUE_CHUNK
844 )
845 assert '"name": "f"' in arg_text
846 assert '"arguments"' in arg_text
847
848 def test_newline_in_args_streaming(self):
849 engine = StreamingParserEngine(self._newline_config(), tokenizer=None)
850 all_events = TestStreaming._feed_chars(
851 engine, '<tool_call>{"name": "f",\n"a": 1}</tool_call>'
852 )
853
854 arg_text = "".join(
855 e.value for e in all_events if e.type == EventType.ARG_VALUE_CHUNK
856 )
857 assert '"name": "f"' in arg_text
858 assert '"a": 1' in arg_text
859
860
861 class TestSkipToolParsing:
862 """When skip_tool_parsing is set, tool tags become content."""
863
864 def test_tool_tags_emitted_as_content(self):
865 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
866 engine.skip_tool_parsing = True
867
868 text = '<tool_call>{"name": "f"}</tool_call>'
869 events = engine.parse_complete(text)
870
871 types = [e.type for e in events]
872 assert EventType.TOOL_CALL_START not in types
873 assert EventType.TOOL_CALL_END not in types
874
875 content = "".join(e.value for e in events if e.type == EventType.TEXT_CHUNK)
876 assert "<tool_call>" in content
877 assert "</tool_call>" in content
878
879 def test_skip_tool_streaming(self):
880 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
881 engine.skip_tool_parsing = True
882
883 all_events = TestStreaming._feed_chars(
884 engine, '<tool_call>{"name": "f"}</tool_call>'
885 )
886
887 types = [e.type for e in all_events]
888 assert EventType.TOOL_CALL_START not in types
889
890 content = "".join(e.value for e in all_events if e.type == EventType.TEXT_CHUNK)
891 assert "<tool_call>" in content
892
893 def test_reset_preserves_skip_tool_parsing(self):
894 engine = StreamingParserEngine(_hermes_config(), tokenizer=None)
895 engine.skip_tool_parsing = True
896 engine.reset()
897 assert engine.skip_tool_parsing is True
898
899
900 def _message_header_config(reasoning_end: bool) -> ParserEngineConfig:
901 """MESSAGE_HEADER-based grammar whose tool opener optionally carries
902 REASONING_END, mirroring the two shapes a transition table can take."""
903 return ParserEngineConfig(
904 name=f"message_header_test_{reasoning_end}",
905 initial_state=ParserState.MESSAGE_HEADER,
906 terminals={"HEADER_END": "<|header_end|>", "TOOL_START": "<tool_call>"},
907 transitions={
908 (ParserState.MESSAGE_HEADER, "HEADER_END"): Transition(
909 ParserState.CONTENT,
910 (EventType.TEXT_CHUNK,),
911 ),
912 (ParserState.MESSAGE_HEADER, "TOOL_START"): Transition(
913 ParserState.TOOL_ARGS,
914 (EventType.REASONING_END, EventType.TOOL_CALL_START)
915 if reasoning_end
916 else (EventType.TOOL_CALL_START,),
917 ),
918 },
919 )
920
921
922 class TestSkipToolParsingFromMessageHeader:
923 """Leaving MESSAGE_HEADER through the skip_tool_parsing gate must honour
924 the transition's REASONING_END.
925
926 The reasoning pass hands off to the tool pass only once it has seen a
927 REASONING_END, so swallowing it under the header passthrough strands the
928 whole tool block in content. Tables carrying no REASONING_END keep the
929 plain passthrough.
930 """
931
932 @staticmethod
933 def _skip_events(reasoning_end: bool):
934 engine = StreamingParserEngine(
935 _message_header_config(reasoning_end), tokenizer=None
936 )
937 engine.skip_tool_parsing = True
938 return engine, engine.parse_complete("name<tool_call>{}")
939
940 def test_reasoning_end_precedes_forwarded_tool_syntax(self):
941 engine, events = self._skip_events(reasoning_end=True)
942 assert [e.type for e in events[:2]] == [
943 EventType.REASONING_END,
944 EventType.TEXT_CHUNK,
945 ]
946 assert events[1].value == "<tool_call>"
947 assert engine._message_header_buffer == ""
948
949 def test_passthrough_only_when_transition_omits_reasoning_end(self):
950 engine, events = self._skip_events(reasoning_end=False)
951 types = [e.type for e in events]
952 assert EventType.REASONING_END not in types
953 assert types[0] == EventType.TEXT_CHUNK
954 assert events[0].value == "<tool_call>"
955 assert engine._message_header_buffer == ""
956
957
958 # ── Terminals with several spellings ──────────────────────────────────
959
960
961 def _alias_config() -> ParserEngineConfig:
962 """``TOOL_START`` has a canonical spelling and one corrupted alias."""
963 return ParserEngineConfig(
964 name="alias_test",
965 terminals={"TOOL_START": ("<tc>", "<tcx>"), "TOOL_END": "</tc>"},
966 transitions={
967 (ParserState.CONTENT, "TOOL_START"): Transition(
968 ParserState.TOOL_ARGS,
969 (EventType.TOOL_CALL_START,),
970 ),
971 (ParserState.TOOL_ARGS, "TOOL_END"): Transition(
972 ParserState.CONTENT,
973 (EventType.TOOL_CALL_END,),
974 ),
975 },
976 content_events={
977 ParserState.CONTENT: EventType.TEXT_CHUNK,
978 ParserState.TOOL_ARGS: EventType.ARG_VALUE_CHUNK,
979 },
980 tool_args_json=False,
981 )
982
983
984 class TestTerminalAliases:
985 def test_terminal_defs_expand_every_spelling(self):
986 defs = terminals_from_literals({"A": ("x", "y"), "B": "z"})
987 assert [(d.name, d.literal) for d in defs] == [
988 ("A", "x"),
989 ("A", "y"),
990 ("B", "z"),
991 ]
992
993 def test_terminal_literal_is_canonical_spelling(self):
994 cfg = _alias_config()
995 assert cfg.terminal_literal("TOOL_START") == "<tc>"
996 assert cfg.terminal_literal("TOOL_END") == "</tc>"
997 assert cfg.terminal_literal("MISSING") is None
998 assert cfg.terminal_literals == {"<tc>", "<tcx>", "</tc>"}
999
1000 @pytest.mark.parametrize("spelling", ["<tc>", "<tcx>"])
1001 @pytest.mark.parametrize("char_by_char", [False, True])
1002 def test_every_spelling_shares_transitions(self, spelling, char_by_char):
1003 engine = StreamingParserEngine(_alias_config(), None)
1004 text = f"{spelling}a</tc>"
1005 events = []
1006 for chunk in list(text) if char_by_char else [text]:
1007 events.extend(engine.feed(chunk, []))
1008 events.extend(engine.finish())
1009 assert [e.type for e in events] == [
1010 EventType.TOOL_CALL_START,
1011 EventType.ARG_VALUE_CHUNK,
1012 EventType.TOOL_CALL_END,
1013 ]