diff --git a/docs/configuration/Chapter_15.md b/docs/configuration/Chapter_15.md index 9ca7f60..ea83db5 100644 --- a/docs/configuration/Chapter_15.md +++ b/docs/configuration/Chapter_15.md @@ -55,7 +55,7 @@ The `SESSION_START` payload wraps the full wired topology snapshot together with | `TOOL_START` | Tool execution begins | | `TOOL_END` | Tool execution completes | | `INTERRUPT` | Agent pauses for human input | -| `AGENT_COMPLETE` | Agent finishes — `data` carries `usage` metrics, `text` (final output string), and `message` (raw message dict) | +| `AGENT_COMPLETE` | Agent finishes — `data` carries `usage` metrics, `model` (`model_id`, `provider`), `text` (final output string), and `message` (raw message dict) | | `ERROR` | Model or execution error | ### Multi-agent events diff --git a/src/strands_compose/hooks/event_publisher.py b/src/strands_compose/hooks/event_publisher.py index 13223d6..763c12c 100644 --- a/src/strands_compose/hooks/event_publisher.py +++ b/src/strands_compose/hooks/event_publisher.py @@ -34,6 +34,7 @@ BeforeToolCallEvent, ) +from ..manifest import model_descriptor from ..types import EventType, StreamEvent logger = logging.getLogger(__name__) @@ -223,6 +224,10 @@ def _on_tool_end(self, event: AfterToolCallEvent) -> None: def _on_complete(self, event: AfterInvocationEvent) -> None: """Emit AGENT_COMPLETE with usage metrics from EventLoopMetrics. + Includes a ``model`` dict with ``model_id`` and ``provider`` (same + values reported in the session manifest) so consumers can compute + cost directly from this event without a manifest round-trip. + Suppressed when the invocation errored — an ERROR event was already emitted via :meth:`_on_model_error`. """ @@ -250,12 +255,18 @@ def _on_complete(self, event: AfterInvocationEvent) -> None: invocation = metrics.latest_agent_invocation usage = invocation.usage if invocation else metrics.accumulated_usage + model = model_descriptor(event.agent) + data: dict[str, Any] = { "usage": { "input_tokens": usage.get("inputTokens", 0), "output_tokens": usage.get("outputTokens", 0), "total_tokens": usage.get("totalTokens", 0), }, + "model": { + "model_id": model.model_id, + "provider": model.provider, + }, "text": str(result) if result is not None else "", "message": result.message if result is not None else {}, } diff --git a/src/strands_compose/manifest.py b/src/strands_compose/manifest.py index 88dc71b..217f84b 100644 --- a/src/strands_compose/manifest.py +++ b/src/strands_compose/manifest.py @@ -118,8 +118,12 @@ def _descriptor_or_none( # ── Agent descriptor ───────────────────────────────────────────────────────── -def _model_descriptor(agent: Agent) -> ModelDescriptor: - """Extract a :class:`ModelDescriptor` from an agent's model.""" +def model_descriptor(agent: Agent) -> ModelDescriptor: + """Extract a :class:`ModelDescriptor` from an agent's model. + + Public so other components (e.g. :class:`~strands_compose.hooks.EventPublisher`) + can derive the same ``model_id``/``provider`` pair without a manifest round-trip. + """ config = agent.model.get_config() if isinstance(config, dict): model_id = config.get("model_id") @@ -136,7 +140,7 @@ def _agent_descriptor(name: str, agent: Agent) -> AgentDescriptor: return AgentDescriptor( name=name, description=agent.description, - model=_model_descriptor(agent), + model=model_descriptor(agent), session_manager=_descriptor_or_none(agent._session_manager), ) diff --git a/tests/runtime/test_event_stream.py b/tests/runtime/test_event_stream.py index 769e5b5..6ad197d 100644 --- a/tests/runtime/test_event_stream.py +++ b/tests/runtime/test_event_stream.py @@ -56,6 +56,17 @@ async def test_text_response_emits_start_tokens_and_complete(): assert "".join(tokens) == "Hello world" +async def test_agent_complete_includes_model_id_and_provider(): + agent = Agent(model=FakeModel(["hi"], model_id="us.anthropic.claude-sonnet-4-6")) + eq = make_event_queue({"a": agent}, entry_name="a") + + events = await _run_agent("hi", agent, eq) + complete = next(e for e in events if e.type == EventType.AGENT_COMPLETE) + + assert complete.data["model"]["model_id"] == "us.anthropic.claude-sonnet-4-6" + assert complete.data["model"]["provider"] == f"{FakeModel.__module__}.{FakeModel.__qualname__}" + + async def test_stream_is_bracketed_by_session_end(): agent = Agent(model=FakeModel(["hi"])) eq = make_event_queue({"a": agent}, entry_name="a")