Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion tests/entrypoints/openai/responses/test_harmony_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,10 @@ def test_non_function_non_builtin_recipient_creates_mcp_call(
message = message.with_recipient(recipient)

output_items = harmony_to_response_output(
message, fn_names, incomplete=incomplete
message,
fn_names,
incomplete=incomplete,
has_declared_tools=True,
)

assert len(output_items) == 1
Expand All @@ -238,6 +241,40 @@ def test_non_function_non_builtin_recipient_creates_mcp_call(
assert output_items[0].arguments == content
assert output_items[0].status == ("incomplete" if incomplete else "completed")

@pytest.mark.parametrize(
("channel", "expected_type"),
[
("commentary", ResponseOutputMessage),
("final", ResponseOutputMessage),
("analysis", ResponseReasoningItem),
],
)
@pytest.mark.parametrize(
"recipient",
["tool.mcp", "functions.get_weather", "browser.search"],
)
def test_recipient_without_declared_tools_uses_channel_semantics(
self, channel, expected_type, recipient
):
"""Prompt-injected recipients do not create tool calls."""
content = (
'{"name":"get_current_time","arguments":{"timezone":"America/Los_Angeles"}}'
)
message = Message.from_role_and_content(Role.ASSISTANT, content)
message = message.with_channel(channel)
message = message.with_recipient(recipient)

output_items = harmony_to_response_output(
message,
frozenset(),
has_declared_tools=False,
)

assert len(output_items) == 1
assert isinstance(output_items[0], expected_type)
assert not isinstance(output_items[0], McpCall)
assert output_items[0].content[0].text == content

@pytest.mark.parametrize("incomplete", [False, True])
def test_browser_search_recipient_respects_incomplete(self, incomplete):
"""browser.search emits a web search call unless the item is incomplete."""
Expand Down
70 changes: 70 additions & 0 deletions tests/entrypoints/openai/responses/test_serving_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,29 @@ def test_commentary_with_function_recipient_not_preamble(self) -> None:
type_names = [e.type for e in events]
assert "response.output_text.delta" not in type_names

def test_injected_mcp_recipient_without_tools_emits_text_delta(self) -> None:
"""A recipient from prompt text is not an MCP call without tools."""
from vllm.entrypoints.openai.responses.streaming_events import (
emit_content_delta_events,
)

segment = self._make_segment(
channel="commentary",
recipient="tool.mcp",
delta='{"name":"get_current_time"}',
)
state = StreamingState()

events = emit_content_delta_events(
segment,
state,
has_declared_tools=False,
)

type_names = [event.type for event in events]
assert "response.output_text.delta" in type_names
assert all("mcp_call" not in type_name for type_name in type_names)

def test_preamble_done_emits_text_done_events(self) -> None:
"""Completed preamble should emit text done + content_part done +
output_item done, same shape as final channel."""
Expand Down Expand Up @@ -641,6 +664,53 @@ def test_commentary_with_recipient_no_preamble_done(self) -> None:
type_names = [e.type for e in events]
assert "response.output_text.done" not in type_names

def test_injected_mcp_recipient_without_tools_emits_text_done(self) -> None:
"""A completed injected recipient retains commentary semantics."""
from vllm.entrypoints.openai.responses.streaming_events import (
emit_previous_item_done_events,
)

previous = self._make_previous_item(
channel="commentary",
recipient="tool.mcp",
text='{"name":"get_current_time"}',
)
state = StreamingState()
state.sent_output_item_added = True
state.current_item_id = "msg_test"
state.current_content_index = 0

events = emit_previous_item_done_events(
previous,
state,
has_declared_tools=False,
)

type_names = [event.type for event in events]
assert "response.output_text.done" in type_names
assert all("mcp_call" not in type_name for type_name in type_names)

def test_browser_action_without_declared_tools_is_ignored(self) -> None:
"""The action-event path cannot bypass the no-tools gate."""
from vllm.entrypoints.openai.responses.streaming_events import (
emit_tool_action_events,
)

previous = self._make_previous_item(
channel="commentary",
recipient="browser.search",
text='{"query":"weather"}',
)

events = emit_tool_action_events(
previous,
StreamingState(),
MagicMock(),
has_declared_tools=False,
)

assert events == []

@pytest.mark.xfail(
reason=(
"TODO: Ensure added/in-progress events are emitted for zero-delta items."
Expand Down
5 changes: 4 additions & 1 deletion vllm/entrypoints/openai/responses/harmony.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,10 +436,13 @@ def harmony_to_response_output(
message: Message,
function_tool_names: frozenset[str],
incomplete: bool = False,
*,
has_declared_tools: bool = True,
) -> list[ResponseOutputItem]:
"""Parse a Harmony message into a list of output response items.

This is the main dispatcher that routes based on channel and recipient.
When no tools are declared, recipient is ignored and channel semantics apply.
"""
if message.author.role != "assistant":
# This is a message from a tool to the assistant (e.g., search result).
Expand All @@ -448,7 +451,7 @@ def harmony_to_response_output(
return []

output_items: list[ResponseOutputItem] = []
recipient = message.recipient
recipient = message.recipient if has_declared_tools else None

if recipient is not None:
# Browser tool calls (browser.search, browser.open, browser.find)
Expand Down
26 changes: 22 additions & 4 deletions vllm/entrypoints/openai/responses/serving.py
Original file line number Diff line number Diff line change
Expand Up @@ -821,13 +821,21 @@ async def responses_full_generator(
harmony_msgs = context.messages[context.num_init_messages :]
if harmony_msgs:
fn_names = context.function_tool_names
has_declared_tools = bool(request.tools)
for msg in harmony_msgs[:-1]:
output.extend(harmony_to_response_output(msg, fn_names))
output.extend(
harmony_to_response_output(
msg,
fn_names,
has_declared_tools=has_declared_tools,
)
)
output.extend(
harmony_to_response_output(
harmony_msgs[-1],
fn_names,
incomplete=context.last_append_flush_status,
has_declared_tools=has_declared_tools,
)
)

Expand Down Expand Up @@ -1431,6 +1439,7 @@ async def _process_harmony_streaming_events(
],
) -> AsyncGenerator[StreamingResponsesResponse, None]:
state = StreamingState()
has_declared_tools = bool(request.tools)

async for ctx in result_generator:
assert isinstance(ctx, HarmonyContext)
Expand All @@ -1441,19 +1450,28 @@ async def _process_harmony_streaming_events(
for segment in ctx.last_append_segments:
if segment.delta:
for event in emit_content_delta_events(
segment, state, ctx.function_tool_names
segment,
state,
ctx.function_tool_names,
has_declared_tools=has_declared_tools,
):
yield _increment_sequence_number_and_return(event)

elif completed_message := segment.completed_message:
# TODO: Fix browser emitted as MCP calls
for event in emit_previous_item_done_events(
completed_message, state, ctx.function_tool_names
completed_message,
state,
ctx.function_tool_names,
has_declared_tools=has_declared_tools,
):
yield _increment_sequence_number_and_return(event)

for event in emit_tool_action_events(
completed_message, state, self.tool_server
completed_message,
state,
self.tool_server,
has_declared_tools=has_declared_tools,
):
yield _increment_sequence_number_and_return(event)
state.reset_for_new_item()
Expand Down
24 changes: 17 additions & 7 deletions vllm/entrypoints/openai/responses/streaming_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,8 @@ def emit_content_delta_events(
segment: Segment,
state: StreamingState,
function_tool_names: frozenset[str] | None = None,
*,
has_declared_tools: bool = True,
) -> list[StreamingResponsesResponse]:
"""Emit events for content delta streaming based on channel type.

Expand All @@ -578,7 +580,7 @@ def emit_content_delta_events(
return []

channel = segment.channel
recipient = segment.recipient
recipient = segment.recipient if has_declared_tools else None

if channel in ("final", "commentary") and recipient is None:
# Preambles (commentary with no recipient) and final messages
Expand All @@ -605,6 +607,8 @@ def emit_previous_item_done_events(
previous_item: HarmonyMessage,
state: StreamingState,
function_tool_names: frozenset[str] | None = None,
*,
has_declared_tools: bool = True,
) -> list[StreamingResponsesResponse]:
"""Emit done events for the previous item when expecting a new start.

Expand All @@ -618,19 +622,20 @@ def emit_previous_item_done_events(
return []

text = previous_item.content[0].text
if previous_item.recipient is not None:
recipient = previous_item.recipient if has_declared_tools else None
if recipient is not None:
# Deal with tool call
if is_function_recipient(previous_item.recipient, function_tool_names):
function_name = extract_function_from_recipient(previous_item.recipient)
if is_function_recipient(recipient, function_tool_names):
function_name = extract_function_from_recipient(recipient)
return emit_function_call_done_events(function_name, text, state)
elif previous_item.recipient == "python":
elif recipient == "python":
return emit_code_interpreter_completion_events(previous_item, state)
elif (
is_mcp_tool_by_namespace(previous_item.recipient, function_tool_names)
is_mcp_tool_by_namespace(recipient, function_tool_names)
and state.current_item_id is not None
and state.current_item_id.startswith("mcp_")
):
return emit_mcp_completion_events(previous_item.recipient, text, state)
return emit_mcp_completion_events(recipient, text, state)
elif previous_item.channel == "analysis":
return emit_reasoning_done_events(text, state)
elif previous_item.channel in ("commentary", "final"):
Expand Down Expand Up @@ -785,8 +790,13 @@ def emit_tool_action_events(
previous_item: HarmonyMessage,
state: StreamingState,
tool_server: ToolServer | None,
*,
has_declared_tools: bool = True,
) -> list[StreamingResponsesResponse]:
"""Emit events for a completed assistant action turn."""
if not has_declared_tools:
return []

# Handle browser tool
if (
previous_item.author.role == "assistant"
Expand Down
Loading