Skip to content

Python: [Feature]: a supported way to retire specific interrupts from a thread snapshot #8759

Description

Description

What we observe at 1.19.0

In _agent_run.py, the two halves of the same cleanup diverge:

should_clear_tool_approval_state = resume_error_code == "APPROVAL_RESUME_CANCELLED" or (
    resume_error_code == "APPROVAL_RESUME_NOT_FOUND"
    and _tool_approval_state_exists_for_cancelled_resume(...)
)
if should_clear_tool_approval_state:
    _clear_tool_approval_state(approval_state_store, approval_thread_id)

if resume_error_code == "APPROVAL_RESUME_CANCELLED":        # ← snapshot half, still CANCELLED-only
    ...
    await snapshot_session.clear_interrupts(interrupt_ids=...)

APPROVAL_RESUME_NOT_FOUND is the more terminal of the two — the occurrence is gone and no process can ever answer it — yet it is the one that leaves the interrupt in the thread snapshot.

Why that matters downstream

A snapshot that still says "parked on interrupt X" makes connect() re-synthesize RUN_FINISHED{outcome:interrupt} and rebuild the card; answering is rejected because nothing holds X; and because a send with an open interrupt is converted into a resume, the user cannot type their way out either. We observed ten rounds of that on a deployed app before the thread was abandoned.

The request

1. Apply the existing cleanup on the NOT_FOUND path, before the terminal event is emitted. The ordering matters: the terminal RUN_FINISHED is what unblocks the client, so if it lands while the snapshot still holds the interrupt, a fast reconnect is re-offered the dead card. This is a race, not a certainty — in steady-state manual testing the run's own end-of-run snapshot save usually clears it first — which is exactly why it is worth making deterministic rather than incidental.

2. Publish the operation. There is no supported way for a host to retire a specific interrupt from a thread snapshot. _clear_thread_snapshot_interrupt and _SNAPSHOT_SCOPE_INPUT_KEY in agent_framework_ag_ui._snapshots do exactly the right thing, but both are private, so our call is wrapped in a try/ImportError that degrades to "no retirement" if a bump moves the seam. A public equivalent — whether on ThreadSnapshotSession or as a documented function — would let any host clean up after an interrupt it knows is dead, and would let us delete our copy.

Selectivity is the part that must survive. Retiring all interrupts is not equivalent: a batch can hold one unanswerable id alongside a sibling card the user can still answer, and wiping both loses a live approval. Whatever shape the API takes, it needs to take the set of ids to retire.

Prior art in the codebase

snapshot_session.clear_interrupts(interrupt_ids=…) already accepts a set of ids and is already called on the CANCELLED path, so the capability exists and is exercised — the request is to reach it from NOT_FOUND and from outside the package.

Relationship to #7570 that report asked for the snapshot interrupt to be cleared on APPROVAL_RESUME_NOT_FOUND, and it was closed completed on 2026-08-18. Re-verified at 1.19.0 on 2026-09-20: the approval state half now runs for NOT_FOUND, but the snapshot interrupt half still does not. This request is the residue plus the part #7570 never asked for — a public API — and should be framed as a follow-up, not a complaint that the earlier fix was wrong.

Code Sample

Language/SDK

Both

Activity

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

Metadata

Metadata

Labels

ag-uiUsage: [Issues, PRs], Target: AG-UI protocol integrationpythonUsage: [Issues, PRs], Target: Python

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions