Skip to content

Commit 7e89518

Browse files
committed
Avoid empty chat tool outputs
1 parent 4c3de2d commit 7e89518

5 files changed

Lines changed: 107 additions & 4 deletions

File tree

src/agents/models/chatcmpl_converter.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,7 @@ def items_to_messages(
468468
preserve_tool_output_all_content: bool = False,
469469
base_url: str | None = None,
470470
should_replay_reasoning_content: ShouldReplayReasoningContent | None = None,
471+
strict_feature_validation: bool = False,
471472
) -> list[ChatCompletionMessageParam]:
472473
"""
473474
Convert a sequence of 'Item' objects into a list of ChatCompletionMessageParam.
@@ -493,6 +494,8 @@ def items_to_messages(
493494
should_replay_reasoning_content: Optional hook that decides whether a
494495
reasoning item should be replayed into the next assistant message as
495496
`reasoning_content`.
497+
strict_feature_validation: Whether to raise a UserError for Responses-only
498+
features that Chat Completions cannot faithfully represent.
496499
497500
Rules:
498501
- EasyInputMessage or InputMessage (role=user) => ChatCompletionUserMessageParam
@@ -748,6 +751,13 @@ def ensure_assistant_message() -> ChatCompletionAssistantMessageParam:
748751
for c in all_output_content
749752
if c.get("type") == "text"
750753
]
754+
if not tool_result_content and all_output_content:
755+
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]"
751761
msg: ChatCompletionToolMessageParam = {
752762
"role": "tool",
753763
"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_converter.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,98 @@ def test_items_to_messages_with_function_output_item():
356356
assert tool_msg["content"] == func_output_item["output"]
357357

358358

359+
def test_items_to_messages_with_non_text_only_function_output_uses_placeholder_by_default():
360+
"""Default conversion should keep running without sending an empty tool message."""
361+
func_output_item: FunctionCallOutput = {
362+
"type": "function_call_output",
363+
"call_id": "somecall",
364+
"output": [
365+
{
366+
"type": "input_image",
367+
"image_url": "https://example.com/image.png",
368+
}
369+
],
370+
}
371+
372+
messages = Converter.items_to_messages([func_output_item])
373+
374+
assert len(messages) == 1
375+
tool_msg = messages[0]
376+
assert tool_msg["role"] == "tool"
377+
assert tool_msg["tool_call_id"] == func_output_item["call_id"]
378+
assert tool_msg["content"] == "[non-text tool output omitted]"
379+
380+
381+
def test_items_to_messages_with_non_text_only_function_output_raises_in_strict_mode():
382+
"""Strict validation should fail explicitly instead of silently losing the output."""
383+
func_output_item: FunctionCallOutput = {
384+
"type": "function_call_output",
385+
"call_id": "somecall",
386+
"output": [
387+
{
388+
"type": "input_image",
389+
"image_url": "https://example.com/image.png",
390+
}
391+
],
392+
}
393+
394+
with pytest.raises(UserError, match="cannot contain only non-text content"):
395+
Converter.items_to_messages([func_output_item], strict_feature_validation=True)
396+
397+
398+
def test_items_to_messages_with_mixed_function_output_keeps_text_by_default():
399+
"""Default conversion should preserve text parts and omit unsupported non-text parts."""
400+
func_output_item: FunctionCallOutput = {
401+
"type": "function_call_output",
402+
"call_id": "somecall",
403+
"output": [
404+
{"type": "input_text", "text": "visible text"},
405+
{
406+
"type": "input_image",
407+
"image_url": "https://example.com/image.png",
408+
},
409+
],
410+
}
411+
412+
messages = Converter.items_to_messages([func_output_item])
413+
414+
assert len(messages) == 1
415+
tool_msg = messages[0]
416+
assert tool_msg["role"] == "tool"
417+
assert tool_msg["tool_call_id"] == func_output_item["call_id"]
418+
assert tool_msg["content"] == [{"type": "text", "text": "visible text"}]
419+
420+
421+
def test_items_to_messages_can_preserve_non_text_function_output() -> None:
422+
"""Compatible providers can opt in to preserving non-text tool output."""
423+
func_output_item: FunctionCallOutput = {
424+
"type": "function_call_output",
425+
"call_id": "somecall",
426+
"output": [
427+
{
428+
"type": "input_image",
429+
"image_url": "https://example.com/image.png",
430+
}
431+
],
432+
}
433+
434+
messages = Converter.items_to_messages(
435+
[func_output_item],
436+
preserve_tool_output_all_content=True,
437+
)
438+
439+
assert len(messages) == 1
440+
tool_msg = messages[0]
441+
assert tool_msg["role"] == "tool"
442+
assert tool_msg["tool_call_id"] == func_output_item["call_id"]
443+
assert tool_msg["content"] == [
444+
{
445+
"type": "image_url",
446+
"image_url": {"url": "https://example.com/image.png", "detail": "auto"},
447+
}
448+
]
449+
450+
359451
def test_extract_all_and_text_content_for_strings_and_lists():
360452
"""
361453
The converter provides helpers for extracting user-supplied message content

0 commit comments

Comments
 (0)