Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions integrations/aws-strands/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,30 @@ agentcore deploy

For the complete deployment walkthrough, see [Deploy AG-UI servers in AgentCore Runtime](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-agui.html).

## Human-in-the-loop (native Strands interrupts)

Tools that pause with `tool_context.interrupt(...)` are bridged to the AG-UI
interrupt round-trip:

- When a run pauses, it finishes with `RUN_FINISHED` carrying a
`RunFinishedInterruptOutcome` (`outcome.type == "interrupt"`) and one AG-UI
`Interrupt` per Strands interrupt. The Strands interrupt *name* becomes the
AG-UI `reason`; the original (free-form) reason object is preserved under
`metadata.strands_reason`.
- To resume, the client sends the next `RunAgentInput` on the **same
`thread_id`** with `resume=[ResumeEntry(interrupt_id=..., status="resolved",
payload=...)]`. The tool's `interrupt()` call returns exactly `payload`.
- `status="cancelled"` resumes the tool with the sentinel
`{"cancelled": True}` (`ag_ui_strands.agent.INTERRUPT_CANCELLED`) so it can
treat the pause as a denial.

> **Persistence:** interrupt state lives on the per-thread agent instance. The
> in-memory per-thread cache only preserves it within a single process, so
> pause and resume must hit the same process. For stateless / multi-container
> HTTP deployments, wire a durable `SessionManager` via
> `StrandsAgentConfig.session_manager_provider` so interrupt state round-trips
> across processes.

## Supported AG-UI Events

The integration supports the following AG-UI event families:
Expand Down
2 changes: 1 addition & 1 deletion integrations/aws-strands/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ authors = [
requires-python = ">=3.12, <3.14"
dependencies = [
"ag-ui-a2ui-toolkit>=0.0.4",
"ag-ui-protocol>=0.1.18",
"ag-ui-protocol>=0.1.19",
"fastapi>=0.115.12",
"strands-agents>=1.15.0",
]
Expand Down
114 changes: 107 additions & 7 deletions integrations/aws-strands/python/src/ag_ui_strands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ def _extract_agent_kwargs(agent: StrandsAgentCore) -> dict:
# outstanding frontend calls at once.
_WIRE_MAP_MAX = 512

# Sentinel handed back to a paused ``tool_context.interrupt()`` when the client
# cancels (``ResumeEntry.status == "cancelled"``) rather than resolving. The
# tool receives this in place of a real answer and can treat it as a denial.
INTERRUPT_CANCELLED = {"cancelled": True}


def _get_strands_session_manager(agent: Any) -> Any:
"""Return the agent's Strands ``SessionManager``, or ``None``.
Expand All @@ -78,12 +83,51 @@ def _get_strands_session_manager(agent: Any) -> Any:
)


def _strands_interrupt_to_agui(strands_interrupt: Any) -> "Interrupt":
"""Map a native Strands ``Interrupt`` onto an AG-UI ``Interrupt``.

Strands' ``reason`` is free-form (any JSON), whereas AG-UI's ``reason`` is a
categorical string. The interrupt *name* is the closest categorical fit, so
it becomes ``reason``; the original reason object is preserved verbatim under
``metadata`` so no information is lost on the wire.
"""
raw_reason = getattr(strands_interrupt, "reason", None)
return Interrupt(
id=getattr(strands_interrupt, "id", ""),
reason=getattr(strands_interrupt, "name", None) or "interrupt",
message=raw_reason if isinstance(raw_reason, str) else None,
metadata=(
{"strands_reason": raw_reason} if raw_reason is not None else None
),
)


def _extract_interrupts(agent: Any, terminal_result: Any) -> list:
"""Return the native Strands interrupts for a paused run, or ``[]``.

Prefers the terminal ``AgentResult`` (``stop_reason == "interrupt"`` with a
populated ``interrupts``); falls back to the live agent's
``_interrupt_state`` so a pause is still detected if the result event was
consumed by the stream's early-break path.
"""
if terminal_result is not None:
if getattr(terminal_result, "stop_reason", None) == "interrupt":
interrupts = getattr(terminal_result, "interrupts", None) or []
if interrupts:
return list(interrupts)
interrupt_state = getattr(agent, "_interrupt_state", None)
if interrupt_state is not None and getattr(interrupt_state, "activated", False):
return list(getattr(interrupt_state, "interrupts", {}).values())
return []


logger = logging.getLogger(__name__)
from ag_ui.core import (
AssistantMessage,
CustomEvent,
EventType,
FunctionCall,
Interrupt,
MessagesSnapshotEvent,
ReasoningEncryptedValueEvent,
ReasoningEndEvent,
Expand All @@ -94,6 +138,7 @@ def _get_strands_session_manager(agent: Any) -> Any:
RunAgentInput,
RunErrorEvent,
RunFinishedEvent,
RunFinishedInterruptOutcome,
RunStartedEvent,
StateSnapshotEvent,
StepFinishedEvent,
Expand Down Expand Up @@ -866,6 +911,10 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]:
stop_text_streaming = False
halt_event_stream = False
pending_halt = False
# Terminal ``AgentResult`` from Strands (carried on the final
# ``{"result": ...}`` stream event). Used after the loop to detect a
# native interrupt pause (``stop_reason == "interrupt"``).
terminal_result = None

# Reasoning/thinking state tracking
reasoning_started = False
Expand Down Expand Up @@ -952,7 +1001,33 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]:
and self.config.replay_history_into_strands
and has_nonvoid_frontend_result
)
if replay_history:

# A client answering a native Strands interrupt sends its responses
# in ``RunAgentInput.resume`` (per the AG-UI interrupt round-trip),
# not as a new user message. Translate those into the Strands resume
# prompt shape ``[{"interruptResponse": {"interruptId", "response"}}]``
# and drive the stream with it β€” this takes precedence over every
# other path, since a resume run carries no fresh prompt.
resume_prompt = None
resume_entries = getattr(input_data, "resume", None)
if isinstance(resume_entries, list) and resume_entries:
resume_prompt = [
{
"interruptResponse": {
"interruptId": entry.interrupt_id,
"response": (
INTERRUPT_CANCELLED
if entry.status == "cancelled"
else entry.payload
),
}
}
for entry in resume_entries
]

if resume_prompt is not None:
agent_stream = strands_agent.stream_async(resume_prompt)
elif replay_history:
native_history = _build_strands_history(input_data.messages)
# Apply ``state_context_builder`` to the last user-text
# message in the reconciled history rather than to the
Expand Down Expand Up @@ -1050,6 +1125,13 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]:

logger.debug(f"Received event: {event}")

# Capture the terminal ``AgentResult`` (always emitted last
# by ``stream_async``) so a native interrupt pause can be
# detected after the loop. Recorded before the
# ``complete``/``force_stop`` break so it is never dropped.
if "result" in event and event["result"] is not None:
terminal_result = event["result"]

# Skip lifecycle events
if event.get("init_event_loop") or event.get("start_event_loop"):
continue
Expand Down Expand Up @@ -1979,12 +2061,30 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]:
snapshot=current_state,
)

# Always finish the run - frontend handles keeping action executing
yield RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=input_data.thread_id,
run_id=input_data.run_id,
)
# If the run paused on a native Strands interrupt, surface it as an
# AG-UI interrupt outcome so the client can collect a response and
# resume via ``RunAgentInput.resume`` next turn. Otherwise finish
# bare, exactly as before (no behavior change for normal runs).
native_interrupts = _extract_interrupts(strands_agent, terminal_result)
if native_interrupts:
yield RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=input_data.thread_id,
run_id=input_data.run_id,
outcome=RunFinishedInterruptOutcome(
type="interrupt",
interrupts=[
_strands_interrupt_to_agui(i) for i in native_interrupts
],
),
)
else:
# Always finish the run - frontend handles keeping action executing
yield RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=input_data.thread_id,
run_id=input_data.run_id,
)

except Exception as e:
import traceback
Expand Down
Loading