[Bugfix][Frontend] Fix Inkling tool name leaking into content after reasoning (#58792)

Signed-off-by: Baljinder Hothi <baljinder.hothi@cohere.com> Co-authored-by: Flora Feng <4florafeng@gmail.com>

Baljinder Hothi committed Sep 25, 2026 at 20:38 UTC fdcce47e9bd9d0068770e4076b8aab5b09150244
2 files changed +38 -3
tests/parser/engine/test_inkling.py
+34
@@ -23,8 +23,13 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
23 ChatCompletionToolsParam,
24 FunctionDefinition,
25 )
26 +from vllm.parser.abstract_parser import DelegatingParser
27 from vllm.parser.engine.events import EventType
28 from vllm.parser.engine.parser_engine_config import ParserState
29 +from vllm.parser.engine.registered_adapters import (
30 + InklingParserReasoningAdapter,
31 + InklingParserToolAdapter,
32 +)
33 from vllm.parser.engine.streaming_parser_engine import StreamingParserEngine
34 from vllm.parser.inkling import InklingParser, _inkling_arg_converter, inkling_config
35 from vllm.parser.parser_manager import ParserManager
@@ -166,6 +171,11 @@ def _delegating(mock_tokenizer, tools=None):
171 return parser_cls(mock_tokenizer, tools or [])
172
173
174 +class _TwoPassInklingParser(DelegatingParser):
175 + reasoning_parser_cls = InklingParserReasoningAdapter
176 + tool_parser_cls = InklingParserToolAdapter
177 +
178 +
179 def _stream_delegating(parser, request, text, chunk_size, prompt_token_ids):
180 """Stream ``text`` through ``DelegatingParser.parse_delta``, ``chunk_size``
181 tokens per delta; return ``(content, reasoning, ordered tool names,
@@ -759,6 +769,30 @@ class TestDelegatingTwoPass:
769 assert TOOL_JSON not in content
770 assert END_MESSAGE not in content
771
772 + def test_function_name_not_in_content_after_reasoning(
773 + self, mock_tokenizer, mock_request
774 + ):
775 + name = "search_documents_by_keyword"
776 + tools = [_function_tool(name)]
777 + mock_request.tools = tools
778 + text = (
779 + f"{THINK_START} context.{END_MESSAGE}{MSG_MODEL}"
780 + f"{name}{_tool_block(name, '{}')}"
781 + )
782 +
783 + content, reasoning, names, args = _stream_delegating(
784 + _TwoPassInklingParser(mock_tokenizer, tools),
785 + mock_request,
786 + text,
787 + chunk_size=12,
788 + prompt_token_ids=self.GEN_PROMPT,
789 + )
790 +
791 + assert reasoning == " context."
792 + assert content == ""
793 + assert names == [name]
794 + assert json.loads(args[0]) == {}
795 +
796 @pytest.mark.parametrize("opener", [TEXT_START, TOOL_TEXT, TOOL_ERROR])
797 @pytest.mark.parametrize("chunk_size", [1, 3, 64])
798 def test_visible_text_then_tool_streaming(
vllm/parser/inkling.py
+4 -3
@@ -167,11 +167,12 @@ def inkling_config() -> ParserEngineConfig:
167 (EventType.REASONING_END, EventType.TOOL_CALL_START),
168 ),
169 }
170 - # Block-end terminals behave identically regardless of label. A
171 - # closed tool block returns to CONTENT (Inkling has no section wrapper;
170 + # A closed tool block returns to CONTENT (Inkling has no section wrapper;
171 # blocks of any kind may follow), which also keeps the block-kind
172 # and role tokens out of the engine's tool-terminal set so the
173 # skip_tool_parsing reasoning pass still classifies reasoning.
174 + # In a reasoning block, THINK_END defers REASONING_END to the next block
175 + # opener so the function-name header is not emitted as content.
176 for end in ("THINK_END", "END_SAMPLING"):
177 transitions[(ParserState.CONTENT, end)] = Transition(
178 ParserState.CONTENT,
@@ -179,7 +180,7 @@ def inkling_config() -> ParserEngineConfig:
180 )
181 transitions[(ParserState.REASONING, end)] = Transition(
182 ParserState.CONTENT,
182 - (EventType.REASONING_END,),
183 + () if end == "THINK_END" else (EventType.REASONING_END,),
184 )
185 transitions[(ParserState.TOOL_ARGS, end)] = Transition(
186 ParserState.CONTENT,