Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
13 changes: 13 additions & 0 deletions src/agents/extensions/memory/sqlalchemy_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions src/agents/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions src/agents/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 15 additions & 1 deletion src/agents/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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."""
Expand Down
54 changes: 54 additions & 0 deletions tests/extensions/memory/test_sqlalchemy_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
8 changes: 2 additions & 6 deletions tests/realtime/test_openai_realtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
)
)

Expand All @@ -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))
)
)

Expand Down
Loading
Loading