Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/agents/run_internal/turn_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

from .. import _debug
from .._mcp_tool_metadata import collect_mcp_list_tools_metadata
from .._run_state_agent_identity import _agent_identity_signature
from .._tool_identity import (
FunctionToolLookupKey,
build_function_tool_lookup_map,
Expand Down Expand Up @@ -2093,8 +2094,6 @@ def _reject_nested_replacement(run: ToolRunFunction) -> None:
pending_nested_drops.append(run.tool_call)
return
qualified_name = get_tool_call_qualified_name(run.tool_call) or run.tool_call.name
# TODO: Persist Agent.as_tool() owner identity so replacement before RunState
# restoration can be detected and safely migrated.
raise ModelBehaviorError(
f"Cannot reconcile queued tool {qualified_name} with a new tool or handoff while "
"its Agent.as_tool() run is interrupted. Restore the original tool configuration "
Expand All @@ -2109,6 +2108,21 @@ def _rebind_function_run(
return current_run
cached_result = _cached_nested_result(stale_run)
pending_result = _pending_nested_result(stale_run)

if pending_result is not None:
persisted_owner_signature = getattr(
pending_result,
"agent_tool_owner_signature",
None,
)
if persisted_owner_signature is not None:
current_owner = getattr(current_run.function_tool, "_agent_instance", None)
if (
not isinstance(current_owner, Agent)
or _agent_identity_signature(current_owner) != persisted_owner_signature
):
Comment on lines +2120 to +2123

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include behavior-affecting tool configuration in owner provenance

When a restored graph replaces the inner agent with another same-name agent containing same-name tools, this comparison accepts the replacement even if those tools have different callbacks, schemas, approval policies, or guardrails: _agent_identity_signature() reduces ordinary function tools to type/name/routing fields and omits all of those behavior-affecting values. The nested approved call can therefore execute the replacement implementation and its side effects, reproducing the provenance bypass this patch is intended to close; use provenance that distinguishes behavior-changing owner configurations.

AGENTS.md reference: AGENTS.md:L115-L119

Useful? React with 👍 / 👎.

_reject_nested_replacement(stale_run)

if stale_run.function_tool is not current_run.function_tool:
stale_owner = getattr(stale_run.function_tool, "_agent_instance", None)
current_owner = getattr(current_run.function_tool, "_agent_instance", None)
Expand Down
39 changes: 34 additions & 5 deletions src/agents/run_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from typing_extensions import TypedDict, TypeVar

from ._run_state_agent_identity import (
_agent_identity_signature,
_build_agent_identity_keys_by_id,
_build_agent_identity_map,
_build_agent_map,
Expand Down Expand Up @@ -226,8 +227,9 @@ def _default_run_state_validation_error(
"override a sticky decision for the same tool."
),
"1.17": (
"Persists Docker container labels and current-response generated-item ownership across "
"resume flows, including pending resumed Session writes and terminal-unrecoverable runs."
"Persists Docker container labels, current-response generated-item ownership, and "
"Agent.as_tool() owner provenance across resume flows, including pending resumed Session "
"writes and terminal-unrecoverable runs."
),
}
SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES)
Expand Down Expand Up @@ -2817,27 +2819,41 @@ def _serialize_pending_nested_agent_tool_runs(
continue

try:
entry["agent_run_state"] = nested_state.to_json(
nested_state_data = nested_state.to_json(
context_serializer=context_serializer,
strict_context=strict_context,
include_tracing_api_key=include_tracing_api_key,
)
function_tool = getattr(function_run, "function_tool", None)
owner = getattr(function_tool, "_agent_instance", None)
owner_signature = _agent_identity_signature(owner) if isinstance(owner, Agent) else None
Comment on lines +2827 to +2829

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude mutable hook state from owner identity

When the original inner agent uses a stateful AgentHooks implementation, normal callbacks before the interruption commonly mutate public fields such as an event list or counter; _agent_identity_signature() recursively includes those fields, so the persisted value represents runtime history rather than stable configuration. Reconstructing the same logical graph in another process with a fresh hook instance then produces a different signature and incorrectly rejects the resume, contrary to the supported equivalent-owner restoration path; derive provenance only from stable declarative identity.

AGENTS.md reference: AGENTS.md:L117-L117

Useful? React with 👍 / 👎.

Comment on lines +2827 to +2829

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve persisted provenance when reserializing restored state

When a snapshot created for owner A is restored against replacement owner B and then serialized again before Runner.run() performs reconciliation, this code recomputes the signature from the already rebound function_run.function_tool and writes B's signature instead of retaining the pending result's persisted A signature. Restoring that second snapshot against B now passes the provenance check and can execute the replacement, so reserialization must carry forward pending_run_result.agent_tool_owner_signature rather than laundering it through the currently bound tool.

AGENTS.md reference: AGENTS.md:L115-L115

Useful? React with 👍 / 👎.

except Exception:
if strict_context:
raise
logger.warning(
"Failed to serialize nested agent run state for tool call %s.",
tool_call.call_id,
)
continue

entry["agent_run_state"] = nested_state_data
if owner_signature is not None:
entry["agent_tool_owner_signature"] = owner_signature
Comment on lines +2840 to +2841

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Hash the owner signature before serializing it

When an interrupted Agent.as_tool() owner has literal instructions, prompt variables, model settings, manifest data, or capability configuration, _agent_identity_signature() returns those values as raw canonical JSON, and this assignment embeds that JSON directly in the persisted RunState. Applications that store or transmit snapshots now disclose private prompts and potentially credential-bearing configuration that was never previously part of the state; persist a non-reversible digest or another explicitly non-sensitive identifier instead.

AGENTS.md reference: AGENTS.md:L16-L17

Useful? React with 👍 / 👎.



class _SerializedAgentToolRunResult:
"""Minimal run-result wrapper used to restore nested agent-as-tool resumptions."""

def __init__(self, state: RunState[Any, Agent[Any]]) -> None:
def __init__(
self,
state: RunState[Any, Agent[Any]],
*,
agent_tool_owner_signature: str | None = None,
) -> None:
self._state = state
self.interruptions = list(state.get_interruptions())
self.final_output = None
self.agent_tool_owner_signature = agent_tool_owner_signature

def to_state(self) -> RunState[Any, Agent[Any]]:
return self._state
Expand All @@ -2849,6 +2865,7 @@ class _DeserializedFunctionAction:

action: ToolRunFunction
nested_agent_run_state_data: Mapping[str, Any] | None
agent_tool_owner_signature: str | None = None


def _serialize_guardrail_results(
Expand Down Expand Up @@ -3013,7 +3030,10 @@ async def _restore_pending_nested_agent_tool_runs(
)
continue

pending_result = _SerializedAgentToolRunResult(nested_state)
pending_result = _SerializedAgentToolRunResult(
nested_state,
agent_tool_owner_signature=function_action.agent_tool_owner_signature,
)
Comment on lines +3033 to +3036

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate owner provenance before restoring nested state

When the configured agent-tool owner has been replaced, provenance is merely attached here after _build_run_state_from_json() has already recursively restored the nested state; that restoration calls the replacement inner agent's get_all_tools(), which evaluates public is_enabled callbacks and may perform MCP discovery. Those callbacks or requests can produce side effects during RunState.from_json() before the mismatch is rejected later by Runner.run(), so compare the persisted signature with the bound owner before recursively restoring the nested state.

AGENTS.md reference: AGENTS.md:L118-L119

Useful? React with 👍 / 👎.

if not pending_result.interruptions:
continue

Expand Down Expand Up @@ -3234,6 +3254,12 @@ def _deserialize_function_actions() -> list[_DeserializedFunctionAction]:
)

nested_state_data = entry.get("agent_run_state")
owner_signature = entry.get("agent_tool_owner_signature")
if owner_signature is not None and not isinstance(owner_signature, str):
raise validation_error_factory(
"Run state Agent.as_tool() owner provenance has an invalid type.",
UserError,
)
Comment on lines 3256 to +3262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject missing provenance in current-schema nested states

When a current-version 1.17 snapshot is corrupted or edited to remove agent_tool_owner_signature, this parser accepts the missing value as None, and _rebind_function_run() subsequently skips the provenance check altogether. Removing one field therefore restores the original replacement vulnerability for any interrupted Agent.as_tool() call; only snapshots carrying an older schema version should receive the legacy fallback, while current-schema nested states should require valid provenance.

AGENTS.md reference: AGENTS.md:L119-L119

Useful? React with 👍 / 👎.

deserialized.append(
_DeserializedFunctionAction(
action=ToolRunFunction(
Expand All @@ -3243,6 +3269,9 @@ def _deserialize_function_actions() -> list[_DeserializedFunctionAction]:
nested_agent_run_state_data=(
nested_state_data if isinstance(nested_state_data, Mapping) else None
),
agent_tool_owner_signature=(
owner_signature if isinstance(owner_signature, str) else None
),
)
)
return deserialized
Expand Down
Loading