Skip to content

fix: detach RunState interruption snapshots - #4409

Merged
seratch merged 1 commit into
mainfrom
fix/detach-runstate-interruptions
Aug 14, 2026
Merged

seratch merged 1 commit into
mainfrom
fix/detach-runstate-interruptions

Conversation

@seratch

@seratch seratch commented Aug 14, 2026 •

Copy link
Copy Markdown
Member

This pull request fixes a mutable-aliasing bug at the RunState interruption boundary.

RunState.get_interruptions() already returned a new list, but the ToolApprovalItem objects inside that list still shared mutable content with the pending approvals owned by RunState. As a result, callers could unintentionally modify authoritative resumable state by changing fields such as tool_name, raw_item.arguments, or nested metadata on a returned interruption.

Those mutations could alter the approval identity retained for resume, interfere with direct or nested approval routing, or otherwise make subsequent approve() and reject() behavior depend on caller-side changes to what was expected to be a snapshot.

This change makes get_interruptions() return fully detached ToolApprovalItem snapshots while preserving the behavior callers rely on:

  • The returned list and each returned ToolApprovalItem are independent of the pending approval stored by RunState.
  • Mutable typed Pydantic payloads, mapping-based payloads, and nested metadata are copied without retaining aliases to authoritative state.
  • The owning Agent is intentionally not copied, preserving its identity.
  • Detached snapshots still resolve to the canonical pending approval when passed to approve() or reject(), including approvals owned by nested agent-tool runs.
  • Ambiguous or unsafe approval ownership fails closed before any decision state is mutated.

The supported Pydantic model adapters are derived from ToolApprovalRawItem, keeping the copy boundary aligned with the production approval pipeline. This includes function, custom, shell, hosted MCP, and local-shell approval payloads, including ResponseCustomToolCall and ResponseFunctionShellToolCall.

If a payload cannot be cloned safely, get_interruptions() raises an actionable, payload-safe SDK UserError before returning any partial result. The implementation does not deep-copy Agent instances, callbacks, arbitrary run state, or unsupported BaseModel objects, and it does not change the serialized RunState schema.

The regression coverage verifies mutation isolation, canonical approve/reject compatibility, nested approval routing, all supported typed approval models, mapping payloads, ambiguous ownership, hostile payload hooks, and all-or-nothing failure behavior. Existing corruption-path tests now mutate authoritative pending state explicitly instead of relying on aliasing through the public snapshot API.

@seratch seratch added this to the 0.21.x milestone Aug 14, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 768ba9ad63

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/run_state.py Resolved
@seratch
seratch force-pushed the fix/detach-runstate-interruptions branch from 768ba9a to 46b5c92 Compare August 14, 2026 08:57

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 46b5c92e45

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/run_state.py Resolved
@seratch
seratch force-pushed the fix/detach-runstate-interruptions branch from 46b5c92 to 0a12daa Compare August 14, 2026 09:20
@seratch
seratch merged commit 95f9d9a into main Aug 14, 2026
14 checks passed
@FU-max-boop

Copy link
Copy Markdown
Contributor

I found a separate snapshot-isolation gap adjacent to this change.

This PR detaches the ToolApprovalItem values returned by RunState.get_interruptions(). However, both RunResult.to_state() paths still construct every new state with the same context_wrapper.

As a result, two states created from one interrupted result share the SDK-owned approval and invocation ledger:

decided = result.to_state()
untouched = result.to_state()
before = untouched.to_json()["context"]["approvals"]

decided.approve(decided.get_interruptions()[0])

assert untouched.to_json()["context"]["approvals"] == before
# Fails: the decision made through decided appears in untouched.

On released openai-agents==0.20.0, resuming untouched then executes the approval-gated tool once even though that state never received an approval. The same alias remains at this PR head because neither to_state() path detaches result.context_wrapper.

This is distinct from detaching the public interruption objects: the leaked mutable state is the decision ledger owned by the wrapper. I reproduced it without an API key across non-streamed and streamed results. I am preparing a focused regression test and content-addressed AgentRunProof record; I can send a follow-up fix unless you would prefer this folded into #4409.

@seratch
seratch deleted the fix/detach-runstate-interruptions branch August 14, 2026 09:27
@seratch

seratch commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Thanks for pointing it out. I'll resolve it in a follow-up PR before making the upcoming release.

FU-max-boop commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Thanks — I’ll avoid opening a competing PR.

I have now published a pinned AgentRunProof counterexample for the released openai-agents==0.20.0 behavior:

The no-API-key run records both non-streamed and streamed variants. In each, deciding one sibling mutates the untouched sibling, and resuming the untouched state executes the protected tool once. The bundle is tied to the tested wheel/source, reproduced in Linux CI, and can be checked offline with the released agentrunproof check-current-bundle command.

I’m keeping a reference fix and a broader regression matrix ready (approve/reject, streamed/non-streamed pause and resume, nested Agent.as_tool(), and legacy approval migration). When the follow-up PR appears, I’ll run that matrix against it and report a concise before/after result, including any uncovered edge case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants