diff --git a/README.md b/README.md index 4d5777d..3a4e528 100644 --- a/README.md +++ b/README.md @@ -387,7 +387,7 @@ async def main(): asyncio.run(main()) ``` -Event types: `AGENT_START`, `TOKEN`, `REASONING`, `TOOL_START`, `TOOL_END`, `NODE_START`, `NODE_STOP`, `HANDOFF`, `COMPLETE`, `MULTIAGENT_START`, `MULTIAGENT_COMPLETE`, `ERROR` — each carrying `{type, agent_name, timestamp, data}`. Enough for a real-time frontend, a log aggregator, or a debugging dashboard. The `AnsiRenderer` gives you coloured terminal output out of the box — agent names, tool calls, reasoning traces, all streaming live. +Event types: `AGENT_START`, `TOKEN`, `REASONING`, `TOOL_START`, `TOOL_END`, `INTERRUPT`, `NODE_START`, `NODE_STOP`, `HANDOFF`, `COMPLETE`, `MULTIAGENT_START`, `MULTIAGENT_COMPLETE`, `ERROR` — each carrying `{type, agent_name, timestamp, data}`. Enough for a real-time frontend, a log aggregator, or a debugging dashboard. The `AnsiRenderer` gives you coloured terminal output out of the box — agent names, tool calls, reasoning traces, all streaming live. --- diff --git a/docs/configuration/Chapter_15.md b/docs/configuration/Chapter_15.md index 03e300a..baffeef 100644 --- a/docs/configuration/Chapter_15.md +++ b/docs/configuration/Chapter_15.md @@ -39,6 +39,7 @@ asyncio.run(main()) | `REASONING` | Reasoning/thinking content from LLM | | `TOOL_START` | Tool execution begins | | `TOOL_END` | Tool execution completes | +| `INTERRUPT` | Agent pauses for human input | | `COMPLETE` | Agent finishes (with usage metrics) | | `ERROR` | Model or execution error | | `NODE_START` | Graph/swarm node begins | diff --git a/examples/12_streaming/README.md b/examples/12_streaming/README.md index 18aa831..ab77bf6 100644 --- a/examples/12_streaming/README.md +++ b/examples/12_streaming/README.md @@ -38,6 +38,7 @@ renderer.flush() | `reasoning` | Streaming reasoning chunk | | `tool_start` | Tool call begins | | `tool_end` | Tool call finished | +| `interrupt` | Agent pauses for human input | | `complete` | Agent finished (includes token usage) | | `node_start` / `node_stop` | Swarm / Graph enters/leaves a node | | `handoff` | Swarm transfers control | diff --git a/src/strands_compose/hooks/event_publisher.py b/src/strands_compose/hooks/event_publisher.py index d3c18db..a18ed3a 100644 --- a/src/strands_compose/hooks/event_publisher.py +++ b/src/strands_compose/hooks/event_publisher.py @@ -230,6 +230,22 @@ def _on_complete(self, event: AfterInvocationEvent) -> None: if self._errored: return + result = event.result + if result is not None and result.stop_reason == "interrupt": + for interrupt in result.interrupts or []: + self._callback( + StreamEvent( + type=EventType.INTERRUPT, + agent_name=self._agent_name, + data={ + "interrupt_id": interrupt.id, + "name": interrupt.name, + "reason": interrupt.reason, + }, + ), + ) + return + metrics = event.agent.event_loop_metrics # Usage from the latest invocation (current turn only). diff --git a/src/strands_compose/types.py b/src/strands_compose/types.py index 5fb2f28..504babb 100644 --- a/src/strands_compose/types.py +++ b/src/strands_compose/types.py @@ -33,6 +33,7 @@ class EventType(StrEnum): TOOL_START = "tool_start" TOOL_END = "tool_end" REASONING = "reasoning" + INTERRUPT = "interrupt" COMPLETE = "complete" ERROR = "error" @@ -50,7 +51,7 @@ class StreamEvent: Produced by :class:`~strands_compose.hooks.EventPublisher` for all agent activity: ``TOKEN``, ``REASONING``, ``TOOL_START``, ``TOOL_END``, - ``COMPLETE``, ``ERROR``, ``NODE_START``, ``NODE_STOP``, + ``INTERRUPT``, ``COMPLETE``, ``ERROR``, ``NODE_START``, ``NODE_STOP``, ``HANDOFF``, ``MULTIAGENT_COMPLETE``. Attributes: diff --git a/src/strands_compose/wire.py b/src/strands_compose/wire.py index 456ed9c..7e2385d 100644 --- a/src/strands_compose/wire.py +++ b/src/strands_compose/wire.py @@ -6,7 +6,7 @@ :class:`EventQueue` is a thin async queue wrapper that hides the sentinel pattern from callers. :func:`make_event_queue` attaches :class:`~strands_compose.hooks.EventPublisher` hooks to every agent -so all events (TOKEN, REASONING, TOOL_START, TOOL_END, COMPLETE, +so all events (TOKEN, REASONING, TOOL_START, TOOL_END, INTERRUPT, COMPLETE, and — for Swarm/Graph — NODE_START, NODE_STOP, HANDOFF, MULTIAGENT_COMPLETE) flow into the shared queue. diff --git a/tests/unit/hooks/test_event_publisher.py b/tests/unit/hooks/test_event_publisher.py index 3961456..c774bdf 100644 --- a/tests/unit/hooks/test_event_publisher.py +++ b/tests/unit/hooks/test_event_publisher.py @@ -2,6 +2,7 @@ from __future__ import annotations +from types import SimpleNamespace from unittest.mock import MagicMock import pytest @@ -97,6 +98,42 @@ def test_complete_emits_with_usage(self): assert events[0].data["usage"]["output_tokens"] == 5 assert events[0].data["usage"]["total_tokens"] == 15 + def test_complete_with_interrupt_result_emits_interrupt_events(self) -> None: + events = [] + pub = EventPublisher(callback=events.append, agent_name="test") + + complete_event = MagicMock() + complete_event.result = SimpleNamespace( + stop_reason="interrupt", + interrupts=[ + SimpleNamespace(id="approval-1", name="approve_delete", reason="Delete file?"), + SimpleNamespace(id="approval-2", name="approve_deploy", reason={"env": "prod"}), + ], + ) + pub._on_complete(complete_event) + + assert [event.type for event in events] == [EventType.INTERRUPT, EventType.INTERRUPT] + assert events[0].data == { + "interrupt_id": "approval-1", + "name": "approve_delete", + "reason": "Delete file?", + } + assert events[1].data == { + "interrupt_id": "approval-2", + "name": "approve_deploy", + "reason": {"env": "prod"}, + } + + def test_complete_with_interrupt_result_does_not_emit_complete(self) -> None: + events = [] + pub = EventPublisher(callback=events.append, agent_name="test") + + complete_event = MagicMock() + complete_event.result = SimpleNamespace(stop_reason="interrupt", interrupts=[]) + pub._on_complete(complete_event) + + assert [event.type for event in events] == [] + class TestHandoffEvent: def test_callback_handler_emits_handoff_on_multiagent_handoff_type(self) -> None: diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 34a89a8..e8aaf73 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -29,6 +29,7 @@ def test_is_str_enum_with_string_values(self): ("TOOL_START", "tool_start"), ("TOOL_END", "tool_end"), ("REASONING", "reasoning"), + ("INTERRUPT", "interrupt"), ("NODE_START", "node_start"), ("NODE_STOP", "node_stop"), ("HANDOFF", "handoff"), @@ -47,6 +48,7 @@ def test_all_members_present(self): "TOOL_START", "TOOL_END", "REASONING", + "INTERRUPT", "COMPLETE", "ERROR", "NODE_START",