Skip to content

Python: [Feature]: persist AG-UI thread snapshots incrementally (and handle client disconnect), not only at run completion #7215

Description

Description

1. Current behavior

_save_thread_snapshot is invoked only at run-completion sites in _agent_run.py (the confirmation-turn path and the main RUN_FINISHED path). A harness agent's Execute todo auto-loop is one AG-UI run that can span many model roundtrips and minutes of tool execution. For its whole duration, the last persisted snapshot is the previous run's end state.

When the client disconnects mid-run (mobile screen-off, page reload, network blip), the SSE generator is aborted (http.disconnect), so the completion code — including the snapshot save — never executes. Meanwhile the underlying agent work can continue running detached.

2. Observed live (Azure Container Apps deployment, harness + AG-UI)

Case A — hard data loss + discarded work (2026-07-14, ~21:35–22:02 UTC). A user refreshed the page ~6 minutes into a long Execute run:

  • the stream died; no Completed streaming was ever logged for the run — the snapshot save was skipped;
  • the reloaded client hydrated the previous run's snapshot: the conversation visibly rolled back to the end of planning; even the user's plan-approval message was gone;
  • the detached run kept executing for many more minutes (event counter reached 3459+ after the disconnect; file_access_write/file_memory_write tool calls kept succeeding up to ~20 minutes later) — tokens billed, results appended to a session no snapshot will ever reference. Worst of both worlds: neither cancelled nor persisted.

Case B — stale-window confusion (2026-07-15, 14:27–14:32 UTC). Screen-off killed the SSE during a 5196-event run; the restored tab reloaded 18 seconds before the run finished and hydrated the previous run's snapshot (including its already-superseded pending-approval interrupt). The run then completed and saved correctly; the user saw "all progress gone" and a stale interrupt card until a later manual refresh. Recoverable — but indistinguishable, from the user's point of view, from case A.

3. Requested

Any of the following (in descending order of preference; they compose):

  1. Incremental snapshot saves at safe boundaries. Persist the thread snapshot after each model roundtrip / executed tool-result batch inside the run loop (optionally throttled, e.g. at most once per N seconds). The snapshot format needs no change — it is the same messages/state/interrupt/session_state payload, just written more often. Hydration then returns the latest roundtrip instead of the previous run's end.
  2. Deterministic disconnect semantics. On client disconnect, either (a) detach and run to completion including the final snapshot save (the save must not live only inside the event-stream generator), or (b) cancel the run promptly. Either is defensible; continue-then-discard is not. A config knob (on_client_disconnect: "complete_and_save" | "cancel") would cover both camps.
  3. A snapshot-save hook/config on the endpoint (e.g. snapshot_save_mode: "run_end" | "roundtrip", or a callback) so hosts can opt into incremental persistence without the framework changing its default.

4. Why it matters

The harness positions AG-UI agents for long-running autonomous loops; at that duration, client-connection lifetime and run lifetime diverge routinely (mobile browsers suspend tabs aggressively). Today the durability contract is "nothing since the last completed run", which for a 5–30 minute auto-loop run means the entire turn. With #7084, private Session Continuation State shares the same fate. Server-side the work usually succeeds — the loss is purely a persistence-timing artifact.

5. Workarounds evaluated (application-side)

  • Post-reload polling of the snapshot store: repairs case B only, after the run eventually completes.
  • Periodic app-initiated saves are not possible: the endpoint owns the snapshot lifecycle and the authoritative in-run message list; a host writing snapshots concurrently would race the endpoint's own run-end save.
  • Keep-alive tricks cannot help: the disconnect originates client-side (tab suspension), not from idle timeouts.

Code Sample

Language/SDK

Both

Activity

  1. added
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Jul 20, 2026
  2. added
    pythonUsage: [Issues, PRs], Target: Python
    ag-uiUsage: [Issues, PRs], Target: AG-UI protocol integration
    and removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Jul 22, 2026
  3. changed the title [-][Feature]: persist AG-UI thread snapshots incrementally (and handle client disconnect), not only at run completion[/-] [+]Python: [Feature]: persist AG-UI thread snapshots incrementally (and handle client disconnect), not only at run completion[/+] on Jul 22, 2026
  4. antsok commented on Aug 22, 2026

    @antsok
    ContributorAuthor

    Since filing this we have hit a concrete data-loss defect in the same area, and it does not depend on the feature this issue asks for. Would you consider reclassifying this as a bug — or splitting it, keeping the incremental-persistence request as a feature and tracking the following separately?

    The failed behaviour

    A completed run's stored transcript can be silently and permanently replaced by a shorter one. No error, no warning, and the loss is only visible later, as a conversation that is missing turns.

    The AG-UI thread snapshot is written as the request's message list plus the current run's output. The stored messages are never consulted:

    # _agent_run.py — the request's list is the only input
    messages, snapshot_messages = normalize_agui_input_messages(raw_messages, ...)
    
    # end of run — that list, plus this run's output, is what gets persisted
    persisted_messages = latest_messages_snapshot
    await snapshot_session.save(messages=persisted_messages, ...)

    So whenever a client posts with a transcript shorter than the stored one, the shorter list becomes the thread's record.

    What makes this look unintended rather than by design is the asymmetry with state, which is merged in the same file:

    # _snapshot_session.py — effective_state()
    state.update(self._stored.state)   # stored first
    state.update(request_state)        # request overlays it, key by key

    State reconciles stored with request. Messages replace outright.

    The rest of the design reads as assuming exactly two client positions — hydrate_events serves the stored snapshot when a request carries no messages, and the request is treated as authoritative when it carries some. A client that carries some messages but is behind the stored snapshot is a third position the protocol has no way to express, and the server resolves it by trusting the client.

    Minimal reproduction (no custom host, no disconnect, no error path)

    1. Open thread T in client A and run a turn to completion. The stored snapshot now holds turns 1..N.
    2. Have a second client B on the same thread whose transcript ends at turn N-1 — a second tab, a second device, or the same tab opened before that turn.
    3. Post any message from B.
    4. When that run completes, the stored snapshot is B's shorter list plus the new turn. Turn N is gone from the thread, and reloading in either client shows the shortened conversation.

    No crash and no dropped connection are required; the ordinary success path is sufficient. A dropped connection makes it easier to reach, because a client that misses events is exactly a client whose transcript is behind, but it is not a precondition.

    Impact

    Completed agent work disappears from a thread with no error surfaced to the user or the application. It is worse the longer a run is, since the window in which another surface can be behind grows with the run — which is what makes it hurt most for the long tool-using runs this framework is built for.

    How it actually reached us: a mobile client and a long run

    The two-client reproduction above is the minimal form. What put us here was a phone, and it is worth describing because it is the ordinary case rather than a contrived one.

    A user started a long analysis from Android Chrome and switched away. The tab was backgrounded and eventually discarded. On mobile this does not produce a clean, immediate disconnect: the browser stops running the page's JavaScript well before the server learns the peer is gone, and when the fetch is eventually aborted the client finalises the run on its own side. So the client's transcript stops at whatever it had already processed, while the server carries on and commits the completed run. Both sides believe they are consistent; neither is.

    The user came back nearly two hours later to a transcript frozen at the moment the tab went away, and sent a message asking why it had stopped. That message carried the frozen transcript. When the run ended, the thread's stored record became the frozen list plus that turn — and roughly forty-four minutes of completed work (224 model round trips, 387 tool calls, ~22M input tokens, two delegated sub-agents, files written to durable storage) stopped being part of the conversation. It had been in the store for over an hour before the next request replaced it.

    That last turn happened to fail on a provider error, so in our case the write came through our own crash-recovery path rather than the normal one. It changes nothing about the outcome: both paths persist the request's list plus the run's output, so a turn that succeeded would have produced exactly the same record. We mention it only so the failure is not mistaken for an error-handling defect — it is the ordinary save.

    Nothing in that sequence is exotic: background a tab on a phone during a long agent run, come back, and type. The only reason the work existed to be lost is that our host detaches runs from the request so a suspended tab does not abandon an analysis in progress. That is our addition and we are not asking you to adopt it — but it is worth being precise about what it does and does not explain. The detach is why there was completed work in the store; it is not why the store lost it. The loss happens in the save path, which never consults what it is overwriting, and the minimal reproduction above reaches it on stock with no detaching, no disconnect and no error at all.

    The mobile angle also bears on the original request in this issue: without detaching, the same suspended tab simply kills the run, and because the end-of-run save sits inline before RUN_FINISHED rather than in a finally, everything it streamed is discarded. Either way, a phone going to sleep costs the user the run — once by losing it in flight, once by overwriting it afterwards.

    Precedent in this repo

    PR #7242 (for #7211) taught save_messages to reconcile its input against what the store already holds, via get_message_identity / filter_new_messages, instead of appending blindly. That is the same move — a store reconciling rather than trusting its input — applied to the history provider. The AG-UI snapshot store has no equivalent.

    Possible directions

    Either would close it:

    • Reconcile on save. Treat a save that drops message ids the stored snapshot already holds as a merge rather than a replacement, mirroring what effective_state already does for state.
    • Let the request declare what it has. A watermark or version on the inbound transcript would let the server detect staleness instead of assuming its absence, which also gives clients a way to notice they are behind.

    On the original scope

    The incremental-persistence and disconnect-handling request in this issue still stands on its own and is genuinely feature-shaped, so splitting is fine if that is cleaner for triage. Worth noting for that half: checkpoint appears throughout the workflow path and not at all in the agent run path, so the durability concept already exists in the framework — the ask is to extend it across that boundary rather than to invent it.

    Environment: agent-framework-core: 1.14.0

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 integrationharness[Issues, PRs], Target: harness-level itemspythonUsage: [Issues, PRs], Target: Python

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions