diff --git a/src/agents/memory/session_settings.py b/src/agents/memory/session_settings.py index eb42f617f2..003aa85e8d 100644 --- a/src/agents/memory/session_settings.py +++ b/src/agents/memory/session_settings.py @@ -48,13 +48,14 @@ def resolve(self, override: SessionSettings | dict[str, Any] | None) -> SessionS if isinstance(override, dict) else None ) - override = _coerce_session_settings(override, settings_type=type(self)) + if type(override) is not SessionSettings: + override = _coerce_session_settings(override, settings_type=type(self)) changes = { - field.name: getattr(override, field.name) + field.name: getattr(override, field.name, None) for field in fields(self) if (override_fields is None or field.name in override_fields) - and getattr(override, field.name) is not None + and getattr(override, field.name, None) is not None } return replace(self, **changes) diff --git a/tests/memory/test_session.py b/tests/memory/test_session.py index 9667c5b23c..6f55df3b0a 100644 --- a/tests/memory/test_session.py +++ b/tests/memory/test_session.py @@ -8,6 +8,7 @@ from typing import Any, cast import pytest +from pydantic.dataclasses import dataclass from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession, TResponseInputItem from agents.memory.sqlite_session import _await_mutation @@ -962,6 +963,106 @@ async def test_session_settings_resolve(): assert final_none.limit == 100 +@dataclass +class _TenantSessionSettings(SessionSettings): + tenant: str = "default" + + +@dataclass +class _OtherSessionSettings(SessionSettings): + tenant: str = "other" + + +def test_session_settings_resolve_accepts_base_override_on_subclass(): + """A SessionSettings subclass accepts a base-class override and keeps its own fields.""" + settings = _TenantSessionSettings(limit=1, tenant="acme") + + resolved = settings.resolve(SessionSettings(limit=5)) + + assert isinstance(resolved, _TenantSessionSettings) + assert resolved.limit == 5 + assert resolved.tenant == "acme" + + +def test_session_settings_resolve_rejects_sibling_subclass_override(): + """Unrelated settings subclasses cannot silently copy overlapping fields.""" + settings = _TenantSessionSettings(limit=1, tenant="acme") + + with pytest.raises(TypeError, match="must be a _TenantSessionSettings instance or a dict"): + settings.resolve(_OtherSessionSettings(limit=5, tenant="other")) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "override", + [SessionSettings(limit=5), {"limit": 5}], + ids=["instance", "dict"], +) +async def test_runner_session_settings_override_applies_to_subclassed_settings( + override: SessionSettings | dict[str, Any], +): + """A base SessionSettings override from RunConfig applies to a session whose settings + are a SessionSettings subclass.""" + session = SQLiteSession( + "subclass_override_test", + session_settings=_TenantSessionSettings(tenant="acme"), + ) + try: + items: list[TResponseInputItem] = [ + {"role": "user", "content": f"Turn {i}"} for i in range(10) + ] + await session.add_items(items) + + model = ScriptedModel() + model.enqueue([get_text_message("Got it")]) + agent = Agent(name="test", model=model) + + await Runner.run( + agent, + "New question", + session=session, + run_config=RunConfig(session_settings=override), + ) + + # The override's limit applies: only the last 5 history items plus the new question. + history_items = [ + item for item in model.calls[-1].input if item.get("content") != "New question" + ] + assert len(history_items) == 5 + finally: + session.close() + + +@pytest.mark.asyncio +async def test_runner_session_settings_override_rejects_sibling_subclass(): + """Runner rejects an unrelated settings subclass before invoking the model.""" + session = SQLiteSession( + "sibling_subclass_override_test", + session_settings=_TenantSessionSettings(tenant="acme"), + ) + try: + model = ScriptedModel() + model.enqueue([get_text_message("Got it")]) + agent = Agent(name="test", model=model) + + with pytest.raises( + TypeError, + match="must be a _TenantSessionSettings instance or a dict", + ): + await Runner.run( + agent, + "New question", + session=session, + run_config=RunConfig( + session_settings=_OtherSessionSettings(limit=5, tenant="other") + ), + ) + + assert not model.calls + finally: + session.close() + + @pytest.mark.asyncio async def test_runner_with_session_settings_override(): """Test that RunConfig can override session's default settings.""" diff --git a/tests/test_run_config.py b/tests/test_run_config.py index d1e0ed2e98..4ef0402de8 100644 --- a/tests/test_run_config.py +++ b/tests/test_run_config.py @@ -1,9 +1,11 @@ from __future__ import annotations +from dataclasses import dataclass from pathlib import PureWindowsPath from typing import Any, cast import pytest +from pydantic.dataclasses import dataclass as pydantic_dataclass from agents import ( Agent, @@ -40,6 +42,16 @@ def get_model(self, model_name: str | None) -> Model: return self.model_to_return +@pydantic_dataclass +class _DeclaredSessionSettings(SessionSettings): + tenant: str = "default" + + +@dataclass +class _DeclaredSessionRunConfig(RunConfig): + session_settings: _DeclaredSessionSettings | None = None + + def test_run_config_normalizes_first_party_dictionary_settings() -> None: config = RunConfig( model_settings={"reasoning": {"context": "all_turns"}, "temperature": 0.0}, @@ -99,6 +111,22 @@ def test_run_config_preserves_typed_configuration_instances() -> None: assert config.session_settings is session_settings +def test_run_config_subclass_uses_declared_session_settings_type() -> None: + config = cast(Any, _DeclaredSessionRunConfig)(session_settings={"limit": 5, "tenant": "acme"}) + + assert isinstance(config.session_settings, _DeclaredSessionSettings) + assert config.session_settings.limit == 5 + assert config.session_settings.tenant == "acme" + + +def test_run_config_subclass_rejects_base_session_settings_instance() -> None: + with pytest.raises( + TypeError, + match="must be a _DeclaredSessionSettings instance or a dict", + ): + cast(Any, _DeclaredSessionRunConfig)(session_settings=SessionSettings(limit=5)) + + def test_run_config_accepts_output_guardrail_blocked_message_customizers() -> None: def formatter(_args: OutputGuardrailBlockedMessageArgs[Any]) -> str: return "custom"