Skip to content

[Bug] Inkling reasoning parser leaks the tool name into visible content when a turn opens with a tool call #33181

Description

@zolinthecow

Checklist

  • I searched related issues but found no solution.
  • The bug persists in the latest version.
  • Issues without environment info and a minimal reproducible demo are hard to resolve and may receive no feedback.
  • If this is not a bug report but a general question, please start a discussion at https://github.com/sgl-project/sglang/discussions. Otherwise, it will be closed.
  • Please use English. Otherwise, it will be closed.

Describe the bug

I encountered this bug when tool call names were outputted by ~10% of our Inkling sessions which seemed very strange for a frontier lab model. Asked Devin to research and it produced this issue report. I confirmed everything myself, but everything beyond here is written by Devin:

Describe the bug

When an Inkling turn's first block is a tool invocation and the generated text does
not start with a <|message_model|> opener, InklingDetector emits the tool name as
ordinary visible content. The tool call itself is still parsed correctly, so the
caller receives both a valid tool_calls entry and a stray assistant message
containing just the tool's name:

{
  "content": "get_weather",
  "tool_calls": [{"function": {"name": "get_weather", "arguments": "{}"}}]
}

A turn that opens with a thinking block is unaffected, because the model emits its
own <|message_model|> for the following block.

The cause is in python/sglang/srt/parser/reasoning_parser.py. _parse_blocks only
enters header mode when it actually sees the opener token:

# reasoning_parser.py:865-869
if token == MESSAGE_MODEL:
    if self._kind in (None, "header"):
        flush_reasoning()
        self._pending_header = ""
        self._kind = "header"

With no opener, self._kind is still None when the tool name is emitted, so
emit() falls through to the trailing branch and routes it to visible content:

# reasoning_parser.py:846-852
elif self._kind == "header":
    self._pending_header += text
elif text:
    # No open block — e.g. a continue_final_message stream resuming
    # mid text block. Route to visible content, matching the
    # ...
    content.append(text)

By the time <|content_invoke_tool_json|> arrives, the name has already been
appended to content. That branch does reconstruct the framing when a header was
buffered, but not when _kind is None:

# reasoning_parser.py:877-885
flush_reasoning()
if self._kind == "header":
    content.extend((MESSAGE_MODEL, self._pending_header, token))
    self._pending_header = ""
else:
    content.append(token)
self._kind = "tool"

I realize the elif text: fallback is deliberate for resumed continue_final_message
streams. The distinguishing signal arrives one token later though: when
<|content_invoke_tool_json|> / <|content_invoke_tool_text|> is reached with
_kind is None, the immediately preceding text run can only have been a tool header,
so it could be reclassified into _pending_header instead of staying in content.
Happy to send a PR along those lines if that's the direction you'd want.

Affects both streaming and non-streaming, and reproduces on a hosted deployment at
roughly 10% of all tool-calling turns — every turn that begins with a tool call
instead of a thinking block.

Reproduction

Parser-level, no model or GPU needed. Run the attached script from an sglang checkout:

python sglang-inkling-issue-repro.py
case | mode | status | reasoning | visible | tool calls
------------------------------------------------------------------------------
A missing opener | one-shot | LEAK | '' | 'get_weather' | get_weather({})
A missing opener | streamed | LEAK | '' | 'get_weather' | get_weather({})
B opener present | one-shot | OK | '' | '' | get_weather({})
B opener present | streamed | OK | '' | '' | get_weather({})
C thinking first | one-shot | OK | 'Checking the forecast.' | '' | get_weather({})
C thinking first | streamed | OK | 'Checking the forecast.' | '' | get_weather({})

The three inputs, all of which should produce empty visible content:

A  get_weather<|content_invoke_tool_json|>{"name":"get_weather","args":{}}<|end_message|>
B  <|message_model|>get_weather<|content_invoke_tool_json|>{"name":"get_weather","args":{}}<|end_message|>
C  <|content_thinking|>Checking the forecast.<|end_message|><|message_model|>get_weather<|content_invoke_tool_json|>{"name":"get_weather","args":{}}<|end_message|>

The script drives the parsers in the same order as the OpenAI serving path —
InklingDetector first, then FunctionCallParser(tool_call_parser="inkling") on the
resulting normal_text — one-shot via detect_and_parse and streamed in 7-character
increments.

Inkling Issue Repro.py

Environment

Reproduced against main @ bae8eb8d6caa26a8820ce70c35cdfe57fa5182b3, Python 3.12.8,
Linux x86_64.

python3 -m sglang.check_env can't run here: the repro only needs the parser modules,
so the venv is CPU-only (torch 2.3.0+cpu, no CUDA), and check_env.py:585-595 selects
an env class from is_cuda_v2() / is_hip() / is_npu() / is_musa() / is_mps()
with no CPU fallback, so env.check_env() raises NameError: name 'env' is not defined. Let me know if you want the output from a GPU host instead.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions