feat(claude-agent-sdk): bridge defer to AG-UI interrupt/resume#2181
feat(claude-agent-sdk): bridge defer to AG-UI interrupt/resume#2181Sri01728 wants to merge 4 commits into
Conversation
The Claude Agent SDK adapter had no way to surface a paused tool call as an AG-UI interrupt or to honour resume verdicts, so a run that deferred a tool (via a PreToolUse hook returning permissionDecision: defer) simply ended with no standard signal a client could act on. LangGraph and the .NET reference already translate their native halt into the interrupt contract; this brings the Claude adapter in line. - interrupts.py: translate DeferredToolUse -> ag_ui.core.Interrupt (approval id distinct from tool_call_id, best-effort response_schema from the run tools). - adapter: emit RunFinishedInterruptOutcome on defer and record RunAgentInput.resume[] verdicts keyed by the frozen tool-use id, read back by the caller's re-fired PreToolUse hook via resume_verdict_for(). Gated behind emit_interrupt_outcome (default off) for back-compat, mirroring LangGraph. - Args stay bound to the frozen DeferredToolUse.input; resume carries only the verdict, so it cannot swap execution args. - Add /interrupt dojo example and unit tests.
…-fires The live worker's client.query(session_id=...) is only a per-message routing tag; it does not trigger SDK session-resume, so the frozen DeferredToolUse never re-fired PreToolUse on resume. On a resume run, evict the live worker and build a fresh one seeded with ClaudeAgentOptions(resume=<claude_session_id>) so the persisted session materializes at connect and replays the frozen call through the hook. Capture worker.session_id per-thread after each run to seed resume. Validated live on Bedrock (haiku-4-5 + sonnet-4-5), approve + reject: frozen call re-fires, executes with unswapped args on approve, blocked on reject. 120 unit tests pass.
Verified local testing (live against Bedrock)Validated this bridge end-to-end live against real Bedrock (not mocks), driving the actual adapter code path ( Matrix: 2 models × 2 verdicts = 4/4 PASS
What was proven:
One fix needed to make resume work through the adapter: the initial version reused the live in-process Tests: 134 total (120 existing + 14 new interrupt unit tests), all green, no regressions. Behavior worth flagging for reviewers: |
What
Bridges the Claude Agent SDK
deferprimitive to the AG-UI interrupt/resume contract in theag-ui-claude-sdkPython adapter.Today, when a
PreToolUsehook returnspermissionDecision: "defer", the run halts and the terminatingResultMessagecarries aDeferredToolUse, but the adapter has no way to surface that as an AG-UIRunFinishedInterruptOutcome, and it ignoresRunAgentInput.resume[]. LangGraph (ag_ui_langgraph/interrupts.py) and the .NET reference already translate their native halt into the interrupt contract; this brings the Claude adapter in line.No framework does true in-flight coroutine suspension — every "suspend/resume" is halt-at-boundary + resume-from-persisted-state. Claude's equivalent is
defer; this PR is a thin translation layer over it, not a new suspension mechanism.Changes
interrupts.py(new):deferred_tool_use_to_interrupt()mapsDeferredToolUse->ag_ui.core.Interrupt. The interruptid(approval-request id) is intentionally distinct fromtool_call_id(the frozen call id), matching the reference contract.response_schemais pulled best-effort from the run's declared tools; a missing schema never fails the run.adapter.py:emit_interrupt_outcome: bool = False. When on, a deferred tool becomes aRunFinishedInterruptOutcomeonRUN_FINISHED, andRunAgentInput.resume[]is honoured. Default-off for back-compat (a client that predates the contract must not be handed anoutcomeit can't resume) — mirrors LangGraph'semit_interrupt_outcome.deferred_tool_usefrom theResultMessage._ingest_resume), exposed to the caller's re-firedPreToolUsehook viaresume_verdict_for().examples/agents/interrupt.py(new) +server.py/__init__.py: a/interruptdojo agent gating a destructivedelete_filetool behind the full defer -> interrupt -> resume loop (Strands/Mastra have an interrupt example; Claude didn't).tests/test_interrupts.py(new): 14 unit tests.Resume-payload semantics
On resume, the persisted Claude session continues (already keyed by
thread_id), so the same frozen tool call re-firesPreToolUse. The hook reads the resume verdict:resolved->allow(frozen args),cancelled->deny. Executed args stay bound toDeferredToolUse.input; the resume payload carries only the verdict, so it cannot swap execution args.Open question for maintainers
Unlike the .NET runtime, the Claude SDK has no native "resolve deferred approval and execute" API. This PR routes the verdict to the re-fired
PreToolUsehook via adapter-owned per-run state (resume_verdict_for). If maintainers would prefer a different resume seam (e.g. injecting a synthetic tool result), happy to adjust — this is the one design point I didn't want to guess on.Testing
python -m pytestinintegrations/claude-agent-sdk/python: 120 existing + 14 new pass.Depends on the interrupt/resume protocol types on
main(Interrupt,ResumeEntry,RunFinishedInterruptOutcome,RunAgentInput.resume).Closes #2180