feat: add approval argument overrides for RunState resumes

Co-authored-by: Kazuhiro Sera <seratch@openai.com>
This commit is contained in:
Kazuhiro Sera
2026-08-11 19:21:22 +09:00
parent 5250cb8605
commit 7bb9415c5b
25 changed files with 5538 additions and 194 deletions
+20 -1
View File
@@ -1649,7 +1649,7 @@ def _find_subset_errors(expected: object, actual: object, path: str = "state") -
def _restore_agent(payload: dict[str, Any]) -> Any:
from agents import Agent, handoff
from agents import Agent, function_tool, handoff
current_agent = payload.get("current_agent")
name = (
@@ -1661,6 +1661,25 @@ def _restore_agent(payload: dict[str, Any]) -> Any:
if identity == f"{name}#2":
duplicate = Agent(name=name)
return Agent(name=name, handoffs=[handoff(duplicate)])
processed_response = payload.get("last_processed_response")
serialized_functions = (
processed_response.get("functions", []) if isinstance(processed_response, dict) else []
)
has_send_email_tool = any(
isinstance(entry, dict)
and isinstance(entry.get("tool"), dict)
and entry["tool"].get("name") == "send_email"
for entry in serialized_functions
)
if has_send_email_tool:
@function_tool(name_override="send_email")
def send_email(recipient: str) -> str:
return f"sent:{recipient}"
return Agent(name=name, tools=[send_email])
return Agent(name=name)
+16
View File
@@ -5,11 +5,19 @@ from typing import TYPE_CHECKING, Any
from .openai_conversations_session import OpenAIConversationsSession
from .openai_responses_compaction_session import OpenAIResponsesCompactionSession
from .session import (
SERVER_MANAGED_CONVERSATION_SESSION_ATTR,
OpenAIResponsesCompactionArgs,
OpenAIResponsesCompactionAwareSession,
ServerManagedConversationSession,
Session,
SessionABC,
SessionHistoryMutation,
SessionHistoryRewriteArgs,
SessionHistoryRewriteAwareSession,
apply_session_history_mutations,
is_openai_responses_compaction_aware_session,
is_server_managed_conversation_session,
is_session_history_rewrite_aware_session,
)
from .session_settings import SessionSettings
from .util import SessionInputCallback
@@ -27,7 +35,15 @@ __all__ = [
"OpenAIResponsesCompactionSession",
"OpenAIResponsesCompactionArgs",
"OpenAIResponsesCompactionAwareSession",
"SERVER_MANAGED_CONVERSATION_SESSION_ATTR",
"SessionHistoryMutation",
"SessionHistoryRewriteArgs",
"SessionHistoryRewriteAwareSession",
"ServerManagedConversationSession",
"apply_session_history_mutations",
"is_server_managed_conversation_session",
"is_openai_responses_compaction_aware_session",
"is_session_history_rewrite_aware_session",
]
@@ -25,6 +25,7 @@ async def start_openai_conversations_session(openai_client: AsyncOpenAI | None =
class OpenAIConversationsSession(SessionABC):
_server_managed_conversation_session = True
session_settings: SessionSettings | None = None
def __init__(
@@ -11,11 +11,14 @@ from ..items import TResponseInputItem
from ..logger import log_model_and_tool_action_warning
from ..models._openai_shared import get_default_openai_client
from ..run_internal.items import normalize_input_items_for_api
from .openai_conversations_session import OpenAIConversationsSession
from .session import (
OpenAIResponsesCompactionArgs,
OpenAIResponsesCompactionAwareSession,
SessionABC,
SessionHistoryRewriteArgs,
apply_session_history_mutations,
is_server_managed_conversation_session,
is_session_history_rewrite_aware_session,
)
if TYPE_CHECKING:
@@ -80,11 +83,13 @@ def is_openai_model_name(model: str) -> bool:
class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwareSession):
"""Session decorator that triggers responses.compact when stored history grows.
Works with OpenAI Responses API models only. Wraps any Session (except
OpenAIConversationsSession) and automatically calls the OpenAI responses.compact
API after each turn when the decision hook returns True.
Works with OpenAI Responses API models only. Wraps any client-managed Session and
automatically calls the OpenAI responses.compact API after each turn when the decision
hook returns True.
"""
supports_expected_history_mutations: Literal[True] = True
def __init__(
self,
session_id: str,
@@ -99,8 +104,7 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
Args:
session_id: Identifier for this session.
underlying_session: Session store that holds the compacted history. Cannot be
OpenAIConversationsSession.
underlying_session: Client-managed session store that holds the compacted history.
client: OpenAI client for responses.compact API calls. Defaults to
get_default_openai_client() or new AsyncOpenAI().
model: Model to use for responses.compact. Defaults to "gpt-4.1". Must be an
@@ -111,10 +115,10 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
should_trigger_compaction: Custom decision hook. Defaults to triggering when
10+ compaction candidates exist.
"""
if isinstance(underlying_session, OpenAIConversationsSession):
if is_server_managed_conversation_session(underlying_session):
raise ValueError(
"OpenAIResponsesCompactionSession cannot wrap OpenAIConversationsSession "
"because it manages its own history on the server."
"OpenAIResponsesCompactionSession cannot wrap a server-managed conversation "
"session because it manages its own history on the server."
)
if not is_openai_model_name(model):
@@ -137,6 +141,8 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
self._response_id: str | None = None
self._deferred_response_id: str | None = None
self._last_unstored_response_id: str | None = None
self._has_pending_local_history_rewrite = False
self._history_rewrite_generation = 0
# Serialize wrapper mutations against compaction snapshot/replace/restore so a
# cancellation rollback cannot rewrite past a newer concurrent write.
self._mutation_lock = asyncio.Lock()
@@ -162,67 +168,75 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
and response_id is not None
and response_id == self._last_unstored_response_id
):
resolved_mode: _ResolvedCompactionMode = "input"
else:
resolved_mode = _resolve_compaction_mode(mode, response_id=response_id, store=store)
if self._has_pending_local_history_rewrite and resolved_mode == "previous_response_id":
logger.debug("compact: forcing input mode after local history rewrite")
return "input"
return _resolve_compaction_mode(mode, response_id=response_id, store=store)
return resolved_mode
async def run_compaction(self, args: OpenAIResponsesCompactionArgs | None = None) -> None:
"""Run compaction using responses.compact API."""
if args and args.get("response_id"):
self._response_id = args["response_id"]
requested_mode = args.get("compaction_mode") if args else None
if args and "store" in args:
store = args["store"]
if store is False and self._response_id:
self._last_unstored_response_id = self._response_id
elif store is True and self._response_id == self._last_unstored_response_id:
self._last_unstored_response_id = None
else:
store = None
resolved_mode = self._resolve_compaction_mode_for_response(
response_id=self._response_id,
store=store,
requested_mode=requested_mode,
)
if resolved_mode == "previous_response_id" and not self._response_id:
raise ValueError(
"OpenAIResponsesCompactionSession.run_compaction requires a response_id "
"when using previous_response_id compaction."
async with self._mutation_lock:
if args and args.get("response_id"):
self._response_id = args["response_id"]
requested_mode = args.get("compaction_mode") if args else None
if args and "store" in args:
store = args["store"]
if store is False and self._response_id:
self._last_unstored_response_id = self._response_id
elif store is True and self._response_id == self._last_unstored_response_id:
self._last_unstored_response_id = None
else:
store = None
resolved_mode = self._resolve_compaction_mode_for_response(
response_id=self._response_id,
store=store,
requested_mode=requested_mode,
)
compaction_candidate_items, session_items = await self._ensure_compaction_candidates()
if resolved_mode == "previous_response_id" and not self._response_id:
raise ValueError(
"OpenAIResponsesCompactionSession.run_compaction requires a response_id "
"when using previous_response_id compaction."
)
force = args.get("force", False) if args else False
should_compact = force or self.should_trigger_compaction(
{
"response_id": self._response_id,
"compaction_mode": resolved_mode,
"compaction_candidate_items": compaction_candidate_items,
"session_items": session_items,
}
)
compaction_candidate_items, session_items = await self._ensure_compaction_candidates()
if not should_compact:
force = args.get("force", False) if args else False
should_compact = force or self.should_trigger_compaction(
{
"response_id": self._response_id,
"compaction_mode": resolved_mode,
"compaction_candidate_items": compaction_candidate_items,
"session_items": session_items,
}
)
if not should_compact:
logger.debug(
"skip: decision hook declined compaction for %s (mode=%s)",
self._response_id,
resolved_mode,
)
return
self._deferred_response_id = None
response_id = self._response_id
rewrite_generation = self._history_rewrite_generation
logger.debug(
"skip: decision hook declined compaction for %s (mode=%s)",
self._response_id,
"compact: start for %s using %s (mode=%s)",
response_id,
self.model,
resolved_mode,
)
return
self._deferred_response_id = None
logger.debug(
"compact: start for %s using %s (mode=%s)",
self._response_id,
self.model,
resolved_mode,
)
compact_kwargs: dict[str, Any] = {"model": self.model}
if resolved_mode == "previous_response_id":
compact_kwargs["previous_response_id"] = self._response_id
else:
compact_kwargs["input"] = session_items
compact_kwargs: dict[str, Any] = {"model": self.model}
if resolved_mode == "previous_response_id":
compact_kwargs["previous_response_id"] = response_id
else:
compact_kwargs["input"] = session_items
compacted = await self.client.responses.compact(**compact_kwargs)
@@ -231,6 +245,12 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
)
async with self._mutation_lock:
if rewrite_generation != self._history_rewrite_generation:
logger.debug(
"compact: discarding stale result for %s after local history mutation",
response_id,
)
return
previous_items = await self._get_all_underlying_session_items()
await self._replace_underlying_session_items(
output_items=output_items,
@@ -238,10 +258,12 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
)
self._compaction_candidate_items = select_compaction_candidate_items(output_items)
self._session_items = output_items
if resolved_mode == "input":
self._has_pending_local_history_rewrite = False
logger.debug(
"compact: done for %s (mode=%s, output=%s, candidates=%s)",
self._response_id,
response_id,
resolved_mode,
len(output_items),
len(self._compaction_candidate_items or []),
@@ -250,29 +272,77 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
return await self.underlying_session.get_items(limit)
async def _get_all_underlying_session_items(self) -> list[TResponseInputItem]:
return await self.underlying_session.get_items(limit=_ALL_SESSION_ITEMS_LIMIT)
async def apply_history_mutations(self, args: SessionHistoryRewriteArgs) -> bool:
"""Rewrite persisted history and keep compaction caches aligned."""
mutations = list(args.get("mutations", []))
if not mutations:
return True
async with self._mutation_lock:
underlying_session = self.underlying_session
if is_server_managed_conversation_session(underlying_session):
raise ValueError(
"OpenAIResponsesCompactionSession cannot rewrite a server-managed "
"conversation session because it manages its own history on the server."
)
# Invalidate any compaction that started before this potentially committing
# rewrite. This evidence must precede the first cancellable storage await.
self._history_rewrite_generation += 1
self._has_pending_local_history_rewrite = True
self._session_items = None
self._compaction_candidate_items = None
if is_session_history_rewrite_aware_session(underlying_session):
applied = await underlying_session.apply_history_mutations({"mutations": mutations})
if applied is not True:
raise ValueError("Underlying session did not confirm its history rewrite.")
rewritten_items = await self._get_all_underlying_session_items(underlying_session)
else:
previous_items = await self._get_all_underlying_session_items(underlying_session)
rewritten_items = apply_session_history_mutations(previous_items, mutations)
await self._replace_underlying_session_items(
output_items=rewritten_items,
previous_items=previous_items,
underlying_session=underlying_session,
)
if self.underlying_session is not underlying_session:
raise ValueError(
"OpenAIResponsesCompactionSession underlying session changed during history "
"rewrite."
)
self._session_items = rewritten_items
self._compaction_candidate_items = select_compaction_candidate_items(rewritten_items)
return True
async def _get_all_underlying_session_items(
self,
underlying_session: Session | None = None,
) -> list[TResponseInputItem]:
session = underlying_session if underlying_session is not None else self.underlying_session
return await session.get_items(limit=_ALL_SESSION_ITEMS_LIMIT)
async def _replace_underlying_session_items(
self,
*,
output_items: list[TResponseInputItem],
previous_items: list[TResponseInputItem],
underlying_session: Session | None = None,
) -> None:
# Treat clear → add as one replacement transaction. Exception and CancelledError
# both restore previous history, and restore settlement is always drained so a
# cancel during restore cannot leave an empty session.
session = underlying_session if underlying_session is not None else self.underlying_session
cleared = False
try:
await self.underlying_session.clear_session()
await session.clear_session()
cleared = True
if output_items:
await self.underlying_session.add_items(output_items)
await session.add_items(output_items)
except Exception as error:
await self._recover_from_failed_replacement(
previous_items=previous_items,
error=error,
cleared=cleared,
underlying_session=session,
)
raise
except asyncio.CancelledError as error:
@@ -280,6 +350,7 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
previous_items=previous_items,
error=error,
cleared=cleared,
underlying_session=session,
)
raise
@@ -289,13 +360,20 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
previous_items: list[TResponseInputItem],
error: BaseException,
cleared: bool,
underlying_session: Session,
) -> None:
if not cleared:
restore = self._restore_underlying_session_items_after_failed_clear(
previous_items, error
previous_items,
error,
underlying_session=underlying_session,
)
else:
restore = self._restore_underlying_session_items(previous_items, error)
restore = self._restore_underlying_session_items(
previous_items,
error,
underlying_session=underlying_session,
)
await self._await_restore_despite_cancellation(restore)
async def _await_restore_despite_cancellation(self, restore: Awaitable[None]) -> None:
@@ -324,9 +402,11 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
self,
previous_items: list[TResponseInputItem],
clear_error: BaseException,
*,
underlying_session: Session,
) -> None:
try:
current_items = await self._get_all_underlying_session_items()
current_items = await self._get_all_underlying_session_items(underlying_session)
except Exception as inspection_error:
log_model_and_tool_action_warning(
logger,
@@ -339,7 +419,10 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
return
await self._restore_underlying_session_items(
previous_items, clear_error, clear_existing_items=False
previous_items,
clear_error,
clear_existing_items=False,
underlying_session=underlying_session,
)
async def _restore_underlying_session_items(
@@ -348,12 +431,13 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
replacement_error: BaseException,
*,
clear_existing_items: bool = True,
underlying_session: Session,
) -> None:
try:
if clear_existing_items:
await self.underlying_session.clear_session()
await underlying_session.clear_session()
if previous_items:
await self.underlying_session.add_items(list(previous_items))
await underlying_session.add_items(list(previous_items))
except Exception as restore_error:
log_model_and_tool_action_warning(
logger,
@@ -369,24 +453,25 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
)
async def _defer_compaction(self, response_id: str, store: bool | None = None) -> None:
if self._deferred_response_id is not None:
return
compaction_candidate_items, session_items = await self._ensure_compaction_candidates()
resolved_mode = self._resolve_compaction_mode_for_response(
response_id=response_id,
store=store,
requested_mode=None,
)
should_compact = self.should_trigger_compaction(
{
"response_id": response_id,
"compaction_mode": resolved_mode,
"compaction_candidate_items": compaction_candidate_items,
"session_items": session_items,
}
)
if should_compact:
self._deferred_response_id = response_id
async with self._mutation_lock:
if self._deferred_response_id is not None:
return
compaction_candidate_items, session_items = await self._ensure_compaction_candidates()
resolved_mode = self._resolve_compaction_mode_for_response(
response_id=response_id,
store=store,
requested_mode=None,
)
should_compact = self.should_trigger_compaction(
{
"response_id": response_id,
"compaction_mode": resolved_mode,
"compaction_candidate_items": compaction_candidate_items,
"session_items": session_items,
}
)
if should_compact:
self._deferred_response_id = response_id
def _get_deferred_compaction_response_id(self) -> str | None:
return self._deferred_response_id
@@ -396,29 +481,54 @@ class OpenAIResponsesCompactionSession(SessionABC, OpenAIResponsesCompactionAwar
async def add_items(self, items: list[TResponseInputItem]) -> None:
async with self._mutation_lock:
cached_candidates = (
list(self._compaction_candidate_items)
if self._compaction_candidate_items is not None
else None
)
cached_items = list(self._session_items) if self._session_items is not None else None
# Invalidate any compaction and cached history before the append can commit and
# propagate cancellation.
self._history_rewrite_generation += 1
self._compaction_candidate_items = None
self._session_items = None
await self.underlying_session.add_items(items)
if self._compaction_candidate_items is not None:
if cached_candidates is not None:
new_items = _normalize_compaction_session_items(items)
new_candidates = select_compaction_candidate_items(new_items)
if new_candidates:
self._compaction_candidate_items.extend(new_candidates)
if self._session_items is not None:
self._session_items.extend(_normalize_compaction_session_items(items))
cached_candidates.extend(new_candidates)
self._compaction_candidate_items = cached_candidates
if cached_items is not None:
cached_items.extend(_normalize_compaction_session_items(items))
self._session_items = cached_items
async def pop_item(self) -> TResponseInputItem | None:
async with self._mutation_lock:
cached_candidates = self._compaction_candidate_items
cached_items = self._session_items
# Invalidate any compaction and cached history before the pop can commit and
# propagate cancellation.
self._history_rewrite_generation += 1
self._compaction_candidate_items = None
self._session_items = None
popped = await self.underlying_session.pop_item()
if popped:
self._compaction_candidate_items = None
self._session_items = None
if popped is None:
self._compaction_candidate_items = cached_candidates
self._session_items = cached_items
return popped
async def clear_session(self) -> None:
async with self._mutation_lock:
# Invalidate compaction before the clear can commit and propagate cancellation.
self._history_rewrite_generation += 1
self._compaction_candidate_items = None
self._session_items = None
await self.underlying_session.clear_session()
self._compaction_candidate_items = []
self._session_items = []
self._deferred_response_id = None
self._has_pending_local_history_rewrite = False
async def _ensure_compaction_candidates(
self,
+127 -1
View File
@@ -1,8 +1,9 @@
from __future__ import annotations
import copy
import inspect
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeGuard, runtime_checkable
from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeGuard, cast, runtime_checkable
from typing_extensions import TypedDict
@@ -11,6 +12,8 @@ if TYPE_CHECKING:
from ..run_context import RunContextWrapper
from .session_settings import SessionSettings
SERVER_MANAGED_CONVERSATION_SESSION_ATTR = "_server_managed_conversation_session"
@runtime_checkable
class Session(Protocol):
@@ -106,6 +109,129 @@ class SessionABC(ABC):
...
@runtime_checkable
class ServerManagedConversationSession(Session, Protocol):
"""Protocol for sessions whose canonical history is managed by a remote service."""
_server_managed_conversation_session: Literal[True]
def is_server_managed_conversation_session(
session: Session | None,
) -> TypeGuard[ServerManagedConversationSession]:
"""Check whether a session advertises server-managed history semantics."""
if session is None:
return False
try:
marker = inspect.getattr_static(session, SERVER_MANAGED_CONVERSATION_SESSION_ATTR, False)
except Exception:
return False
return marker is True
class ReplaceFunctionCallSessionHistoryMutation(TypedDict):
"""Replace the canonical persisted function call for a tool call."""
type: Literal["replace_function_call"]
call_id: str
expected: TResponseInputItem
replacement: TResponseInputItem
SessionHistoryMutation = ReplaceFunctionCallSessionHistoryMutation
class SessionHistoryRewriteArgs(TypedDict):
"""Arguments for persisted-history rewrites."""
mutations: list[SessionHistoryMutation]
@runtime_checkable
class SessionHistoryRewriteAwareSession(Session, Protocol):
"""Protocol for sessions that can compare and rewrite persisted function calls."""
supports_expected_history_mutations: Literal[True]
async def apply_history_mutations(self, args: SessionHistoryRewriteArgs) -> bool:
"""Apply every expected mutation and confirm all targets were reconciled."""
...
def is_session_history_rewrite_aware_session(
session: Session | None,
) -> TypeGuard[SessionHistoryRewriteAwareSession]:
"""Check whether a session supports expected persisted-history rewrites."""
if session is None:
return False
try:
apply_history_mutations = inspect.getattr_static(session, "apply_history_mutations", None)
supports_expected_mutations = inspect.getattr_static(
session, "supports_expected_history_mutations", False
)
except Exception:
return False
return callable(apply_history_mutations) and supports_expected_mutations is True
def apply_session_history_mutations(
items: list[TResponseInputItem],
mutations: list[SessionHistoryMutation],
) -> list[TResponseInputItem]:
"""Apply structured history mutations to a list of persisted session items."""
next_items = list(items)
for mutation in mutations:
if mutation["type"] == "replace_function_call":
next_items = _apply_replace_function_call_mutation(next_items, mutation)
return next_items
def _apply_replace_function_call_mutation(
items: list[TResponseInputItem],
mutation: ReplaceFunctionCallSessionHistoryMutation,
) -> list[TResponseInputItem]:
"""Replace the latest expected call or accept an already-applied replacement."""
call_id = mutation["call_id"]
expected = _snapshot_matching_function_call(mutation["expected"], call_id)
replacement = _snapshot_matching_function_call(mutation["replacement"], call_id)
if expected is None or replacement is None:
raise ValueError("Session history mutation contains an invalid function call.")
for index in range(len(items) - 1, -1, -1):
candidate = _snapshot_matching_function_call(items[index], call_id)
if candidate is None:
continue
if candidate == replacement:
return list(items)
if candidate == expected:
next_items = list(items)
next_items[index] = cast(Any, replacement)
return next_items
break
raise ValueError("Session history mutation target did not match the expected function call.")
def _snapshot_matching_function_call(item: Any, call_id: str) -> dict[str, Any] | None:
"""Snapshot a matching function call without inspecting unrelated history values."""
if isinstance(item, dict):
payload = item
elif hasattr(item, "model_dump"):
try:
payload = item.model_dump(exclude_unset=True)
except TypeError:
payload = item.model_dump()
else:
return None
if (
not isinstance(payload, dict)
or payload.get("type") != "function_call"
or payload.get("call_id") != call_id
):
return None
return copy.deepcopy(payload)
class OpenAIResponsesCompactionArgs(TypedDict, total=False):
"""Arguments for the run_compaction method."""
+68 -2
View File
@@ -7,10 +7,10 @@ import threading
from collections.abc import Awaitable, Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any, ClassVar, TypeVar
from typing import Any, ClassVar, Literal, TypeVar
from ..items import TResponseInputItem
from .session import SessionABC
from .session import SessionABC, SessionHistoryRewriteArgs, apply_session_history_mutations
from .session_settings import SessionSettings, coerce_session_settings, resolve_session_limit
_T = TypeVar("_T")
@@ -47,6 +47,7 @@ class SQLiteSession(SessionABC):
"""
session_settings: SessionSettings | None = None
supports_expected_history_mutations: Literal[True] = True
_file_locks: ClassVar[dict[Path, threading.RLock]] = {}
_file_lock_counts: ClassVar[dict[Path, int]] = {}
_file_locks_guard: ClassVar[threading.Lock] = threading.Lock()
@@ -429,6 +430,71 @@ class SQLiteSession(SessionABC):
await _await_mutation(asyncio.to_thread(_clear_session_sync))
async def apply_history_mutations(self, args: SessionHistoryRewriteArgs) -> bool:
"""Rewrite persisted session history using structured mutations."""
mutations = list(args.get("mutations", []))
if not mutations:
return True
def _apply_history_mutations_sync() -> None:
with self._write_connection() as conn:
cursor = conn.execute(
f"""
SELECT id, message_data FROM {self.messages_table}
WHERE session_id = ?
ORDER BY id ASC
""",
(self.session_id,),
)
rows = cursor.fetchall()
decoded_rows: list[tuple[int, TResponseInputItem]] = []
for row_id, message_data in rows:
try:
item = json.loads(message_data)
except (json.JSONDecodeError, TypeError):
continue
decoded_rows.append((row_id, item))
for mutation in mutations:
previous_items = [item for _, item in decoded_rows]
rewritten_items = apply_session_history_mutations(
previous_items,
[mutation],
)
for (row_id, previous_item), rewritten_item in zip(
decoded_rows, rewritten_items, strict=True
):
if previous_item == rewritten_item:
continue
conn.execute(
f"""
UPDATE {self.messages_table}
SET message_data = ?
WHERE id = ? AND session_id = ?
""",
(json.dumps(rewritten_item), row_id, self.session_id),
)
decoded_rows = [
(row_id, rewritten_item)
for (row_id, _), rewritten_item in zip(
decoded_rows, rewritten_items, strict=True
)
]
conn.execute(
f"""
UPDATE {self.sessions_table}
SET updated_at = CURRENT_TIMESTAMP
WHERE session_id = ?
""",
(self.session_id,),
)
conn.commit()
await _await_mutation(asyncio.to_thread(_apply_history_mutations_sync))
return True
def close(self) -> None:
"""Close the database connection."""
with self._lock:
+47 -12
View File
@@ -74,6 +74,7 @@ from .run_internal.agent_runner_helpers import (
snapshot_usage,
update_run_state_for_interruption,
usage_delta,
validate_override_history_persistence_support,
validate_session_conversation_settings,
)
from .run_internal.approvals import approvals_from_step
@@ -117,6 +118,7 @@ from .run_internal.run_steps import (
from .run_internal.session_persistence import (
_session_get_items,
admit_pending_input,
apply_pending_session_history_mutations,
commit_server_pending_input,
persist_session_items_for_guardrail_trip,
prepare_input_with_session,
@@ -543,6 +545,7 @@ class AgentRunner:
session_input_items_for_persistence: list[TResponseInputItem] | None = (
[] if (session is not None and is_resumed_state) else None
)
server_manages_conversation = False
# Track the most recent input batch we persisted so conversation-lock retries can rewind
# exactly those items (and not the full history).
last_saved_input_snapshot_for_rewind: list[TResponseInputItem] | None = None
@@ -622,6 +625,16 @@ class AgentRunner:
)
original_input_for_state = prepared_input
server_manages_conversation = (
conversation_id is not None
or previous_response_id is not None
or auto_previous_response_id
)
validate_override_history_persistence_support(
input=input,
session=session,
response_history_is_server_managed=server_manages_conversation,
)
# Check whether to enable OpenAI server-managed conversation
if (
conversation_id is not None
@@ -998,20 +1011,31 @@ class AgentRunner:
)
raise UserError("No processed response found in previous state")
turn_result = await resolve_interrupted_turn(
bindings=current_bindings,
original_input=original_input,
original_pre_step_items=generated_items,
new_response=run_state._model_responses[-1],
processed_response=run_state._last_processed_response,
hooks=hooks,
context_wrapper=context_wrapper,
run_config=run_config,
server_manages_conversation=server_conversation_tracker is not None,
run_state=run_state,
error_handlers=error_handlers,
await apply_pending_session_history_mutations(
session,
run_state,
wrapper=context_wrapper,
)
try:
turn_result = await resolve_interrupted_turn(
bindings=current_bindings,
original_input=original_input,
original_pre_step_items=generated_items,
new_response=run_state._model_responses[-1],
processed_response=run_state._last_processed_response,
hooks=hooks,
context_wrapper=context_wrapper,
run_config=run_config,
server_manages_conversation=(
server_conversation_tracker is not None
),
run_state=run_state,
error_handlers=error_handlers,
)
finally:
run_state._clear_executed_approval_argument_overrides()
if run_state._last_processed_response is not None:
tool_use_tracker.record_processed_response(
current_agent,
@@ -1981,6 +2005,7 @@ class AgentRunner:
run_state: RunState[TContext] | None = None
input_for_result: str | list[TResponseInputItem]
starting_input = input if not is_resumed_state else None
server_manages_conversation = False
if is_resumed_state:
run_state = cast(RunState[TContext], input)
@@ -2058,6 +2083,16 @@ class AgentRunner:
auto_previous_response_id=auto_previous_response_id,
)
server_manages_conversation = (
conversation_id is not None
or previous_response_id is not None
or auto_previous_response_id
)
validate_override_history_persistence_support(
input=input,
session=session,
response_history_is_server_managed=server_manages_conversation,
)
resolved_reasoning_item_id_policy: ReasoningItemIdPolicy | None = (
run_config.reasoning_item_id_policy
if run_config.reasoning_item_id_policy is not None
@@ -12,7 +12,11 @@ from ..agent_tool_state import set_agent_tool_state_scope
from ..exceptions import UserError
from ..guardrail import InputGuardrailResult
from ..items import ModelResponse, RunItem, ToolApprovalItem, TResponseInputItem
from ..memory import Session
from ..memory import (
Session,
is_server_managed_conversation_session,
is_session_history_rewrite_aware_session,
)
from ..models.openai_agent_registration import add_openai_harness_id_to_metadata
from ..result import RunResult
from ..run_config import RunConfig
@@ -63,6 +67,7 @@ __all__ = [
"save_turn_items_if_needed",
"should_cancel_parallel_model_task_on_input_guardrail_trip",
"update_run_state_for_interruption",
"validate_override_history_persistence_support",
]
_PARALLEL_INPUT_GUARDRAIL_CANCEL_PATCH_ID = (
@@ -257,6 +262,54 @@ def validate_session_conversation_settings(
)
def validate_override_history_persistence_support(
*,
input: str | list[TResponseInputItem] | RunState[Any],
session: Session | None,
response_history_is_server_managed: bool,
) -> None:
"""Fail fast when approval override persistence requirements are not satisfied."""
if not isinstance(input, RunState):
return
session_history_is_server_managed = is_server_managed_conversation_session(session)
canonical_history_is_server_managed = (
response_history_is_server_managed or session_history_is_server_managed
)
if (
input._has_pending_execution_only_approval_overrides()
and not canonical_history_is_server_managed
):
raise UserError(
"save_override_arguments=False is only supported when canonical history is managed "
"by a server, such as with OpenAIConversationsSession, conversation_id, "
"previous_response_id, or auto_previous_response_id."
)
mutations = input._get_session_history_mutations()
if not mutations:
return
if canonical_history_is_server_managed:
raise UserError(
"save_override_arguments requires local canonical history. "
"Server-managed conversations cannot persist corrected function_call arguments. "
"Pass save_override_arguments=False to apply the override only to the current "
"execution."
)
if session is None or is_session_history_rewrite_aware_session(session):
return
raise UserError(
"save_override_arguments requires a session that supports expected history rewrites. "
"Use SQLiteSession, OpenAIResponsesCompactionSession, or another "
"SessionHistoryRewriteAwareSession. The configured session has no safe execution-only "
"alternative because it owns the local canonical history."
)
def resolve_trace_settings(
*,
run_state: RunState[TContext] | None,
+22 -12
View File
@@ -152,6 +152,7 @@ from .run_steps import (
from .session_persistence import (
_session_get_items,
admit_pending_input,
apply_pending_session_history_mutations,
commit_server_pending_input,
persist_session_items_for_guardrail_trip,
prepare_input_with_session,
@@ -1017,20 +1018,29 @@ async def start_streaming(
last_model_response = run_state._model_responses[-1]
turn_result = await resolve_interrupted_turn(
bindings=current_bindings,
original_input=run_state._original_input,
original_pre_step_items=run_state._generated_items,
new_response=last_model_response,
processed_response=run_state._last_processed_response,
hooks=hooks,
context_wrapper=context_wrapper,
run_config=run_config,
server_manages_conversation=server_conversation_tracker is not None,
run_state=run_state,
error_handlers=error_handlers,
await apply_pending_session_history_mutations(
session,
run_state,
wrapper=context_wrapper,
)
try:
turn_result = await resolve_interrupted_turn(
bindings=current_bindings,
original_input=run_state._original_input,
original_pre_step_items=run_state._generated_items,
new_response=last_model_response,
processed_response=run_state._last_processed_response,
hooks=hooks,
context_wrapper=context_wrapper,
run_config=run_config,
server_manages_conversation=server_conversation_tracker is not None,
run_state=run_state,
error_handlers=error_handlers,
)
finally:
run_state._clear_executed_approval_argument_overrides()
tool_use_tracker.record_processed_response(
current_agent, run_state._last_processed_response
)
+147 -41
View File
@@ -32,9 +32,11 @@ from ..logger import (
from ..memory import (
OpenAIResponsesCompactionArgs,
Session,
SessionHistoryMutation,
SessionInputCallback,
SessionSettings,
is_openai_responses_compaction_aware_session,
is_session_history_rewrite_aware_session,
)
from ..memory.openai_conversations_session import OpenAIConversationsSession
from ..memory.session import _call_session_method, _get_session_wrapper
@@ -63,6 +65,7 @@ from .oai_conversation import OpenAIServerConversationTracker
from .run_steps import NextStepInterruption, ProcessedResponse, SingleStepResult
__all__ = [
"apply_pending_session_history_mutations",
"admit_pending_input",
"commit_server_pending_input",
"prepare_input_with_session",
@@ -540,6 +543,7 @@ def update_run_state_after_resume(
if session_items is not None:
run_state._session_items = list(session_items)
run_state._current_step = turn_result.next_step # type: ignore[assignment]
run_state._clear_executed_approval_argument_overrides()
async def save_result_to_session(
@@ -563,6 +567,8 @@ async def save_result_to_session(
already_persisted = run_state._current_turn_persisted_item_count if run_state is not None else 0
if session is None:
if run_state is not None:
run_state._clear_session_history_mutations()
return 0
wrapper = _get_session_wrapper(session, wrapper)
@@ -643,60 +649,137 @@ async def save_result_to_session(
]
if len(items_to_save) == 0:
has_pending_history_mutations = bool(
run_state is not None and run_state._get_session_history_mutations()
)
if has_pending_history_mutations:
await apply_pending_session_history_mutations(
session,
run_state,
wrapper=wrapper,
)
await _run_compaction_on_session(
session=session,
response_id=response_id,
new_items=new_items,
store=store,
wrapper=wrapper,
)
if run_state is not None:
run_state._current_turn_persisted_item_count = already_persisted + saved_run_items_count
return saved_run_items_count
await _session_add_items(session, items_to_save, wrapper=wrapper)
await apply_pending_session_history_mutations(
session,
run_state,
wrapper=wrapper,
)
if run_state is not None:
run_state._current_turn_persisted_item_count = already_persisted + saved_run_items_count
if response_id and is_openai_responses_compaction_aware_session(session):
has_local_tool_outputs = any(
isinstance(item, ToolCallOutputItem | HandoffOutputItem) for item in new_items
)
if has_local_tool_outputs:
defer_compaction = getattr(session, "_defer_compaction", None)
if callable(defer_compaction):
await _call_session_method(
defer_compaction,
response_id,
store=store,
wrapper=wrapper,
)
logger.debug(
"skip: deferring compaction for response %s due to local tool outputs",
response_id,
)
return saved_run_items_count
deferred_response_id = None
get_deferred = getattr(session, "_get_deferred_compaction_response_id", None)
if callable(get_deferred):
deferred_response_id = get_deferred()
force_compaction = deferred_response_id is not None
if force_compaction:
logger.debug(
"compact: forcing for response %s after deferred %s",
response_id,
deferred_response_id,
)
compaction_args: OpenAIResponsesCompactionArgs = {
"response_id": response_id,
"force": force_compaction,
}
if store is not None:
compaction_args["store"] = store
await _call_session_method(
session.run_compaction,
compaction_args,
wrapper=wrapper,
)
await _run_compaction_on_session(
session=session,
response_id=response_id,
new_items=new_items,
store=store,
wrapper=wrapper,
)
return saved_run_items_count
async def apply_pending_session_history_mutations(
session: Session | None,
run_state: RunState | None,
*,
wrapper: RunContextWrapper[Any] | None = None,
) -> None:
"""Apply pending history rewrites before approved tools execute."""
if run_state is None:
return
mutations = run_state._get_session_history_mutations()
if not mutations:
return
if session is None:
return
normalized_mutations = _normalize_history_mutations_for_session_persistence(session, mutations)
if not is_session_history_rewrite_aware_session(session):
raise UserError(
"Cannot persist approval argument overrides because the session does not support "
"expected history rewrites."
)
applied = await _call_session_method(
session.apply_history_mutations,
{"mutations": normalized_mutations},
wrapper=wrapper,
)
if applied is not True:
raise UserError(
"Cannot persist approval argument overrides because the session did not confirm "
"that every target call was rewritten."
)
async def _run_compaction_on_session(
*,
session: Session,
response_id: str | None,
new_items: list[RunItem],
store: bool | None,
wrapper: RunContextWrapper[Any] | None = None,
) -> None:
"""Run session compaction hooks after persistence or approval-only mutation cycles."""
if not response_id or not is_openai_responses_compaction_aware_session(session):
return
has_local_tool_outputs = any(
isinstance(item, ToolCallOutputItem | HandoffOutputItem) for item in new_items
)
if has_local_tool_outputs:
defer_compaction = getattr(session, "_defer_compaction", None)
if callable(defer_compaction):
await _call_session_method(
defer_compaction,
response_id,
store=store,
wrapper=wrapper,
)
logger.debug(
"skip: deferring compaction for response %s due to local tool outputs", response_id
)
return
deferred_response_id = None
get_deferred = getattr(session, "_get_deferred_compaction_response_id", None)
if callable(get_deferred):
deferred_response_id = get_deferred()
force_compaction = deferred_response_id is not None
if force_compaction:
logger.debug(
"compact: forcing for response %s after deferred %s",
response_id,
deferred_response_id,
)
compaction_args: OpenAIResponsesCompactionArgs = {
"response_id": response_id,
"force": force_compaction,
}
if store is not None:
compaction_args["store"] = store
await _call_session_method(
session.run_compaction,
compaction_args,
wrapper=wrapper,
)
async def save_resumed_turn_items(
*,
session: Session | None,
@@ -1097,6 +1180,29 @@ def _collect_retry_owned_tail_serializations(
return []
def _normalize_history_mutations_for_session_persistence(
session: Session,
mutations: list[SessionHistoryMutation],
) -> list[SessionHistoryMutation]:
"""Normalize persisted-history mutations to the same session-safe item shape used on writes."""
normalized: list[SessionHistoryMutation] = []
for mutation in mutations:
if mutation["type"] != "replace_function_call":
continue
replacement = ensure_input_item_format(mutation["replacement"])
if isinstance(session, OpenAIConversationsSession):
replacement = _sanitize_openai_conversation_item(replacement)
normalized.append(
{
"type": "replace_function_call",
"call_id": mutation["call_id"],
"expected": ensure_input_item_format(mutation["expected"]),
"replacement": replacement,
}
)
return normalized
def _session_item_key(item: Any, *, ignore_openai_conversation_item_ids: bool = False) -> str:
"""Return a stable representation of a session item for comparison."""
try:
+1 -1
View File
@@ -2126,7 +2126,7 @@ async def resolve_interrupted_turn(
continue
_add_pending_interruption(original_approval)
continue
nested_state, nested_item = nested_approval
nested_state, nested_item, _outer_tool_call, _pending_result = nested_approval
nested_context = nested_state._context
nested_call_id = get_tool_approval_item_call_id(nested_item)
nested_status = (
+810 -11
View File
@@ -6,6 +6,7 @@ import asyncio
import copy
import dataclasses
import json
import math
import threading
from collections import deque
from collections.abc import Callable, Collection, Iterator, Mapping, Sequence
@@ -100,6 +101,7 @@ from .logger import (
log_model_and_tool_data_warning,
logger,
)
from .memory import SessionHistoryMutation
from .run_context import RunContextWrapper
from .run_internal.items import (
NestedHistoryOwnedItemRef,
@@ -150,6 +152,8 @@ if TYPE_CHECKING:
ToolRunFunction,
)
from .run_internal.items import ensure_input_item_format
TContext = TypeVar("TContext", default=Any)
TAgent = TypeVar("TAgent", bound="Agent[Any]", default="Agent[Any]")
TAction = TypeVar("TAction")
@@ -177,7 +181,7 @@ def _default_run_state_validation_error(
# 3. to_json() always emits CURRENT_SCHEMA_VERSION.
# 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer or unsupported
# versions).
CURRENT_SCHEMA_VERSION = "1.15"
CURRENT_SCHEMA_VERSION = "1.16"
_PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION = "1.13"
_HOSTED_MCP_APPROVALS_MIN_SCHEMA_VERSION = "1.14"
# Keep this mapping in chronological order. Every schema bump must add a one-line summary here.
@@ -207,6 +211,7 @@ SCHEMA_VERSION_SUMMARIES: dict[str, str] = {
"Persists canonical tool invocation identity plus sanitized mount authority and trusted "
"rebind metadata, durable pending input, and resumable next-model-call state."
),
"1.16": "Persists approval argument overrides and pending session-history rewrites.",
}
SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES)
@@ -346,6 +351,14 @@ class RunState(Generic[TContext, TAgent]):
_tool_use_tracker_snapshot: dict[str, list[str]] = field(default_factory=dict)
"""Serialized snapshot of the AgentToolUseTracker (agent name -> tools used)."""
_session_history_mutations: list[SessionHistoryMutation] = field(default_factory=list)
"""Pending session history rewrites that must be applied after persistence."""
_approval_argument_override_modes: dict[str, Literal["durable", "execution_only"]] = field(
default_factory=dict
)
"""Persistence mode for each pending approved argument override."""
_trace_state: TraceState | None = field(default=None, repr=False)
"""Serialized trace metadata for resuming tracing context."""
@@ -395,6 +408,8 @@ class RunState(Generic[TContext, TAgent]):
self._generated_items_last_processed_marker = None
self._current_turn_persisted_item_count = 0
self._tool_use_tracker_snapshot = {}
self._session_history_mutations = []
self._approval_argument_override_modes = {}
self._trace_state = None
self._sandbox = None
self._schema_version = CURRENT_SCHEMA_VERSION
@@ -491,8 +506,8 @@ class RunState(Generic[TContext, TAgent]):
def _find_nested_approval_state(
self,
approval_item: ToolApprovalItem,
) -> tuple[RunState[Any, Agent[Any]], ToolApprovalItem] | None:
"""Find the nested agent-tool state that owns an approval interruption."""
) -> tuple[RunState[Any, Agent[Any]], ToolApprovalItem, Any, Any] | None:
"""Find the nested state, approval, owning tool call, and cached result."""
if self._last_processed_response is None:
return None
@@ -579,8 +594,8 @@ class RunState(Generic[TContext, TAgent]):
current_state_owns_approval and approval_identity in current_response_identities
)
exact_match: tuple[RunState[Any, Agent[Any]], ToolApprovalItem] | None = None
canonical_matches: list[tuple[RunState[Any, Agent[Any]], ToolApprovalItem]] = []
exact_match: tuple[RunState[Any, Agent[Any]], ToolApprovalItem, Any, Any] | None = None
canonical_matches: list[tuple[RunState[Any, Agent[Any]], ToolApprovalItem, Any, Any]] = []
for function_run in self._last_processed_response.functions:
pending_result = peek_agent_tool_run_result(
function_run.tool_call,
@@ -597,10 +612,12 @@ class RunState(Generic[TContext, TAgent]):
if not isinstance(candidate, ToolApprovalItem):
continue
if candidate is approval_item:
exact_match = (nested_state, candidate)
exact_match = (nested_state, candidate, function_run.tool_call, pending_result)
break
if self._approval_items_match(candidate, approval_item):
canonical_matches.append((nested_state, candidate))
canonical_matches.append(
(nested_state, candidate, function_run.tool_call, pending_result)
)
if exact_match is not None:
break
@@ -620,17 +637,287 @@ class RunState(Generic[TContext, TAgent]):
)
return None
def approve(self, approval_item: ToolApprovalItem, always_approve: bool = False) -> None:
def approve(
self,
approval_item: ToolApprovalItem,
always_approve: bool = False,
*,
override_arguments: dict[str, Any] | None = None,
save_override_arguments: bool | None = None,
) -> None:
"""Approve a tool call and rerun with this state to continue."""
if self._context is None:
raise UserError("Cannot approve tool: RunState has no context")
nested_approval = self._find_nested_approval_state(approval_item)
if nested_approval is not None:
nested_state, nested_item = nested_approval
nested_state.approve(nested_item, always_approve=always_approve)
nested_state, nested_item, outer_tool_call, pending_result = nested_approval
if override_arguments is None:
nested_state.approve(
nested_item,
always_approve=always_approve,
save_override_arguments=save_override_arguments,
)
return
from .agent_tool_state import (
record_agent_tool_resume_state,
record_agent_tool_run_result,
)
approval_items = getattr(pending_result, "interruptions", None)
record_agent_tool_resume_state(
outer_tool_call,
nested_state,
scope_id=self._agent_tool_state_scope_id,
approval_items=approval_items if isinstance(approval_items, list) else None,
)
try:
nested_state.approve(
nested_item,
always_approve=always_approve,
override_arguments=override_arguments,
save_override_arguments=save_override_arguments,
)
except Exception:
record_agent_tool_run_result(
outer_tool_call,
pending_result,
scope_id=self._agent_tool_state_scope_id,
)
raise
return
if save_override_arguments is not None and override_arguments is None:
raise UserError(
"save_override_arguments can only be used together with override_arguments."
)
if override_arguments is not None:
self._apply_approval_argument_override(
approval_item=approval_item,
override_arguments=override_arguments,
always_approve=always_approve,
save_override_arguments=save_override_arguments,
)
self._context.approve_tool(approval_item, always_approve=always_approve)
def _get_session_history_mutations(self) -> list[SessionHistoryMutation]:
"""Return a defensive copy of pending persisted-history mutations."""
return copy.deepcopy(self._session_history_mutations)
def _has_pending_execution_only_approval_overrides(self) -> bool:
"""Return whether execution-only argument overrides still need a server-managed resume."""
return "execution_only" in self._approval_argument_override_modes.values()
def _clear_executed_approval_argument_overrides(
self,
*,
_visited_state_ids: set[int] | None = None,
) -> None:
"""Clear executed override bookkeeping in this state and owned checkpoints."""
visited_state_ids = _visited_state_ids if _visited_state_ids is not None else set()
if id(self) in visited_state_ids:
return
visited_state_ids.add(id(self))
if self._context is None:
return
for interruption in self.get_interruptions():
call_id = _get_raw_item_call_id(interruption.raw_item)
if call_id is None:
continue
mode = self._approval_argument_override_modes.get(call_id)
if mode is None:
continue
try:
invocation_status = self._context._approved_tool_invocation_status(
interruption.raw_item,
tool_lookup_key=interruption.tool_lookup_key,
tool_name=interruption.tool_name,
)
except ModelBehaviorError:
continue
if invocation_status is not None and invocation_status[2]:
self._approval_argument_override_modes.pop(call_id, None)
if mode == "durable":
self._session_history_mutations = [
mutation
for mutation in self._session_history_mutations
if mutation["call_id"] != call_id
]
if self._last_processed_response is None:
return
from .agent_tool_state import get_agent_tool_resume_state, peek_agent_tool_run_result
for function_run in getattr(self._last_processed_response, "functions", ()):
pending_result = peek_agent_tool_run_result(
function_run.tool_call,
scope_id=self._agent_tool_state_scope_id,
)
nested_state = get_agent_tool_resume_state(pending_result)
if isinstance(nested_state, RunState):
nested_state._clear_executed_approval_argument_overrides(
_visited_state_ids=visited_state_ids
)
def _clear_session_history_mutations(self) -> None:
"""Clear pending persisted-history mutations after they are applied."""
self._session_history_mutations = []
self._approval_argument_override_modes = {
call_id: mode
for call_id, mode in self._approval_argument_override_modes.items()
if mode != "durable"
}
def _apply_approval_argument_override(
self,
*,
approval_item: ToolApprovalItem,
override_arguments: dict[str, Any],
always_approve: bool,
save_override_arguments: bool | None,
) -> None:
context = self._context
assert context is not None
if always_approve:
raise UserError("override_arguments cannot be used together with always_approve.")
if not _is_function_call_raw_item(approval_item.raw_item):
raise UserError("override_arguments is only supported for function_call approvals.")
if type(override_arguments) is not dict:
raise UserError("override_arguments must be a plain JSON object.")
serialized_arguments: str | None = None
try:
if not _is_plain_json_value(override_arguments):
raise TypeError("Approval arguments must contain only plain JSON values")
serialized_arguments = json.dumps(override_arguments, allow_nan=False)
except Exception:
pass
if not isinstance(serialized_arguments, str):
raise UserError(
"override_arguments must contain only JSON-serializable values."
) from None
should_save_override_arguments = save_override_arguments is not False
has_server_managed_conversation = bool(
self._conversation_id or self._previous_response_id or self._auto_previous_response_id
)
if should_save_override_arguments and has_server_managed_conversation:
raise UserError(
"save_override_arguments requires local canonical history. "
"Server-managed conversations cannot persist corrected function_call arguments. "
"Pass save_override_arguments=False to apply the override only to the current "
"execution."
)
call_id = _get_raw_item_call_id(approval_item.raw_item)
if not isinstance(call_id, str):
raise UserError("override_arguments requires a function_call with a call_id.")
previous_override_mode = self._approval_argument_override_modes.get(call_id)
if previous_override_mode is not None:
raise UserError(
"Cannot replace an existing argument override for the same function_call. "
"Rebuild the RunState from before the first argument override."
)
expected_tool_call = copy.deepcopy(
ensure_input_item_format(cast(TResponseInputItem, approval_item.raw_item))
)
updated_tool_call = _create_function_call_override(
approval_item.raw_item,
serialized_arguments,
)
previous_identity = tool_invocation_identity(
approval_item.raw_item,
tool_lookup_key=approval_item.tool_lookup_key,
tool_name=approval_item.tool_name,
)
if previous_identity is None:
raise UserError("override_arguments requires a canonical function_call identity.")
rebound = context._rebind_tool_invocation(
updated_tool_call,
previous_identity=previous_identity,
tool_lookup_key=approval_item.tool_lookup_key,
tool_name=approval_item.tool_name,
)
if rebound is None:
raise UserError("override_arguments could not rebind the function_call identity.")
approval_item.raw_item = updated_tool_call
self._replace_function_call_in_interruptions(call_id, updated_tool_call)
if self._last_processed_response is not None:
for interruption in self._last_processed_response.interruptions:
if not _is_function_call_raw_item(interruption.raw_item):
continue
if _get_raw_item_call_id(interruption.raw_item) != call_id:
continue
interruption.raw_item = updated_tool_call
self._approval_argument_override_modes[call_id] = (
"durable" if should_save_override_arguments else "execution_only"
)
if self._last_processed_response is not None:
for function_run in self._last_processed_response.functions:
if _get_raw_item_call_id(function_run.tool_call) != call_id:
continue
function_run.tool_call = updated_tool_call
if should_save_override_arguments:
self._replace_function_call_in_run_items(
self._last_processed_response.new_items,
call_id,
updated_tool_call,
)
if should_save_override_arguments:
self._replace_function_call_in_run_items(
self._generated_items, call_id, updated_tool_call
)
self._replace_function_call_in_run_items(
self._session_items, call_id, updated_tool_call
)
self._record_session_history_mutation(
{
"type": "replace_function_call",
"call_id": call_id,
"expected": expected_tool_call,
"replacement": ensure_input_item_format(updated_tool_call),
}
)
self._mark_generated_items_merged_with_last_processed()
def _replace_function_call_in_interruptions(self, call_id: str, tool_call: Any) -> None:
"""Replace a function call inside pending approval interruptions."""
for interruption in self.get_interruptions():
if not _is_function_call_raw_item(interruption.raw_item):
continue
if _get_raw_item_call_id(interruption.raw_item) != call_id:
continue
interruption.raw_item = tool_call
def _replace_function_call_in_run_items(
self,
items: list[RunItem],
call_id: str,
tool_call: Any,
) -> None:
"""Replace matching function-call raw items inside serialized run item history."""
for item in items:
if not _is_function_call_raw_item(getattr(item, "raw_item", None)):
continue
if _get_raw_item_call_id(item.raw_item) != call_id:
continue
item.raw_item = tool_call
def _record_session_history_mutation(self, mutation: SessionHistoryMutation) -> None:
"""Record one pending persisted-history mutation for an approved function call."""
self._session_history_mutations.append(copy.deepcopy(mutation))
def reject(
self,
approval_item: ToolApprovalItem,
@@ -648,7 +935,7 @@ class RunState(Generic[TContext, TAgent]):
raise UserError("Cannot reject tool: RunState has no context")
nested_approval = self._find_nested_approval_state(approval_item)
if nested_approval is not None:
nested_state, nested_item = nested_approval
nested_state, nested_item, _outer_tool_call, _pending_result = nested_approval
nested_state.reject(
nested_item,
always_reject=always_reject,
@@ -1167,6 +1454,14 @@ class RunState(Generic[TContext, TAgent]):
for item_ref in self._nested_history_owned_session_item_refs
],
"generated_session_item_indexes": self._generated_session_item_indexes(generated_items),
"approval_argument_override_modes": [
{"call_id": call_id, "mode": mode}
for call_id, mode in self._approval_argument_override_modes.items()
],
"session_history_mutations": [
_serialize_session_history_mutation(mutation)
for mutation in self._session_history_mutations
],
}
result["generated_items"] = [
@@ -1776,6 +2071,474 @@ def _serialize_agent_reference(
return entry
def _serialize_session_history_mutation(mutation: SessionHistoryMutation) -> dict[str, Any]:
"""Serialize a session history mutation into a JSON-compatible dictionary."""
return {
"type": mutation["type"],
"call_id": mutation["call_id"],
"expected": _serialize_raw_item_value(mutation["expected"]),
"replacement": _serialize_raw_item_value(mutation["replacement"]),
}
def _is_plain_json_value(value: Any, active_containers: set[int] | None = None) -> bool:
"""Return whether a value is an acyclic tree of exact built-in JSON types."""
value_type = type(value)
if value is None or value_type in {str, bool, int}:
return True
if value_type is float:
return math.isfinite(value)
if active_containers is None:
active_containers = set()
if value_type is list:
value_id = id(value)
if value_id in active_containers:
return False
active_containers.add(value_id)
try:
return all(_is_plain_json_value(item, active_containers) for item in value)
finally:
active_containers.remove(value_id)
if value_type is dict:
value_id = id(value)
if value_id in active_containers:
return False
active_containers.add(value_id)
try:
return all(
type(key) is str and _is_plain_json_value(item, active_containers)
for key, item in dict.items(value)
)
finally:
active_containers.remove(value_id)
return False
def _deserialize_session_history_mutations(
serialized_mutations: Any,
*,
validation_error_factory: RunStateValidationErrorFactory = _default_run_state_validation_error,
) -> list[SessionHistoryMutation]:
"""Deserialize persisted session history mutations from JSON data."""
if not isinstance(serialized_mutations, Sequence) or isinstance(
serialized_mutations, str | bytes
):
raise validation_error_factory(
"Run state session_history_mutations must be a list of valid function-call "
"replacements.",
UserError,
)
mutations: list[SessionHistoryMutation] = []
for mutation in serialized_mutations:
call_id = mutation.get("call_id") if isinstance(mutation, Mapping) else None
expected = mutation.get("expected") if isinstance(mutation, Mapping) else None
replacement = mutation.get("replacement") if isinstance(mutation, Mapping) else None
normalized_expected = dict(expected) if isinstance(expected, Mapping) else {}
normalized_replacement = dict(replacement) if isinstance(replacement, Mapping) else {}
is_valid = (
isinstance(mutation, Mapping)
and mutation.get("type") == "replace_function_call"
and isinstance(call_id, str)
and normalized_expected.get("type") == "function_call"
and normalized_expected.get("call_id") == call_id
and isinstance(normalized_expected.get("name"), str)
and isinstance(normalized_expected.get("arguments"), str)
and normalized_replacement.get("type") == "function_call"
and normalized_replacement.get("call_id") == call_id
and isinstance(normalized_replacement.get("name"), str)
and isinstance(normalized_replacement.get("arguments"), str)
)
if not is_valid:
raise validation_error_factory(
"Run state session_history_mutations contains an invalid function-call "
"replacement.",
UserError,
)
mutations.append(
{
"type": "replace_function_call",
"call_id": cast(str, call_id),
"expected": cast(TResponseInputItem, normalized_expected),
"replacement": cast(TResponseInputItem, normalized_replacement),
}
)
return mutations
def _validate_session_history_mutations_match_interruptions(
state: RunState[Any, Agent[Any]],
*,
validation_error_factory: RunStateValidationErrorFactory,
) -> None:
"""Validate that durable rewrites exactly match their pending execution calls."""
interruptions = state.get_interruptions()
canonical_calls = [
item.raw_item
for item in state._generated_items
if isinstance(item, ToolCallItem) and _is_function_call_raw_item(item.raw_item)
]
session_calls = [
item.raw_item
for item in state._session_items
if isinstance(item, ToolCallItem) and _is_function_call_raw_item(item.raw_item)
]
processed_calls = (
[run.tool_call for run in state._last_processed_response.functions]
if state._last_processed_response is not None
else []
)
processed_new_item_calls = (
[
item.raw_item
for item in state._last_processed_response.new_items
if isinstance(item, ToolCallItem) and _is_function_call_raw_item(item.raw_item)
]
if state._last_processed_response is not None
else []
)
model_response_calls = [
raw_item
for response in state._model_responses
for raw_item in response.output
if _is_function_call_raw_item(raw_item)
]
for mutation in state._session_history_mutations:
call_id = mutation["call_id"]
matching_interruptions = [
interruption
for interruption in interruptions
if _is_function_call_raw_item(interruption.raw_item)
and _get_raw_item_call_id(interruption.raw_item) == call_id
]
matching_canonical_calls = [
raw_item for raw_item in canonical_calls if _get_raw_item_call_id(raw_item) == call_id
]
matching_session_calls = [
raw_item for raw_item in session_calls if _get_raw_item_call_id(raw_item) == call_id
]
matching_processed_calls = [
raw_item for raw_item in processed_calls if _get_raw_item_call_id(raw_item) == call_id
]
matching_processed_new_item_calls = [
raw_item
for raw_item in processed_new_item_calls
if _get_raw_item_call_id(raw_item) == call_id
]
matching_model_response_calls = [
raw_item
for raw_item in model_response_calls
if _get_raw_item_call_id(raw_item) == call_id
]
expected = ensure_input_item_format(mutation["expected"])
replacement = mutation["replacement"]
normalized_replacement = ensure_input_item_format(replacement)
is_match = (
isinstance(expected, dict)
and isinstance(normalized_replacement, dict)
and len(matching_interruptions) == 1
and len(matching_canonical_calls) == 1
and len(matching_session_calls) == 1
and len(matching_processed_calls) == 1
and len(matching_processed_new_item_calls) == 1
and len(matching_model_response_calls) == 1
and expected
== ensure_input_item_format(cast(TResponseInputItem, matching_model_response_calls[0]))
and normalized_replacement
== ensure_input_item_format(
cast(TResponseInputItem, matching_interruptions[0].raw_item)
)
and normalized_replacement
== ensure_input_item_format(cast(TResponseInputItem, matching_canonical_calls[0]))
and normalized_replacement
== ensure_input_item_format(cast(TResponseInputItem, matching_session_calls[0]))
and normalized_replacement
== ensure_input_item_format(cast(TResponseInputItem, matching_processed_calls[0]))
and normalized_replacement
== ensure_input_item_format(
cast(TResponseInputItem, matching_processed_new_item_calls[0])
)
)
if not is_match:
raise validation_error_factory(
"Run state session_history_mutations does not match the pending function call.",
UserError,
)
def _deserialize_approval_argument_override_modes(
serialized_modes: Any,
*,
validation_error_factory: RunStateValidationErrorFactory,
) -> dict[str, Literal["durable", "execution_only"]]:
"""Deserialize per-call override modes with strict current-schema validation."""
if type(serialized_modes) is not list:
raise validation_error_factory(
"Run state approval_argument_override_modes must be a list of unique valid "
"per-call modes.",
UserError,
)
result: dict[str, Literal["durable", "execution_only"]] = {}
for item in serialized_modes:
if type(item) is not dict or set(item) != {"call_id", "mode"}:
raise validation_error_factory(
"Run state approval_argument_override_modes must be a list of unique valid "
"per-call modes.",
UserError,
)
call_id = item.get("call_id")
mode = item.get("mode")
if (
type(call_id) is not str
or not call_id
or mode not in {"durable", "execution_only"}
or call_id in result
):
raise validation_error_factory(
"Run state approval_argument_override_modes must be a list of unique valid "
"per-call modes.",
UserError,
)
result[call_id] = cast(Literal["durable", "execution_only"], mode)
return result
def _validate_approval_argument_overrides(
state: RunState[Any, Agent[Any]],
*,
validation_error_factory: RunStateValidationErrorFactory,
) -> None:
"""Validate override modes against pending execution, replay, and durable mutations."""
context = state._context
assert context is not None
modes = state._approval_argument_override_modes
mutation_call_ids = [mutation["call_id"] for mutation in state._session_history_mutations]
durable_call_ids = {call_id for call_id, mode in modes.items() if mode == "durable"}
if len(mutation_call_ids) != len(set(mutation_call_ids)) or durable_call_ids != set(
mutation_call_ids
):
raise validation_error_factory(
"Run state approval_argument_override_modes is inconsistent with pending function "
"calls and session history mutations.",
UserError,
)
interruptions = [
interruption
for interruption in state.get_interruptions()
if _is_function_call_raw_item(interruption.raw_item)
]
canonical_calls = [
item.raw_item
for item in state._generated_items
if isinstance(item, ToolCallItem) and _is_function_call_raw_item(item.raw_item)
]
session_calls = [
item.raw_item
for item in state._session_items
if isinstance(item, ToolCallItem) and _is_function_call_raw_item(item.raw_item)
]
model_response_calls = [
raw_item
for response in state._model_responses
for raw_item in response.output
if _is_function_call_raw_item(raw_item)
]
processed_calls = (
[run.tool_call for run in state._last_processed_response.functions]
if state._last_processed_response is not None
else []
)
processed_new_item_calls = (
[
item.raw_item
for item in state._last_processed_response.new_items
if isinstance(item, ToolCallItem) and _is_function_call_raw_item(item.raw_item)
]
if state._last_processed_response is not None
else []
)
approved_pending_call_ids: set[str] = set()
for interruption in interruptions:
call_id = _get_raw_item_call_id(interruption.raw_item)
if call_id is None:
continue
try:
approval_status = context._approved_tool_invocation_status(
interruption.raw_item,
tool_lookup_key=interruption.tool_lookup_key,
tool_name=interruption.tool_name,
)
except ModelBehaviorError:
continue
if approval_status is None or approval_status[1] or approval_status[2]:
continue
if call_id in approved_pending_call_ids:
raise validation_error_factory(
"Run state approval_argument_override_modes is inconsistent with pending "
"function calls and session history mutations.",
UserError,
)
approved_pending_call_ids.add(call_id)
matching_canonical_calls = [
raw_item for raw_item in canonical_calls if _get_raw_item_call_id(raw_item) == call_id
]
matching_model_response_calls = [
raw_item
for raw_item in model_response_calls
if _get_raw_item_call_id(raw_item) == call_id
]
matching_session_calls = [
raw_item for raw_item in session_calls if _get_raw_item_call_id(raw_item) == call_id
]
matching_processed_calls = [
raw_item for raw_item in processed_calls if _get_raw_item_call_id(raw_item) == call_id
]
matching_processed_new_item_calls = [
raw_item
for raw_item in processed_new_item_calls
if _get_raw_item_call_id(raw_item) == call_id
]
execution_payload = ensure_input_item_format(
cast(TResponseInputItem, interruption.raw_item)
)
canonical_payload = (
ensure_input_item_format(cast(TResponseInputItem, matching_canonical_calls[0]))
if len(matching_canonical_calls) == 1
else None
)
session_payloads = [
ensure_input_item_format(cast(TResponseInputItem, raw_item))
for raw_item in matching_session_calls
]
model_response_payloads = [
ensure_input_item_format(cast(TResponseInputItem, raw_item))
for raw_item in matching_model_response_calls
]
processed_payloads = [
ensure_input_item_format(cast(TResponseInputItem, raw_item))
for raw_item in matching_processed_calls
]
processed_new_item_payloads = [
ensure_input_item_format(cast(TResponseInputItem, raw_item))
for raw_item in matching_processed_new_item_calls
]
mode = modes.get(call_id)
model_response_identity_payload = (
_function_call_payload_without_arguments(model_response_payloads[0])
if len(model_response_payloads) == 1
else None
)
execution_identity_payload = _function_call_payload_without_arguments(execution_payload)
audit_identity_is_consistent = mode not in {"durable", "execution_only"} or (
model_response_identity_payload is not None
and execution_identity_payload == model_response_identity_payload
)
execution_only_is_consistent = mode != "execution_only" or (
canonical_payload is not None
and len(session_payloads) == 1
and len(model_response_payloads) == 1
and len(processed_payloads) == 1
and len(processed_new_item_payloads) == 1
and canonical_payload == session_payloads[0]
and canonical_payload == model_response_payloads[0]
and canonical_payload == processed_new_item_payloads[0]
and execution_payload == processed_payloads[0]
)
unmarked_payload_diverges = mode is None and (
(canonical_payload is not None and execution_payload != canonical_payload)
or any(execution_payload != payload for payload in model_response_payloads)
)
if (
not audit_identity_is_consistent
or not execution_only_is_consistent
or unmarked_payload_diverges
):
raise validation_error_factory(
"Run state approval_argument_override_modes is inconsistent with pending "
"function calls and session history mutations.",
UserError,
)
if not set(modes).issubset(approved_pending_call_ids):
raise validation_error_factory(
"Run state approval_argument_override_modes is inconsistent with pending function "
"calls and session history mutations.",
UserError,
)
def _function_call_payload_without_arguments(
payload: TResponseInputItem,
) -> dict[str, Any] | None:
"""Return the stable function-call payload used to compare override audit identity."""
if not isinstance(payload, dict):
return None
identity_payload = dict(payload)
identity_payload.pop("arguments", None)
return identity_payload
def _validate_last_model_response_matches_model_responses(
state_json: Mapping[str, Any],
*,
validation_error_factory: RunStateValidationErrorFactory,
) -> None:
"""Validate the current writer's duplicate last-model-response audit record."""
model_responses = state_json.get("model_responses")
expected_last_response = (
model_responses[-1] if isinstance(model_responses, list) and model_responses else None
)
if state_json.get("last_model_response") != expected_last_response:
raise validation_error_factory(
"Run state approval_argument_override_modes is inconsistent with pending function "
"calls and session history mutations.",
UserError,
)
def _is_function_call_raw_item(raw_item: Any) -> bool:
"""Return whether the raw item represents a function call."""
if isinstance(raw_item, dict):
return raw_item.get("type") == "function_call"
return getattr(raw_item, "type", None) == "function_call"
def _get_raw_item_call_id(raw_item: Any) -> str | None:
"""Return the call_id for a raw tool item when available."""
if isinstance(raw_item, dict):
call_id = raw_item.get("call_id") or raw_item.get("callId") or raw_item.get("id")
return call_id if isinstance(call_id, str) else None
for attr in ("call_id", "callId", "id"):
value = getattr(raw_item, attr, None)
if isinstance(value, str):
return value
return None
def _create_function_call_override(raw_item: Any, serialized_arguments: str) -> Any:
"""Return a copy of a function call raw item with corrected arguments."""
if isinstance(raw_item, ResponseFunctionToolCall):
return raw_item.model_copy(update={"arguments": serialized_arguments})
if isinstance(raw_item, dict):
updated = dict(raw_item)
updated["arguments"] = serialized_arguments
return updated
if hasattr(raw_item, "model_dump"):
try:
payload = raw_item.model_dump(exclude_unset=True)
except TypeError:
payload = raw_item.model_dump()
payload = dict(payload)
payload["arguments"] = serialized_arguments
try:
return ResponseFunctionToolCall(**payload)
except Exception:
return payload
raise UserError("override_arguments is only supported for function_call approvals.")
def _ensure_json_compatible(value: Any) -> Any:
try:
return json.loads(json.dumps(value, default=str))
@@ -3631,6 +4394,31 @@ async def _build_run_state_from_json(
state._generated_prompt_cache_key = (
serialized_prompt_cache_key if isinstance(serialized_prompt_cache_key, str) else None
)
if (schema_major, schema_minor) >= (1, 16):
state._approval_argument_override_modes = _deserialize_approval_argument_override_modes(
state_json.get("approval_argument_override_modes", []),
validation_error_factory=validation_error_factory,
)
if state._approval_argument_override_modes:
_validate_last_model_response_matches_model_responses(
state_json,
validation_error_factory=validation_error_factory,
)
state._session_history_mutations = _deserialize_session_history_mutations(
state_json.get("session_history_mutations", []),
validation_error_factory=validation_error_factory,
)
_validate_session_history_mutations_match_interruptions(
state,
validation_error_factory=validation_error_factory,
)
_validate_approval_argument_overrides(
state,
validation_error_factory=validation_error_factory,
)
else:
state._approval_argument_override_modes = {}
state._session_history_mutations = []
state.set_tool_use_tracker_snapshot(state_json.get("tool_use_tracker", {}))
trace_data = state_json.get("trace")
if isinstance(trace_data, Mapping):
@@ -4779,6 +5567,17 @@ _TRUSTED_RUN_STATE_ERROR_MESSAGES = frozenset(
),
"Run state agent not found in agent map",
"Run state pending_input must be a list",
("Run state session_history_mutations must be a list of valid function-call replacements."),
("Run state session_history_mutations contains an invalid function-call replacement."),
"Run state session_history_mutations does not match the pending function call.",
(
"Run state approval_argument_override_modes must be a list of unique valid per-call "
"modes."
),
(
"Run state approval_argument_override_modes is inconsistent with pending function "
"calls and session history mutations."
),
"Run state references an agent identity that is not present in the restored graph",
(
"RunState context was serialized from a custom type; provide context_deserializer "
+4 -2
View File
@@ -1,6 +1,6 @@
# RunState compatibility corpus
The `minimal/` fixtures cover every schema version accepted by the current reader. The `features/` fixtures cover the schema-bearing behavior introduced in versions 1.2 through 1.15. The `resume/` fixture records an actual pending function-tool approval emitted by the v0.19.4 writer, and the `security/` fixture records that writer's credential-bearing sandbox state. `sources.json` records the source commit and provenance for every fixture.
The `minimal/` fixtures cover every schema version accepted by the current reader. The `features/` fixtures cover the schema-bearing behavior introduced in versions 1.2 through 1.16. The `resume/` fixture records an actual pending function-tool approval emitted by the v0.19.4 writer, and the `security/` fixture records that writer's credential-bearing sandbox state. `sources.json` records the source commit and provenance for every fixture.
Regenerate the feature corpus from the recorded historical source trees with:
@@ -10,6 +10,8 @@ UV_DEFAULT_INDEX=https://pypi.org/simple uv run python tests/fixtures/run_state/
The generator extracts each recorded commit with `git archive` and runs that commit's writer in a fresh locked environment. It does not import the current checkout.
Versions 1.7 and 1.8 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. Their fixtures are therefore marked `canonical_compatibility`: the recorded 1.9 writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output.
Versions 1.7 and 1.8 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. Their fixtures are therefore marked `canonical_compatibility`: the recorded writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output.
The current unreleased schema uses `current_writer` provenance because a fixture cannot name the commit that contains itself. The generator executes the current checkout for that fixture, and `lineage_commit` records the pre-takeover implementation commit from which the behavior originated. Once a later commit becomes the historical writer, replace the current-writer provenance with that stable commit rather than relabeling the fixture as historical output prematurely.
Ordinary tests never run the generator. They read the frozen payloads, compare every durable field emitted by the historical writer across the upgrade, rewrite to the current schema, and verify that the rewritten form is idempotent. They also approve and reject the historical pending interruption through actual `Runner` resumes. The schema version itself is the only normalization for the ordinary corpus; fields added by newer writers may be absent from an older payload, but every field present in that payload must survive. The security fixture has one explicit migration normalization: persisted mount credentials and opaque driver options are removed and the trusted-rebind marker is added. All non-authority topology remains part of the comparison.
@@ -0,0 +1,280 @@
{
"$schemaVersion": "1.16",
"approval_argument_override_modes": [
{
"call_id": "approval-override-1",
"mode": "execution_only"
}
],
"auto_previous_response_id": false,
"context": {
"approvals": {
"send_email": {
"approved": [
"approval-override-1"
],
"rejected": []
}
},
"context": {},
"context_meta": {
"omitted": false,
"original_type": "mapping",
"requires_deserializer": false,
"serialized_via": "mapping"
},
"tool_invocations": {
"approval-override-1": {
"approval_scope": "03c0b69e3d8062f4c5ad4a51305b5ac6f32321871b2433b01ea6025ed2565956",
"completed": false,
"executed": false,
"fingerprint": "05d7f7f0356c79296d60ce0ccc75dbb2d5f990eb09664cd7a7a459182cae969a",
"type": "function_call"
}
},
"usage": {
"input_tokens": 0,
"input_tokens_details": [
{
"cache_write_tokens": 0,
"cached_tokens": 0
}
],
"output_tokens": 0,
"output_tokens_details": [
{
"reasoning_tokens": 0
}
],
"request_usage_entries": [],
"requests": 0,
"total_tokens": 0
}
},
"conversation_id": null,
"current_agent": {
"name": "compat-agent"
},
"current_step": {
"data": {
"interruptions": [
{
"agent": {
"name": "compat-agent"
},
"raw_item": {
"arguments": "{\"recipient\": \"bob@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
},
"tool_lookup_key": {
"kind": "bare",
"name": "send_email"
},
"tool_name": "send_email",
"type": "tool_approval_item"
}
],
"llm_end_hooks_started": true,
"response_accepted": false
},
"type": "next_step_interruption"
},
"current_turn": 0,
"current_turn_persisted_item_count": 0,
"generated_items": [
{
"agent": {
"name": "compat-agent"
},
"raw_item": {
"arguments": "{\"recipient\": \"alice@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
},
"tool_name": "send_email",
"type": "tool_call_item"
}
],
"generated_prompt_cache_key": null,
"generated_session_item_indexes": [
null
],
"input_guardrail_results": [],
"last_model_response": {
"output": [
{
"arguments": "{\"recipient\": \"alice@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
}
],
"request_id": null,
"response_id": "response-override",
"usage": {
"input_tokens": 0,
"input_tokens_details": [
{
"cache_write_tokens": 0,
"cached_tokens": 0
}
],
"output_tokens": 0,
"output_tokens_details": [
{
"reasoning_tokens": 0
}
],
"request_usage_entries": [],
"requests": 0,
"total_tokens": 0
}
},
"last_processed_response": {
"apply_patch_actions": [],
"computer_actions": [],
"custom_tool_actions": [],
"functions": [
{
"tool": {
"description": "",
"lookupKey": {
"kind": "bare",
"name": "send_email"
},
"name": "send_email",
"paramsJsonSchema": {
"additionalProperties": false,
"properties": {
"recipient": {
"title": "Recipient",
"type": "string"
}
},
"required": [
"recipient"
],
"title": "send_email_args",
"type": "object"
}
},
"tool_call": {
"arguments": "{\"recipient\": \"bob@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
}
}
],
"handoffs": [],
"interruptions": [
{
"agent": {
"name": "compat-agent"
},
"raw_item": {
"arguments": "{\"recipient\": \"bob@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
},
"tool_lookup_key": {
"kind": "bare",
"name": "send_email"
},
"tool_name": "send_email",
"type": "tool_approval_item"
}
],
"local_shell_actions": [],
"mcp_approval_requests": [],
"new_items": [
{
"agent": {
"name": "compat-agent"
},
"raw_item": {
"arguments": "{\"recipient\": \"alice@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
},
"tool_name": "send_email",
"type": "tool_call_item"
}
],
"shell_actions": [],
"tools_used": []
},
"max_turns": 10,
"model_responses": [
{
"output": [
{
"arguments": "{\"recipient\": \"alice@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
}
],
"request_id": null,
"response_id": "response-override",
"usage": {
"input_tokens": 0,
"input_tokens_details": [
{
"cache_write_tokens": 0,
"cached_tokens": 0
}
],
"output_tokens": 0,
"output_tokens_details": [
{
"reasoning_tokens": 0
}
],
"request_usage_entries": [],
"requests": 0,
"total_tokens": 0
}
}
],
"nested_history_owned_session_item_refs": [],
"no_active_agent_run": true,
"original_input": "historical input",
"output_guardrail_results": [],
"pending_input": [],
"previous_response_id": null,
"reasoning_item_id_policy": null,
"session_history_mutations": [],
"session_items": [
{
"agent": {
"name": "compat-agent"
},
"raw_item": {
"arguments": "{\"recipient\": \"alice@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
},
"tool_name": "send_email",
"type": "tool_call_item"
}
],
"tool_input_guardrail_results": [],
"tool_output_guardrail_results": [],
"tool_use_tracker": {},
"trace": null
}
@@ -0,0 +1,299 @@
{
"$schemaVersion": "1.16",
"approval_argument_override_modes": [
{
"call_id": "approval-override-1",
"mode": "durable"
}
],
"auto_previous_response_id": false,
"context": {
"approvals": {
"send_email": {
"approved": [
"approval-override-1"
],
"rejected": []
}
},
"context": {},
"context_meta": {
"omitted": false,
"original_type": "mapping",
"requires_deserializer": false,
"serialized_via": "mapping"
},
"tool_invocations": {
"approval-override-1": {
"approval_scope": "03c0b69e3d8062f4c5ad4a51305b5ac6f32321871b2433b01ea6025ed2565956",
"completed": false,
"executed": false,
"fingerprint": "05d7f7f0356c79296d60ce0ccc75dbb2d5f990eb09664cd7a7a459182cae969a",
"type": "function_call"
}
},
"usage": {
"input_tokens": 0,
"input_tokens_details": [
{
"cache_write_tokens": 0,
"cached_tokens": 0
}
],
"output_tokens": 0,
"output_tokens_details": [
{
"reasoning_tokens": 0
}
],
"request_usage_entries": [],
"requests": 0,
"total_tokens": 0
}
},
"conversation_id": null,
"current_agent": {
"name": "compat-agent"
},
"current_step": {
"data": {
"interruptions": [
{
"agent": {
"name": "compat-agent"
},
"raw_item": {
"arguments": "{\"recipient\": \"bob@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
},
"tool_lookup_key": {
"kind": "bare",
"name": "send_email"
},
"tool_name": "send_email",
"type": "tool_approval_item"
}
],
"llm_end_hooks_started": true,
"response_accepted": false
},
"type": "next_step_interruption"
},
"current_turn": 0,
"current_turn_persisted_item_count": 0,
"generated_items": [
{
"agent": {
"name": "compat-agent"
},
"raw_item": {
"arguments": "{\"recipient\": \"bob@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
},
"tool_name": "send_email",
"type": "tool_call_item"
}
],
"generated_prompt_cache_key": null,
"generated_session_item_indexes": [
null
],
"input_guardrail_results": [],
"last_model_response": {
"output": [
{
"arguments": "{\"recipient\": \"alice@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
}
],
"request_id": null,
"response_id": "response-override",
"usage": {
"input_tokens": 0,
"input_tokens_details": [
{
"cache_write_tokens": 0,
"cached_tokens": 0
}
],
"output_tokens": 0,
"output_tokens_details": [
{
"reasoning_tokens": 0
}
],
"request_usage_entries": [],
"requests": 0,
"total_tokens": 0
}
},
"last_processed_response": {
"apply_patch_actions": [],
"computer_actions": [],
"custom_tool_actions": [],
"functions": [
{
"tool": {
"description": "",
"lookupKey": {
"kind": "bare",
"name": "send_email"
},
"name": "send_email",
"paramsJsonSchema": {
"additionalProperties": false,
"properties": {
"recipient": {
"title": "Recipient",
"type": "string"
}
},
"required": [
"recipient"
],
"title": "send_email_args",
"type": "object"
}
},
"tool_call": {
"arguments": "{\"recipient\": \"bob@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
}
}
],
"handoffs": [],
"interruptions": [
{
"agent": {
"name": "compat-agent"
},
"raw_item": {
"arguments": "{\"recipient\": \"bob@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
},
"tool_lookup_key": {
"kind": "bare",
"name": "send_email"
},
"tool_name": "send_email",
"type": "tool_approval_item"
}
],
"local_shell_actions": [],
"mcp_approval_requests": [],
"new_items": [
{
"agent": {
"name": "compat-agent"
},
"raw_item": {
"arguments": "{\"recipient\": \"bob@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
},
"tool_name": "send_email",
"type": "tool_call_item"
}
],
"shell_actions": [],
"tools_used": []
},
"max_turns": 10,
"model_responses": [
{
"output": [
{
"arguments": "{\"recipient\": \"alice@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
}
],
"request_id": null,
"response_id": "response-override",
"usage": {
"input_tokens": 0,
"input_tokens_details": [
{
"cache_write_tokens": 0,
"cached_tokens": 0
}
],
"output_tokens": 0,
"output_tokens_details": [
{
"reasoning_tokens": 0
}
],
"request_usage_entries": [],
"requests": 0,
"total_tokens": 0
}
}
],
"nested_history_owned_session_item_refs": [],
"no_active_agent_run": true,
"original_input": "historical input",
"output_guardrail_results": [],
"pending_input": [],
"previous_response_id": null,
"reasoning_item_id_policy": null,
"session_history_mutations": [
{
"call_id": "approval-override-1",
"expected": {
"arguments": "{\"recipient\": \"alice@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
},
"replacement": {
"arguments": "{\"recipient\": \"bob@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
},
"type": "replace_function_call"
}
],
"session_items": [
{
"agent": {
"name": "compat-agent"
},
"raw_item": {
"arguments": "{\"recipient\": \"bob@example.com\"}",
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call"
},
"tool_name": "send_email",
"type": "tool_call_item"
}
],
"tool_input_guardrail_results": [],
"tool_output_guardrail_results": [],
"tool_use_tracker": {},
"trace": null
}
+101 -7
View File
@@ -33,11 +33,66 @@ state = RunState(
@dataclass(frozen=True)
class Scenario:
version: str
commit: str
commit: str | None
name: str
code: str
provenance: str = "historical_writer"
emitted_version: str | None = None
lineage_commit: str | None = None
APPROVAL_OVERRIDE_SETUP = """
from agents.items import ModelResponse, ToolApprovalItem, ToolCallItem
from agents.run_context import RunContextWrapper
from agents.run_internal.run_loop import NextStepInterruption, ProcessedResponse, ToolRunFunction
from agents.tool import function_tool
from agents.usage import Usage
from openai.types.responses import ResponseFunctionToolCall
@function_tool(name_override="send_email")
def send_email(recipient: str) -> str:
return f"sent:{recipient}"
agent = Agent(name="compat-agent", tools=[send_email])
state = RunState(
context=RunContextWrapper(context={}),
original_input="historical input",
starting_agent=agent,
max_turns=10,
)
raw_item = ResponseFunctionToolCall(
type="function_call",
id="fc-override",
call_id="approval-override-1",
name="send_email",
arguments=json.dumps({"recipient": "alice@example.com"}),
)
tool_call_item = ToolCallItem(agent=agent, raw_item=raw_item)
approval_item = ToolApprovalItem(
agent=agent,
raw_item=raw_item,
tool_name="send_email",
)
state._generated_items = [tool_call_item]
state._session_items = [ToolCallItem(agent=agent, raw_item=raw_item)]
state._current_step = NextStepInterruption(interruptions=[approval_item])
state._model_responses = [
ModelResponse(output=[raw_item], usage=Usage(), response_id="response-override")
]
state._last_processed_response = ProcessedResponse(
new_items=[ToolCallItem(agent=agent, raw_item=raw_item)],
handoffs=[],
functions=[ToolRunFunction(tool_call=raw_item, function_tool=send_email)],
computer_actions=[],
local_shell_calls=[],
shell_calls=[],
apply_patch_calls=[],
tools_used=[],
mcp_approval_requests=[],
interruptions=[approval_item],
)
state._mark_generated_items_merged_with_last_processed()
"""
SCENARIOS = (
@@ -387,6 +442,35 @@ approval = ToolApprovalItem(
state.approve(approval)
""",
),
Scenario(
"1.16",
None,
"approval_argument_overrides",
APPROVAL_OVERRIDE_SETUP
+ """
state.approve(
approval_item,
override_arguments={"recipient": "bob@example.com"},
save_override_arguments=False,
)
""",
provenance="current_writer",
lineage_commit="672358a3c0eef02c15f62c0ba449a63f9581f6ad",
),
Scenario(
"1.16",
None,
"durable_approval_argument_overrides",
APPROVAL_OVERRIDE_SETUP
+ """
state.approve(
approval_item,
override_arguments={"recipient": "bob@example.com"},
)
""",
provenance="current_writer",
lineage_commit="672358a3c0eef02c15f62c0ba449a63f9581f6ad",
),
)
@@ -479,9 +563,7 @@ def _extract(commit: str, destination: Path) -> None:
def _generate(scenario: Scenario) -> dict[str, object]:
with tempfile.TemporaryDirectory(prefix=f"run-state-{scenario.version}-") as temp:
tree = Path(temp)
_extract(scenario.commit, tree)
def generate_from_tree(tree: Path) -> dict[str, object]:
env = dict(os.environ)
env["UV_DEFAULT_INDEX"] = "https://pypi.org/simple"
for variable in (
@@ -526,6 +608,14 @@ def _generate(scenario: Scenario) -> dict[str, object]:
payload["$schemaVersion"] = scenario.version
return cast(dict[str, object], payload)
if scenario.commit is None:
return generate_from_tree(ROOT)
with tempfile.TemporaryDirectory(prefix=f"run-state-{scenario.version}-") as temp:
tree = Path(temp)
_extract(scenario.commit, tree)
return generate_from_tree(tree)
def main() -> None:
OUTPUT.mkdir(parents=True, exist_ok=True)
@@ -540,16 +630,20 @@ def main() -> None:
source = {
"version": scenario.version,
"feature": scenario.name,
"commit": scenario.commit,
"fixture": f"features/{filename}",
"provenance": scenario.provenance,
}
if scenario.commit is not None:
source["commit"] = scenario.commit
if scenario.lineage_commit is not None:
source["lineage_commit"] = scenario.lineage_commit
if scenario.emitted_version is not None:
source["emitted_version"] = scenario.emitted_version
source["note"] = (
"The release-boundary schema renumbering introduced this reader version "
"without a writer that emitted it. The recorded writer emitted 1.9; only "
"the schema label is changed to exercise the canonical compatibility branch."
"without a writer that emitted it. The recorded writer emitted "
f"{scenario.emitted_version}; only the schema label is changed to exercise "
"the canonical compatibility branch."
)
feature_sources.append(source)
+62
View File
@@ -0,0 +1,62 @@
{
"$schemaVersion": "1.16",
"auto_previous_response_id": false,
"context": {
"approvals": {},
"context": {},
"context_meta": {
"omitted": false,
"original_type": "mapping",
"requires_deserializer": false,
"serialized_via": "mapping"
},
"tool_invocations": {},
"usage": {
"input_tokens": 0,
"input_tokens_details": [
{
"cache_write_tokens": 0,
"cached_tokens": 0
}
],
"output_tokens": 0,
"output_tokens_details": [
{
"reasoning_tokens": 0
}
],
"request_usage_entries": [],
"requests": 0,
"total_tokens": 0
}
},
"conversation_id": null,
"current_agent": {
"name": "compat-agent"
},
"current_step": null,
"current_turn": 0,
"current_turn_persisted_item_count": 0,
"approval_argument_override_modes": [],
"generated_items": [],
"generated_prompt_cache_key": null,
"generated_session_item_indexes": [],
"input_guardrail_results": [],
"last_model_response": null,
"last_processed_response": null,
"max_turns": 10,
"model_responses": [],
"nested_history_owned_session_item_refs": [],
"no_active_agent_run": true,
"original_input": "historical input",
"output_guardrail_results": [],
"pending_input": [],
"previous_response_id": null,
"reasoning_item_id_policy": null,
"session_history_mutations": [],
"session_items": [],
"tool_input_guardrail_results": [],
"tool_output_guardrail_results": [],
"tool_use_tracker": {},
"trace": null
}
+19
View File
@@ -109,6 +109,20 @@
"fixture": "features/v1_15_canonical_invocation_identity.json",
"provenance": "historical_writer",
"version": "1.15"
},
{
"feature": "approval_argument_overrides",
"fixture": "features/v1_16_approval_argument_overrides.json",
"lineage_commit": "672358a3c0eef02c15f62c0ba449a63f9581f6ad",
"provenance": "current_writer",
"version": "1.16"
},
{
"feature": "durable_approval_argument_overrides",
"fixture": "features/v1_16_durable_approval_argument_overrides.json",
"lineage_commit": "672358a3c0eef02c15f62c0ba449a63f9581f6ad",
"provenance": "current_writer",
"version": "1.16"
}
],
"resume": {
@@ -163,6 +177,11 @@
"commit": "4720150fde047baa4e88b16082b282bee3a5e87d",
"fixture": "minimal/v1_15.json"
},
"1.16": {
"fixture": "minimal/v1_16.json",
"lineage_commit": "672358a3c0eef02c15f62c0ba449a63f9581f6ad",
"provenance": "current_writer"
},
"1.2": {
"commit": "74e8c1e22d7441bd42c58bcd4270937ccc2dca8c",
"fixture": "minimal/v1_2.json"
@@ -15,6 +15,7 @@ from agents.items import TResponseInputItem
from agents.memory import (
OpenAIResponsesCompactionSession,
Session,
SessionHistoryRewriteArgs,
SessionSettings,
SQLiteSession,
is_openai_responses_compaction_aware_session,
@@ -31,7 +32,24 @@ from agents.run_internal.items import (
)
from tests.fake_model import FakeModel
from tests.test_responses import get_function_tool, get_function_tool_call, get_text_message
from tests.utils.simple_session import SimpleListSession
from tests.utils.simple_session import (
RewriteAwareSimpleSession,
ServerManagedSimpleSession,
SimpleListSession,
)
def _history_function_call(arguments: str) -> TResponseInputItem:
return cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": arguments,
},
)
class TestIsOpenAIModelName:
@@ -131,6 +149,141 @@ class TestOpenAIResponsesCompactionSession:
)
assert session.model == "gpt-4.1"
def test_init_rejects_structurally_server_managed_session(self) -> None:
underlying_session = ServerManagedSimpleSession()
with pytest.raises(ValueError, match="server-managed conversation session"):
OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=underlying_session,
)
@pytest.mark.asyncio
async def test_history_rewrite_rejects_replaced_server_managed_session(self) -> None:
original_item = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"name": "test_tool",
"arguments": '{"value":"old"}',
},
)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=SimpleListSession(history=[original_item]),
)
replacement = ServerManagedSimpleSession(history=[original_item])
session.underlying_session = replacement
with pytest.raises(ValueError, match="cannot rewrite a server-managed conversation"):
await session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": original_item,
"replacement": cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"name": "test_tool",
"arguments": '{"value":"new"}',
},
),
}
]
}
)
assert await replacement.get_items() == [original_item]
assert session._history_rewrite_generation == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("falsey_snapshot", [False, True], ids=["truthy", "falsey"])
async def test_history_rewrite_rejects_underlying_session_changed_during_transaction(
self,
falsey_snapshot: bool,
) -> None:
original_item = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"name": "test_tool",
"arguments": '{"value":"old"}',
},
)
replacement_item = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"name": "test_tool",
"arguments": '{"value":"new"}',
},
)
storage_started = asyncio.Event()
release_storage = asyncio.Event()
class BlockingRewriteSession(RewriteAwareSimpleSession):
async def apply_history_mutations(self, args: SessionHistoryRewriteArgs) -> bool:
result = await super().apply_history_mutations(args)
storage_started.set()
await release_storage.wait()
return result
class FalseyBlockingSession(SimpleListSession):
def __bool__(self) -> bool:
return False
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
storage_started.set()
await release_storage.wait()
return await super().get_items(limit)
underlying: SimpleListSession
if falsey_snapshot:
underlying = FalseyBlockingSession(history=[original_item])
else:
underlying = BlockingRewriteSession(history=[original_item])
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=underlying,
)
server_managed_replacement = ServerManagedSimpleSession(history=[original_item])
rewrite_task = asyncio.create_task(
session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": original_item,
"replacement": replacement_item,
}
]
}
)
)
try:
await asyncio.wait_for(storage_started.wait(), timeout=2)
session.underlying_session = server_managed_replacement
finally:
release_storage.set()
with pytest.raises(ValueError, match="underlying session changed during history rewrite"):
await rewrite_task
assert await server_managed_replacement.get_items() == [original_item]
assert await underlying.get_items() == [replacement_item]
assert session._session_items is None
assert session._compaction_candidate_items is None
assert session._history_rewrite_generation == 1
@pytest.mark.asyncio
async def test_add_items_delegates(self) -> None:
mock_session = self.create_mock_session()
@@ -160,6 +313,136 @@ class TestOpenAIResponsesCompactionSession:
assert len(result) == 1
mock_session.get_items.assert_called_once()
@pytest.mark.asyncio
async def test_apply_history_mutations_rewrites_underlying_history(self) -> None:
underlying = SimpleListSession(
history=[
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "hello"}),
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"foo"}',
},
),
cast(
TResponseInputItem,
{
"type": "function_call_output",
"call_id": "call-1",
"output": "ok",
},
),
]
)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=underlying,
)
await session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": _history_function_call('{"value":"foo"}'),
"replacement": cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"bar"}',
},
),
}
]
}
)
saved_items = await underlying.get_items()
assert cast(dict[str, Any], saved_items[1])["arguments"] == '{"value":"bar"}'
@pytest.mark.asyncio
async def test_apply_history_mutations_rejects_newer_divergent_reused_call_id(self) -> None:
expected_call = _history_function_call('{"value":"expected"}')
divergent_call = _history_function_call('{"value":"divergent"}')
replacement_call = _history_function_call('{"value":"corrected"}')
underlying = SimpleListSession(history=[expected_call, divergent_call])
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=underlying,
)
with pytest.raises(ValueError, match="did not match the expected function call"):
await session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": expected_call,
"replacement": replacement_call,
}
]
}
)
assert await underlying.get_items() == [expected_call, divergent_call]
@pytest.mark.asyncio
async def test_apply_history_mutations_delegates_to_rewrite_aware_underlying_session(
self,
) -> None:
underlying = RewriteAwareSimpleSession(
history=[
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"foo"}',
},
)
]
)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=underlying,
)
await session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": _history_function_call('{"value":"foo"}'),
"replacement": cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"bar"}',
},
),
}
]
}
)
saved_items = await underlying.get_items()
assert cast(dict[str, Any], saved_items[0])["arguments"] == '{"value":"bar"}'
@pytest.mark.asyncio
async def test_run_compaction_requires_response_id(self) -> None:
mock_session = self.create_mock_session()
@@ -406,6 +689,542 @@ class TestOpenAIResponsesCompactionSession:
assert "previous_response_id" not in call_kwargs
assert call_kwargs.get("input") == items
@pytest.mark.asyncio
async def test_run_compaction_forces_input_mode_after_local_history_rewrite(self) -> None:
underlying = RewriteAwareSimpleSession(
history=[
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "hello"}),
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"foo"}',
},
),
cast(
TResponseInputItem,
{
"type": "function_call_output",
"call_id": "call-1",
"output": "ok",
},
),
]
)
mock_compact_response = MagicMock()
mock_compact_response.output = []
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=underlying,
client=mock_client,
compaction_mode="auto",
)
await session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": _history_function_call('{"value":"foo"}'),
"replacement": cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"bar"}',
},
),
}
]
}
)
await session.run_compaction({"response_id": "resp-1", "force": True})
first_call_kwargs = mock_client.responses.compact.call_args.kwargs
assert "previous_response_id" not in first_call_kwargs
assert isinstance(first_call_kwargs.get("input"), list)
mock_client.responses.compact.reset_mock()
await session.run_compaction({"response_id": "resp-2", "force": True})
second_call_kwargs = mock_client.responses.compact.call_args.kwargs
assert second_call_kwargs.get("previous_response_id") == "resp-2"
@pytest.mark.asyncio
async def test_deferred_candidate_load_cannot_overwrite_newer_history_rewrite(self) -> None:
original_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"foo"}',
},
)
corrected_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"bar"}',
},
)
class BlockingSnapshotSession(RewriteAwareSimpleSession):
def __init__(self) -> None:
super().__init__(history=[original_call])
self.first_read_started = asyncio.Event()
self.release_first_read = asyncio.Event()
self.read_count = 0
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
self.read_count += 1
snapshot = await super().get_items(limit)
if self.read_count == 1:
self.first_read_started.set()
await self.release_first_read.wait()
return snapshot
underlying = BlockingSnapshotSession()
mock_compact_response = MagicMock()
mock_compact_response.output = []
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=underlying,
client=mock_client,
compaction_mode="auto",
should_trigger_compaction=lambda _context: True,
)
deferred = asyncio.create_task(session._defer_compaction("resp-old"))
await underlying.first_read_started.wait()
rewrite = asyncio.create_task(
session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": original_call,
"replacement": corrected_call,
}
]
}
)
)
await asyncio.sleep(0)
assert not rewrite.done()
underlying.release_first_read.set()
await deferred
await rewrite
await session.run_compaction({"response_id": "resp-new", "force": True})
call_kwargs = mock_client.responses.compact.call_args.kwargs
assert "previous_response_id" not in call_kwargs
assert cast(list[dict[str, Any]], call_kwargs["input"])[0]["arguments"] == (
'{"value":"bar"}'
)
@pytest.mark.asyncio
@pytest.mark.parametrize("compaction_mode", ["previous_response_id", "input"])
async def test_run_compaction_discards_result_started_before_local_history_rewrite(
self, compaction_mode: str
) -> None:
underlying = RewriteAwareSimpleSession(
history=[
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"foo"}',
},
)
]
)
compact_started = asyncio.Event()
release_compact = asyncio.Event()
mock_compact_response = MagicMock()
mock_compact_response.output = [{"type": "compaction", "summary": "stale"}]
async def compact(**_kwargs: Any) -> Any:
compact_started.set()
await release_compact.wait()
return mock_compact_response
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(side_effect=compact)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=underlying,
client=mock_client,
compaction_mode=cast(Any, compaction_mode),
)
compaction_task = asyncio.create_task(
session.run_compaction({"response_id": "resp-1", "force": True})
)
await compact_started.wait()
await session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": _history_function_call('{"value":"foo"}'),
"replacement": cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"bar"}',
},
),
}
]
}
)
release_compact.set()
await compaction_task
saved_items = await underlying.get_items()
assert cast(dict[str, Any], saved_items[0])["arguments"] == '{"value":"bar"}'
assert session._has_pending_local_history_rewrite is True
call_kwargs = mock_client.responses.compact.call_args.kwargs
if compaction_mode == "previous_response_id":
assert call_kwargs["previous_response_id"] == "resp-1"
else:
assert cast(list[dict[str, Any]], call_kwargs["input"])[0]["arguments"] == (
'{"value":"foo"}'
)
@pytest.mark.asyncio
@pytest.mark.parametrize("mutation", ["add", "pop"])
async def test_run_compaction_discards_result_started_before_newer_history_mutation(
self, mutation: str
) -> None:
corrected_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"bar"}',
},
)
original_output = cast(
TResponseInputItem,
{"type": "function_call_output", "call_id": "call-1", "output": "old"},
)
underlying = RewriteAwareSimpleSession(
history=[
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"foo"}',
},
),
original_output,
]
)
compact_started = asyncio.Event()
release_compact = asyncio.Event()
mock_compact_response = MagicMock()
mock_compact_response.output = [{"type": "compaction", "summary": "stale"}]
async def compact(**_kwargs: Any) -> Any:
compact_started.set()
await release_compact.wait()
return mock_compact_response
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(side_effect=compact)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=underlying,
client=mock_client,
compaction_mode="input",
)
await session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": _history_function_call('{"value":"foo"}'),
"replacement": corrected_call,
}
]
}
)
compaction_task = asyncio.create_task(
session.run_compaction({"response_id": "resp-1", "force": True})
)
await compact_started.wait()
if mutation == "add":
newer_output = cast(
TResponseInputItem,
{"type": "function_call_output", "call_id": "call-2", "output": "new"},
)
await session.add_items([newer_output])
expected_items = [corrected_call, original_output, newer_output]
else:
assert await session.pop_item() == original_output
expected_items = [corrected_call]
release_compact.set()
await compaction_task
assert await underlying.get_items() == expected_items
assert session._history_rewrite_generation == 2
@pytest.mark.asyncio
@pytest.mark.parametrize("compaction_mode", ["previous_response_id", "input"])
async def test_run_compaction_discards_stale_result_when_rewrite_is_cancelled_after_commit(
self, compaction_mode: str
) -> None:
class CommitThenSuspendSession(RewriteAwareSimpleSession):
def __init__(self) -> None:
super().__init__(
history=[
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"foo"}',
},
)
]
)
self.get_count = 0
self.post_rewrite_read_started = asyncio.Event()
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
self.get_count += 1
if self.get_count == 2:
self.post_rewrite_read_started.set()
await asyncio.Event().wait()
return await super().get_items(limit)
underlying = CommitThenSuspendSession()
compact_started = asyncio.Event()
release_compact = asyncio.Event()
mock_compact_response = MagicMock()
mock_compact_response.output = [{"type": "compaction", "summary": "stale"}]
async def compact(**_kwargs: Any) -> Any:
compact_started.set()
await release_compact.wait()
return mock_compact_response
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(side_effect=compact)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=underlying,
client=mock_client,
compaction_mode=cast(Any, compaction_mode),
)
compaction_task = asyncio.create_task(
session.run_compaction({"response_id": "resp-1", "force": True})
)
await compact_started.wait()
rewrite_task = asyncio.create_task(
session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": _history_function_call('{"value":"foo"}'),
"replacement": cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"bar"}',
},
),
}
]
}
)
)
await underlying.post_rewrite_read_started.wait()
rewrite_task.cancel()
with pytest.raises(asyncio.CancelledError):
await rewrite_task
release_compact.set()
await compaction_task
assert cast(dict[str, Any], underlying._items[0])["arguments"] == '{"value":"bar"}'
assert session._history_rewrite_generation == 1
assert session._has_pending_local_history_rewrite is True
@pytest.mark.asyncio
@pytest.mark.parametrize("compaction_mode", ["previous_response_id", "input"])
async def test_run_compaction_discards_stale_result_when_clear_is_cancelled_after_commit(
self, compaction_mode: str
) -> None:
class CommitThenSuspendClearSession(SimpleListSession):
def __init__(self) -> None:
super().__init__(
history=[
cast(
TResponseInputItem,
{"type": "message", "role": "user", "content": "old"},
)
]
)
self.clear_committed = asyncio.Event()
self.clear_count = 0
async def clear_session(self) -> None:
self.clear_count += 1
await super().clear_session()
if self.clear_count == 1:
self.clear_committed.set()
await asyncio.Event().wait()
underlying = CommitThenSuspendClearSession()
compact_started = asyncio.Event()
release_compact = asyncio.Event()
mock_compact_response = MagicMock()
mock_compact_response.output = [{"type": "compaction", "summary": "stale"}]
async def compact(**_kwargs: Any) -> Any:
compact_started.set()
await release_compact.wait()
return mock_compact_response
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(side_effect=compact)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=underlying,
client=mock_client,
compaction_mode=cast(Any, compaction_mode),
)
compaction_task = asyncio.create_task(
session.run_compaction({"response_id": "resp-1", "force": True})
)
await compact_started.wait()
clear_task = asyncio.create_task(session.clear_session())
await underlying.clear_committed.wait()
clear_task.cancel()
with pytest.raises(asyncio.CancelledError):
await clear_task
release_compact.set()
await compaction_task
assert underlying._items == []
assert session._history_rewrite_generation == 1
@pytest.mark.asyncio
async def test_run_compaction_keeps_local_rewrite_pending_until_input_compaction_succeeds(
self,
) -> None:
underlying = RewriteAwareSimpleSession(
history=[
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "hello"}),
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"foo"}',
},
),
cast(
TResponseInputItem,
{
"type": "function_call_output",
"call_id": "call-1",
"output": "ok",
},
),
]
)
mock_compact_response = MagicMock()
mock_compact_response.output = []
mock_client = MagicMock()
mock_client.responses.compact = AsyncMock(return_value=mock_compact_response)
session = OpenAIResponsesCompactionSession(
session_id="test",
underlying_session=underlying,
client=mock_client,
compaction_mode="auto",
)
await session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": _history_function_call('{"value":"foo"}'),
"replacement": cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"bar"}',
},
),
}
]
}
)
await session.run_compaction({"response_id": "resp-1"})
mock_client.responses.compact.assert_not_called()
await session.run_compaction({"response_id": "resp-2", "force": True})
call_kwargs = mock_client.responses.compact.call_args.kwargs
assert "previous_response_id" not in call_kwargs
assert isinstance(call_kwargs.get("input"), list)
assert cast(dict[str, Any], call_kwargs["input"][1])["arguments"] == '{"value":"bar"}'
@pytest.mark.asyncio
async def test_run_compaction_auto_uses_default_store_when_unset(self) -> None:
mock_session = self.create_mock_session()
+334
View File
@@ -1,6 +1,7 @@
"""Tests for session memory functionality."""
import asyncio
import json
import sqlite3
import tempfile
import threading
@@ -10,11 +11,160 @@ from typing import Any, cast
import pytest
from agents import Agent, RunConfig, Runner, SessionSettings, SQLiteSession, TResponseInputItem
from agents.memory import SessionHistoryMutation, apply_session_history_mutations
from agents.memory.sqlite_session import _await_mutation
from tests.fake_model import FakeModel
from tests.test_responses import get_text_message
class _RecordingLock:
def __init__(self, lock):
self._lock = lock
self.enter_count = 0
def __enter__(self):
self.enter_count += 1
return self._lock.__enter__()
def __exit__(self, exc_type, exc, tb):
return self._lock.__exit__(exc_type, exc, tb)
def test_history_mutation_rewrites_only_latest_expected_reused_call_id() -> None:
earlier_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "reused-call",
"name": "tool",
"arguments": '{"value":"earlier"}',
},
)
expected_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "reused-call",
"name": "tool",
"arguments": '{"value":"current"}',
},
)
replacement_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "reused-call",
"name": "tool",
"arguments": '{"value":"corrected"}',
},
)
items: list[TResponseInputItem] = [
earlier_call,
{"role": "user", "content": "later"},
expected_call,
]
mutation: SessionHistoryMutation = {
"type": "replace_function_call",
"call_id": "reused-call",
"expected": expected_call,
"replacement": replacement_call,
}
rewritten = apply_session_history_mutations(items, [mutation])
assert rewritten == [earlier_call, {"role": "user", "content": "later"}, replacement_call]
assert apply_session_history_mutations(rewritten, [mutation]) == rewritten
def test_history_mutation_rejects_divergent_reused_call_id() -> None:
expected_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "reused-call",
"name": "tool",
"arguments": '{"value":"expected"}',
},
)
persisted_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "reused-call",
"name": "tool",
"arguments": '{"value":"different"}',
},
)
replacement_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "reused-call",
"name": "tool",
"arguments": '{"value":"corrected"}',
},
)
with pytest.raises(ValueError, match="did not match the expected function call"):
apply_session_history_mutations(
[persisted_call],
[
{
"type": "replace_function_call",
"call_id": "reused-call",
"expected": expected_call,
"replacement": replacement_call,
}
],
)
@pytest.mark.parametrize("older_arguments", ['{"value":"expected"}', '{"value":"corrected"}'])
def test_history_mutation_rejects_newer_divergent_reused_call_id(
older_arguments: str,
) -> None:
"""A newer same-ID call is the only eligible canonical mutation target."""
expected_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "reused-call",
"name": "tool",
"arguments": '{"value":"expected"}',
},
)
replacement_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "reused-call",
"name": "tool",
"arguments": '{"value":"corrected"}',
},
)
older_call = cast(
TResponseInputItem,
{**expected_call, "arguments": older_arguments},
)
newer_call = cast(
TResponseInputItem,
{**expected_call, "arguments": '{"value":"divergent"}'},
)
with pytest.raises(ValueError, match="did not match the expected function call"):
apply_session_history_mutations(
[older_call, newer_call],
[
{
"type": "replace_function_call",
"call_id": "reused-call",
"expected": expected_call,
"replacement": replacement_call,
}
],
)
@pytest.mark.asyncio
async def test_await_mutation_cancellation_hides_later_failure_without_loop_error() -> None:
"""A failed mutation must not leak a false loop error after caller cancellation."""
@@ -812,6 +962,190 @@ async def test_sqlite_session_failed_add_items_releases_write_lock():
session.close()
@pytest.mark.asyncio
async def test_sqlite_session_apply_history_mutations_uses_file_lock():
"""File-backed history rewrites should reuse the session lock."""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "test_rewrite_lock.db"
session = SQLiteSession("rewrite_lock_test", db_path)
function_call: TResponseInputItem = {
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"before"}',
}
replacement: TResponseInputItem = {
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"after"}',
}
await session.add_items([function_call])
recording_lock = _RecordingLock(session._lock)
session.__dict__["_lock"] = recording_lock
await session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": function_call,
"replacement": replacement,
}
]
}
)
assert recording_lock.enter_count == 1
retrieved = await session.get_items()
assert retrieved == [replacement]
session.close()
@pytest.mark.asyncio
async def test_sqlite_session_history_rewrite_requires_matching_target():
"""A missing mutation target should leave SQLite history unchanged."""
with tempfile.TemporaryDirectory() as temp_dir:
session = SQLiteSession("rewrite_missing_test", Path(temp_dir) / "rewrite_missing.db")
function_call: TResponseInputItem = {
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"before"}',
}
await session.add_items([function_call])
with pytest.raises(ValueError, match="did not match the expected function call"):
await session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "missing-call",
"expected": {
"type": "function_call",
"call_id": "missing-call",
"id": "fc_missing",
"name": "test_tool",
"arguments": '{"value":"before"}',
},
"replacement": {
"type": "function_call",
"call_id": "missing-call",
"id": "fc_missing",
"name": "test_tool",
"arguments": '{"value":"after"}',
},
}
]
}
)
assert await session.get_items() == [function_call]
session.close()
@pytest.mark.asyncio
async def test_sqlite_session_history_rewrite_rejects_newer_divergent_reused_call_id():
"""SQLite should not rewrite an older call shadowed by a newer same-ID call."""
with tempfile.TemporaryDirectory() as temp_dir:
session = SQLiteSession("rewrite_reused_test", Path(temp_dir) / "rewrite_reused.db")
expected_call: TResponseInputItem = {
"type": "function_call",
"call_id": "call-1",
"name": "test_tool",
"arguments": '{"value":"expected"}',
}
divergent_call: TResponseInputItem = {
**expected_call,
"arguments": '{"value":"divergent"}',
}
replacement_call: TResponseInputItem = {
**expected_call,
"arguments": '{"value":"corrected"}',
}
original_items = [expected_call, divergent_call]
await session.add_items(original_items)
with pytest.raises(ValueError, match="did not match the expected function call"):
await session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": expected_call,
"replacement": replacement_call,
}
]
}
)
assert await session.get_items() == original_items
session.close()
@pytest.mark.asyncio
async def test_sqlite_session_history_rewrite_preserves_unrelated_corrupt_rows():
"""A targeted rewrite should not delete an unrelated undecodable record."""
with tempfile.TemporaryDirectory() as temp_dir:
session = SQLiteSession("rewrite_corrupt_test", Path(temp_dir) / "rewrite_corrupt.db")
function_call: TResponseInputItem = {
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"before"}',
}
replacement = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call-1",
"id": "fc_1",
"name": "test_tool",
"arguments": '{"value":"after"}',
},
)
await session.add_items([function_call])
with session._write_connection() as conn:
conn.execute(
f"INSERT INTO {session.messages_table} (session_id, message_data) VALUES (?, ?)",
(session.session_id, "not-json"),
)
conn.commit()
await session.apply_history_mutations(
{
"mutations": [
{
"type": "replace_function_call",
"call_id": "call-1",
"expected": function_call,
"replacement": replacement,
}
]
}
)
with session._write_connection() as conn:
rows = [
row[0]
for row in conn.execute(
f"SELECT message_data FROM {session.messages_table} "
"WHERE session_id = ? ORDER BY id",
(session.session_id,),
).fetchall()
]
assert rows == [json.dumps(replacement), "not-json"]
session.close()
@pytest.mark.asyncio
async def test_session_add_items_exception_propagates_in_streamed():
"""Test that exceptions from session.add_items are properly propagated
File diff suppressed because it is too large Load Diff
+173 -1
View File
@@ -37,6 +37,7 @@ from agents import (
OutputGuardrailTripwireTriggered,
RunContextWrapper,
Runner,
RunState,
SQLiteSession,
ToolGuardrailFunctionOutput,
ToolInputGuardrailData,
@@ -78,7 +79,7 @@ from .utils.hitl import (
queue_function_call_and_text,
resume_streamed_after_first_approval,
)
from .utils.simple_session import CountingSession, SimpleListSession
from .utils.simple_session import CountingSession, RewriteAwareSimpleSession, SimpleListSession
def _conversation_locked_error() -> BadRequestError:
@@ -2285,6 +2286,177 @@ async def test_streaming_resume_with_session_does_not_duplicate_items():
assert output_count == 1
@pytest.mark.asyncio
async def test_streaming_resume_with_durable_override_rewrites_session_history() -> None:
async def test_tool(test: str) -> str:
return f"result:{test}"
tool = function_tool(test_tool, name_override="test_tool", needs_approval=True)
model, agent = make_model_and_agent(name="test", tools=[tool])
session = RewriteAwareSimpleSession()
queue_function_call_and_text(
model,
get_function_tool_call("test_tool", json.dumps({"test": "foo"}), call_id="call-resume"),
followup=[get_text_message("done")],
)
first = Runner.run_streamed(agent, input="Use test_tool", session=session)
await consume_stream(first)
assert first.interruptions
state = first.to_state()
state.approve(first.interruptions[0], override_arguments={"test": "bar"})
resumed = Runner.run_streamed(agent, state, session=session)
await consume_stream(resumed)
assert resumed.final_output == "done"
saved_items = await session.get_items()
assert cast(dict[str, Any], saved_items[1])["arguments"] == json.dumps({"test": "bar"})
@pytest.mark.asyncio
async def test_streaming_durable_override_rewrite_fails_before_tool_execution() -> None:
calls: list[str] = []
async def test_tool(test: str) -> str:
calls.append(test)
return f"result:{test}"
class FailingRewriteSession(RewriteAwareSimpleSession):
async def apply_history_mutations(self, _args: Any) -> bool:
raise RuntimeError("rewrite failed")
tool = function_tool(test_tool, name_override="test_tool", needs_approval=True)
model = FakeModel()
agent = Agent(name="test", model=model, tools=[tool])
session = FailingRewriteSession()
model.set_next_output(
[get_function_tool_call("test_tool", '{"test":"foo"}', call_id="call-resume")]
)
first = Runner.run_streamed(agent, input="Use test_tool", session=session)
await consume_stream(first)
state = first.to_state()
state.approve(first.interruptions[0], override_arguments={"test": "bar"})
resumed = Runner.run_streamed(agent, state, session=session)
with pytest.raises(RuntimeError, match="rewrite failed"):
await consume_stream(resumed)
assert calls == []
assert state._get_session_history_mutations()
@pytest.mark.asyncio
async def test_streaming_processed_override_mismatch_fails_before_session_rewrite() -> None:
calls: list[str] = []
class TrackingRewriteSession(RewriteAwareSimpleSession):
def __init__(self) -> None:
super().__init__()
self.rewrite_calls = 0
async def apply_history_mutations(self, args: Any) -> bool:
self.rewrite_calls += 1
return await super().apply_history_mutations(args)
async def test_tool(test: str) -> str:
calls.append(test)
return f"result:{test}"
tool = function_tool(test_tool, name_override="test_tool", needs_approval=True)
model = FakeModel()
agent = Agent(name="test", model=model, tools=[tool])
session = TrackingRewriteSession()
model.set_next_output(
[get_function_tool_call("test_tool", '{"test":"foo"}', call_id="call-resume")]
)
first = Runner.run_streamed(agent, input="Use test_tool", session=session)
await consume_stream(first)
state = first.to_state()
state.approve(first.interruptions[0], override_arguments={"test": "bar"})
serialized = state.to_json()
serialized["last_processed_response"]["functions"][0]["tool_call"]["arguments"] = (
'{"test":"different"}'
)
with pytest.raises(UserError, match="does not match the pending function call"):
await RunState.from_json(agent, serialized)
assert session.rewrite_calls == 0
assert calls == []
@pytest.mark.asyncio
async def test_streaming_durable_override_missing_history_fails_before_tool_execution() -> None:
calls: list[str] = []
async def test_tool(test: str) -> str:
calls.append(test)
return f"result:{test}"
tool = function_tool(test_tool, name_override="test_tool", needs_approval=True)
model = FakeModel()
agent = Agent(name="test", model=model, tools=[tool])
session = RewriteAwareSimpleSession()
model.set_next_output(
[get_function_tool_call("test_tool", '{"test":"foo"}', call_id="call-resume")]
)
first = Runner.run_streamed(agent, input="Use test_tool", session=session)
await consume_stream(first)
state = first.to_state()
state.approve(first.interruptions[0], override_arguments={"test": "bar"})
await session.clear_session()
resumed = Runner.run_streamed(agent, state, session=session)
with pytest.raises(ValueError, match="did not match the expected function call"):
await consume_stream(resumed)
assert calls == []
assert state._get_session_history_mutations()
@pytest.mark.asyncio
async def test_streaming_resume_supports_execution_only_override_with_previous_response_id() -> (
None
):
async def test_tool(test: str) -> str:
return f"result:{test}"
tool = function_tool(test_tool, name_override="test_tool", needs_approval=True)
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[tool],
tool_use_behavior="stop_on_first_tool",
)
model.add_multiple_turn_outputs(
[[get_function_tool_call("test_tool", json.dumps({"test": "foo"}), call_id="call-resume")]]
)
first = Runner.run_streamed(agent, input="Use test_tool", previous_response_id="resp-root")
await consume_stream(first)
assert first.interruptions
state = first.to_state()
state.approve(
first.interruptions[0],
override_arguments={"test": "bar"},
save_override_arguments=False,
)
resumed = Runner.run_streamed(agent, state)
await consume_stream(resumed)
assert resumed.final_output == "result:bar"
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.parametrize("tripwire", [False, True], ids=["passes", "trips"])
@pytest.mark.asyncio
+618
View File
@@ -304,6 +304,56 @@ def set_last_processed_response(
state._last_processed_response = make_processed_response(new_items=new_items)
def build_overrideable_approval_state(
*,
conversation_id: str | None = None,
previous_response_id: str | None = None,
auto_previous_response_id: bool = False,
) -> tuple[RunState[Any, Agent[Any]], ToolApprovalItem, ResponseFunctionToolCall]:
"""Build a RunState whose interruption can override function-call arguments."""
@function_tool(name_override="send_email")
def send_email(recipient: str) -> str:
return f"sent:{recipient}"
context: RunContextWrapper[dict[str, Any]] = RunContextWrapper(context={})
agent = Agent(name="OverrideAgent", tools=[send_email])
raw_item = ResponseFunctionToolCall(
type="function_call",
id="fc_override",
call_id="call-override",
name="send_email",
arguments=json.dumps({"recipient": "alice@example.com"}),
)
tool_call_item = ToolCallItem(agent=agent, raw_item=raw_item)
approval_item = ToolApprovalItem(
agent=agent,
raw_item=raw_item,
tool_name="send_email",
)
state = make_state(agent, context=context, original_input="input", max_turns=2)
state._conversation_id = conversation_id
state._previous_response_id = previous_response_id
state._auto_previous_response_id = auto_previous_response_id
state._generated_items = [tool_call_item]
state._session_items = [ToolCallItem(agent=agent, raw_item=raw_item)]
state._current_step = NextStepInterruption(interruptions=[approval_item])
state._model_responses = [
ModelResponse(
output=[raw_item],
usage=Usage(),
response_id="resp-override",
)
]
state._last_processed_response = make_processed_response(
new_items=[ToolCallItem(agent=agent, raw_item=raw_item)],
functions=[ToolRunFunction(tool_call=raw_item, function_tool=send_email)],
interruptions=[approval_item],
)
state._mark_generated_items_merged_with_last_processed()
return state, approval_item, raw_item
class TestRunState:
"""Test RunState initialization, serialization, and core functionality."""
@@ -904,6 +954,573 @@ class TestRunState:
assert state._context is not None
assert state._context.is_tool_approved(tool_name="toolX", call_id="cid123") is True
@pytest.mark.parametrize(
"method_name",
[
"get_session_history_mutations",
"has_pending_execution_only_approval_overrides",
"clear_execution_only_approval_overrides",
"clear_session_history_mutations",
],
)
def test_approval_override_bookkeeping_methods_are_not_public(self, method_name: str) -> None:
"""Override bookkeeping should remain internal to validated resume boundaries."""
assert not hasattr(RunState, method_name)
def test_approve_with_override_arguments_updates_durable_replay_state(self):
"""approve() should update replay history and record a session history mutation."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
assert approval_item.arguments == json.dumps({"recipient": "bob@example.com"})
assert cast(Any, state._generated_items[0].raw_item).arguments == json.dumps(
{"recipient": "bob@example.com"}
)
assert cast(Any, state._session_items[0].raw_item).arguments == json.dumps(
{"recipient": "bob@example.com"}
)
assert (
state._last_processed_response is not None
and state._last_processed_response.functions[0].tool_call.arguments
== json.dumps({"recipient": "bob@example.com"})
)
assert cast(Any, state._model_responses[0].output[0]).arguments == json.dumps(
{"recipient": "alice@example.com"}
)
assert state._get_session_history_mutations() == [
{
"type": "replace_function_call",
"call_id": "call-override",
"expected": {
"type": "function_call",
"id": "fc_override",
"call_id": "call-override",
"name": "send_email",
"arguments": json.dumps({"recipient": "alice@example.com"}),
},
"replacement": {
"type": "function_call",
"id": "fc_override",
"call_id": "call-override",
"name": "send_email",
"arguments": json.dumps({"recipient": "bob@example.com"}),
},
}
]
assert state.to_json()["approval_argument_override_modes"] == [
{"call_id": "call-override", "mode": "durable"}
]
assert state._has_pending_execution_only_approval_overrides() is False
def test_approve_with_execution_only_override_keeps_replay_history_unchanged(self):
"""Execution-only overrides should only affect the pending execution surface."""
state, approval_item, raw_item = build_overrideable_approval_state()
state.approve(
approval_item,
override_arguments={"recipient": "bob@example.com"},
save_override_arguments=False,
)
assert approval_item.arguments == json.dumps({"recipient": "bob@example.com"})
assert cast(Any, state._generated_items[0].raw_item).arguments == json.dumps(
{"recipient": "alice@example.com"}
)
assert cast(Any, state._session_items[0].raw_item).arguments == json.dumps(
{"recipient": "alice@example.com"}
)
assert state._last_processed_response is not None
assert state._last_processed_response.functions[0].tool_call.arguments == json.dumps(
{"recipient": "bob@example.com"}
)
assert state._model_responses[0].output[0] == raw_item
assert state._get_session_history_mutations() == []
assert state.to_json()["approval_argument_override_modes"] == [
{"call_id": "call-override", "mode": "execution_only"}
]
assert state._has_pending_execution_only_approval_overrides() is True
@pytest.mark.parametrize("save_override_arguments", [None, False])
def test_repeated_argument_override_is_rejected_before_rebinding(
self,
save_override_arguments: bool | None,
) -> None:
"""A second argument override should leave the first approved state unchanged."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(
approval_item,
override_arguments={"recipient": "bob@example.com"},
save_override_arguments=save_override_arguments,
)
serialized = state.to_json()
with pytest.raises(UserError, match="Cannot replace an existing argument override"):
state.approve(
approval_item,
override_arguments={"recipient": "carol@example.com"},
save_override_arguments=save_override_arguments,
)
assert approval_item.arguments == json.dumps({"recipient": "bob@example.com"})
assert state.to_json() == serialized
def test_approve_with_override_arguments_rejects_server_managed_conversation_defaults(self):
"""Durable overrides should fail fast for server-managed conversations."""
state, approval_item, _ = build_overrideable_approval_state(previous_response_id="resp-1")
with pytest.raises(
UserError, match="save_override_arguments requires local canonical history"
):
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
def test_approve_with_override_arguments_validates_options(self):
"""approve() should reject invalid override option combinations."""
state, approval_item, _ = build_overrideable_approval_state()
with pytest.raises(UserError, match="save_override_arguments can only be used"):
state.approve(approval_item, save_override_arguments=False)
with pytest.raises(UserError, match="cannot be used together with always_approve"):
state.approve(
approval_item,
always_approve=True,
override_arguments={"recipient": "bob@example.com"},
)
with pytest.raises(UserError, match="plain JSON object"):
state.approve(approval_item, override_arguments=cast(Any, ["not", "a", "dict"]))
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")])
def test_approve_with_override_arguments_rejects_non_finite_numbers(self, value: float) -> None:
"""Approval overrides should contain only standards-compliant JSON values."""
state, approval_item, _ = build_overrideable_approval_state()
with pytest.raises(UserError, match="must contain only JSON-serializable values"):
state.approve(approval_item, override_arguments={"value": value})
def test_approve_with_override_arguments_redacts_serialization_errors(self) -> None:
"""Serialization failures should not retain payload-bearing exception context."""
class LeakyList(list[Any]):
def __iter__(self):
raise ValueError("replacement-secret-sentinel")
state, approval_item, _ = build_overrideable_approval_state()
with pytest.raises(UserError) as exc_info:
state.approve(
approval_item,
override_arguments={"value": LeakyList(["secret"])},
)
assert str(exc_info.value) == (
"override_arguments must contain only JSON-serializable values."
)
assert "replacement-secret-sentinel" not in str(exc_info.value)
assert exc_info.value.__cause__ is None
assert exc_info.value.__context__ is None
@pytest.mark.parametrize(
"override_arguments",
[
{1: "integer"},
{1: "integer", "1": "string"},
{"nested": [{False: "boolean"}]},
],
)
def test_approve_with_override_arguments_rejects_non_string_object_keys(
self, override_arguments: Any
) -> None:
"""JSON object keys should remain identical through serialization."""
state, approval_item, _ = build_overrideable_approval_state()
with pytest.raises(UserError, match="must contain only JSON-serializable values"):
state.approve(approval_item, override_arguments=override_arguments)
@pytest.mark.parametrize(
"override_arguments",
[
{"value": (1, 2)},
{"value": [{"nested": (1,)}]},
],
)
def test_approve_with_override_arguments_rejects_non_json_containers(
self, override_arguments: Any
) -> None:
"""Approval arguments should not coerce Python-only containers."""
state, approval_item, _ = build_overrideable_approval_state()
with pytest.raises(UserError, match="must contain only JSON-serializable values"):
state.approve(approval_item, override_arguments=override_arguments)
async def test_override_state_roundtrips_through_serialization(self):
"""Override-specific bookkeeping should survive RunState serialization."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(
approval_item,
override_arguments={"recipient": "bob@example.com"},
save_override_arguments=False,
)
restored = await RunState.from_string(state._current_agent, state.to_string()) # type: ignore[arg-type]
assert restored._has_pending_execution_only_approval_overrides() is True
assert restored._get_session_history_mutations() == []
@pytest.mark.parametrize(
"serialized_modes",
[
None,
[123],
[{"call_id": 123, "mode": "execution_only"}],
[{"call_id": "", "mode": "execution_only"}],
[{"call_id": "call-override", "mode": "unknown"}],
[
{"call_id": "call-override", "mode": "execution_only"},
{"call_id": "call-override", "mode": "durable"},
],
],
)
async def test_current_deserialization_rejects_malformed_approval_override_modes(
self, serialized_modes: Any
) -> None:
"""Current-schema override modes should be exact and unambiguous."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(
approval_item,
override_arguments={"recipient": "bob@example.com"},
save_override_arguments=False,
)
serialized = state.to_json()
serialized["approval_argument_override_modes"] = serialized_modes
with pytest.raises(UserError, match="must be a list of unique valid per-call modes"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
@pytest.mark.parametrize("remove_field", [False, True])
async def test_current_deserialization_requires_execution_only_mode_for_divergence(
self, remove_field: bool
) -> None:
"""Divergent pending execution should retain its execution-only mode."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(
approval_item,
override_arguments={"recipient": "bob@example.com"},
save_override_arguments=False,
)
serialized = state.to_json()
if remove_field:
serialized.pop("approval_argument_override_modes")
else:
serialized["approval_argument_override_modes"] = []
with pytest.raises(UserError, match="is inconsistent with pending function calls"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
async def test_current_deserialization_requires_canonical_call_for_pending_approval(
self,
) -> None:
"""A pending function approval should retain exactly one canonical replay call."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(
approval_item,
override_arguments={"recipient": "bob@example.com"},
save_override_arguments=False,
)
serialized = state.to_json()
serialized["approval_argument_override_modes"] = []
serialized["generated_items"] = []
serialized["generated_session_item_indexes"] = []
with pytest.raises(UserError, match="is inconsistent with pending function calls"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
@pytest.mark.parametrize(
"surface",
["generated_items", "session_items", "processed_new_items", "model_responses"],
)
async def test_current_deserialization_rejects_execution_only_history_mismatch(
self, surface: str
) -> None:
"""Execution-only historical copies should retain one original model call."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(
approval_item,
override_arguments={"recipient": "bob@example.com"},
save_override_arguments=False,
)
serialized = state.to_json()
if surface == "generated_items":
raw_item = serialized["generated_items"][0]["raw_item"]
elif surface == "session_items":
raw_item = serialized["session_items"][0]["raw_item"]
elif surface == "processed_new_items":
raw_item = serialized["last_processed_response"]["new_items"][0]["raw_item"]
else:
raw_item = serialized["model_responses"][0]["output"][0]
raw_item["arguments"] = json.dumps({"recipient": "mallory@example.com"})
with pytest.raises(UserError, match="is inconsistent with pending function calls"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
async def test_current_deserialization_rejects_execution_only_processed_call_mismatch(
self,
) -> None:
"""Execution-only processed calls should match the approved pending execution."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(
approval_item,
override_arguments={"recipient": "bob@example.com"},
save_override_arguments=False,
)
serialized = state.to_json()
serialized["last_processed_response"]["functions"][0]["tool_call"]["arguments"] = (
json.dumps({"recipient": "mallory@example.com"})
)
with pytest.raises(UserError, match="is inconsistent with pending function calls"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
async def test_durable_override_state_roundtrips_through_serialization(self):
"""Durable override mutations should survive RunState serialization."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
restored = await RunState.from_string(state._current_agent, state.to_string()) # type: ignore[arg-type]
assert restored._get_session_history_mutations() == [
{
"type": "replace_function_call",
"call_id": "call-override",
"expected": {
"type": "function_call",
"id": "fc_override",
"call_id": "call-override",
"name": "send_email",
"arguments": json.dumps({"recipient": "alice@example.com"}),
},
"replacement": {
"type": "function_call",
"id": "fc_override",
"call_id": "call-override",
"name": "send_email",
"arguments": json.dumps({"recipient": "bob@example.com"}),
},
}
]
@pytest.mark.parametrize(
"missing_field", ["approval_argument_override_modes", "session_history_mutations"]
)
async def test_current_deserialization_requires_complete_durable_override_provenance(
self, missing_field: str
) -> None:
"""Durable overrides should retain both mode provenance and the rewrite payload."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
serialized = state.to_json()
serialized[missing_field] = []
with pytest.raises(UserError, match="is inconsistent with pending function calls"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
async def test_current_deserialization_rejects_durable_override_marked_execution_only(
self,
) -> None:
"""A durable mutation should not be accepted under execution-only provenance."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
serialized = state.to_json()
serialized["approval_argument_override_modes"][0]["mode"] = "execution_only"
with pytest.raises(UserError, match="is inconsistent with pending function calls"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
async def test_current_deserialization_rejects_mutation_execution_mismatch(self) -> None:
"""A durable mutation should exactly match the pending function call."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
serialized = state.to_json()
serialized["session_history_mutations"][0]["replacement"]["arguments"] = json.dumps(
{"recipient": "mallory@example.com"}
)
with pytest.raises(UserError, match="does not match the pending function call"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
async def test_current_deserialization_rejects_mutation_expected_audit_mismatch(self) -> None:
"""A durable mutation should remain bound to the original model call."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
serialized = state.to_json()
serialized["session_history_mutations"][0]["expected"]["arguments"] = json.dumps(
{"recipient": "mallory@example.com"}
)
with pytest.raises(UserError, match="does not match the pending function call"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
async def test_current_deserialization_rejects_mutation_item_identity_mismatch(self) -> None:
"""A durable replacement should preserve the complete canonical call payload."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
serialized = state.to_json()
serialized["session_history_mutations"][0]["replacement"]["id"] = "fc-tampered"
with pytest.raises(UserError, match="does not match the pending function call"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
async def test_current_deserialization_rejects_mutation_processed_call_mismatch(self) -> None:
"""A durable replacement must match the function call that resume will execute."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
serialized = state.to_json()
serialized["last_processed_response"]["functions"][0]["tool_call"]["arguments"] = (
json.dumps({"recipient": "mallory@example.com"})
)
with pytest.raises(UserError, match="does not match the pending function call"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
@pytest.mark.parametrize(
"surface",
["session_items", "processed_new_items"],
)
async def test_current_deserialization_rejects_durable_replay_surface_mismatch(
self, surface: str
) -> None:
"""A durable replacement should match every stored replay copy."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
serialized = state.to_json()
if surface == "session_items":
raw_item = serialized["session_items"][0]["raw_item"]
else:
raw_item = serialized["last_processed_response"]["new_items"][0]["raw_item"]
raw_item["arguments"] = json.dumps({"recipient": "mallory@example.com"})
with pytest.raises(UserError, match="does not match the pending function call"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
async def test_current_deserialization_preserves_raw_model_response_provenance(self) -> None:
"""Raw model responses should retain the model-requested arguments for auditability."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
restored = await RunState.from_json(state._current_agent, state.to_json()) # type: ignore[arg-type]
assert cast(Any, restored._model_responses[0].output[0]).arguments == json.dumps(
{"recipient": "alice@example.com"}
)
assert restored._get_session_history_mutations()[0]["replacement"][
"arguments"
] == json.dumps({"recipient": "bob@example.com"})
async def test_current_deserialization_requires_durable_raw_model_audit_call(self) -> None:
"""A durable override should retain its matching raw model audit call."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
serialized = json.loads(state.to_string())
serialized["model_responses"][0]["output"] = []
serialized["last_model_response"] = serialized["model_responses"][-1]
with pytest.raises(UserError, match="session_history_mutations does not match"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
async def test_current_deserialization_rejects_durable_raw_model_identity_mismatch(
self,
) -> None:
"""A durable raw audit call should preserve the overridden call's identity."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
serialized = json.loads(state.to_string())
serialized["model_responses"][0]["output"][0]["id"] = "fc-tampered"
serialized["last_model_response"] = serialized["model_responses"][-1]
with pytest.raises(UserError, match="session_history_mutations does not match"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
async def test_current_deserialization_rejects_last_model_response_mismatch(self) -> None:
"""The duplicate last model response should match the serialized response list."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
serialized = json.loads(state.to_string())
serialized["last_model_response"]["output"][0]["arguments"] = json.dumps(
{"recipient": "mallory@example.com"}
)
with pytest.raises(UserError, match="is inconsistent with pending function calls"):
await RunState.from_json(state._current_agent, serialized) # type: ignore[arg-type]
@pytest.mark.parametrize(
"replacement",
[
None,
{},
{
"type": "function_call",
"call_id": "different-call",
"name": "send_email",
"arguments": "{}",
},
{
"type": "function_call",
"call_id": "call-override",
"arguments": "{}",
},
{
"type": "function_call",
"call_id": "call-override",
"name": "send_email",
"arguments": {"recipient": "bob@example.com"},
},
],
)
async def test_deserialization_rejects_invalid_current_session_history_mutations(
self, replacement: Any
) -> None:
"""Malformed current-schema mutations should fail closed."""
state, _, _ = build_overrideable_approval_state()
serialized = state.to_json()
serialized["session_history_mutations"] = [
{
"type": "replace_function_call",
"call_id": "call-override",
"expected": serialized["model_responses"][-1]["output"][0],
"replacement": replacement,
}
]
with pytest.raises(UserError, match="contains an invalid function-call replacement"):
await RunState.from_string( # type: ignore[arg-type]
state._current_agent,
json.dumps(serialized),
)
@pytest.mark.parametrize(
"schema_version",
[
version
for version in sorted(SUPPORTED_SCHEMA_VERSIONS)
if tuple(int(part) for part in version.split(".")) < (1, 16)
],
)
async def test_legacy_deserialization_ignores_approval_override_fields(
self, schema_version: str
) -> None:
"""Schemas predating override bookkeeping should ignore its fields."""
state, approval_item, _ = build_overrideable_approval_state()
state.approve(approval_item, override_arguments={"recipient": "bob@example.com"})
serialized = state.to_json()
serialized["$schemaVersion"] = schema_version
restored = await RunState.from_string( # type: ignore[arg-type]
state._current_agent,
json.dumps(serialized),
)
assert restored._get_session_history_mutations() == []
assert restored.to_json()["approval_argument_override_modes"] == []
def test_returns_undefined_when_approval_status_is_unknown(self):
"""Test that isToolApproved returns None for unknown tools."""
context: RunContextWrapper[dict[str, str]] = RunContextWrapper(context={})
@@ -7198,6 +7815,7 @@ class TestRunStateSerializationEdgeCases:
"1.12",
"1.13",
"1.14",
"1.15",
CURRENT_SCHEMA_VERSION,
}
)
+53 -1
View File
@@ -165,16 +165,68 @@ def test_historical_state_comparison_preserves_json_scalar_types() -> None:
def test_historical_fixture_corpus_matches_supported_schema_versions() -> None:
assert SOURCES["baseline"] == "v0.19.4"
assert frozenset(SOURCES["versions"]) == SUPPORTED_SCHEMA_VERSIONS
assert all(entry["commit"] for entry in SOURCES["versions"].values())
assert all(
entry.get("commit") or entry.get("lineage_commit") for entry in SOURCES["versions"].values()
)
assert {entry["version"] for entry in SOURCES["features"]} == {
version for version in SUPPORTED_SCHEMA_VERSIONS if version not in {"1.0", "1.1"}
}
assert {entry["provenance"] for entry in SOURCES["features"]} == {
"historical_writer",
"canonical_compatibility",
"current_writer",
}
def test_current_approval_override_fixtures_cover_both_persistence_modes() -> None:
entries = {
entry["feature"]: entry for entry in SOURCES["features"] if entry["version"] == "1.16"
}
assert set(entries) == {
"approval_argument_overrides",
"durable_approval_argument_overrides",
}
execution_only = json.loads(
(FIXTURE_ROOT / entries["approval_argument_overrides"]["fixture"]).read_text(
encoding="utf-8"
)
)
durable = json.loads(
(FIXTURE_ROOT / entries["durable_approval_argument_overrides"]["fixture"]).read_text(
encoding="utf-8"
)
)
assert execution_only["approval_argument_override_modes"] == [
{"call_id": "approval-override-1", "mode": "execution_only"}
]
assert execution_only["session_history_mutations"] == []
assert durable["approval_argument_override_modes"] == [
{"call_id": "approval-override-1", "mode": "durable"}
]
assert durable["session_history_mutations"] == [
{
"call_id": "approval-override-1",
"expected": {
"arguments": json.dumps({"recipient": "alice@example.com"}),
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call",
},
"replacement": {
"arguments": json.dumps({"recipient": "bob@example.com"}),
"call_id": "approval-override-1",
"id": "fc-override",
"name": "send_email",
"type": "function_call",
},
"type": "replace_function_call",
}
]
@pytest.mark.parametrize(
("schema_version", "entry"),
sorted(SOURCES["versions"].items()),
+23 -2
View File
@@ -1,9 +1,13 @@
from __future__ import annotations
from typing import cast
from typing import Literal, cast
from agents.items import TResponseInputItem
from agents.memory.session import Session
from agents.memory.session import (
Session,
SessionHistoryRewriteArgs,
apply_session_history_mutations,
)
from agents.memory.session_settings import SessionSettings
@@ -80,3 +84,20 @@ class IdStrippingSession(CountingSession):
else:
sanitized.append(item)
await super().add_items(sanitized)
class RewriteAwareSimpleSession(SimpleListSession):
"""In-memory test session that supports persisted-history rewrites."""
supports_expected_history_mutations: Literal[True] = True
async def apply_history_mutations(self, args: SessionHistoryRewriteArgs) -> bool:
self._items = apply_session_history_mutations(self._items, args.get("mutations", []))
self.saved_items = self._items
return True
class ServerManagedSimpleSession(SimpleListSession):
"""In-memory test session that advertises server-managed history semantics."""
_server_managed_conversation_session = True