[Bugfix][Responses API] Preserve built-in tool output call IDs (#55596)

Signed-off-by: CorgiBoyG <111257566+CorgiBoyG@users.noreply.github.com> Co-authored-by: Flora Feng <4florafeng@gmail.com>

RongJie G committed Sep 30, 2026 at 06:45 UTC 0755e69e75838babf3eca3fcdb74799b92aa3ea2
3 files changed +78 -10
tests/entrypoints/openai/responses/test_parsable_context_unit.py
+64 -1
@@ -6,10 +6,13 @@ These tests verify that ParsableContext correctly delegates to the unified
6 Parser (via parse) and properly builds response output items.
7 """
8
9 +import json
10 from collections.abc import Sequence
10 -from unittest.mock import MagicMock
11 +from types import SimpleNamespace
12 +from unittest.mock import AsyncMock, MagicMock
13
14 import pytest
15 +from openai.types.responses import ResponseFunctionToolCall
16
17 from vllm.entrypoints.generate.base.protocol import (
18 DeltaMessage,
@@ -17,6 +20,7 @@ from vllm.entrypoints.generate.base.protocol import (
20 FunctionCall,
21 ToolCall,
22 )
23 +from vllm.entrypoints.mcp.tool import HarmonyPythonTool
24 from vllm.entrypoints.openai.responses.context import ParsableContext
25 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
26 from vllm.outputs import CompletionOutput, RequestOutput
@@ -317,6 +321,65 @@ def test_process_extracts_tool_calls():
321 assert tool_item.status == "completed"
322
323
324 +@pytest.mark.parametrize(
325 + ("tool_name", "session_name", "dispatched_name", "arguments"),
326 + [
327 + ("code_interpreter", "python", "python", {"code": "print(42)"}),
328 + ("web_search_preview", "browser", "search", {"query": "vLLM"}),
329 + ("container.exec", "container", "exec", {"cmd": ["pwd"]}),
330 + ],
331 +)
332 +@pytest.mark.asyncio
333 +async def test_builtin_tool_output_preserves_function_call_id(
334 + tool_name, session_name, dispatched_name, arguments
335 +):
336 + """Built-in tool outputs remain correlated with their originating call."""
337 + tool_session = MagicMock()
338 + tool_session.call_tool = AsyncMock(
339 + return_value=SimpleNamespace(content=[SimpleNamespace(text="result")])
340 + )
341 + context = _make_context(None, available_tools=[session_name])
342 + tool_call = ResponseFunctionToolCall(
343 + id=f"fc_{session_name}",
344 + call_id=f"call_{session_name}",
345 + type="function_call",
346 + name=tool_name,
347 + arguments=json.dumps(arguments),
348 + )
349 + context.response_messages.append(tool_call)
350 + context._tool_sessions[session_name] = tool_session
351 +
352 + output = await context.call_tool()
353 +
354 + tool_session.call_tool.assert_awaited_once_with(dispatched_name, arguments)
355 + assert output[0].call_id == tool_call.call_id
356 +
357 +
358 +@pytest.mark.asyncio
359 +async def test_local_python_tool_output_preserves_function_call_id():
360 + """Local code-interpreter output remains correlated with its call."""
361 +
362 + async def process(_):
363 + yield SimpleNamespace(content=[SimpleNamespace(text="result")])
364 +
365 + python_tool = object.__new__(HarmonyPythonTool)
366 + python_tool.python_tool = MagicMock(process=process)
367 + context = _make_context(None, available_tools=["python"])
368 + tool_call = ResponseFunctionToolCall(
369 + id="fc_python",
370 + call_id="call_python",
371 + type="function_call",
372 + name="code_interpreter",
373 + arguments='{"code": "print(42)"}',
374 + )
375 + context.response_messages.append(tool_call)
376 + context._tool_sessions["python"] = python_tool
377 +
378 + output = await context.call_tool()
379 +
380 + assert output[0].call_id == tool_call.call_id
381 +
382 +
383 # ---------------------------------------------------------------------------
384 # Tests: finish_reason tracking
385 # ---------------------------------------------------------------------------
vllm/entrypoints/mcp/tool.py
+1 -1
@@ -173,7 +173,7 @@ class HarmonyPythonTool(Tool):
173 processed = ResponseFunctionToolCallOutputItem(
174 id=f"fco_{random_uuid()}",
175 type="function_call_output",
176 - call_id=f"call_{random_uuid()}",
176 + call_id=last_msg.call_id,
177 output=msg.content[0].text,
178 status="completed",
179 )
vllm/entrypoints/openai/responses/context.py
+13 -8
@@ -24,7 +24,6 @@ from vllm import envs
24 from vllm.entrypoints.chat_utils import (
25 ChatTemplateContentFormatOption,
26 )
27 -from vllm.entrypoints.generate.base.protocol import FunctionCall
27 from vllm.entrypoints.mcp.tool import Tool
28 from vllm.entrypoints.mcp.tool_server import ToolServer
29 from vllm.entrypoints.openai.parser.harmony_utils import render_for_completion
@@ -440,7 +439,9 @@ class ParsableContext(ConversationContext):
439 return False
440
441 async def call_python_tool(
443 - self, tool_session: Union["ClientSession", Tool], last_msg: FunctionCall
442 + self,
443 + tool_session: Union["ClientSession", Tool],
444 + last_msg: ResponseFunctionToolCall,
445 ) -> list[ResponseInputOutputItem]:
446 self.called_tools.add("python")
447 if isinstance(tool_session, Tool):
@@ -455,7 +456,7 @@ class ParsableContext(ConversationContext):
456 message = ResponseFunctionToolCallOutputItem(
457 id=f"mcpo_{random_uuid()}",
458 type="function_call_output",
458 - call_id=f"call_{random_uuid()}",
459 + call_id=last_msg.call_id,
460 output=result_str,
461 status="completed",
462 )
@@ -463,7 +464,9 @@ class ParsableContext(ConversationContext):
464 return [message]
465
466 async def call_search_tool(
466 - self, tool_session: Union["ClientSession", Tool], last_msg: FunctionCall
467 + self,
468 + tool_session: Union["ClientSession", Tool],
469 + last_msg: ResponseFunctionToolCall,
470 ) -> list[ResponseInputOutputItem]:
471 self.called_tools.add("browser")
472 if isinstance(tool_session, Tool):
@@ -481,7 +484,7 @@ class ParsableContext(ConversationContext):
484 message = ResponseFunctionToolCallOutputItem(
485 id=f"fco_{random_uuid()}",
486 type="function_call_output",
484 - call_id=f"call_{random_uuid()}",
487 + call_id=last_msg.call_id,
488 output=result_str,
489 status="completed",
490 )
@@ -489,8 +492,10 @@ class ParsableContext(ConversationContext):
492 return [message]
493
494 async def call_container_tool(
492 - self, tool_session: Union["ClientSession", Tool], last_msg: Message
493 - ) -> list[Message]:
495 + self,
496 + tool_session: Union["ClientSession", Tool],
497 + last_msg: ResponseFunctionToolCall,
498 + ) -> list[ResponseInputOutputItem]:
499 """Call container tool. Expect this to be run in a stateful docker
500 with command line terminal.
501 The official container tool would at least
@@ -523,7 +528,7 @@ class ParsableContext(ConversationContext):
528 message = ResponseFunctionToolCallOutputItem(
529 id=f"fco_{random_uuid()}",
530 type="function_call_output",
526 - call_id=f"call_{random_uuid()}",
531 + call_id=last_msg.call_id,
532 output=result_str,
533 status="completed",
534 )