Skip to content

Commit 64de1cb

Browse files
authored
fix: #3310 avoid empty chat tool outputs (#3312)
1 parent 1d3df7f commit 64de1cb

6 files changed

Lines changed: 262 additions & 4 deletions

File tree

src/agents/models/chatcmpl_converter.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from ..exceptions import AgentsException, UserError
4848
from ..handoffs import Handoff
4949
from ..items import TResponseInputItem, TResponseOutputItem
50+
from ..logger import logger
5051
from ..model_settings import MCPToolChoice
5152
from ..tool import (
5253
FunctionTool,
@@ -66,6 +67,8 @@
6667
ResponseInputContentParam | ResponseInputAudioParam | dict[str, Any]
6768
)
6869

70+
_OMITTED_TOOL_OUTPUT_PLACEHOLDER = "[tool output omitted]"
71+
6972

7073
class Converter:
7174
@classmethod
@@ -468,6 +471,7 @@ def items_to_messages(
468471
preserve_tool_output_all_content: bool = False,
469472
base_url: str | None = None,
470473
should_replay_reasoning_content: ShouldReplayReasoningContent | None = None,
474+
strict_feature_validation: bool = False,
471475
) -> list[ChatCompletionMessageParam]:
472476
"""
473477
Convert a sequence of 'Item' objects into a list of ChatCompletionMessageParam.
@@ -493,6 +497,8 @@ def items_to_messages(
493497
should_replay_reasoning_content: Optional hook that decides whether a
494498
reasoning item should be replayed into the next assistant message as
495499
`reasoning_content`.
500+
strict_feature_validation: Whether to raise a UserError for Responses-only
501+
features that Chat Completions cannot faithfully represent.
496502
497503
Rules:
498504
- EasyInputMessage or InputMessage (role=user) => ChatCompletionUserMessageParam
@@ -748,6 +754,19 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
748754
for c in all_output_content
749755
if c.get("type") == "text"
750756
]
757+
if not tool_result_content:
758+
message = (
759+
"Chat Completions tool outputs cannot be empty or contain only "
760+
"non-text content unless preserve_tool_output_all_content=True."
761+
)
762+
if strict_feature_validation:
763+
raise UserError(message)
764+
logger.warning(
765+
"%s Replacing the tool output with a placeholder; enable strict "
766+
"feature validation to raise an error instead.",
767+
message,
768+
)
769+
tool_result_content = _OMITTED_TOOL_OUTPUT_PLACEHOLDER
751770
msg: ChatCompletionToolMessageParam = {
752771
"role": "tool",
753772
"tool_call_id": func_output["call_id"],

src/agents/models/multi_provider.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ def __init__(
109109
responses API.
110110
openai_strict_feature_validation: Whether OpenAI Chat Completions models should raise
111111
a UserError when callers pass Responses-only features such as previous_response_id,
112-
conversation_id, or prompt. Defaults to False, which preserves the previous
113-
ignore-and-warn behavior.
112+
conversation_id, prompt, or non-text-only tool outputs. Defaults to False, which
113+
preserves the default compatibility behavior.
114114
openai_websocket_base_url: The websocket base URL to use for the OpenAI provider.
115115
If not provided, the provider will use `OPENAI_WEBSOCKET_BASE_URL` when set.
116116
openai_prefix_mode: Controls how ``openai/...`` model strings are interpreted.

src/agents/models/openai_chatcompletions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ async def _fetch_response(
412412
model=self.model,
413413
base_url=str(self._client.base_url),
414414
should_replay_reasoning_content=self.should_replay_reasoning_content,
415+
strict_feature_validation=self._strict_feature_validation,
415416
)
416417

417418
if system_instructions:

src/agents/models/openai_provider.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ def __init__(
7474
API.
7575
strict_feature_validation: Whether Chat Completions models should raise a UserError
7676
when callers pass Responses-only features such as previous_response_id,
77-
conversation_id, or prompt. Defaults to False, which preserves the previous
78-
ignore-and-warn behavior.
77+
conversation_id, prompt, or non-text-only tool outputs. Defaults to False, which
78+
preserves the default compatibility behavior.
7979
agent_registration: Optional agent registration configuration.
8080
responses_websocket_options: Optional low-level websocket keepalive options for the
8181
OpenAI Responses websocket transport.

tests/models/test_openai_chatcompletions.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,110 @@ async def patched_fetch_response(self, *args, **kwargs):
320320
)
321321

322322

323+
@pytest.mark.allow_call_model_methods
324+
@pytest.mark.asyncio
325+
async def test_get_response_rejects_non_text_tool_output_in_strict_mode() -> None:
326+
class DummyCompletions:
327+
async def create(self, **kwargs: Any) -> Any:
328+
raise AssertionError("chat.completions.create should not run")
329+
330+
class DummyClient:
331+
def __init__(self) -> None:
332+
self.chat = type("_Chat", (), {"completions": DummyCompletions()})()
333+
self.base_url = httpx.URL("http://fake")
334+
335+
model = OpenAIChatCompletionsModel(
336+
model="gpt-4",
337+
openai_client=DummyClient(), # type: ignore[arg-type]
338+
strict_feature_validation=True,
339+
)
340+
341+
with pytest.raises(UserError, match="cannot be empty or contain only non-text content"):
342+
await model.get_response(
343+
system_instructions=None,
344+
input=[
345+
{
346+
"type": "function_call_output",
347+
"call_id": "call_image",
348+
"output": [
349+
{
350+
"type": "input_image",
351+
"image_url": "https://example.com/image.png",
352+
}
353+
],
354+
}
355+
],
356+
model_settings=ModelSettings(),
357+
tools=[],
358+
output_schema=None,
359+
handoffs=[],
360+
tracing=ModelTracing.DISABLED,
361+
previous_response_id=None,
362+
conversation_id=None,
363+
prompt=None,
364+
)
365+
366+
367+
@pytest.mark.allow_call_model_methods
368+
@pytest.mark.asyncio
369+
async def test_get_response_warns_and_sends_placeholder_for_non_text_tool_output(
370+
caplog: pytest.LogCaptureFixture,
371+
) -> None:
372+
class DummyCompletions:
373+
def __init__(self) -> None:
374+
self.kwargs: dict[str, Any] = {}
375+
376+
async def create(self, **kwargs: Any) -> Any:
377+
self.kwargs = kwargs
378+
return _minimal_chat_completion()
379+
380+
class DummyClient:
381+
def __init__(self) -> None:
382+
self.completions = DummyCompletions()
383+
self.chat = type("_Chat", (), {"completions": self.completions})()
384+
self.base_url = httpx.URL("http://fake")
385+
386+
client = DummyClient()
387+
model = OpenAIChatCompletionsModel(
388+
model="gpt-4",
389+
openai_client=client, # type: ignore[arg-type]
390+
)
391+
392+
with caplog.at_level(logging.WARNING, logger="openai.agents"):
393+
await model.get_response(
394+
system_instructions=None,
395+
input=[
396+
{
397+
"type": "function_call_output",
398+
"call_id": "call_image",
399+
"output": [
400+
{
401+
"type": "input_image",
402+
"image_url": "https://example.com/image.png",
403+
}
404+
],
405+
}
406+
],
407+
model_settings=ModelSettings(),
408+
tools=[],
409+
output_schema=None,
410+
handoffs=[],
411+
tracing=ModelTracing.DISABLED,
412+
previous_response_id=None,
413+
conversation_id=None,
414+
prompt=None,
415+
)
416+
417+
assert client.completions.kwargs["messages"] == [
418+
{
419+
"role": "tool",
420+
"tool_call_id": "call_image",
421+
"content": "[tool output omitted]",
422+
}
423+
]
424+
assert "Replacing the tool output with a placeholder" in caplog.text
425+
426+
323427
@pytest.mark.allow_call_model_methods
324428
@pytest.mark.asyncio
325429
async def test_get_response_attaches_logprobs(monkeypatch) -> None:

tests/models/test_openai_chatcompletions_converter.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from __future__ import annotations
2525

26+
import logging
2627
from typing import Literal, cast
2728

2829
import pytest
@@ -356,6 +357,139 @@ def test_items_to_messages_with_function_output_item():
356357
assert tool_msg["content"] == func_output_item["output"]
357358

358359

360+
def test_items_to_messages_with_non_text_only_function_output_uses_placeholder_by_default(
361+
caplog: pytest.LogCaptureFixture,
362+
):
363+
"""Default conversion should keep running without sending an empty tool message."""
364+
func_output_item: FunctionCallOutput = {
365+
"type": "function_call_output",
366+
"call_id": "somecall",
367+
"output": [
368+
{
369+
"type": "input_image",
370+
"image_url": "https://example.com/image.png",
371+
}
372+
],
373+
}
374+
375+
with caplog.at_level(logging.WARNING, logger="openai.agents"):
376+
messages = Converter.items_to_messages([func_output_item])
377+
378+
assert len(messages) == 1
379+
tool_msg = messages[0]
380+
assert tool_msg["role"] == "tool"
381+
assert tool_msg["tool_call_id"] == func_output_item["call_id"]
382+
assert tool_msg["content"] == "[tool output omitted]"
383+
assert "Replacing the tool output with a placeholder" in caplog.text
384+
385+
386+
def test_items_to_messages_with_non_text_only_function_output_raises_in_strict_mode():
387+
"""Strict validation should fail explicitly instead of silently losing the output."""
388+
func_output_item: FunctionCallOutput = {
389+
"type": "function_call_output",
390+
"call_id": "somecall",
391+
"output": [
392+
{
393+
"type": "input_image",
394+
"image_url": "https://example.com/image.png",
395+
}
396+
],
397+
}
398+
399+
with pytest.raises(UserError, match="cannot be empty or contain only non-text content"):
400+
Converter.items_to_messages([func_output_item], strict_feature_validation=True)
401+
402+
403+
def test_items_to_messages_with_empty_function_output_uses_placeholder_by_default(
404+
caplog: pytest.LogCaptureFixture,
405+
):
406+
"""Default conversion should not send an empty tool message."""
407+
func_output_item: FunctionCallOutput = {
408+
"type": "function_call_output",
409+
"call_id": "somecall",
410+
"output": [],
411+
}
412+
413+
with caplog.at_level(logging.WARNING, logger="openai.agents"):
414+
messages = Converter.items_to_messages([func_output_item])
415+
416+
assert len(messages) == 1
417+
tool_msg = messages[0]
418+
assert tool_msg["role"] == "tool"
419+
assert tool_msg["tool_call_id"] == func_output_item["call_id"]
420+
assert tool_msg["content"] == "[tool output omitted]"
421+
assert "Replacing the tool output with a placeholder" in caplog.text
422+
423+
424+
def test_items_to_messages_with_empty_function_output_raises_in_strict_mode():
425+
"""Strict validation should fail explicitly instead of sending empty output."""
426+
func_output_item: FunctionCallOutput = {
427+
"type": "function_call_output",
428+
"call_id": "somecall",
429+
"output": [],
430+
}
431+
432+
with pytest.raises(UserError, match="cannot be empty or contain only non-text content"):
433+
Converter.items_to_messages([func_output_item], strict_feature_validation=True)
434+
435+
436+
def test_items_to_messages_with_mixed_function_output_keeps_text_by_default(
437+
caplog: pytest.LogCaptureFixture,
438+
):
439+
"""Default conversion should preserve text parts and omit unsupported non-text parts."""
440+
func_output_item: FunctionCallOutput = {
441+
"type": "function_call_output",
442+
"call_id": "somecall",
443+
"output": [
444+
{"type": "input_text", "text": "visible text"},
445+
{
446+
"type": "input_image",
447+
"image_url": "https://example.com/image.png",
448+
},
449+
],
450+
}
451+
452+
with caplog.at_level(logging.WARNING, logger="openai.agents"):
453+
messages = Converter.items_to_messages([func_output_item])
454+
455+
assert len(messages) == 1
456+
tool_msg = messages[0]
457+
assert tool_msg["role"] == "tool"
458+
assert tool_msg["tool_call_id"] == func_output_item["call_id"]
459+
assert tool_msg["content"] == [{"type": "text", "text": "visible text"}]
460+
assert "tool output omitted" not in caplog.text
461+
462+
463+
def test_items_to_messages_can_preserve_non_text_function_output() -> None:
464+
"""Compatible providers can opt in to preserving non-text tool output."""
465+
func_output_item: FunctionCallOutput = {
466+
"type": "function_call_output",
467+
"call_id": "somecall",
468+
"output": [
469+
{
470+
"type": "input_image",
471+
"image_url": "https://example.com/image.png",
472+
}
473+
],
474+
}
475+
476+
messages = Converter.items_to_messages(
477+
[func_output_item],
478+
preserve_tool_output_all_content=True,
479+
)
480+
481+
assert len(messages) == 1
482+
tool_msg = messages[0]
483+
assert tool_msg["role"] == "tool"
484+
assert tool_msg["tool_call_id"] == func_output_item["call_id"]
485+
assert tool_msg["content"] == [
486+
{
487+
"type": "image_url",
488+
"image_url": {"url": "https://example.com/image.png", "detail": "auto"},
489+
}
490+
]
491+
492+
359493
def test_extract_all_and_text_content_for_strings_and_lists():
360494
"""
361495
The converter provides helpers for extracting user-supplied message content

0 commit comments

Comments
 (0)