From 6f69d5cb49232b341ef543542b301f52fd21a3bc Mon Sep 17 00:00:00 2001 From: Rio Yu <52408936+rioyu123@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:31:05 +0800 Subject: [PATCH 1/2] fix(sessions): accept base SessionSettings overrides on subclasses SessionSettings.resolve() rejected the exact base SessionSettings override when the stored settings object was a subclass. RunConfig also normalizes dictionary overrides to the base type, so both public override forms failed through Runner.run. Accept the exact base type or an instance compatible with the requested settings type, while preserving validation for unrelated sibling subclasses. Treat missing subclass-only override fields as unset so the stored subtype and its extra fields survive. Add direct and Runner-level regressions for typed and dictionary overrides, sibling rejection, and failure before model invocation. --- src/agents/memory/session_settings.py | 7 +- tests/memory/test_session.py | 101 ++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/agents/memory/session_settings.py b/src/agents/memory/session_settings.py index eb42f617f2..152b3a3f40 100644 --- a/src/agents/memory/session_settings.py +++ b/src/agents/memory/session_settings.py @@ -51,10 +51,10 @@ def resolve(self, override: SessionSettings | dict[str, Any] | None) -> SessionS 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) @@ -76,6 +76,9 @@ def _coerce_session_settings( *, settings_type: type[SessionSettings], ) -> SessionSettings: + """Normalize SDK-owned session settings while preserving compatible typed instances.""" + if type(value) is SessionSettings or isinstance(value, settings_type): + return value return coerce_dataclass_config(value, settings_type, parameter_name="session") 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.""" From 928ba6a1d26cccba3acfdade9b1a545a831751df Mon Sep 17 00:00:00 2001 From: Rio Yu <52408936+rioyu123@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:14:42 +0800 Subject: [PATCH 2/2] fix: scope base session settings overlays to resolve --- src/agents/memory/session_settings.py | 6 ++---- tests/test_run_config.py | 28 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/agents/memory/session_settings.py b/src/agents/memory/session_settings.py index 152b3a3f40..003aa85e8d 100644 --- a/src/agents/memory/session_settings.py +++ b/src/agents/memory/session_settings.py @@ -48,7 +48,8 @@ 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, None) @@ -76,9 +77,6 @@ def _coerce_session_settings( *, settings_type: type[SessionSettings], ) -> SessionSettings: - """Normalize SDK-owned session settings while preserving compatible typed instances.""" - if type(value) is SessionSettings or isinstance(value, settings_type): - return value return coerce_dataclass_config(value, settings_type, parameter_name="session") 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"