Skip to content
Open
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
7 changes: 4 additions & 3 deletions src/agents/memory/session_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
101 changes: 101 additions & 0 deletions tests/memory/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
28 changes: 28 additions & 0 deletions tests/test_run_config.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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"
Expand Down