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
2 changes: 1 addition & 1 deletion docs/configuration/Chapter_15.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/strands_compose/hooks/event_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
BeforeToolCallEvent,
)

from ..manifest import model_descriptor
from ..types import EventType, StreamEvent

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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`.
"""
Expand Down Expand Up @@ -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,
},
Comment thread
galuszkm marked this conversation as resolved.
"text": str(result) if result is not None else "",
"message": result.message if result is not None else {},
}
Expand Down
10 changes: 7 additions & 3 deletions src/strands_compose/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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),
)

Expand Down
11 changes: 11 additions & 0 deletions tests/runtime/test_event_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down