diff --git a/README.md b/README.md index 90303619a..99b3f63ce 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# OpenAI Agents SDK +# OpenAI Agents SDK [![PyPI](https://img.shields.io/pypi/v/openai-agents?label=pypi%20package)](https://pypi.org/project/openai-agents/) The OpenAI Agents SDK is a lightweight yet powerful framework for building multi-agent workflows. It is provider-agnostic, supporting the OpenAI Responses and Chat Completions APIs, as well as 100+ other LLMs. diff --git a/pyproject.toml b/pyproject.toml index 0632139dc..f8e92da98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-agents" -version = "0.4.0" +version = "0.4.1" description = "OpenAI Agents SDK" readme = "README.md" requires-python = ">=3.9" diff --git a/src/agents/extensions/memory/sqlalchemy_session.py b/src/agents/extensions/memory/sqlalchemy_session.py index e1fc885bb..d9e52e391 100644 --- a/src/agents/extensions/memory/sqlalchemy_session.py +++ b/src/agents/extensions/memory/sqlalchemy_session.py @@ -319,3 +319,16 @@ async def clear_session(self) -> None: await sess.execute( delete(self._sessions).where(self._sessions.c.session_id == self.session_id) ) + + @property + def engine(self) -> AsyncEngine: + """Access the underlying SQLAlchemy AsyncEngine. + + This property provides direct access to the engine for advanced use cases, + such as checking connection pool status, configuring engine settings, + or manually disposing the engine when needed. + + Returns: + AsyncEngine: The SQLAlchemy async engine instance. + """ + return self._engine diff --git a/src/agents/items.py b/src/agents/items.py index 1a699077a..8e7d1cfc3 100644 --- a/src/agents/items.py +++ b/src/agents/items.py @@ -361,6 +361,9 @@ def _maybe_get_output_as_structured_function_output( if isinstance(output, (ToolOutputText, ToolOutputImage, ToolOutputFileContent)): return output elif isinstance(output, dict): + # Require explicit 'type' field in dict to be considered a structured output + if "type" not in output: + return None try: return ValidToolOutputPydanticModelsTypeAdapter.validate_python(output) except pydantic.ValidationError: diff --git a/src/agents/run.py b/src/agents/run.py index 85607e7dd..58eef335e 100644 --- a/src/agents/run.py +++ b/src/agents/run.py @@ -1138,6 +1138,15 @@ async def _start_streaming( streamed_result.is_complete = True finally: + if streamed_result._input_guardrails_task: + try: + await AgentRunner._input_guardrail_tripwire_triggered_for_stream( + streamed_result + ) + except Exception as e: + logger.debug( + f"Error in streamed_result finalize for agent {current_agent.name} - {e}" + ) if current_span: current_span.finish(reset_current=True) if streamed_result.trace: diff --git a/src/agents/tool.py b/src/agents/tool.py index 66c70c29d..39db129b7 100644 --- a/src/agents/tool.py +++ b/src/agents/tool.py @@ -15,7 +15,7 @@ from openai.types.responses.tool_param import CodeInterpreter, ImageGeneration, Mcp from openai.types.responses.web_search_tool import Filters as WebSearchToolFilters from openai.types.responses.web_search_tool_param import UserLocation -from pydantic import BaseModel, TypeAdapter, ValidationError +from pydantic import BaseModel, TypeAdapter, ValidationError, model_validator from typing_extensions import Concatenate, NotRequired, ParamSpec, TypedDict from . import _debug @@ -75,6 +75,13 @@ class ToolOutputImage(BaseModel): file_id: str | None = None detail: Literal["low", "high", "auto"] | None = None + @model_validator(mode="after") + def check_at_least_one_required_field(self) -> ToolOutputImage: + """Validate that at least one of image_url or file_id is provided.""" + if self.image_url is None and self.file_id is None: + raise ValueError("At least one of image_url or file_id must be provided") + return self + class ToolOutputImageDict(TypedDict, total=False): """TypedDict variant for image tool outputs.""" @@ -98,6 +105,13 @@ class ToolOutputFileContent(BaseModel): file_id: str | None = None filename: str | None = None + @model_validator(mode="after") + def check_at_least_one_required_field(self) -> ToolOutputFileContent: + """Validate that at least one of file_data, file_url, or file_id is provided.""" + if self.file_data is None and self.file_url is None and self.file_id is None: + raise ValueError("At least one of file_data, file_url, or file_id must be provided") + return self + class ToolOutputFileContentDict(TypedDict, total=False): """TypedDict variant for file content tool outputs.""" diff --git a/tests/extensions/memory/test_sqlalchemy_session.py b/tests/extensions/memory/test_sqlalchemy_session.py index 5d4f35bc3..0a498c189 100644 --- a/tests/extensions/memory/test_sqlalchemy_session.py +++ b/tests/extensions/memory/test_sqlalchemy_session.py @@ -14,6 +14,7 @@ Summary, ) from sqlalchemy import select, text, update +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlalchemy.sql import Select pytest.importorskip("sqlalchemy") # Skip tests if SQLAlchemy is not installed @@ -390,3 +391,56 @@ async def recording_execute(statement: Any, *args: Any, **kwargs: Any) -> Any: assert _item_ids(retrieved_full) == ["rs_first", "msg_second"] assert _item_ids(retrieved_limited) == ["rs_first", "msg_second"] + + +async def test_engine_property_from_url(): + """Test that the engine property returns the AsyncEngine from from_url.""" + session_id = "engine_property_test" + session = SQLAlchemySession.from_url(session_id, url=DB_URL, create_tables=True) + + # Verify engine property returns an AsyncEngine instance + assert isinstance(session.engine, AsyncEngine) + + # Verify we can use the engine for advanced operations + # For example, check pool status + assert session.engine.pool is not None + + # Verify we can manually dispose the engine + await session.engine.dispose() + + +async def test_engine_property_from_external_engine(): + """Test that the engine property returns the external engine.""" + session_id = "external_engine_test" + + # Create engine externally + external_engine = create_async_engine(DB_URL) + + # Create session with external engine + session = SQLAlchemySession(session_id, engine=external_engine, create_tables=True) + + # Verify engine property returns the same engine instance + assert session.engine is external_engine + + # Verify we can use the engine + assert isinstance(session.engine, AsyncEngine) + + # Clean up - user is responsible for disposing external engine + await external_engine.dispose() + + +async def test_engine_property_is_read_only(): + """Test that the engine property cannot be modified.""" + session_id = "readonly_engine_test" + session = SQLAlchemySession.from_url(session_id, url=DB_URL, create_tables=True) + + # Verify engine property exists + assert hasattr(session, "engine") + + # Verify it's a property (read-only, cannot be set) + # Type ignore needed because mypy correctly detects this is read-only + with pytest.raises(AttributeError): + session.engine = create_async_engine(DB_URL) # type: ignore[misc] + + # Clean up + await session.engine.dispose() diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index 2b9683456..85297ec62 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -518,9 +518,7 @@ async def test_interrupt_force_cancel_overrides_auto_cancellation(self, model, m model._ongoing_response = True model._created_session = SimpleNamespace( audio=SimpleNamespace( - input=SimpleNamespace( - turn_detection=SimpleNamespace(interrupt_response=True) - ) + input=SimpleNamespace(turn_detection=SimpleNamespace(interrupt_response=True)) ) ) @@ -545,9 +543,7 @@ async def test_interrupt_respects_auto_cancellation_when_not_forced(self, model, model._ongoing_response = True model._created_session = SimpleNamespace( audio=SimpleNamespace( - input=SimpleNamespace( - turn_detection=SimpleNamespace(interrupt_response=True) - ) + input=SimpleNamespace(turn_detection=SimpleNamespace(interrupt_response=True)) ) ) diff --git a/tests/test_stream_input_guardrail_timing.py b/tests/test_stream_input_guardrail_timing.py new file mode 100644 index 000000000..3de8897aa --- /dev/null +++ b/tests/test_stream_input_guardrail_timing.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import asyncio +from datetime import datetime +from typing import Any + +import pytest +from openai.types.responses import ResponseCompletedEvent + +from agents import Agent, GuardrailFunctionOutput, InputGuardrail, RunContextWrapper, Runner +from agents.exceptions import InputGuardrailTripwireTriggered +from agents.items import TResponseInputItem +from tests.fake_model import FakeModel +from tests.test_responses import get_text_message +from tests.testing_processor import fetch_events, fetch_ordered_spans + + +def make_input_guardrail(delay_seconds: float, *, trip: bool) -> InputGuardrail[Any]: + async def guardrail( + ctx: RunContextWrapper[Any], agent: Agent[Any], input: str | list[TResponseInputItem] + ) -> GuardrailFunctionOutput: + # Simulate variable guardrail completion timing. + if delay_seconds > 0: + await asyncio.sleep(delay_seconds) + return GuardrailFunctionOutput( + output_info={"delay": delay_seconds}, tripwire_triggered=trip + ) + + name = "tripping_input_guardrail" if trip else "delayed_input_guardrail" + return InputGuardrail(guardrail_function=guardrail, name=name) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("guardrail_delay", [0.0, 0.2]) +async def test_run_streamed_input_guardrail_timing_is_consistent(guardrail_delay: float): + """Ensure streaming behavior matches when input guardrail finishes before and after LLM stream. + + We verify that: + - The sequence of streamed event types is identical. + - Final output matches. + - Exactly one input guardrail result is recorded and does not trigger. + """ + + # Arrange: Agent with a single text output and a delayed input guardrail + model = FakeModel() + model.set_next_output([get_text_message("Final response")]) + + agent = Agent( + name="TimingAgent", + model=model, + input_guardrails=[make_input_guardrail(guardrail_delay, trip=False)], + ) + + # Act: Run streamed and collect event types + result = Runner.run_streamed(agent, input="Hello") + event_types: list[str] = [] + + async for event in result.stream_events(): + event_types.append(event.type) + + # Assert: Guardrail results populated and identical behavioral outcome + assert len(result.input_guardrail_results) == 1, "Expected exactly one input guardrail result" + assert result.input_guardrail_results[0].guardrail.get_name() == "delayed_input_guardrail", ( + "Guardrail name mismatch" + ) + assert result.input_guardrail_results[0].output.tripwire_triggered is False, ( + "Guardrail should not trigger in this test" + ) + + # Final output should be the text from the model's single message + assert result.final_output == "Final response" + + # Minimal invariants on event sequence to ensure stability across timing + # Must start with agent update and include raw response events + assert len(event_types) >= 3, f"Unexpectedly few events: {event_types}" + assert event_types[0] == "agent_updated_stream_event" + # Ensure we observed raw response events in the stream irrespective of guardrail timing + assert any(t == "raw_response_event" for t in event_types) + + +@pytest.mark.asyncio +async def test_run_streamed_input_guardrail_sequences_match_between_fast_and_slow(): + """Run twice with fast vs slow input guardrail and compare event sequences exactly.""" + + async def run_once(delay: float) -> list[str]: + model = FakeModel() + model.set_next_output([get_text_message("Final response")]) + agent = Agent( + name="TimingAgent", + model=model, + input_guardrails=[make_input_guardrail(delay, trip=False)], + ) + result = Runner.run_streamed(agent, input="Hello") + events: list[str] = [] + async for ev in result.stream_events(): + events.append(ev.type) + return events + + events_fast = await run_once(0.0) + events_slow = await run_once(0.2) + + assert events_fast == events_slow, ( + f"Event sequences differ between guardrail timings:\nfast={events_fast}\nslow={events_slow}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("guardrail_delay", [0.0, 0.2]) +async def test_run_streamed_input_guardrail_tripwire_raises(guardrail_delay: float): + """Guardrail tripwire must raise from stream_events regardless of timing.""" + + model = FakeModel() + model.set_next_output([get_text_message("Final response")]) + + agent = Agent( + name="TimingAgentTrip", + model=model, + input_guardrails=[make_input_guardrail(guardrail_delay, trip=True)], + ) + + result = Runner.run_streamed(agent, input="Hello") + + with pytest.raises(InputGuardrailTripwireTriggered) as excinfo: + async for _ in result.stream_events(): + pass + + # Exception contains the guardrail result and run data + exc = excinfo.value + assert exc.guardrail_result.output.tripwire_triggered is True + assert exc.run_data is not None + assert len(exc.run_data.input_guardrail_results) == 1 + assert ( + exc.run_data.input_guardrail_results[0].guardrail.get_name() == "tripping_input_guardrail" + ) + + +class SlowCompleteFakeModel(FakeModel): + """A FakeModel that delays just before emitting ResponseCompletedEvent in streaming.""" + + def __init__(self, delay_seconds: float, tracing_enabled: bool = True): + super().__init__(tracing_enabled=tracing_enabled) + self._delay_seconds = delay_seconds + + async def stream_response(self, *args, **kwargs): + async for ev in super().stream_response(*args, **kwargs): + if isinstance(ev, ResponseCompletedEvent) and self._delay_seconds > 0: + await asyncio.sleep(self._delay_seconds) + yield ev + + +def _get_span_by_type(spans, span_type: str): + for s in spans: + exported = s.export() + if not exported: + continue + if exported.get("span_data", {}).get("type") == span_type: + return s + return None + + +def _iso(s: str | None) -> datetime: + assert s is not None + return datetime.fromisoformat(s) + + +@pytest.mark.asyncio +async def test_parent_span_and_trace_finish_after_slow_input_guardrail(): + """Agent span and trace finish after guardrail when guardrail completes last.""" + + model = FakeModel(tracing_enabled=True) + model.set_next_output([get_text_message("Final response")]) + agent = Agent( + name="TimingAgentTrace", + model=model, + input_guardrails=[make_input_guardrail(0.2, trip=False)], # guardrail slower than model + ) + + result = Runner.run_streamed(agent, input="Hello") + async for _ in result.stream_events(): + pass + + spans = fetch_ordered_spans() + agent_span = _get_span_by_type(spans, "agent") + guardrail_span = _get_span_by_type(spans, "guardrail") + generation_span = _get_span_by_type(spans, "generation") + + assert agent_span and guardrail_span and generation_span, ( + "Expected agent, guardrail, generation spans" + ) + + # Agent span must finish last + assert _iso(agent_span.ended_at) >= _iso(guardrail_span.ended_at) + assert _iso(agent_span.ended_at) >= _iso(generation_span.ended_at) + + # Trace should end after all spans end + events = fetch_events() + assert events[-1] == "trace_end" + + +@pytest.mark.asyncio +async def test_parent_span_and_trace_finish_after_slow_model(): + """Agent span and trace finish after model when model completes last.""" + + model = SlowCompleteFakeModel(delay_seconds=0.2, tracing_enabled=True) + model.set_next_output([get_text_message("Final response")]) + agent = Agent( + name="TimingAgentTrace", + model=model, + input_guardrails=[make_input_guardrail(0.0, trip=False)], # guardrail faster than model + ) + + result = Runner.run_streamed(agent, input="Hello") + async for _ in result.stream_events(): + pass + + spans = fetch_ordered_spans() + agent_span = _get_span_by_type(spans, "agent") + guardrail_span = _get_span_by_type(spans, "guardrail") + generation_span = _get_span_by_type(spans, "generation") + + assert agent_span and guardrail_span and generation_span, ( + "Expected agent, guardrail, generation spans" + ) + + # Agent span must finish last + assert _iso(agent_span.ended_at) >= _iso(guardrail_span.ended_at) + assert _iso(agent_span.ended_at) >= _iso(generation_span.ended_at) + + events = fetch_events() + assert events[-1] == "trace_end" diff --git a/tests/test_tool_output_conversion.py b/tests/test_tool_output_conversion.py index bef4745bd..cd3a2a11a 100644 --- a/tests/test_tool_output_conversion.py +++ b/tests/test_tool_output_conversion.py @@ -76,7 +76,52 @@ def test_tool_call_output_item_mixed_list() -> None: assert items[2]["type"] == "input_file" and items[2]["file_data"] == "ZmlsZS1kYXRh" +def test_tool_call_output_item_image_forwards_file_id_and_detail() -> None: + """Ensure image outputs forward provided file_id and detail fields.""" + call = _make_tool_call() + out = ToolOutputImage(file_id="file_123", detail="high") + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + item = payload["output"][0] + assert isinstance(item, dict) + assert item["type"] == "input_image" + assert item["file_id"] == "file_123" + assert item["detail"] == "high" + + +def test_tool_call_output_item_file_forwards_file_id_and_filename() -> None: + """Ensure file outputs forward provided file_id and filename fields.""" + call = _make_tool_call() + out = ToolOutputFileContent(file_id="file_456", filename="report.pdf") + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + item = payload["output"][0] + assert isinstance(item, dict) + assert item["type"] == "input_file" + assert item["file_id"] == "file_456" + assert item["filename"] == "report.pdf" + + +def test_tool_call_output_item_file_forwards_file_url() -> None: + """Ensure file outputs forward provided file_url when present.""" + call = _make_tool_call() + out = ToolOutputFileContent(file_url="https://example.com/report.pdf") + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + item = payload["output"][0] + assert isinstance(item, dict) + assert item["type"] == "input_file" + assert item["file_url"] == "https://example.com/report.pdf" + + def test_tool_call_output_item_text_dict_variant() -> None: + """Dict with type='text' and text field should be treated as structured output.""" call = _make_tool_call() # Dict variant using the pydantic model schema (type="text"). out = {"type": "text", "text": "hey"} @@ -91,45 +136,237 @@ def test_tool_call_output_item_text_dict_variant() -> None: assert item["text"] == "hey" -def test_tool_call_output_item_image_forwards_file_id_and_detail() -> None: - """Ensure image outputs forward provided file_id and detail fields.""" +def test_tool_call_output_item_image_dict_variant() -> None: + """Dict with type='image' and image_url field should be treated as structured output.""" call = _make_tool_call() - out = ToolOutputImage(file_id="file_123", detail="high") + out = {"type": "image", "image_url": "http://example.com/img.png", "detail": "auto"} payload = ItemHelpers.tool_call_output_item(call, out) assert payload["type"] == "function_call_output" assert payload["call_id"] == call.call_id + assert isinstance(payload["output"], list) and len(payload["output"]) == 1 + item = payload["output"][0] + assert isinstance(item, dict) + assert item["type"] == "input_image" + assert item["image_url"] == "http://example.com/img.png" + assert item["detail"] == "auto" + + +def test_tool_call_output_item_image_dict_variant_with_file_id() -> None: + """Dict with type='image' and image_url field should be treated as structured output.""" + call = _make_tool_call() + out = {"type": "image", "file_id": "file_123"} + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + assert isinstance(payload["output"], list) and len(payload["output"]) == 1 item = payload["output"][0] assert isinstance(item, dict) assert item["type"] == "input_image" assert item["file_id"] == "file_123" - assert item["detail"] == "high" -def test_tool_call_output_item_file_forwards_file_id_and_filename() -> None: - """Ensure file outputs forward provided file_id and filename fields.""" +def test_tool_call_output_item_file_dict_variant_with_file_data() -> None: + """Dict with type='file' and file_data field should be treated as structured output.""" call = _make_tool_call() - out = ToolOutputFileContent(file_id="file_456", filename="report.pdf") + out = {"type": "file", "file_data": "foobar", "filename": "report.pdf"} payload = ItemHelpers.tool_call_output_item(call, out) assert payload["type"] == "function_call_output" assert payload["call_id"] == call.call_id + assert isinstance(payload["output"], list) and len(payload["output"]) == 1 item = payload["output"][0] assert isinstance(item, dict) assert item["type"] == "input_file" - assert item["file_id"] == "file_456" + assert item["file_data"] == "foobar" assert item["filename"] == "report.pdf" -def test_tool_call_output_item_file_forwards_file_url() -> None: - """Ensure file outputs forward provided file_url when present.""" +def test_tool_call_output_item_file_dict_variant_with_file_url() -> None: + """Dict with type='file' and file_url field should be treated as structured output.""" call = _make_tool_call() - out = ToolOutputFileContent(file_url="https://example.com/report.pdf") + out = {"type": "file", "file_url": "https://example.com/report.pdf", "filename": "report.pdf"} payload = ItemHelpers.tool_call_output_item(call, out) assert payload["type"] == "function_call_output" assert payload["call_id"] == call.call_id + assert isinstance(payload["output"], list) and len(payload["output"]) == 1 item = payload["output"][0] assert isinstance(item, dict) assert item["type"] == "input_file" assert item["file_url"] == "https://example.com/report.pdf" + assert item["filename"] == "report.pdf" + + +def test_tool_call_output_item_file_dict_variant_with_file_id() -> None: + """Dict with type='file' and file_id field should be treated as structured output.""" + call = _make_tool_call() + out = {"type": "file", "file_id": "file_123", "filename": "report.pdf"} + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + assert isinstance(payload["output"], list) and len(payload["output"]) == 1 + item = payload["output"][0] + assert isinstance(item, dict) + assert item["type"] == "input_file" + assert item["file_id"] == "file_123" + assert item["filename"] == "report.pdf" + + +def test_tool_call_output_item_image_with_extra_fields() -> None: + """Dict with type='image', image_url, and extra fields should still be converted.""" + call = _make_tool_call() + out = {"type": "image", "image_url": "http://example.com/img.png", "foobar": 213} + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + assert isinstance(payload["output"], list) and len(payload["output"]) == 1 + item = payload["output"][0] + assert isinstance(item, dict) + assert item["type"] == "input_image" + assert item["image_url"] == "http://example.com/img.png" + # Extra field should be ignored by Pydantic + assert "foobar" not in item + + +def test_tool_call_output_item_mixed_list_with_valid_dicts() -> None: + """List with valid dict variants (with type field) should be converted.""" + call = _make_tool_call() + out = [ + {"type": "text", "text": "hello"}, + {"type": "image", "image_url": "http://example.com/img.png"}, + {"type": "file", "file_id": "file_123"}, + ] + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + assert isinstance(payload["output"], list) and len(payload["output"]) == 3 + + assert payload["output"][0]["type"] == "input_text" + assert payload["output"][0]["text"] == "hello" + assert payload["output"][1]["type"] == "input_image" + assert payload["output"][1]["image_url"] == "http://example.com/img.png" + assert payload["output"][2]["type"] == "input_file" + assert payload["output"][2]["file_id"] == "file_123" + + +def test_tool_call_output_item_text_type_only_not_converted() -> None: + """Dict with only type='text' should NOT be treated as structured output.""" + call = _make_tool_call() + out = {"type": "text"} + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + # Should be converted to string since it doesn't have required fields + assert isinstance(payload["output"], str) + assert payload["output"] == "{'type': 'text'}" + + +def test_tool_call_output_item_image_type_only_not_converted() -> None: + """Dict with only type='image' should NOT be treated as structured output.""" + call = _make_tool_call() + out = {"type": "image"} + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + # Should be converted to string since it doesn't have required fields + assert isinstance(payload["output"], str) + assert payload["output"] == "{'type': 'image'}" + + +def test_tool_call_output_item_file_type_only_not_converted() -> None: + """Dict with only type='file' should NOT be treated as structured output.""" + call = _make_tool_call() + out = {"type": "file"} + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + assert isinstance(payload["output"], str) + assert payload["output"] == "{'type': 'file'}" + + +def test_tool_call_output_item_empty_dict_not_converted() -> None: + """Empty dict should NOT be treated as structured output.""" + call = _make_tool_call() + out: dict[str, str] = {} + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + assert isinstance(payload["output"], str) + assert payload["output"] == "{}" + + +def test_tool_call_output_item_dict_without_type_not_converted() -> None: + """Dict without 'type' field should NOT be treated as structured output.""" + call = _make_tool_call() + out = {"msg": "1234"} + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + # Should be converted to string since it lacks 'type' field + assert isinstance(payload["output"], str) + assert payload["output"] == "{'msg': '1234'}" + + +def test_tool_call_output_item_image_dict_variant_with_location_not_converted() -> None: + """Dict with type='image' and location field should NOT be treated as structured output.""" + call = _make_tool_call() + out = {"type": "image", "location": "/path/to/img.png"} + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + # Should be converted to string since it lacks required fields (image_url or file_id) + assert isinstance(payload["output"], str) + assert payload["output"] == "{'type': 'image', 'location': '/path/to/img.png'}" + + +def test_tool_call_output_item_file_dict_variant_with_path_not_converted() -> None: + """Dict with type='file' and path field should NOT be treated as structured output.""" + call = _make_tool_call() + out = {"type": "file", "path": "/path/to/file.txt"} + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + # Should be converted to string since it lacks required fields (file_data, file_url, or file_id) + assert isinstance(payload["output"], str) + assert payload["output"] == "{'type': 'file', 'path': '/path/to/file.txt'}" + + +def test_tool_call_output_item_list_without_type_not_converted() -> None: + """List with dicts lacking 'type' field should NOT be treated as structured output.""" + call = _make_tool_call() + out = [{"msg": "foobar"}] + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + # Should be converted to string since list items lack 'type' field + assert isinstance(payload["output"], str) + assert payload["output"] == "[{'msg': 'foobar'}]" + + +def test_tool_call_output_item_mixed_list_partial_invalid_not_converted() -> None: + """List with mix of valid and invalid dicts should NOT be treated as structured output.""" + call = _make_tool_call() + out = [ + {"type": "text", "text": "hello"}, # Valid + {"msg": "foobar"}, # Invalid + ] + payload = ItemHelpers.tool_call_output_item(call, out) + + assert payload["type"] == "function_call_output" + assert payload["call_id"] == call.call_id + # All-or-nothing: if any item is invalid, convert entire list to string + assert isinstance(payload["output"], str) + assert payload["output"] == "[{'type': 'text', 'text': 'hello'}, {'msg': 'foobar'}]" diff --git a/uv.lock b/uv.lock index a23084862..0ec84a0fc 100644 --- a/uv.lock +++ b/uv.lock @@ -1877,7 +1877,7 @@ wheels = [ [[package]] name = "openai-agents" -version = "0.4.0" +version = "0.4.1" source = { editable = "." } dependencies = [ { name = "griffe" },