Skip to content

feat(claude-agent-sdk): bridge defer to AG-UI interrupt/resume#2181

Open
Sri01728 wants to merge 4 commits into
ag-ui-protocol:mainfrom
Sri01728:feat/claude-adapter-interrupt-resume
Open

feat(claude-agent-sdk): bridge defer to AG-UI interrupt/resume#2181
Sri01728 wants to merge 4 commits into
ag-ui-protocol:mainfrom
Sri01728:feat/claude-adapter-interrupt-resume

Conversation

@Sri01728

@Sri01728 Sri01728 commented Jul 14, 2026

Copy link
Copy Markdown

What

Bridges the Claude Agent SDK defer primitive to the AG-UI interrupt/resume contract in the ag-ui-claude-sdk Python adapter.

Today, when a PreToolUse hook returns permissionDecision: "defer", the run halts and the terminating ResultMessage carries a DeferredToolUse, but the adapter has no way to surface that as an AG-UI RunFinishedInterruptOutcome, and it ignores RunAgentInput.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() maps DeferredToolUse -> ag_ui.core.Interrupt. The interrupt id (approval-request id) is intentionally distinct from tool_call_id (the frozen call id), matching the reference contract. response_schema is pulled best-effort from the run's declared tools; a missing schema never fails the run.
  • adapter.py:
    • New constructor flag emit_interrupt_outcome: bool = False. When on, a deferred tool becomes a RunFinishedInterruptOutcome on RUN_FINISHED, and RunAgentInput.resume[] is honoured. Default-off for back-compat (a client that predates the contract must not be handed an outcome it can't resume) — mirrors LangGraph's emit_interrupt_outcome.
    • Captures deferred_tool_use from the ResultMessage.
    • Records resume verdicts per thread keyed by the frozen tool-use id (_ingest_resume), exposed to the caller's re-fired PreToolUse hook via resume_verdict_for().
  • examples/agents/interrupt.py (new) + server.py/__init__.py: a /interrupt dojo agent gating a destructive delete_file tool behind the full defer -> interrupt -> resume loop (Strands/Mastra have an interrupt example; Claude didn't).
  • tests/test_interrupts.py (new): 14 unit tests.
  • README: feature note + example row.

Resume-payload semantics

On resume, the persisted Claude session continues (already keyed by thread_id), so the same frozen tool call re-fires PreToolUse. The hook reads the resume verdict: resolved -> allow (frozen args), cancelled -> deny. Executed args stay bound to DeferredToolUse.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 PreToolUse hook 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 pytest in integrations/claude-agent-sdk/python: 120 existing + 14 new pass.
  • Tests cover emit-on-defer, opt-out, resume ingest (resolved/cancelled), interrupt-id round-trip, and the frozen-args security invariant. No LLM calls (pure translation + verdict-recording).

Depends on the interrupt/resume protocol types on main (Interrupt, ResumeEntry, RunFinishedInterruptOutcome, RunAgentInput.resume).

Closes #2180

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.
@Sri01728 Sri01728 marked this pull request as ready for review July 14, 2026 01:14
@Sri01728 Sri01728 requested a review from a team as a code owner July 14, 2026 01:14
salahari and others added 2 commits July 13, 2026 21:36
…-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.
@Sri01728

Copy link
Copy Markdown
Author

Verified local testing (live against Bedrock)

Validated this bridge end-to-end live against real Bedrock (not mocks), driving the actual adapter code path (adapter.run() with RunAgentInput + resume[]), not just the raw SDK.

Matrix: 2 models × 2 verdicts = 4/4 PASS

Model Verdict Emit Frozen call re-fired on resume Result
us.anthropic.claude-haiku-4-5 APPROVE tool executed with frozen args {'path': '/tmp/report.txt'}
us.anthropic.claude-haiku-4-5 REJECT tool did not execute (non-vacuous)
us.anthropic.claude-sonnet-4-5 APPROVE tool executed with frozen args
us.anthropic.claude-sonnet-4-5 REJECT tool did not execute

What was proven:

  • Emit path — a gated call returning permissionDecision: "defer" ends the run cleanly with stop_reason=tool_deferred and a real DeferredToolUse{id, name, input}, which translates to RunFinishedInterruptOutcome.
  • Resume path — on resume with RunAgentInput.resume[], the exact frozen tool call re-fires the PreToolUse hook (same tool_use_id, same args); the hook reads the verdict and allows/denies.
  • Frozen-args guaranteeDeferredToolUse.input is immutable; the model cannot swap args after approval (args frozen-match: True).
  • Reject is real — the REJECT path blocks execution, it is not merely absent.

One fix needed to make resume work through the adapter: the initial version reused the live in-process ClaudeSDKClient, whose query(session_id=...) is only a routing tag and does not trigger SDK session-resume. Real session-resume requires ClaudeAgentOptions(resume=session_id) at connect() time. The adapter now evicts the live worker on a resume run and spins up a fresh worker with the resume option so the frozen deferred call re-fires. After that, all 4 matrix cases passed.

Tests: 134 total (120 existing + 14 new interrupt unit tests), all green, no regressions.

Behavior worth flagging for reviewers: defer ends the run per deferred tool, so a turn with multiple gated tools becomes a chain of runs rather than a single interrupt carrying multiple entries. Callers that need at-most-once execution across that chain must track it themselves; the adapter does not.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Human-in-the-loop interrupt/resume support in Claude Agent SDK integration

1 participant