Skip to content
58 changes: 54 additions & 4 deletions integrations/aws-strands/python/src/ag_ui_strands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1044,9 +1044,26 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]:

try:
async for event in agent_stream:
# If we've halted, consume remaining events silently to allow proper cleanup
# Frontend-tool halt: STOP the loop rather than muting the
# wire and draining it. The proxy tool returns a SUCCESSFUL
# "Forwarded to client" placeholder, so Strands has every
# reason to run another model cycle — and another. Draining
# those cycles costs: frontend tool calls the client never
# sees (so it can never answer them), real backend tool
# side effects, phantom assistant turns persisted to the
# session store, and RUN_FINISHED stuck behind work the
# client is not watching. Single-agent Strands has no cycle
# cap, so that tail is unbounded — a model that keeps
# retrying the read never yields a terminal event at all.
#
# Safe here because the halt latches only AFTER Strands
# appended the assistant toolUse + placeholder toolResult
# and MessageAddedEvent synced agent state (see
# SessionManager.register_hooks), so the next run's
# reconcile still finds a placeholder to overwrite and the
# wire->native map to key it by.
if halt_event_stream:
continue
break

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

Expand Down Expand Up @@ -1228,9 +1245,26 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]:

# Handle tool results from Strands for backend tool rendering
elif "message" in event and event["message"].get("role") == "user":
# A deferred frontend-tool halt takes effect here — but
# do NOT skip the message. In a parallel batch mixing a
# frontend tool with backend tools, THIS message carries
# the backend tools' real results, and dropping it loses
# them permanently: the client's tool card never
# resolves, the result never reaches MESSAGES_SNAPSHOT
# (the only path into client-side history — the
# TOOL_CALL_RESULT below is deliberately role-less and
# is not history), and state_from_result /
# custom_result_handler never fire. Consumers that
# persist from the event stream then hold a transcript
# whose toolUse has no toolResult, which the next run
# replays straight to the model provider.
#
# Fall through instead: the per-item loop already skips
# frontend placeholders (the client produces the real
# result), so only genuine backend results go out. Stop
# after the batch, before the next model cycle.
if pending_halt:
halt_event_stream = True
continue
message_content = event["message"].get("content", [])
if not message_content or not isinstance(message_content, list):
continue
Expand Down Expand Up @@ -1405,6 +1439,14 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]:
# Break inner loop — no further results should be emitted
break

# The batch is fully emitted; stop before Strands runs
# another model cycle. Breaking HERE rather than relying
# on the check at the top of the loop means termination
# does not depend on Strands happening to yield one more
# event after this message.
if halt_event_stream:
break

# Handle tool calls
elif "current_tool_use" in event and event["current_tool_use"]:
tool_use = event["current_tool_use"]
Expand Down Expand Up @@ -1907,8 +1949,16 @@ async def run(self, input_data: RunAgentInput) -> AsyncIterator[Any]:
# The generator should complete naturally when we consume all events,
# but we still try to close it explicitly to be safe
try:
# A frontend-tool halt breaks out of the loop with the
# generator SUSPENDED at a yield, where ``ag_running`` is
# False. The exhausted-generator check below would read
# that as "already closed" and defer teardown to GC,
# leaving the halted Strands cycle (and its model stream)
# open. Close it explicitly instead.
if halt_event_stream:
await agent_stream.aclose()
# Check if generator is already closed/exhausted
if not agent_stream.ag_running:
elif not agent_stream.ag_running:
# Generator is already closed, nothing to do
pass
else:
Expand Down
Loading
Loading