Skip to content

feat(strands): bridge native Strands interrupts to AG-UI interrupt round-trip - #2286

Open
ciolo wants to merge 6 commits into
ag-ui-protocol:mainfrom
ciolo:feat/strands-native-interrupts
Open

feat(strands): bridge native Strands interrupts to AG-UI interrupt round-trip#2286
ciolo wants to merge 6 commits into
ag-ui-protocol:mainfrom
ciolo:feat/strands-native-interrupts

Conversation

@ciolo

@ciolo ciolo commented Jul 30, 2026

Copy link
Copy Markdown

Fixes #2205

Summary

Bridges native Strands tool_context.interrupt() pauses to the AG-UI interrupt lifecycle, so human-in-the-loop flows work over the protocol. Previously a native interrupt either surfaced as RUN_ERROR or silently finished as a completed run, and a client's resume payload was dropped.

This is a wrapper-only change in integrations/aws-strands/python/ — the AG-UI protocol types it relies on already ship in ag-ui-protocol 0.1.19.

I filed #2205 ~2 weeks ago and pinged #-💎-contributing on Discord; opening this PR to move the discussion forward. Happy to adjust scope or approach.

Changes

  • Consume RunAgentInput.resume — builds the Strands resume prompt [{"interruptResponse": {"interruptId", "response"}}] and drives stream_async with it (takes precedence over other stream paths, since a resume run carries no fresh prompt). status="cancelled" resumes with the documented denial sentinel INTERRUPT_CANCELLED = {"cancelled": True}.
  • Detect the pause after the stream loop — inspects the terminal AgentResult (stop_reason == "interrupt"), captured before the complete/force_stop early-break so it's never dropped; falls back to the agent's _interrupt_state.activated if the result event was consumed.
  • Emit RunFinishedInterruptOutcome — one AG-UI Interrupt per Strands interrupt. The Strands interrupt name maps to the categorical AG-UI reason; the free-form Strands reason object is preserved under metadata.strands_reason. Non-interrupt runs still finish bare (no behavior change).
  • Bump ag-ui-protocol floor to >=0.1.19 (ships the interrupt types) and re-lock.
  • Docs — README section on the HITL round-trip and the durable SessionManager requirement for stateless / multi-container deployments (the in-memory per-thread cache only preserves interrupt state within one process).

Test plan

  • New unit tests in tests/test_interrupt.py (6 cases): pause → interrupt outcome, state-fallback detection, no-interrupt-finishes-bare, resolved-resume prompt shape, cancelled sentinel, multi-entry resume.
  • Full suite green: uv run pytest tests/ → 182 passed, 2 skipped.
  • Dojo human_in_the_loop example not included in this PR — happy to add if maintainers want it.

🤖 Generated with Claude Code

…und-trip

Translate native Strands `tool_context.interrupt()` pauses into the AG-UI
interrupt lifecycle so human-in-the-loop flows work over the protocol:

- Consume `RunAgentInput.resume`, building the Strands resume prompt
  `[{"interruptResponse": {"interruptId", "response"}}]`; `status="cancelled"`
  resumes with the `INTERRUPT_CANCELLED` denial sentinel.
- Detect a paused run after the stream loop via the terminal `AgentResult`
  (`stop_reason == "interrupt"`), falling back to the agent's
  `_interrupt_state` when the result event is consumed by the early-break path.
- Emit `RunFinishedInterruptOutcome` with one AG-UI `Interrupt` per Strands
  interrupt (name -> categorical `reason`, original reason under
  `metadata.strands_reason`); non-interrupt runs still finish bare.
- Bump `ag-ui-protocol` floor to >=0.1.19 (ships the interrupt types).
- Document the HITL round-trip and the durable `SessionManager` requirement
  for stateless deployments; add unit tests for all four behaviors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ciolo
ciolo force-pushed the feat/strands-native-interrupts branch from 269a418 to 062cb45 Compare July 30, 2026 10:10
@leotac

leotac commented Aug 4, 2026

Copy link
Copy Markdown

hi @contextablemark and @ranst91 ! Apologies for direct tagging.
This feature is blocking us from adopting AG-UI with our current strands-based agents, who could help us review / discuss this change?
Thansk!

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1785893517 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then install the packages you need:

# Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1785893517' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1785893517' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1785893517' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1785893517' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1785893517' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1785893517' --index testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1785893517

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: 7b4c8f2

@pkg-pr-new

pkg-pr-new Bot commented Aug 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@2286

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@2286

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@2286

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@2286

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@2286

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@2286

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@2286

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@2286

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@2286

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@2286

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@2286

@ag-ui/claude-managed-agents

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-managed-agents@2286

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@2286

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@2286

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@2286

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@2286

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@2286

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@2286

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@2286

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@2286

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@2286

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@2286

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@2286

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@2286

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@2286

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@2286

commit: 32b48ff

@contextablemark

Copy link
Copy Markdown
Contributor

Thanks for this, and apologies for the slow review — worth explaining why, because you've walked into a genuinely crowded area rather than a quiet one.

Context: four changes are currently converging on integrations/aws-strands/python/src/ag_ui_strands/agent.py.

We've verified yours is orthogonal to the halt work: cherry-picking our commits onto your head applies with zero conflicts, the union suite is green, and your feature behaves identically with and without them. So this isn't blocked on the others — but it does explain the delay, and it's why the review below is unusually specific about which code path each finding lives on.

Separately, the uv.lock churn you hit was our fault, not yours: every release bump was editing pyproject.toml alone and leaving the lock's self-entry stale, so any contributor running uv lock inherited the catch-up. #2313 repaired the accumulated drift; #2314 stops the release script causing it, and #2315 adds a CI gate so it can't recur silently.


Now the review. Credit where it's due first: this avoids three traps that are genuinely hard to know about, and we only found them by instrumenting Strands directly.

  • It never breaks the stream loop on the interrupt event, so Strands' post-drain parking of sibling tool results completes. Breaking early there loses the parked result and leaves the session unresumable.
  • It never calls _interrupt_state.deactivate(), which would discard parked sibling results and backfill "Tool was interrupted." errors.
  • It stashes nothing in invocation_state, which is a single shared dict across a concurrent batch and would cross-talk between parallel tools.

The ag-ui-protocol >=0.1.19 floor is also correctly required, not cosmetic — Interrupt/ResumeEntry are absent from 0.1.18.

Three blocking issues

1. A falsy resume payload never resolves. agent.py:1014-1024 passes entry.payload through raw; only the cancelled path gets a truthy envelope. Strands gates on truthiness — strands/types/interrupt.py:108 is if interrupt_.response:, not is not None. Measured against real Strands: payload=None, False, "" each re-emit the same interrupt id on the resume run, re-run the tool body, and make no model call — an infinite approve loop.

This is the default path, not an edge case: ResumeEntry.payload defaults to None, and the TS SDK's buildResumeArray omits payload when undefined. So a plain "Approve" button hangs the thread. Suggest wrapping resolved responses the way INTERRUPT_CANCELLED already is and unwrapping tool-side, plus cases for None/False/""/0/[]/{}. Upstream narrowed this to is not None in 1.19.0, but None still re-raises through 1.50.2 — so pair the encoding with a narrowed strands-agents floor, or make it version-independent.

2. The resume branch should be additive, not exclusive. The comment at agent.py:1009-1010"a resume run carries no fresh prompt" — doesn't hold for a batch mixing a frontend proxy tool with an interrupting backend tool. Strands returns at event_loop.py:498 without a ToolResultMessageEvent, so the frontend TOOL_CALL_START already reached the client and turn 2 legitimately carries both a tool result and resume. Measured: the client's real result never reaches the model (toolResult:tu-fe=[{"text":"Forwarded to client"}]), and the run emits TOOL_CALL_RESULT under a native id the client never saw. Please still run reconcile_session_results / set native_history before resuming, or explicitly reject mixed batches.

3. tool_behaviors are silently skipped on resume runs. tool_calls_seen is per-run (agent.py:909) and Strands skips model invocation while interrupted, so tool_name is None at the result-handling site. Measured: zero non-empty STATE_SNAPSHOTs on the resume run with state_from_result configured. Persisting the toolUseId -> {name, args} map across runs (like the existing wire map) fixes this and re-enables the frontend-placeholder skip, which is half of item 2.

Non-blocking

4. Populate Interrupt.tool_call_id. The Strands id already embeds the toolUseId (strands/types/tools.py:162), so it's free, and it's what lets a client bind the prompt to the tool card it already rendered. docs/concepts/interrupts.mdx:168-174 also reserves reason="tool_call" for this case and asks custom reasons to be namespaced — at minimum strands:<name>. Your own #2205 proposed both. Worth settling pre-release. Related: message is null whenever reason is a dict, which is the idiomatic Strands usage, so the human-readable prompt lands only in metadata.

5. Add an escape from an abandoned interrupt. A next turn with no resume gives RUN_ERROR: must resume from interrupt with list of interruptResponse's — forever, with activated still true; resume=[] bricks identically. This isn't caused by your change — it reproduces pre-PR — but this PR is what makes the state reachable and advertised. An activated and not input_data.resume pre-check that either auto-cancels or emits a purposeful error naming the open interrupts would close it. docs/concepts/interrupts.mdx:155-157 treats a missing resume as a spec RunError case.

6. One test that drives a real strands.Agent. All six use _MockStrandsCore replaying canned dicts, and _make_base_agent() sets replay_history_into_strands=False. A single test with a real @tool(context=True) and a scripted stub model — no network needed; that's how everything above was measured — would have caught items 1, 2 and 3. Relatedly, test_detects_interrupt_from_state_when_result_missing asserts terminal_events=[], a shape real Strands never produces.

7. Three small ones. (a) Move the terminal_result capture above the if halt_event_stream: continue guard — as written the "never dropped" comment is false on the halt path. (b) INTERRUPT_CANCELLED is a module-level mutable dict handed to user tool code by reference; a tool that empties it makes it falsy and future cancels livelock — pass dict(INTERRUPT_CANCELLED) per entry. (c) _extract_interrupts falls through to the private-state fallback whenever stop_reason != "interrupt", and that fallback over-reports — on a partial resume it reported an interrupt whose response was already set.

8. Two things for the README: the interrupted tool's body re-executes on resume (measured), so anything before interrupt() must be idempotent — the current snippet isn't safe for a tool with a pre-pause side effect. And the durable-SessionManager path you recommend is where a bytes-bearing sibling ToolResult plus an interrupt raises TypeError: Object of type bytes is not JSON serializable and aborts the run, because SessionAgent.to_dict is a plain asdict unlike SessionMessage.to_dict.


Happy to pair on 1–3 if useful, and the dojo human_in_the_loop example you offered would be genuinely valuable — given those three, an end-to-end example is the cheapest way to keep them from regressing.

cc @leotac, since you asked about adoption timing.

Co-authored-by: Francesco De Felice <francesco.de.felice@verizonconnect.com>
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]: ag_ui_strands: bridge native Strands interrupts to AG-UI

5 participants