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: 2 additions & 0 deletions .agents/skills/implementation-strategy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ Use this skill before editing code when the task changes runtime behavior or any
- When unsupported OpenAI API or provider-adapter behavior already has a released default path, avoid turning it into a default hard error unless the latest release boundary justifies that break. Prefer an opt-in strict mode such as `strict_feature_validation=True`, while keeping the default path compatible through warning, ignoring unsupported data, or a clearly non-empty placeholder.
- For OpenAI API feature gaps, evaluate streaming and non-streaming paths together. Custom tool calls, multi-choice Chat Completions chunks, non-text tool outputs, and similar provider payload differences must not be strict in one path and permissive or malformed in the other.
- When a change creates new public SDK behavior, do not expose it only through hard-coded module globals. Prefer an explicit public configuration object or parameter, preserve the existing default behavior when compatibility-sensitive, and make opt-in SDK defaults explicit.
- For SDK-owned public configuration, accept existing typed objects and equivalent dictionaries at the public input boundary while preserving the internal typed representation. Respect the owning model's validation and extra-field policy instead of recreating arbitrary third-party schema semantics.
- Keep model-specific settings inside the existing `model_settings` parameter. Preserve released constructor arguments, typed-object behavior, and provider request payloads when adding dictionary support.
- Append new optional fields or constructor parameters to public dataclasses and constructors. Do not insert them before existing public fields unless you also provide a compatibility layer and regression coverage for the old positional call shape.
- Treat threshold and quota values as part of the API design when they affect runtime behavior. Distinguish OpenAI platform quota-derived values from defensive SDK defaults; if the value is not anchored in a documented platform limit, avoid making it an unconditional default-on behavior.
- Define `None` semantics deliberately for public configuration. For example, use separate meanings for "feature disabled or no SDK limit", "use SDK default limits", and "disable only this specific limit" rather than relying on implicit truthiness checks.
Expand Down
2 changes: 1 addition & 1 deletion src/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ def set_default_openai_responses_transport(transport: Literal["http", "websocket


def set_default_openai_agent_registration(
config: OpenAIAgentRegistrationConfig | None,
config: OpenAIAgentRegistrationConfig | dict[str, Any] | None,
) -> None:
"""Set the default OpenAI agent registration config.
Expand Down
4 changes: 2 additions & 2 deletions src/agents/_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Literal
from typing import Any, Literal

from openai import AsyncOpenAI

Expand Down Expand Up @@ -40,7 +40,7 @@ def set_default_openai_responses_transport(transport: Literal["http", "websocket


def set_default_openai_agent_registration(
config: OpenAIAgentRegistrationConfig | None,
config: OpenAIAgentRegistrationConfig | dict[str, Any] | None,
) -> None:
set_default_openai_agent_registration_config(config)

Expand Down
97 changes: 97 additions & 0 deletions src/agents/_config_coercion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from __future__ import annotations

from dataclasses import fields, is_dataclass
from types import UnionType
from typing import Any, TypeVar, Union, cast, get_args, get_origin, get_type_hints

from pydantic import AliasChoices, BaseModel

ConfigT = TypeVar("ConfigT")
DataclassConfigT = TypeVar("DataclassConfigT")
PydanticConfigT = TypeVar("PydanticConfigT", bound=BaseModel)


def _declared_dataclass_type(
owner_type: type[Any],
field_name: str,
default_type: type[DataclassConfigT],
) -> type[DataclassConfigT]:
try:
annotation = get_type_hints(owner_type).get(field_name)
except (NameError, TypeError):
return default_type

candidates = (
get_args(annotation) if get_origin(annotation) in (Union, UnionType) else (annotation,)
)
for candidate in candidates:
if (
isinstance(candidate, type)
and is_dataclass(candidate)
and issubclass(candidate, default_type)
):
return candidate
return default_type


def _dataclass_input_values(
value: dict[str, Any],
config_type: type[Any],
) -> dict[str, Any]:
field_names = {config_field.name for config_field in fields(config_type)}
return {name: field_value for name, field_value in value.items() if name in field_names}


def coerce_dataclass_config(
value: ConfigT | dict[str, Any],
config_type: type[ConfigT],
*,
parameter_name: str,
) -> ConfigT:
"""Normalize an SDK-owned dataclass configuration at its public input boundary."""
if isinstance(value, config_type):
return value
if not isinstance(value, dict):
raise TypeError(
f"{parameter_name} must be a {config_type.__name__} instance or a dict, "
f"got {type(value).__name__}"
)

field_names = {
config_field.name for config_field in fields(cast(Any, config_type)) if config_field.init
}
unknown_fields = sorted(str(name) for name in value if name not in field_names)
if unknown_fields:
raise TypeError(f"Unknown {parameter_name} settings: {', '.join(unknown_fields)}")
return config_type(**value)


def coerce_pydantic_config(
value: PydanticConfigT | dict[str, Any],
config_type: type[PydanticConfigT],
*,
parameter_name: str,
) -> PydanticConfigT:
"""Normalize an SDK-owned Pydantic configuration using its declared extra policy."""
if isinstance(value, config_type):
return value
if not isinstance(value, dict):
raise TypeError(
f"{parameter_name} must be a {config_type.__name__} instance or a dict, "
f"got {type(value).__name__}"
)

if config_type.model_config.get("extra") != "allow":
accepted_fields: set[str] = set(config_type.model_fields)
for field_info in config_type.model_fields.values():
if isinstance(field_info.validation_alias, str):
accepted_fields.add(field_info.validation_alias)
elif isinstance(field_info.validation_alias, AliasChoices):
accepted_fields.update(
alias for alias in field_info.validation_alias.choices if isinstance(alias, str)
)
unknown_fields = sorted(str(name) for name in value if name not in accepted_fields)
if unknown_fields:
raise TypeError(f"Unknown {parameter_name} settings: {', '.join(unknown_fields)}")

return config_type.model_validate(value)
61 changes: 54 additions & 7 deletions src/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from .handoffs import Handoff
from .logger import logger
from .mcp import MCPUtil
from .model_settings import ModelSettings
from .model_settings import ModelSettings, _coerce_model_settings, _declared_model_settings_type
from .models.default_models import (
get_default_model_settings,
)
Expand Down Expand Up @@ -317,6 +317,8 @@ class Agent(AgentBase, Generic[TContext]):

model_settings: ModelSettings = field(default_factory=get_default_model_settings)
"""Configures model-specific tuning parameters (e.g. temperature, top_p).

Accepts a ``ModelSettings`` instance or a dictionary containing its fields.
"""

input_guardrails: list[InputGuardrail[TContext]] = field(default_factory=list)
Expand Down Expand Up @@ -368,6 +370,39 @@ class Agent(AgentBase, Generic[TContext]):
"""Whether to reset the tool choice to the default value after a tool has been called. Defaults
to True. This ensures that the agent doesn't enter an infinite loop of tool usage."""

if TYPE_CHECKING:

def __init__(
self,
name: str,
handoff_description: str | None = None,
tools: list[Tool] = ...,
mcp_servers: list[MCPServer] = ...,
mcp_config: MCPConfig = ...,
instructions: (
str
| Callable[
[RunContextWrapper[TContext], Agent[TContext]],
MaybeAwaitable[str],
]
| None
) = None,
prompt: Prompt | DynamicPromptFunction | None = None,
handoffs: list[Agent[Any] | Handoff[TContext, Any]] = ...,
model: str | Model | None = None,
model_settings: ModelSettings | dict[str, Any] = ...,
input_guardrails: list[InputGuardrail[TContext]] = ...,
output_guardrails: list[OutputGuardrail[TContext]] = ...,
output_type: type[Any] | AgentOutputSchemaBase | None = None,
hooks: AgentHooks[TContext] | None = None,
tool_use_behavior: (
Literal["run_llm_again", "stop_on_first_tool"]
| StopAtTools
| ToolsToFinalOutputFunction
) = "run_llm_again",
reset_tool_choice: bool = True,
) -> None: ...

def __post_init__(self):
from typing import get_origin

Expand Down Expand Up @@ -424,11 +459,11 @@ def __post_init__(self):
f"Agent model must be a string, Model, or None, got {type(self.model).__name__}"
)

if not isinstance(self.model_settings, ModelSettings):
raise TypeError(
f"Agent model_settings must be a ModelSettings instance, "
f"got {type(self.model_settings).__name__}"
)
self.model_settings = _coerce_model_settings(
self.model_settings,
parameter_name="Agent model_settings",
model_settings_type=_declared_model_settings_type(type(self), "model_settings"),
)
Comment thread
seratch marked this conversation as resolved.
Comment thread
seratch marked this conversation as resolved.

if self.model is not None and self.model_settings == get_default_model_settings():
self.model_settings = _initial_model_settings_for_model(self.model)
Expand Down Expand Up @@ -503,6 +538,13 @@ def clone(self, **kwargs: Any) -> Agent[TContext]:
and _model_settings_match_implicit_model_defaults(self.model, self.model_settings)
):
kwargs["model_settings"] = _initial_model_settings_for_model(kwargs["model"])
if "model_settings" in kwargs:
kwargs["model_settings"] = _coerce_model_settings(
kwargs["model_settings"],
parameter_name="Agent model_settings",
model_settings_type=type(self.model_settings),
inherited_model_settings=self.model_settings,
)
return dataclasses.replace(self, **kwargs)

def as_tool(
Expand All @@ -515,7 +557,7 @@ def as_tool(
is_enabled: bool
| Callable[[RunContextWrapper[Any], AgentBase[Any]], MaybeAwaitable[bool]] = True,
on_stream: Callable[[AgentToolStreamEvent], MaybeAwaitable[None]] | None = None,
run_config: RunConfig | None = None,
run_config: RunConfig | dict[str, Any] | None = None,
max_turns: int | None = None,
hooks: RunHooks[TContext] | None = None,
previous_response_id: str | None = None,
Expand Down Expand Up @@ -558,6 +600,11 @@ def as_tool(
include_input_schema: Whether to include the full JSON schema in structured input.
"""

if run_config is not None:
from .run_config import _coerce_run_config

run_config = _coerce_run_config(run_config)

def _is_supported_parameters(value: Any) -> bool:
if not isinstance(value, type):
return False
Expand Down
2 changes: 1 addition & 1 deletion src/agents/extensions/memory/advanced_sqlite_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def __init__(
db_path: str | Path = ":memory:",
create_tables: bool = False,
logger: logging.Logger | None = None,
session_settings: SessionSettings | None = None,
session_settings: SessionSettings | dict[str, Any] | None = None,
**kwargs,
):
"""Initialize the AdvancedSQLiteSession.
Expand Down
16 changes: 12 additions & 4 deletions src/agents/extensions/memory/async_sqlite_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import cast
from typing import Any, cast

import aiosqlite

from ...items import TResponseInputItem
from ...memory import SessionABC
from ...memory.session_settings import SessionSettings, resolve_session_limit
from ...memory.session_settings import (
SessionSettings,
coerce_session_settings,
resolve_session_limit,
)


class AsyncSQLiteSession(SessionABC):
Expand All @@ -30,7 +34,7 @@ def __init__(
db_path: str | Path = ":memory:",
sessions_table: str = "agent_sessions",
messages_table: str = "agent_messages",
session_settings: SessionSettings | None = None,
session_settings: SessionSettings | dict[str, Any] | None = None,
):
"""Initialize the async SQLite session.
Expand All @@ -44,7 +48,11 @@ def __init__(
retrieving items. If None, uses default SessionSettings().
"""
self.session_id = session_id
self.session_settings = session_settings or SessionSettings()
self.session_settings = (
coerce_session_settings(session_settings)
if session_settings is not None
else SessionSettings()
)
self.db_path = db_path
self.sessions_table = sessions_table
self.messages_table = messages_table
Expand Down
16 changes: 12 additions & 4 deletions src/agents/extensions/memory/dapr_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@
from ...items import TResponseInputItem
from ...logger import logger
from ...memory.session import SessionABC
from ...memory.session_settings import SessionSettings, resolve_session_limit
from ...memory.session_settings import (
SessionSettings,
coerce_session_settings,
resolve_session_limit,
)

# Type alias for consistency levels
ConsistencyLevel = Literal["eventual", "strong"]
Expand All @@ -72,7 +76,7 @@ def __init__(
dapr_client: DaprClient,
ttl: int | None = None,
consistency: ConsistencyLevel = DAPR_CONSISTENCY_EVENTUAL,
session_settings: SessionSettings | None = None,
session_settings: SessionSettings | dict[str, Any] | None = None,
):
"""Initializes a new DaprSession.

Expand All @@ -90,7 +94,11 @@ def __init__(
default limit for retrieving items. If None, uses default SessionSettings().
"""
self.session_id = session_id
self.session_settings = session_settings or SessionSettings()
self.session_settings = (
coerce_session_settings(session_settings)
if session_settings is not None
else SessionSettings()
)
self._dapr_client = dapr_client
self._state_store_name = state_store_name
self._ttl = ttl
Expand All @@ -109,7 +117,7 @@ def from_address(
*,
state_store_name: str,
dapr_address: str = "localhost:50001",
session_settings: SessionSettings | None = None,
session_settings: SessionSettings | dict[str, Any] | None = None,
**kwargs: Any,
) -> DaprSession:
"""Create a session from a Dapr sidecar address.
Expand Down
16 changes: 12 additions & 4 deletions src/agents/extensions/memory/mongodb_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,11 @@

from ...items import TResponseInputItem
from ...memory.session import SessionABC
from ...memory.session_settings import SessionSettings, resolve_session_limit
from ...memory.session_settings import (
SessionSettings,
coerce_session_settings,
resolve_session_limit,
)

# Identifies this library in the MongoDB handshake for server-side telemetry.
_DRIVER_INFO = DriverInfo(name="openai-agents", version=_VERSION)
Expand Down Expand Up @@ -110,7 +114,7 @@ def __init__(
database: str = "agents",
sessions_collection: str = "agent_sessions",
messages_collection: str = "agent_messages",
session_settings: SessionSettings | None = None,
session_settings: SessionSettings | dict[str, Any] | None = None,
):
"""Initialize a new MongoDBSession.

Expand All @@ -128,7 +132,11 @@ def __init__(
is used (no item limit).
"""
self.session_id = session_id
self.session_settings = session_settings or SessionSettings()
self.session_settings = (
coerce_session_settings(session_settings)
if session_settings is not None
else SessionSettings()
)
self._client = client
self._owns_client = False

Expand All @@ -153,7 +161,7 @@ def from_uri(
uri: str,
database: str = "agents",
client_kwargs: dict[str, Any] | None = None,
session_settings: SessionSettings | None = None,
session_settings: SessionSettings | dict[str, Any] | None = None,
**kwargs: Any,
) -> MongoDBSession:
"""Create a session from a MongoDB URI string.
Expand Down
Loading