Skip to content

Commit 67f9d75

Browse files
committed
Add chat tool output fallback warning
1 parent 736076f commit 67f9d75

3 files changed

Lines changed: 167 additions & 12 deletions

File tree

src/agents/models/chatcmpl_converter.py

Lines changed: 15 additions & 6 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
@@ -751,13 +754,19 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
751754
for c in all_output_content
752755
if c.get("type") == "text"
753756
]
754-
if not tool_result_content and all_output_content:
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+
)
755762
if strict_feature_validation:
756-
raise UserError(
757-
"Chat Completions tool outputs cannot contain only non-text "
758-
"content unless preserve_tool_output_all_content=True"
759-
)
760-
tool_result_content = "[non-text tool output omitted]"
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
761770
msg: ChatCompletionToolMessageParam = {
762771
"role": "tool",
763772
"tool_call_id": func_output["call_id"],

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: 48 additions & 6 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,7 +357,9 @@ def test_items_to_messages_with_function_output_item():
356357
assert tool_msg["content"] == func_output_item["output"]
357358

358359

359-
def test_items_to_messages_with_non_text_only_function_output_uses_placeholder_by_default():
360+
def test_items_to_messages_with_non_text_only_function_output_uses_placeholder_by_default(
361+
caplog: pytest.LogCaptureFixture,
362+
):
360363
"""Default conversion should keep running without sending an empty tool message."""
361364
func_output_item: FunctionCallOutput = {
362365
"type": "function_call_output",
@@ -369,13 +372,15 @@ def test_items_to_messages_with_non_text_only_function_output_uses_placeholder_b
369372
],
370373
}
371374

372-
messages = Converter.items_to_messages([func_output_item])
375+
with caplog.at_level(logging.WARNING, logger="openai.agents"):
376+
messages = Converter.items_to_messages([func_output_item])
373377

374378
assert len(messages) == 1
375379
tool_msg = messages[0]
376380
assert tool_msg["role"] == "tool"
377381
assert tool_msg["tool_call_id"] == func_output_item["call_id"]
378-
assert tool_msg["content"] == "[non-text tool output omitted]"
382+
assert tool_msg["content"] == "[tool output omitted]"
383+
assert "Replacing the tool output with a placeholder" in caplog.text
379384

380385

381386
def test_items_to_messages_with_non_text_only_function_output_raises_in_strict_mode():
@@ -391,11 +396,46 @@ def test_items_to_messages_with_non_text_only_function_output_raises_in_strict_m
391396
],
392397
}
393398

394-
with pytest.raises(UserError, match="cannot contain only non-text content"):
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"):
395433
Converter.items_to_messages([func_output_item], strict_feature_validation=True)
396434

397435

398-
def test_items_to_messages_with_mixed_function_output_keeps_text_by_default():
436+
def test_items_to_messages_with_mixed_function_output_keeps_text_by_default(
437+
caplog: pytest.LogCaptureFixture,
438+
):
399439
"""Default conversion should preserve text parts and omit unsupported non-text parts."""
400440
func_output_item: FunctionCallOutput = {
401441
"type": "function_call_output",
@@ -409,13 +449,15 @@ def test_items_to_messages_with_mixed_function_output_keeps_text_by_default():
409449
],
410450
}
411451

412-
messages = Converter.items_to_messages([func_output_item])
452+
with caplog.at_level(logging.WARNING, logger="openai.agents"):
453+
messages = Converter.items_to_messages([func_output_item])
413454

414455
assert len(messages) == 1
415456
tool_msg = messages[0]
416457
assert tool_msg["role"] == "tool"
417458
assert tool_msg["tool_call_id"] == func_output_item["call_id"]
418459
assert tool_msg["content"] == [{"type": "text", "text": "visible text"}]
460+
assert "tool output omitted" not in caplog.text
419461

420462

421463
def test_items_to_messages_can_preserve_non_text_function_output() -> None:

0 commit comments

Comments
 (0)