Conversation
An AG-UI agent can now stop mid-run and ask. A tool calling `context.input(...)` leaves the question as the outcome of the run that raised it; the turn is held in the server, suspended exactly where it stopped, until a later run on the same thread answers it through `resume`. The call resumes from the line it stopped on, with its locals intact — nothing is replayed, because the frame never died. Tool-call approval rides the same path: an interrupt with `reason="tool_approval"` carrying the `toolCallId`, answerable with a bare bool. Both AG-UI transports serve this through one `serve_exchange`, so a client cannot tell them apart, and a GET on the AG-UI route returns the capabilities document. Changes a default: a run with no human-input hook used to fail with "nobody could be asked" and now pauses and asks the client. A supplied hook behaves exactly as before. Held turns are bounded by `Retention(ttl=900, max_held=128)`, the advertised `expiresAt` is the earlier of that bound and any `context.input(timeout=)`, a resume is refused unless it echoes the proof issued with its interrupt, and every refusal is a `RUN_ERROR` with a machine-readable code rather than a stream that simply stops. Requires `ag-ui-protocol>=0.1.21`, the first release whose resume entries carry the metadata envelope the proof travels in. One question is outstanding per turn: concurrent asking — parallel subtasks that each ask a human — is refused with `HumanInputError` rather than silently orphaning the first question. Serving several at once is follow-up work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refactor the human-in-the-loop test suite to improve maintainability, type coverage, and reliability. - Flatten nested test classes in `test_interrupts.py` for better readability. - Add explicit type annotations to test helpers in `test_refusals.py` and `test_resume_proof.py` to improve static analysis. - Replace `asyncio.timeout` blocks with `asyncio.wait_for` in `test_turn_lifetime.py` to ensure consistent behavior during long-running test waits. - Update `CODEOWNERS` to include `@vvlrff` for MCP-related files.
A proof arriving from a client went straight into `secrets.compare_digest`, which raises `TypeError` rather than returning `False` when handed a non-ASCII `str`. `serve_exchange` catches only `ResumeRefusedError`, so the generator died after `RUN_STARTED` and left the client with no terminating event — and since the turn had already been taken from the registry, it was then unreachable by resume, sweep and eviction alike, held until the process went down. Proofs are now compared as bytes, a turn taken for a resume is put back on any failure and not only on a refusal, and a run already started on the wire is always terminated on the wire. Two smaller ones alongside it. `ServedTurns.release_all` was `async` but awaited nothing, so shutdown scheduled its cancellations and returned before any held turn had unwound; it now gathers and waits. And the guard against two questions on one turn raised into a turn whose own exchange had already ended, so the refusal reached no client and no log either; it now reports at `error` before raising. Also in this pass, from reviewing the two commits before it: - Revert the `CODEOWNERS` change made in a9f66a1: it is unrelated to #3202. - Drop `ToolApprovalRequest.tool_name`, which nothing read, and the `""` default on `tool_call_id`, which made an approval naming no call constructible. - Declare `aclose` on the `A2UITransport` protocol instead of duck-checking it with `getattr`; `RestTransport` implements it as a no-op. - Replace the per-turn `_discarder` closure with a class, per AGENTS.md. - Cut `interrupts.__all__` from 29 names to 19 and re-export the four refusal codes from `ag2.ag_ui`, so nothing reaches into a private module for them. - Rewrite `ag2/ag_ui/` docstrings against the openai SDK's conventions: no `Raises:`/`Returns:`/`Attributes:` sections, fields documented beneath themselves, backticks rather than Sphinx roles, and rationale moved into comments beside the code. 419 lines of docstring become 218.
Add a warning to the user guide explaining that paused tool calls span multiple runs. Clarify that events are split across different `runId`s and that clients must track open tool calls by `threadId` to correctly associate `TOOL_CALL_RESULT` events with their corresponding starts.
vvlrff
marked this pull request as ready for review
September 16, 2026 17:53
Refactor the AG-UI interrupt system to use more descriptive reason codes and improve the robustness of the served agent lifecycle. This includes a major overhaul of the testing infrastructure to support end-to-end testing of served agents via real HTTP/ASGI transports. - Rename `HUMAN_INPUT_REASON` to `INPUT_REQUIRED_REASON` and `TOOL_APPROVAL_REASON` to `TOOL_CALL_REASON` for better clarity. - Add `HumanInputTimeoutError` to the exception hierarchy. - Implement validation for `Retention` parameters (`ttl` and `max_held`). - Introduce a new testing harness (`test/ag_ui/harness.py`) and serving utilities (`test/ag_ui/serving.py`) to drive agents over in-process HTTP. - Restructure AG-UI tests into a `served` sub-package to distinguish between generator-based and transport-based testing. - Add support for reasoning message mapping and empty chunk handling in the AG-UI protocol. - Update documentation to reflect new interrupt reason codes and retention behavior.
Update the AG-UI documentation to reflect the new overview structure, including a comprehensive guide on the AG-UI protocol, supported capabilities, and basic server implementation. - Rename `docs/user-guide/ag-ui/index` to `docs/user-guide/ag-ui/overview` and update all internal cross-references. - Add a new `overview.mdx` providing a deep dive into AG-UI capabilities such as streaming, tool lifecycle events, and human-in-the-loop interrupts. - Enhance `human_in_the_loop.mdx` and `approval_required.mdx` with notes explaining how agents served via `AGUIStream` handle interrupts differently than standard agents. - Update `mint-json-template.json.jinja` to reflect the new file structure in the sidebar.
Replace the built-in `TimeoutError` with `asyncio.TimeoutError` in `ag2/ag_ui/interrupts.py` to ensure compatibility with Python 3.10+ where these are distinct exception classes. This ensures that timeouts triggered by the `ask` method are correctly caught.
Codecov Report❌ Patch coverage is
... and 3 files with indirect coverage changes 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why are these changes needed?
A tool inside a served agent can call
context.input(), but over AG-UI the human it wants is behind the client, and nothing carried the question there. With nohitl_hookconfigured,default_hitl_hookraisedHumanInputNotProvidedErrorand the turn died pointing you atAgent(..., hitl_hook=...)— so the obvious setup was the broken one, and the only working one answered in the server process, where the human is not.The protocol already describes the round trip: a run may finish with an interrupt outcome instead of a success, and a later run carries the answer back in
resume. Nothing inag2produced or consumed it.The question leaves as the outcome of the run that raised it. The exchange ends normally — not with an error — and the turn stays behind:
The client answers with a new run id on the same thread — the thread is the unit of continuity, which is what the protocol's own examples and its TypeScript client do:
{"threadId": "thread-1", "runId": "run-2", "messages": [], "resume": [{"interruptId": "e2c1...", "status": "resolved", "payload": "yes", "metadata": {"ag2": {"proof": "g7Qk..."}}}]}Continuing means holding the turn, not replaying it. The call is suspended inside the Python function —
context.inputstopped mid-way through the tool, with its locals, its loop position and whatever it had already done still live. A message history cannot rebuild that: replaying would re-enter the tool from the top and redo its side effects. So the turn is held in the serving process between the two exchanges, suspended exactly where it stopped.That has consequences an operator needs up front, and they are documented rather than hidden: sticky routing is required (route by
threadId; behind a round-robin balancer a resume lands on a process that knows nothing), a restart drops every held turn, and memory is proportional to retention — a held turn is a suspended coroutine carrying its whole conversation history, not a record.The structural risk is that an interrupt is delivered as the terminating event of its own exchange, so the response closes by design while the agent is still suspended. Turn lifetime therefore had to move out of the request's task group, which is where it used to be cancelled.
This changes a default
A run with no human-input hook now pauses and asks the client, where it used to fail. A caller-supplied hook wins and behaves exactly as today: answered in process, no interrupt emitted, run finished in one exchange. The interrupt path is what an unconfigured run does instead of dying. Worth a line in the release notes; it also diverges from the ACP integration, which still refuses — justified, because ACP has nobody to ask.
A resume is authenticated
An interrupt id alone is a bare string in a request body, and an answer attributed to a human is the input an agent trusts most. Each interrupt carries a proof in the protocol's
metadataenvelope, which clients are required to echo back, so a compliant one already does this.It is a capability, not a signature: the question it proves is suspended in this process, so the value to compare against is already in memory beside it — no key, nothing to rotate,
secrets.compare_digestagainst what was issued, per interrupt. Giving up needs it too, since ending someone else's turn is not a lesser act than answering it. This is why the version floor moves: the resume side of that envelope exists only fromag-ui-protocol0.1.21.Every refusal is a
RUN_ERRORwith a machine-readablecode, never a stream that just stops:codeINTERRUPT_NOT_HELDINTERRUPT_NOT_OUTSTANDINGINTERRUPT_NOT_PROVENINTERRUPT_PAYLOAD_REFUSEDA refusal that is not the turn's own fault leaves the held turn resumable by a legitimate answer without restamping its clock, so a stream of bad resumes cannot keep a turn alive past the deadline its client was shown.
Both AG-UI transports
ag2/ag_ui/stream.pyandag2/a2ui/transports/ag_ui.pyeach emit their own run lifecycle and a client cannot tell them apart, so both are in scope. They share oneserve_exchangeinag2/ag_ui/interrupts.py, which is the exchange — a transport supplies only how it launches a turn. If the two ever look different to a client, it will be because something was added outside that function.Public API
A pausing agent needs no server-side setup. The tool just asks:
Retention is where the operator tunes it, on either transport:
ttlis not an internal detail: it is what a client is shown asexpiresAt. The advertised deadline is the earlier ofttland the deadline implied by anytimeout=passed tocontext.input— the protocol treats an absent deadline as a promise that the interrupt never expires, and clients reject late answers locally on the strength of it, so the figure on the wire is always the one that will in fact apply. Reaching that number neededHumanInputRequest.timeout;asyncio.wait_forexposes no deadline.Tool-call approval rides the same path.
ApprovalRequirednow asks throughContext.ask(request)— the seamcontext.input()delegates to, for callers needing a richer request than a bare question string — raising aToolApprovalRequest. The interrupt carriesreason="tool_approval"and thetoolCallId, and a baretrue/falsepayload is accepted for an approval and only for an approval: the client drew two buttons, the middleware reads words like "always", and translating between them beats teaching either side the other's vocabulary.Shutdown.
A2UIServercancels held turns for you on ASGI shutdown, and waits for them to unwind rather than only scheduling the cancellation.build_asgireturns anHTTPEndpointclass rather than an app, so nothing can hang a hook onAGUIStream— callawait stream.aclose()from your own, or use it as an async context manager.This adds a required member to
A2UITransport. A transport holding state past a single request has to be closed, and duck-checking foraclosewithgetattrhides that from the type checker, soacloseis declared on the protocol itself.RestTransportimplements it as a no-op. The protocol shipped in 1.0.0, so a third-party transport withoutaclosestops satisfying it — at type-check time only: nothingisinstance-checksA2UITransportat runtime, so existing deployments keep working.The four refusal codes below are re-exported from
ag2.ag_uialongsideRetention, so nothing has to reach intoag2.ag_ui.interruptsfor them.A
GETon the AG-UI route returns the capabilities document; the protocol defines the document but no event and no transport for it. A runnable server-plus-client script is inexamples/ag_ui/human_input.py.Related issue number
Closes #3202
Also in the diff, unrelated to the feature:
@vvlrffjoins thearea:mcpCODEOWNERS entries, on the strength of the MCP work already landed.Known limitations
HumanInputErrorrather than silently orphaning the first question. Serving several at once needs one outcome carrying them all and a resume routed per interrupt; that is follow-up work, not covered here.INTERRUPT_NOT_HELD, whose message names all three possibilities; a distinct code would need per-thread memory of spent ids.examples/ag_ui/human_input.pyhas not itself been run against a live model, though every path it exercises now has: a scratch probe drove the round trip, tool approval and refusal, abandonment, all four refusal codes, two sequential questions, shutdown and expiry againstgpt-5.6-lunaon the Responses API, 46 assertions, all passing.Checks
70 new tests across turn lifetime, the round trip, retention and deadlines, refusals, resume proof, tool approval, and end-to-end coverage of the second transport. Full suite locally: 5131 passed, 360 skipped, 2 xfailed;
ruff checkandruff formatclean,mypygains no new kind of error. Tests drive in-process HTTP against the built ASGI application rather than the registry — the feature is two exchanges sharing state, and both transports look identical from that height. Deadlines run off an injectable clock instead of sleeping.AI assistance
🤖 Generated with Claude Code