Skip to content
Merged
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: 18 additions & 6 deletions integrations/aws-strands/python/src/ag_ui_strands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ def _extract_agent_kwargs(agent: StrandsAgentCore) -> dict:
INTERRUPT_CANCELLED = {"cancelled": True}


def _wrap_resume_response(status: str, payload: Any) -> dict:
"""Package a ``ResumeEntry`` for Strands' ``interruptResponse`` shape.

Strands' resume gate is truthiness-based (i.e. ``if interrupt_.response:``),
so a raw falsy payload (``None``, ``False``, ``""``, ``0``, ``[]``, ``{}``)
re-raises the same interrupt and re-runs the tool body — an infinite approve loop.
Always hand Strands a truthy envelope; up to the tool implementation to properly
destructures it (e.g. via ``.get("cancelled")`` / ``.get("response")``).
"""
if status == "cancelled":
return INTERRUPT_CANCELLED
return {"response": payload}


def _get_strands_session_manager(agent: Any) -> Any:
"""Return the agent's Strands ``SessionManager``, or ``None``.

Expand Down Expand Up @@ -1002,8 +1016,8 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]:
and has_nonvoid_frontend_result
)

# A client answering a native Strands interrupt sends its responses
# in ``RunAgentInput.resume`` (per the AG-UI interrupt round-trip),
# A client answering to an interrupt sends its responses
# in ``RunAgentInput.resume`` (as 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
Expand All @@ -1015,10 +1029,8 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]:
{
"interruptResponse": {
"interruptId": entry.interrupt_id,
"response": (
INTERRUPT_CANCELLED
if entry.status == "cancelled"
else entry.payload
"response": _wrap_resume_response(
entry.status, entry.payload
),
}
}
Expand Down
44 changes: 39 additions & 5 deletions integrations/aws-strands/python/tests/test_interrupt.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,11 @@ async def test_no_interrupt_finishes_bare(self):
class TestResumeConsumption:
@pytest.mark.asyncio
async def test_resolved_resume_builds_interrupt_response_prompt(self):
"""A resolved ResumeEntry is translated into the Strands resume prompt."""
"""A resolved ResumeEntry is translated into the Strands resume prompt.

The raw payload is wrapped in ``{"response": ...}`` so Strands' truthiness
gate always passes; the tool destructures via ``.get("response")``.
"""
core = _MockStrandsCore(terminal_events=[])
agent = _make_base_agent()
resume = [ResumeEntry(interrupt_id="int-1", status="resolved", payload="yes")]
Expand All @@ -167,8 +171,33 @@ async def test_resolved_resume_builds_interrupt_response_prompt(self):
await _collect_events(agent, _make_run_input(resume=resume))

assert core.stream_prompts == [
[{"interruptResponse": {"interruptId": "int-1", "response": "yes"}}]
[{"interruptResponse": {"interruptId": "int-1", "response": {"response": "yes"}}}]
]

@pytest.mark.parametrize("falsy_payload", [None, False, "", 0, [], {}])
@pytest.mark.asyncio
async def test_resolved_resume_wraps_falsy_payload_in_truthy_envelope(
self, falsy_payload
):
"""Falsy resume payloads must be wrapped so Strands' ``if response:`` gate passes.

Regression for ``round1.md`` #1: without the envelope, ``None``/``False``/
``""``/``0``/``[]``/``{}`` re-emit the same interrupt id on the resume
run, re-running the tool body forever.
"""
core = _MockStrandsCore(terminal_events=[])
agent = _make_base_agent()
resume = [ResumeEntry(interrupt_id="int-1", status="resolved", payload=falsy_payload)]

with patch("ag_ui_strands.agent.StrandsAgentCore", return_value=core):
await _collect_events(agent, _make_run_input(resume=resume))

[wrapped] = core.stream_prompts
assert wrapped == [
{"interruptResponse": {"interruptId": "int-1", "response": {"response": falsy_payload}}}
]
# The envelope itself must be truthy — that is the whole point.
assert bool(wrapped[0]["interruptResponse"]["response"])

@pytest.mark.asyncio
async def test_cancelled_resume_uses_sentinel(self):
Expand Down Expand Up @@ -199,7 +228,7 @@ async def test_multiple_resume_entries(self):

assert core.stream_prompts == [
[
{"interruptResponse": {"interruptId": "a", "response": {"k": 1}}},
{"interruptResponse": {"interruptId": "a", "response": {"response": {"k": 1}}}},
{"interruptResponse": {"interruptId": "b", "response": INTERRUPT_CANCELLED}},
]
]
Expand All @@ -218,8 +247,13 @@ async def test_multiple_resume_entries(self):

@tool(context=True)
def confirm_action(key: str, tool_context: ToolContext) -> dict:
approval = tool_context.interrupt("confirm_action", reason={"key": key})
if approval:
# Resume envelope: {"cancelled": True} on cancel, {"response": <raw>} on
# resolve. Destructure — do NOT truthiness-check the envelope, since it is
# always truthy on resolve (that's the whole point of the wrap).
envelope = tool_context.interrupt("confirm_action", reason={"key": key})
if envelope.get("cancelled"):
return {"status": "success", "content": [{"text": f"denied {key}"}]}
if envelope.get("response"):
return {"status": "success", "content": [{"text": f"confirmed {key}"}]}
return {"status": "success", "content": [{"text": f"denied {key}"}]}

Expand Down
Loading