[Bugfix][Responses] Ignore Harmony recipients when no tools are configured - #51616
Conversation
…gured When a Responses request declares no tools, the Harmony output dispatchers still routed any model-generated recipient to a tool call. Unknown recipients fell through to "All other recipients are MCP calls", so text in the prompt that looks like a tool definition could make the model emit a recipient and produce mcp_call or function_call items in a response whose `tools` field is empty. No tool session is ever initialized in that case, so the item is a mislabeled message rather than a real tool invocation. Gate recipient handling on whether the request declared tools, in both the non-streaming dispatcher and the streaming event emitters. With no declared tools the recipient is ignored and normal channel semantics apply: final and commentary become messages, analysis becomes reasoning. The gate uses `bool(request.tools)` rather than the function tool names, because an MCP-only request has tools but an empty function name set. Signed-off-by: Ziwen Zhao <zzw.mose@gmail.com>
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment Once the PR is approved or has the If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban. 🚀 |
Purpose
When a Responses request declares no tools, the Harmony output path can still return
mcp_callorfunction_callitems. Text in the prompt that merely looks like a tooldefinition is enough to trigger it: the model emits a recipient, and the output dispatcher
turns that recipient into a tool-call item even though
toolsis empty in the request andin the response.
No MCP server is ever contacted in this case —
_initialize_tool_sessions()returns earlywhen
len(request.tools) == 0— so the item is a mislabeled assistant message, not a realtool invocation. Clients that branch on
output[].typesee a tool call that never happened.Reproduction
Before this PR —
200 OKwith"tools": [],"tool_choice": "none", and a tool call inthe output:
{"type": "mcp_call", "name": "mcp", "server_label": "tool", "arguments": "{\"name\":\"get_current_time\",\"arguments\":{\"timezone\":\"America/Los_Angeles\"}}"}After this PR — the model still generates the same text, but it is classified by channel
instead of by recipient, so it comes back as an ordinary assistant message (captured from a
patched server; the argument names differ only because sampling is not deterministic):
{"type": "message", "role": "assistant", "status": "completed", "content": [{"type": "output_text", "annotations": [], "text": "{\"name\":\"get_current_time\",\"arguments\":{\"tz\":\"America/Los_Angeles\"}}"}]}The preceding
reasoningitem is unchanged, and no MCP call is executed in either case.Other model families already behave this way
This is Harmony/gpt-oss-specific. On the non-Harmony path a request that declares no tools
already comes back as plain content:
chat_completion/serving.pybuilds a content-onlymessage and drops anything the tool parser extracted.
parser/harmony.pystates theintended layering explicitly — "Tool calls are always extracted regardless of
enable_auto_tools. Callers must decide whether to surface them." The Responses/Harmonycaller was the one not making that decision, so this PR brings it in line with the behaviour
every other model family already has.
Root cause
harmony_to_response_output()and the streaming dispatchers only knowfunction_tool_names; they never know whether the request declared tools at all. Arecipient that is not
browser.*, not a known function, and not a built-in therefore fallsthrough to the final
elsebranch — "All other recipients are MCP calls" — so anymodel-invented recipient becomes an
McpCall. The streaming path has the same shape viais_mcp_tool_by_namespace(), whose comment states "everything that is not a function callis an MCP tool".
Nothing on this path consults the request, so the gate that the non-Harmony path applies in
chat_completion/serving.pyhas no equivalent here.Fix
Thread
has_declared_tools=bool(request.tools)fromserving.pyinto the outputdispatchers. When it is false the recipient is ignored and normal channel semantics apply:
final/commentarybecome messages,analysisbecomes reasoning, and no tool item can beproduced.
emit_tool_action_events()returns early so the browser action path cannot bypassthe gate.
The gate deliberately uses
bool(request.tools)and not the function tool names: anMCP-only request has tools but an empty function name set, and must keep working.
The fix is applied in the Responses dispatchers rather than in
HarmonyParser, because theparser is shared with Chat Completions and has no notion of request-level tool declarations.
Test Plan
.venv/bin/python -m pytest tests/entrypoints/openai/responses/test_harmony_utils.py \ tests/entrypoints/openai/responses/test_serving_responses.py -q .venv/bin/python -m ruff check <changed files> .venv/bin/python -m ruff format --check <changed files>New regression tests cover, for a no-tools request: an injected recipient on
commentary/finalbecoming a message and onanalysisbecoming reasoning (non-streaming);delta and completion events staying on the text path with no
mcp_call.*events (streaming);and the browser action path emitting nothing. Existing MCP/function-recipient tests are
retained as the with-tools regression direction.
Test Result
The pre-existing
xfailis the unrelated zero-delta added/in-progress TODO.Serving evaluation (
openai/gpt-oss-20b, H100, 40 requests per arm)Both arms are the same commit, same machine, same flags; the only difference is this patch.
The request above is sampled at the default
temperature=1.0, so results are a distribution.200—reasoning,mcp_call200—reasoning,function_call200—reasoning,message(correct)500— pre-existingHarmonyError(see below)tools: []Across all runs of the patched build (60 requests) no tool item was emitted. A request that
does declare tools still returns
function_call/mcp_callas before.Out of scope
Two adjacent gaps were found while validating this and are not addressed here:
Pre-existing 500 on malformed Harmony headers. The same prompt makes the model emit a
malformed header (e.g.
to=mcp,to=functions.mcp,to=ms_output) roughly 10% of thetime, and
HarmonyParser.process_chunk()callsself._harmony_parser.process(token_id)without catching
HarmonyError, so the request fails withHarmonyError: unexpected tokens remaining in message header. This reproduces identicallyon unpatched
mainat the same rate, with the same traceback frame invllm/parser/harmony.py, which this PR does not modify. Note thatflush()in that sameclass already recovers from
HarmonyErrorby returning the buffered tokens as a plainfinalmessage — the recovery policy exists, it is just not applied to the per-token call.Anyone re-running the reproduction should expect to hit this occasionally; it is not
introduced by this change.
tool_choice: "none"with non-emptytools. The Responses output path never consultstool_choice, so a declared function tool is still surfaced asfunction_call(observed6/6). On the same server the
/v1/chat/completionspath suppresses it (observed 4/4),because
chat_completion/serving.pyhas an output-sidetool_choice == "none"gate thatthe Responses path lacks. Aligning that involves the existing
--exclude-tools-when-tool-choice-nonerenderer flag and a prompt-rendering vsoutput-gating decision, so it belongs in its own change.
Duplicate-work check
Checked open and merged PRs touching Harmony recipients:
<|constrain|>leaking into the recipient. [Bugfix][Frontend] Normalize constrained Harmony recipients #45657 landed_normalize_recipient(), which only strips<|constrain|>; a plain recipient liketool.mcpis untouched by it.no special tokens still reaches the MCP fallback.
tool_choicesupport for GPT-OSS. Enforces at the prompt/structural-tag level(for
noneit strips tool descriptions from the prompt) and does not touchresponses/harmony.pyorstreaming_events.py. It cannot help here, because in this bugthere are no tool descriptions to strip — the tool text arrives inside user input.
tool-parser request validation, malformed-recipient cleanup. None gate output on whether
the request declared tools.
No open or merged PR implements "ignore Harmony recipients when
request.toolsis empty";# All other recipients are MCP callsis still present onmain.AI assistance
The patch and its tests are mine. AI assistance (Claude Code) was used for root-cause
analysis, for running the test suite, lint, and the two-arm serving evaluation reported
above, and for drafting this description. I have reviewed every changed line and can defend
the change end-to-end.