@@ -436,4 +436,160 @@ async def run():
436436 self .assertEqual (
437437 assembled , "Paris is the capital of France." ,
438438 f"Content wrong or duplicated: { assembled !r} " ,
439+ )
440+ @unittest .expectedFailure
441+ def test_streaming_tool_parser_marker_split_across_chunks (self ):
442+ """
443+ Case 4 (TDD — Option B state machine, marker spans chunk boundary):
444+
445+ vLLM may yield "Some text <tool_" in one iteration and
446+ "call>\\ n{...}\\ n</tool_call>" in the next. The state machine must hold
447+ the incomplete prefix ("<tool_") in an uncertainty buffer rather than
448+ streaming it, since it could be the start of a marker. Once the full
449+ marker is confirmed in the next chunk, buffering activates.
450+
451+ Expected:
452+ - "Some text " streams through as intermediate content.
453+ - "<tool_" does NOT appear in any streamed intermediate content.
454+ - Final chunk carries the parsed tool_call (name "calc").
455+ """
456+ import sys , os , asyncio
457+ from types import SimpleNamespace
458+ sys .path .insert (0 , os .path .dirname (os .path .abspath (__file__ )))
459+ from backend import BackendServicer
460+
461+ def make_generate (chunks ):
462+ async def gen (* a , ** k ):
463+ for t in chunks :
464+ yield SimpleNamespace (
465+ outputs = [SimpleNamespace (text = t , token_ids = [1 ], logprobs = None )],
466+ prompt_token_ids = [0 ],
467+ )
468+ return lambda * a , ** k : gen ()
469+
470+ call = SimpleNamespace (
471+ id = "call_1" ,
472+ function = SimpleNamespace (name = "calc" , arguments = '{"x": 1}' ),
473+ )
474+
475+ class StateMachineParser :
476+ def __init__ (self , tokenizer , tools = None ):
477+ pass
478+
479+ def extract_tool_calls (self , c , request = None ):
480+ return SimpleNamespace (tools_called = True , content = "" , tool_calls = [call ])
481+
482+ def collect (servicer , req ):
483+ async def run ():
484+ return [r async for r in servicer ._predict (req , None , streaming = True )]
485+ return asyncio .run (run ())
486+
487+ # Marker "<tool_call>" is split: first chunk ends with "<tool_",
488+ # second chunk starts with "call>..."
489+ full_tool = '<tool_call>\n {"name": "calc", "arguments": {"x": 1}}\n </tool_call>'
490+ s = BackendServicer ()
491+ s .reasoning_parser_cls = None
492+ s .tokenizer = None
493+ s .llm = SimpleNamespace (generate = make_generate ([
494+ "Some text <tool_" ,
495+ "Some text " + full_tool ,
496+ ]))
497+ s .tool_parser_cls = StateMachineParser
498+
499+ tools_json = '[{"type":"function","function":{"name":"calc"}}]'
500+ req = backend_pb2 .PredictOptions (Prompt = "x" , Tools = tools_json )
501+ replies = collect (s , req )
502+
503+ intermediate_content = [
504+ cd .content
505+ for r in replies [:- 1 ]
506+ for cd in r .chat_deltas
507+ if cd .content
508+ ]
509+
510+ # "Some text " must have streamed through before the marker was seen
511+ self .assertTrue (
512+ any ("Some text" in c for c in intermediate_content ),
513+ "Content before the marker was not streamed progressively." ,
514+ )
515+
516+ # The marker prefix "<tool_" must never appear as streamed content
517+ self .assertFalse (
518+ any ("<tool_" in c for c in intermediate_content ),
519+ "Incomplete marker prefix was streamed before it was confirmed." ,
520+ )
521+
522+ # Final chunk must carry the parsed tool_call
523+ names = [tc .name for r in replies for cd in r .chat_deltas for tc in cd .tool_calls ]
524+ self .assertIn ("calc" , names , "Structured tool_call not present in final chunk." )
525+
526+ @unittest .expectedFailure
527+ def test_streaming_tool_parser_false_positive_marker (self ):
528+ """
529+ Case 5 (TDD — Option B state machine, false-positive marker prefix):
530+
531+ The model writes text that starts like a tool-call marker but is not one,
532+ e.g. "Let me use a <tool" followed by "box> to help." The state machine
533+ must flush the uncertainty buffer as content once the prefix is disconfirmed.
534+
535+ Expected:
536+ - All text streams through as content (no tool call).
537+ - Assembled content equals the full sentence exactly once (no duplication).
538+ - No tool_calls in the final reply.
539+ """
540+ import sys , os , asyncio
541+ from types import SimpleNamespace
542+ sys .path .insert (0 , os .path .dirname (os .path .abspath (__file__ )))
543+ from backend import BackendServicer
544+
545+ def make_generate (chunks ):
546+ async def gen (* a , ** k ):
547+ for t in chunks :
548+ yield SimpleNamespace (
549+ outputs = [SimpleNamespace (text = t , token_ids = [1 ], logprobs = None )],
550+ prompt_token_ids = [0 ],
551+ )
552+ return lambda * a , ** k : gen ()
553+
554+ class StateMachineParser :
555+ def __init__ (self , tokenizer , tools = None ):
556+ pass
557+
558+ def extract_tool_calls (self , c , request = None ):
559+ return SimpleNamespace (tools_called = False , content = c , tool_calls = [])
560+
561+ def collect (servicer , req ):
562+ async def run ():
563+ return [r async for r in servicer ._predict (req , None , streaming = True )]
564+ return asyncio .run (run ())
565+
566+ # "<tool" looks like the start of "<tool_call>" but completes as "<toolbox>"
567+ full_text = "Let me use a <toolbox> to help."
568+ s = BackendServicer ()
569+ s .reasoning_parser_cls = None
570+ s .tokenizer = None
571+ s .llm = SimpleNamespace (generate = make_generate ([
572+ "Let me use a <tool" ,
573+ full_text ,
574+ ]))
575+ s .tool_parser_cls = StateMachineParser
576+
577+ tools_json = '[{"type":"function","function":{"name":"calc"}}]'
578+ req = backend_pb2 .PredictOptions (Prompt = "x" , Tools = tools_json )
579+ replies = collect (s , req )
580+
581+ # Full text must be assembled exactly once (uncertainty buffer flushed correctly)
582+ assembled = "" .join (
583+ cd .content for r in replies for cd in r .chat_deltas if cd .content
584+ )
585+ self .assertEqual (
586+ assembled , full_text ,
587+ f"False-positive marker mangled or duplicated the content: { assembled !r} " ,
588+ )
589+
590+ # No tool_calls must be present
591+ tool_calls = [tc for r in replies for cd in r .chat_deltas for tc in cd .tool_calls ]
592+ self .assertEqual (
593+ len (tool_calls ), 0 ,
594+ "Tool call emitted for plain-text response with false-positive marker." ,
439595 )
0 commit comments