Bläddra i källkod

feat: add comparable invocation metrics

Problem: ChatAgent-only round usage hid EventAgent fallback cost, logical tool spans, and true turn wall time, preventing fair dual-agent versus direct-tool comparison.

Risk: Legacy experimental databases containing duplicate non-null metric_key rows still require a follow-up deduplication migration before the unique index can be created.
zhenyu.hu 2 veckor sedan
förälder
incheckning
38d674ef48

+ 75 - 23
src/agent_lab/application/event_agent.py

@@ -1,8 +1,9 @@
 import asyncio
 import asyncio
 import json
 import json
+import time
 from collections.abc import AsyncIterator, Hashable, Iterable, Sequence
 from collections.abc import AsyncIterator, Hashable, Iterable, Sequence
 from dataclasses import dataclass
 from dataclasses import dataclass
-from typing import Any, Protocol
+from typing import Any, Callable, Protocol
 
 
 from agent_lab.application.contracts import AgentParams, EventAgentParams
 from agent_lab.application.contracts import AgentParams, EventAgentParams
 from agent_lab.application.events import (
 from agent_lab.application.events import (
@@ -20,7 +21,7 @@ from agent_lab.application.tools import (
     build_default_tool_registry,
     build_default_tool_registry,
 )
 )
 from agent_lab.domain.events import ToolCallEvent
 from agent_lab.domain.events import ToolCallEvent
-from agent_lab.domain.messages import ChatMessage, StreamItem
+from agent_lab.domain.messages import ChatMessage, ModelCallMetric, StreamItem, TokenUsage
 
 
 
 
 class EventAgentChatClient(Protocol):
 class EventAgentChatClient(Protocol):
@@ -55,16 +56,19 @@ class EventAgent:
         registry: ToolRegistry | None = None,
         registry: ToolRegistry | None = None,
         chat_client: EventAgentChatClient | None = None,
         chat_client: EventAgentChatClient | None = None,
         params: AgentParams | None = None,
         params: AgentParams | None = None,
+        monotonic_clock: Callable[[], float] | None = None,
     ) -> None:
     ) -> None:
         self.enabled_tools = set(enabled_tools)
         self.enabled_tools = set(enabled_tools)
         self.registry = registry or build_default_tool_registry()
         self.registry = registry or build_default_tool_registry()
         self.chat_client = chat_client
         self.chat_client = chat_client
         self.params = params or EventAgentParams()
         self.params = params or EventAgentParams()
+        self._clock = monotonic_clock or time.monotonic
         self.kernel = EventKernel(
         self.kernel = EventKernel(
             self.registry.event_registry,
             self.registry.event_registry,
             argument_fallback=(
             argument_fallback=(
                 self._resolve_arguments_with_llm if chat_client is not None else None
                 self._resolve_arguments_with_llm if chat_client is not None else None
             ),
             ),
+            monotonic_clock=self._clock,
         )
         )
         self.executor = EventBatchExecutor(
         self.executor = EventBatchExecutor(
             self.kernel,
             self.kernel,
@@ -76,6 +80,7 @@ class EventAgent:
             ),
             ),
         )
         )
         self._raw_chunks_by_event: dict[str, list[dict[str, Any]]] = {}
         self._raw_chunks_by_event: dict[str, list[dict[str, Any]]] = {}
+        self._fallback_calls_by_event: dict[str, list[ModelCallMetric]] = {}
 
 
     async def handle(
     async def handle(
         self,
         self,
@@ -103,6 +108,7 @@ class EventAgent:
     ) -> EventBatchResult:
     ) -> EventBatchResult:
         for event in events:
         for event in events:
             self._raw_chunks_by_event[event.id] = []
             self._raw_chunks_by_event[event.id] = []
+            self._fallback_calls_by_event[event.id] = []
         resolved_scope = scope if scope is not None else object()
         resolved_scope = scope if scope is not None else object()
         try:
         try:
             return await self.executor.execute(
             return await self.executor.execute(
@@ -134,6 +140,16 @@ class EventAgent:
             for event in events
             for event in events
         ]
         ]
 
 
+    def fallback_model_calls(
+        self,
+        events: Sequence[ToolCallEvent],
+    ) -> list[dict[str, Any]]:
+        return [
+            metric.as_dict()
+            for event in events
+            for metric in self._fallback_calls_by_event.get(event.id, [])
+        ]
+
     async def _resolve_arguments_with_llm(
     async def _resolve_arguments_with_llm(
         self,
         self,
         definition: EventDefinition,
         definition: EventDefinition,
@@ -161,29 +177,65 @@ class EventAgent:
             }
             }
         )
         )
         raw_chunks: list[dict[str, Any]] = []
         raw_chunks: list[dict[str, Any]] = []
-        async for item in self.chat_client.stream_chat(
-            messages=messages,
-            tools=[tool],
-            params=params,
-            tool_choice={
-                "type": "function",
-                "function": {"name": event.name},
-            },
-        ):
-            if item.kind == "raw_chunk" and item.raw_chunk is not None:
-                raw_chunks.append(item.raw_chunk)
-                continue
-
-            if item.kind == "provider_tool_call" and item.event is not None:
-                self._raw_chunks_by_event[event.id] = raw_chunks
-                return ResolvedEventArguments(
-                    event_name=item.event.name,
-                    arguments=dict(item.event.arguments),
-                    raw_arguments=item.event.raw_arguments,
+        usage = TokenUsage()
+        resolved: ResolvedEventArguments | None = None
+        started_at = self._clock()
+        first_item_at: float | None = None
+        try:
+            async for item in self.chat_client.stream_chat(
+                messages=messages,
+                tools=[tool],
+                params=params,
+                tool_choice={
+                    "type": "function",
+                    "function": {"name": event.name},
+                },
+            ):
+                if first_item_at is None:
+                    first_item_at = self._clock()
+                if item.kind == "raw_chunk" and item.raw_chunk is not None:
+                    raw_chunks.append(item.raw_chunk)
+                    continue
+                if item.kind == "usage" and item.usage is not None:
+                    usage = item.usage
+                    continue
+                if (
+                    resolved is None
+                    and item.kind == "provider_tool_call"
+                    and item.event is not None
+                ):
+                    resolved = ResolvedEventArguments(
+                        event_name=item.event.name,
+                        arguments=dict(item.event.arguments),
+                        raw_arguments=item.event.raw_arguments,
+                    )
+        finally:
+            self._raw_chunks_by_event[event.id] = raw_chunks
+            self._fallback_calls_by_event.setdefault(event.id, []).append(
+                ModelCallMetric(
+                    event_id=event.id,
+                    event_name=event.name,
+                    usage=usage,
+                    ttft_ms=(
+                        None
+                        if first_item_at is None
+                        else self._elapsed_ms(started_at, ended_at=first_item_at)
+                    ),
+                    elapsed_ms=self._elapsed_ms(started_at),
                 )
                 )
+            )
+        return resolved
 
 
-        self._raw_chunks_by_event[event.id] = raw_chunks
-        return None
+    def _elapsed_ms(
+        self,
+        started_at: float,
+        *,
+        ended_at: float | None = None,
+    ) -> int:
+        return max(
+            0,
+            round(((self._clock() if ended_at is None else ended_at) - started_at) * 1000),
+        )
 
 
     def _build_argument_messages(
     def _build_argument_messages(
         self,
         self,

+ 23 - 0
src/agent_lab/application/events/kernel.py

@@ -3,6 +3,7 @@ from __future__ import annotations
 import asyncio
 import asyncio
 import inspect
 import inspect
 import json
 import json
+import time
 from collections.abc import Awaitable, Callable, Iterable
 from collections.abc import Awaitable, Callable, Iterable
 from dataclasses import dataclass, replace
 from dataclasses import dataclass, replace
 from typing import Any
 from typing import Any
@@ -39,9 +40,11 @@ class EventKernel:
         self,
         self,
         registry: EventRegistry,
         registry: EventRegistry,
         argument_fallback: ArgumentFallback | None = None,
         argument_fallback: ArgumentFallback | None = None,
+        monotonic_clock: Callable[[], float] | None = None,
     ) -> None:
     ) -> None:
         self.registry = registry
         self.registry = registry
         self.argument_fallback = argument_fallback
         self.argument_fallback = argument_fallback
+        self._clock = monotonic_clock or time.monotonic
 
 
     async def execute(
     async def execute(
         self,
         self,
@@ -152,6 +155,7 @@ class EventKernel:
             arguments=self._strict_json_copy(resolved.arguments),
             arguments=self._strict_json_copy(resolved.arguments),
             raw_arguments=resolved.raw_arguments,
             raw_arguments=resolved.raw_arguments,
         )
         )
+        handler_started_at = self._clock()
         try:
         try:
             if inspect.iscoroutinefunction(definition.handler):
             if inspect.iscoroutinefunction(definition.handler):
                 payload = definition.handler(resolved_request)
                 payload = definition.handler(resolved_request)
@@ -163,6 +167,7 @@ class EventKernel:
             if inspect.isawaitable(payload):
             if inspect.isawaitable(payload):
                 payload = await payload
                 payload = await payload
         except Exception as exc:
         except Exception as exc:
+            tool_latency_ms = self._elapsed_ms(handler_started_at)
             return self._result(
             return self._result(
                 definition,
                 definition,
                 request,
                 request,
@@ -170,7 +175,9 @@ class EventKernel:
                 resolved=resolved,
                 resolved=resolved,
                 error=f"event handler failed: {exc}",
                 error=f"event handler failed: {exc}",
                 used_fallback=used_fallback,
                 used_fallback=used_fallback,
+                tool_latency_ms=tool_latency_ms,
             )
             )
+        tool_latency_ms = self._elapsed_ms(handler_started_at)
         if not isinstance(payload, dict):
         if not isinstance(payload, dict):
             return self._result(
             return self._result(
                 definition,
                 definition,
@@ -179,6 +186,7 @@ class EventKernel:
                 resolved=resolved,
                 resolved=resolved,
                 error="event handler returned non-object payload",
                 error="event handler returned non-object payload",
                 used_fallback=used_fallback,
                 used_fallback=used_fallback,
+                tool_latency_ms=tool_latency_ms,
             )
             )
         try:
         try:
             payload = self._strict_json_copy(payload)
             payload = self._strict_json_copy(payload)
@@ -190,6 +198,7 @@ class EventKernel:
                 resolved=resolved,
                 resolved=resolved,
                 error="event handler returned non-JSON payload",
                 error="event handler returned non-JSON payload",
                 used_fallback=used_fallback,
                 used_fallback=used_fallback,
+                tool_latency_ms=tool_latency_ms,
             )
             )
         return self._result(
         return self._result(
             definition,
             definition,
@@ -198,6 +207,7 @@ class EventKernel:
             resolved=resolved,
             resolved=resolved,
             payload=dict(payload),
             payload=dict(payload),
             used_fallback=used_fallback,
             used_fallback=used_fallback,
+            tool_latency_ms=tool_latency_ms,
         )
         )
 
 
     def execute_sync(
     def execute_sync(
@@ -254,16 +264,20 @@ class EventKernel:
             arguments=self._strict_json_copy(resolved.arguments),
             arguments=self._strict_json_copy(resolved.arguments),
             raw_arguments=resolved.raw_arguments,
             raw_arguments=resolved.raw_arguments,
         )
         )
+        handler_started_at = self._clock()
         try:
         try:
             payload = definition.handler(resolved_request)
             payload = definition.handler(resolved_request)
         except Exception as exc:
         except Exception as exc:
+            tool_latency_ms = self._elapsed_ms(handler_started_at)
             return self._result(
             return self._result(
                 definition,
                 definition,
                 request,
                 request,
                 EventStatus.HANDLER_ERROR,
                 EventStatus.HANDLER_ERROR,
                 resolved=resolved,
                 resolved=resolved,
                 error=f"event handler failed: {exc}",
                 error=f"event handler failed: {exc}",
+                tool_latency_ms=tool_latency_ms,
             )
             )
+        tool_latency_ms = self._elapsed_ms(handler_started_at)
         if inspect.isawaitable(payload):
         if inspect.isawaitable(payload):
             if inspect.iscoroutine(payload):
             if inspect.iscoroutine(payload):
                 payload.close()
                 payload.close()
@@ -273,6 +287,7 @@ class EventKernel:
                 EventStatus.HANDLER_ERROR,
                 EventStatus.HANDLER_ERROR,
                 resolved=resolved,
                 resolved=resolved,
                 error="event handler failed: async event handlers require EventKernel.execute",
                 error="event handler failed: async event handlers require EventKernel.execute",
+                tool_latency_ms=tool_latency_ms,
             )
             )
         if not isinstance(payload, dict):
         if not isinstance(payload, dict):
             return self._result(
             return self._result(
@@ -281,6 +296,7 @@ class EventKernel:
                 EventStatus.HANDLER_ERROR,
                 EventStatus.HANDLER_ERROR,
                 resolved=resolved,
                 resolved=resolved,
                 error="event handler returned non-object payload",
                 error="event handler returned non-object payload",
+                tool_latency_ms=tool_latency_ms,
             )
             )
         try:
         try:
             payload = self._strict_json_copy(payload)
             payload = self._strict_json_copy(payload)
@@ -291,6 +307,7 @@ class EventKernel:
                 EventStatus.HANDLER_ERROR,
                 EventStatus.HANDLER_ERROR,
                 resolved=resolved,
                 resolved=resolved,
                 error="event handler returned non-JSON payload",
                 error="event handler returned non-JSON payload",
+                tool_latency_ms=tool_latency_ms,
             )
             )
         return self._result(
         return self._result(
             definition,
             definition,
@@ -298,6 +315,7 @@ class EventKernel:
             EventStatus.SUCCESS,
             EventStatus.SUCCESS,
             resolved=resolved,
             resolved=resolved,
             payload=dict(payload),
             payload=dict(payload),
+            tool_latency_ms=tool_latency_ms,
         )
         )
 
 
     def _lookup(
     def _lookup(
@@ -607,6 +625,7 @@ class EventKernel:
         payload: dict[str, Any] | None = None,
         payload: dict[str, Any] | None = None,
         error: str | None = None,
         error: str | None = None,
         used_fallback: bool = False,
         used_fallback: bool = False,
+        tool_latency_ms: int | None = None,
     ) -> EventResult:
     ) -> EventResult:
         return EventResult(
         return EventResult(
             event_id=request.id,
             event_id=request.id,
@@ -618,6 +637,7 @@ class EventKernel:
             payload=self._safe_json_object_snapshot(payload or {}),
             payload=self._safe_json_object_snapshot(payload or {}),
             error=error,
             error=error,
             used_fallback=used_fallback,
             used_fallback=used_fallback,
+            tool_latency_ms=tool_latency_ms,
             result_policy=definition.result_policy,
             result_policy=definition.result_policy,
             confirmation_policy=definition.confirmation_policy,
             confirmation_policy=definition.confirmation_policy,
             risk_level=definition.risk_level,
             risk_level=definition.risk_level,
@@ -628,6 +648,9 @@ class EventKernel:
             terminal=definition.terminal,
             terminal=definition.terminal,
         )
         )
 
 
+    def _elapsed_ms(self, started_at: float) -> int:
+        return max(0, round((self._clock() - started_at) * 1000))
+
     def _json_arguments(self, arguments: dict[str, Any]) -> str:
     def _json_arguments(self, arguments: dict[str, Any]) -> str:
         return json.dumps(
         return json.dumps(
             arguments,
             arguments,

+ 1 - 0
src/agent_lab/application/events/models.py

@@ -113,6 +113,7 @@ class EventResult:
     payload: dict[str, Any] = field(default_factory=dict)
     payload: dict[str, Any] = field(default_factory=dict)
     error: str | None = None
     error: str | None = None
     used_fallback: bool = False
     used_fallback: bool = False
+    tool_latency_ms: int | None = None
     result_policy: ResultPolicy = ResultPolicy.LLM_FOLLOW_UP
     result_policy: ResultPolicy = ResultPolicy.LLM_FOLLOW_UP
     confirmation_policy: ConfirmationPolicy = ConfirmationPolicy.NONE
     confirmation_policy: ConfirmationPolicy = ConfirmationPolicy.NONE
     risk_level: RiskLevel = RiskLevel.LOW
     risk_level: RiskLevel = RiskLevel.LOW

+ 93 - 6
src/agent_lab/application/runtime.py

@@ -138,6 +138,7 @@ class DebugRuntime:
             registry=self.registry,
             registry=self.registry,
             chat_client=self.chat_client,
             chat_client=self.chat_client,
             params=request.event_agent,
             params=request.event_agent,
+            monotonic_clock=self.clock,
         )
         )
         return asyncio.create_task(self._consume_events(queues, event_agent))
         return asyncio.create_task(self._consume_events(queues, event_agent))
 
 
@@ -520,7 +521,8 @@ class DebugRuntime:
         session_id: str | None = None,
         session_id: str | None = None,
         turn_started_at: float | None = None,
         turn_started_at: float | None = None,
     ) -> bool:
     ) -> bool:
-        turn_started_at = turn_started_at or self.clock()
+        if turn_started_at is None:
+            turn_started_at = self.clock()
         messages.append(user_message)
         messages.append(user_message)
         self._start_persisted_turn(session_id, turn_index, user_message)
         self._start_persisted_turn(session_id, turn_index, user_message)
         await queues.output.put({"type": "turn_started", "turn_index": turn_index})
         await queues.output.put({"type": "turn_started", "turn_index": turn_index})
@@ -546,6 +548,7 @@ class DebugRuntime:
             round_index += 1
             round_index += 1
             started_at = self.clock()
             started_at = self.clock()
             ttft_ms: int | None = None
             ttft_ms: int | None = None
+            provider_ttft_ms: int | None = None
             prompt_tokens = 0
             prompt_tokens = 0
             completion_tokens = 0
             completion_tokens = 0
             total_tokens = 0
             total_tokens = 0
@@ -603,13 +606,20 @@ class DebugRuntime:
                 tools=chat_tools,
                 tools=chat_tools,
                 params=request.chat_agent,
                 params=request.chat_agent,
             ):
             ):
+                first_provider_item = provider_ttft_ms is None
+                if first_provider_item:
+                    provider_ttft_ms = self._elapsed_ms(started_at)
                 if item.kind == "raw_chunk" and item.raw_chunk is not None:
                 if item.kind == "raw_chunk" and item.raw_chunk is not None:
                     raw_chunks.append(item.raw_chunk)
                     raw_chunks.append(item.raw_chunk)
                     continue
                     continue
 
 
                 if item.kind == "message_delta":
                 if item.kind == "message_delta":
                     if ttft_ms is None:
                     if ttft_ms is None:
-                        ttft_ms = self._elapsed_ms(started_at)
+                        ttft_ms = (
+                            provider_ttft_ms
+                            if first_provider_item
+                            else self._elapsed_ms(started_at)
+                        )
                     if not message_stream_started:
                     if not message_stream_started:
                         message_stream_started = True
                         message_stream_started = True
                         await self._audit(
                         await self._audit(
@@ -787,6 +797,12 @@ class DebugRuntime:
                         {"type": "tool_result", "message": reply.model_dump()}
                         {"type": "tool_result", "message": reply.model_dump()}
                     )
                     )
                 assert batch_result is not None
                 assert batch_result is not None
+                self._append_persisted_tool_metrics(
+                    session_id,
+                    turn_index,
+                    round_index,
+                    batch_result,
+                )
                 logical_result_count = len(
                 logical_result_count = len(
                     self._logical_batch_results(batch_result)
                     self._logical_batch_results(batch_result)
                 )
                 )
@@ -853,8 +869,13 @@ class DebugRuntime:
                     total_tokens=total_tokens,
                     total_tokens=total_tokens,
                     cached_tokens=cached_tokens,
                     cached_tokens=cached_tokens,
                 ),
                 ),
-                ttft_ms=ttft_ms,
+                ttft_ms=provider_ttft_ms,
                 elapsed_ms=elapsed_ms,
                 elapsed_ms=elapsed_ms,
+                metadata={
+                    "agent": "chat_agent",
+                    "call_kind": "chat_completion",
+                    "metric_key": f"chat:{turn_index}:{round_index}",
+                },
             )
             )
             await queues.output.put(
             await queues.output.put(
                 {
                 {
@@ -894,6 +915,7 @@ class DebugRuntime:
 
 
         for deferred_message in deferred_user_messages:
         for deferred_message in deferred_user_messages:
             await queues.input.put(deferred_message)
             await queues.input.put(deferred_message)
+        turn_wall_time_ms = self._elapsed_ms(turn_started_at)
         await self._audit(
         await self._audit(
             queues,
             queues,
             "turn_completed",
             "turn_completed",
@@ -901,8 +923,13 @@ class DebugRuntime:
             session_id=session_id,
             session_id=session_id,
             turn_index=turn_index,
             turn_index=turn_index,
             round_count=round_index,
             round_count=round_index,
+            wall_time_ms=turn_wall_time_ms,
+        )
+        self._complete_persisted_turn(
+            session_id,
+            turn_index,
+            wall_time_ms=turn_wall_time_ms,
         )
         )
-        self._complete_persisted_turn(session_id, turn_index)
         await queues.output.put(
         await queues.output.put(
             {
             {
                 "type": "turn_completed",
                 "type": "turn_completed",
@@ -951,6 +978,26 @@ class DebugRuntime:
                     extra_body=request.extra_body,
                     extra_body=request.extra_body,
                     scope=request.scope,
                     scope=request.scope,
                 )
                 )
+                for metric in event_agent.fallback_model_calls(request.events):
+                    self._append_persisted_usage(
+                        request.session_id,
+                        request.turn_index or 0,
+                        request.round_index or 0,
+                        usage=metric["usage"],
+                        ttft_ms=metric["ttft_ms"],
+                        elapsed_ms=metric["elapsed_ms"],
+                        metadata={
+                            "agent": "event_agent",
+                            "call_kind": "argument_fallback",
+                            "event_id": metric["event_id"],
+                            "event_name": metric["event_name"],
+                            "used_fallback": True,
+                            "metric_key": (
+                                f"fallback:{request.turn_index}:"
+                                f"{request.round_index}:{metric['event_id']}"
+                            ),
+                        },
+                    )
                 replies = event_agent.registry.tool_replies(request.events, batch)
                 replies = event_agent.registry.tool_replies(request.events, batch)
                 await self._audit(
                 await self._audit(
                     queues,
                     queues,
@@ -1092,7 +1139,10 @@ class DebugRuntime:
         executor = self._batch_executors.get(key)
         executor = self._batch_executors.get(key)
         if executor is None:
         if executor is None:
             executor = EventBatchExecutor(
             executor = EventBatchExecutor(
-                EventKernel(self.registry.event_registry),
+                EventKernel(
+                    self.registry.event_registry,
+                    monotonic_clock=self.clock,
+                ),
                 max_parallel_events=params.max_parallel_events,
                 max_parallel_events=params.max_parallel_events,
                 batch_timeout_seconds=params.batch_timeout_seconds,
                 batch_timeout_seconds=params.batch_timeout_seconds,
             )
             )
@@ -1353,10 +1403,16 @@ class DebugRuntime:
         self,
         self,
         session_id: str | None,
         session_id: str | None,
         turn_index: int,
         turn_index: int,
+        *,
+        wall_time_ms: int | None = None,
     ) -> None:
     ) -> None:
         if self.session_store is None or session_id is None:
         if self.session_store is None or session_id is None:
             return
             return
-        self.session_store.complete_turn(session_id, turn_index=turn_index)
+        self.session_store.complete_turn(
+            session_id,
+            turn_index=turn_index,
+            wall_time_ms=wall_time_ms,
+        )
 
 
     def _append_persisted_message(
     def _append_persisted_message(
         self,
         self,
@@ -1395,6 +1451,7 @@ class DebugRuntime:
         usage: TokenUsage,
         usage: TokenUsage,
         ttft_ms: int | None,
         ttft_ms: int | None,
         elapsed_ms: int,
         elapsed_ms: int,
+        metadata: dict[str, Any] | None = None,
     ) -> None:
     ) -> None:
         if self.session_store is None or session_id is None:
         if self.session_store is None or session_id is None:
             return
             return
@@ -1405,8 +1462,38 @@ class DebugRuntime:
             usage=usage,
             usage=usage,
             ttft_ms=ttft_ms,
             ttft_ms=ttft_ms,
             elapsed_ms=elapsed_ms,
             elapsed_ms=elapsed_ms,
+            metadata=metadata,
         )
         )
 
 
+    def _append_persisted_tool_metrics(
+        self,
+        session_id: str | None,
+        turn_index: int,
+        round_index: int,
+        batch: EventBatchResult,
+    ) -> None:
+        for result in self._logical_batch_results(batch):
+            tool_latency_ms = result.tool_latency_ms or 0
+            self._append_persisted_usage(
+                session_id,
+                turn_index,
+                round_index,
+                usage=TokenUsage(),
+                ttft_ms=None,
+                elapsed_ms=tool_latency_ms,
+                metadata={
+                    "agent": "event_kernel",
+                    "call_kind": "tool_execution",
+                    "event_id": result.event_id,
+                    "event_name": result.event_name,
+                    "used_fallback": result.used_fallback,
+                    "tool_latency_ms": result.tool_latency_ms,
+                    "metric_key": (
+                        f"tool:{turn_index}:{round_index}:{result.event_id}"
+                    ),
+                },
+            )
+
     def _session_title(self, user_message: str) -> str:
     def _session_title(self, user_message: str) -> str:
         title = " ".join(user_message.split())
         title = " ".join(user_message.split())
         if not title:
         if not title:

+ 8 - 1
src/agent_lab/application/session_store.py

@@ -40,7 +40,13 @@ class SessionStore(Protocol):
     ) -> None:
     ) -> None:
         ...
         ...
 
 
-    def complete_turn(self, session_id: str, *, turn_index: int) -> None:
+    def complete_turn(
+        self,
+        session_id: str,
+        *,
+        turn_index: int,
+        wall_time_ms: int | None = None,
+    ) -> None:
         ...
         ...
 
 
     def append_message(
     def append_message(
@@ -81,6 +87,7 @@ class SessionStore(Protocol):
         usage: TokenUsage,
         usage: TokenUsage,
         ttft_ms: int | None,
         ttft_ms: int | None,
         elapsed_ms: int,
         elapsed_ms: int,
+        metadata: dict[str, Any] | None = None,
     ) -> None:
     ) -> None:
         ...
         ...
 
 

+ 18 - 0
src/agent_lab/domain/messages.py

@@ -43,6 +43,24 @@ class TokenUsage(BaseModel):
     cached_tokens: int = 0
     cached_tokens: int = 0
 
 
 
 
+@dataclass(frozen=True)
+class ModelCallMetric:
+    event_id: str
+    event_name: str
+    usage: TokenUsage
+    ttft_ms: int | None
+    elapsed_ms: int
+
+    def as_dict(self) -> dict[str, Any]:
+        return {
+            "event_id": self.event_id,
+            "event_name": self.event_name,
+            "usage": self.usage,
+            "ttft_ms": self.ttft_ms,
+            "elapsed_ms": self.elapsed_ms,
+        }
+
+
 @dataclass
 @dataclass
 class StreamItem:
 class StreamItem:
     kind: str
     kind: str

+ 158 - 28
src/agent_lab/infrastructure/sqlite_store.py

@@ -159,16 +159,22 @@ class SQLiteSessionStore:
             self._touch_session_locked(session_id, now)
             self._touch_session_locked(session_id, now)
             self._connect().commit()
             self._connect().commit()
 
 
-    def complete_turn(self, session_id: str, *, turn_index: int) -> None:
+    def complete_turn(
+        self,
+        session_id: str,
+        *,
+        turn_index: int,
+        wall_time_ms: int | None = None,
+    ) -> None:
         now = self._now()
         now = self._now()
         with self._lock:
         with self._lock:
             self._connect().execute(
             self._connect().execute(
                 """
                 """
                 UPDATE turns
                 UPDATE turns
-                SET completed_at = ?
+                SET completed_at = ?, wall_time_ms = ?
                 WHERE session_id = ? AND turn_index = ?
                 WHERE session_id = ? AND turn_index = ?
                 """,
                 """,
-                (now, session_id, turn_index),
+                (now, wall_time_ms, session_id, turn_index),
             )
             )
             self._touch_session_locked(session_id, now)
             self._touch_session_locked(session_id, now)
             self._connect().commit()
             self._connect().commit()
@@ -274,29 +280,42 @@ class SQLiteSessionStore:
         usage: TokenUsage,
         usage: TokenUsage,
         ttft_ms: int | None,
         ttft_ms: int | None,
         elapsed_ms: int,
         elapsed_ms: int,
+        metadata: dict[str, Any] | None = None,
     ) -> None:
     ) -> None:
+        metadata = metadata or {}
+        call_kind = metadata.get("call_kind", "chat_completion")
+        persisted_usage = TokenUsage() if call_kind == "tool_execution" else usage
         now = self._now()
         now = self._now()
         with self._lock:
         with self._lock:
             self._connect().execute(
             self._connect().execute(
                 """
                 """
-                INSERT INTO usage_stats
+                INSERT OR IGNORE INTO usage_stats
                     (
                     (
                         session_id, turn_index, round_index, prompt_tokens,
                         session_id, turn_index, round_index, prompt_tokens,
                         completion_tokens, total_tokens, cached_tokens,
                         completion_tokens, total_tokens, cached_tokens,
-                        ttft_ms, elapsed_ms, created_at
+                        ttft_ms, elapsed_ms, agent, call_kind, event_id,
+                        event_name, used_fallback, tool_latency_ms,
+                        metric_key, created_at
                     )
                     )
-                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                 """,
                 """,
                 (
                 (
                     session_id,
                     session_id,
                     turn_index,
                     turn_index,
                     round_index,
                     round_index,
-                    usage.prompt_tokens,
-                    usage.completion_tokens,
-                    usage.total_tokens,
-                    usage.cached_tokens,
+                    persisted_usage.prompt_tokens,
+                    persisted_usage.completion_tokens,
+                    persisted_usage.total_tokens,
+                    persisted_usage.cached_tokens,
                     ttft_ms,
                     ttft_ms,
                     elapsed_ms,
                     elapsed_ms,
+                    metadata.get("agent", "chat_agent"),
+                    call_kind,
+                    metadata.get("event_id"),
+                    metadata.get("event_name"),
+                    bool(metadata.get("used_fallback", False)),
+                    metadata.get("tool_latency_ms"),
+                    metadata.get("metric_key"),
                     now,
                     now,
                 ),
                 ),
             )
             )
@@ -352,31 +371,52 @@ class SQLiteSessionStore:
         ]
         ]
 
 
     def usage_summary(self, session_id: str) -> dict[str, Any]:
     def usage_summary(self, session_id: str) -> dict[str, Any]:
+        session_record = self.get_session(session_id)
+        mode = (
+            session_record["config"].get("tool_invocation_mode", "dual_agent")
+            if session_record is not None
+            else "dual_agent"
+        )
         call_rows = self._fetchall(
         call_rows = self._fetchall(
             """
             """
             SELECT
             SELECT
                 id, turn_index, round_index, prompt_tokens, completion_tokens,
                 id, turn_index, round_index, prompt_tokens, completion_tokens,
-                total_tokens, cached_tokens, ttft_ms, elapsed_ms, created_at
+                total_tokens, cached_tokens, ttft_ms, elapsed_ms, agent,
+                call_kind, event_id, event_name, used_fallback,
+                tool_latency_ms, metric_key, created_at
             FROM usage_stats
             FROM usage_stats
             WHERE session_id = ?
             WHERE session_id = ?
             ORDER BY id ASC
             ORDER BY id ASC
             """,
             """,
             (session_id,),
             (session_id,),
         )
         )
-        calls = [self._usage_row(row) for row in call_rows]
+        calls = [self._usage_row(row, mode=mode) for row in call_rows]
         turn_rows = self._fetchall(
         turn_rows = self._fetchall(
             """
             """
             SELECT
             SELECT
-                turn_index,
-                COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
-                COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
-                COALESCE(SUM(total_tokens), 0) AS total_tokens,
-                COALESCE(SUM(cached_tokens), 0) AS cached_tokens,
-                COALESCE(SUM(elapsed_ms), 0) AS elapsed_ms
-            FROM usage_stats
-            WHERE session_id = ?
-            GROUP BY turn_index
-            ORDER BY turn_index ASC
+                turns.turn_index,
+                COALESCE(SUM(CASE WHEN usage_stats.call_kind != 'tool_execution'
+                    THEN usage_stats.prompt_tokens ELSE 0 END), 0) AS prompt_tokens,
+                COALESCE(SUM(CASE WHEN usage_stats.call_kind != 'tool_execution'
+                    THEN usage_stats.completion_tokens ELSE 0 END), 0) AS completion_tokens,
+                COALESCE(SUM(CASE WHEN usage_stats.call_kind != 'tool_execution'
+                    THEN usage_stats.total_tokens ELSE 0 END), 0) AS total_tokens,
+                COALESCE(SUM(CASE WHEN usage_stats.call_kind != 'tool_execution'
+                    THEN usage_stats.cached_tokens ELSE 0 END), 0) AS cached_tokens,
+                COALESCE(SUM(usage_stats.elapsed_ms), 0) AS elapsed_ms,
+                COUNT(usage_stats.id) AS call_count,
+                COALESCE(SUM(CASE WHEN usage_stats.call_kind = 'argument_fallback'
+                    THEN 1 ELSE 0 END), 0) AS fallback_count,
+                COALESCE(SUM(CASE WHEN usage_stats.call_kind = 'tool_execution'
+                    THEN 1 ELSE 0 END), 0) AS tool_count,
+                turns.wall_time_ms AS turn_wall_time_ms
+            FROM turns
+            JOIN usage_stats
+                ON usage_stats.session_id = turns.session_id
+                AND usage_stats.turn_index = turns.turn_index
+            WHERE turns.session_id = ?
+            GROUP BY turns.turn_index, turns.wall_time_ms
+            ORDER BY turns.turn_index ASC
             """,
             """,
             (session_id,),
             (session_id,),
         )
         )
@@ -388,22 +428,43 @@ class SQLiteSessionStore:
                 "total_tokens": row["total_tokens"],
                 "total_tokens": row["total_tokens"],
                 "cached_tokens": row["cached_tokens"],
                 "cached_tokens": row["cached_tokens"],
                 "elapsed_ms": row["elapsed_ms"],
                 "elapsed_ms": row["elapsed_ms"],
+                "call_count": row["call_count"],
+                "fallback_count": row["fallback_count"],
+                "tool_count": row["tool_count"],
+                "turn_wall_time_ms": row["turn_wall_time_ms"],
             }
             }
             for row in turn_rows
             for row in turn_rows
         ]
         ]
         session = self._fetchone(
         session = self._fetchone(
             """
             """
             SELECT
             SELECT
-                COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
-                COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
-                COALESCE(SUM(total_tokens), 0) AS total_tokens,
-                COALESCE(SUM(cached_tokens), 0) AS cached_tokens,
-                COALESCE(SUM(elapsed_ms), 0) AS elapsed_ms
+                COALESCE(SUM(CASE WHEN call_kind != 'tool_execution'
+                    THEN prompt_tokens ELSE 0 END), 0) AS prompt_tokens,
+                COALESCE(SUM(CASE WHEN call_kind != 'tool_execution'
+                    THEN completion_tokens ELSE 0 END), 0) AS completion_tokens,
+                COALESCE(SUM(CASE WHEN call_kind != 'tool_execution'
+                    THEN total_tokens ELSE 0 END), 0) AS total_tokens,
+                COALESCE(SUM(CASE WHEN call_kind != 'tool_execution'
+                    THEN cached_tokens ELSE 0 END), 0) AS cached_tokens,
+                COALESCE(SUM(elapsed_ms), 0) AS elapsed_ms,
+                COUNT(*) AS call_count,
+                COALESCE(SUM(CASE WHEN call_kind = 'argument_fallback'
+                    THEN 1 ELSE 0 END), 0) AS fallback_count,
+                COALESCE(SUM(CASE WHEN call_kind = 'tool_execution'
+                    THEN 1 ELSE 0 END), 0) AS tool_count
             FROM usage_stats
             FROM usage_stats
             WHERE session_id = ?
             WHERE session_id = ?
             """,
             """,
             (session_id,),
             (session_id,),
         )
         )
+        wall_time = self._fetchone(
+            """
+            SELECT SUM(wall_time_ms) AS turn_wall_time_ms
+            FROM turns
+            WHERE session_id = ?
+            """,
+            (session_id,),
+        )
         return {
         return {
             "calls": calls,
             "calls": calls,
             "turns": turns,
             "turns": turns,
@@ -413,10 +474,14 @@ class SQLiteSessionStore:
                 "total_tokens": session["total_tokens"],
                 "total_tokens": session["total_tokens"],
                 "cached_tokens": session["cached_tokens"],
                 "cached_tokens": session["cached_tokens"],
                 "elapsed_ms": session["elapsed_ms"],
                 "elapsed_ms": session["elapsed_ms"],
+                "call_count": session["call_count"],
+                "fallback_count": session["fallback_count"],
+                "tool_count": session["tool_count"],
+                "turn_wall_time_ms": wall_time["turn_wall_time_ms"],
             },
             },
         }
         }
 
 
-    def _usage_row(self, row: sqlite3.Row) -> dict[str, Any]:
+    def _usage_row(self, row: sqlite3.Row, *, mode: str) -> dict[str, Any]:
         return {
         return {
             "id": row["id"],
             "id": row["id"],
             "turn_index": row["turn_index"],
             "turn_index": row["turn_index"],
@@ -427,6 +492,14 @@ class SQLiteSessionStore:
             "cached_tokens": row["cached_tokens"],
             "cached_tokens": row["cached_tokens"],
             "ttft_ms": row["ttft_ms"],
             "ttft_ms": row["ttft_ms"],
             "elapsed_ms": row["elapsed_ms"],
             "elapsed_ms": row["elapsed_ms"],
+            "mode": mode,
+            "agent": row["agent"],
+            "call_kind": row["call_kind"],
+            "event_id": row["event_id"],
+            "event_name": row["event_name"],
+            "used_fallback": bool(row["used_fallback"]),
+            "tool_latency_ms": row["tool_latency_ms"],
+            "metric_key": row["metric_key"],
             "created_at": row["created_at"],
             "created_at": row["created_at"],
         }
         }
 
 
@@ -476,6 +549,7 @@ class SQLiteSessionStore:
                 user_message TEXT NOT NULL,
                 user_message TEXT NOT NULL,
                 started_at TEXT NOT NULL,
                 started_at TEXT NOT NULL,
                 completed_at TEXT,
                 completed_at TEXT,
+                wall_time_ms INTEGER,
                 PRIMARY KEY (session_id, turn_index)
                 PRIMARY KEY (session_id, turn_index)
             );
             );
 
 
@@ -512,6 +586,13 @@ class SQLiteSessionStore:
                 cached_tokens INTEGER NOT NULL,
                 cached_tokens INTEGER NOT NULL,
                 ttft_ms INTEGER,
                 ttft_ms INTEGER,
                 elapsed_ms INTEGER NOT NULL,
                 elapsed_ms INTEGER NOT NULL,
+                agent TEXT NOT NULL DEFAULT 'chat_agent',
+                call_kind TEXT NOT NULL DEFAULT 'chat_completion',
+                event_id TEXT,
+                event_name TEXT,
+                used_fallback INTEGER NOT NULL DEFAULT 0,
+                tool_latency_ms INTEGER,
+                metric_key TEXT,
                 created_at TEXT NOT NULL
                 created_at TEXT NOT NULL
             );
             );
             """
             """
@@ -532,6 +613,35 @@ class SQLiteSessionStore:
                         or "tool_calls_json" not in self._message_columns()
                         or "tool_calls_json" not in self._message_columns()
                     ):
                     ):
                         raise
                         raise
+            self._add_column_if_missing(
+                "turns",
+                "wall_time_ms",
+                "INTEGER",
+            )
+            for column_name, declaration in (
+                ("agent", "TEXT NOT NULL DEFAULT 'chat_agent'"),
+                ("call_kind", "TEXT NOT NULL DEFAULT 'chat_completion'"),
+                ("event_id", "TEXT"),
+                ("event_name", "TEXT"),
+                ("used_fallback", "INTEGER NOT NULL DEFAULT 0"),
+                ("tool_latency_ms", "INTEGER"),
+                ("metric_key", "TEXT"),
+            ):
+                self._add_column_if_missing(
+                    "usage_stats",
+                    column_name,
+                    declaration,
+                )
+            self._connection.execute(
+                "DROP INDEX IF EXISTS usage_stats_metric_key_uq"
+            )
+            self._connection.execute(
+                """
+                CREATE UNIQUE INDEX IF NOT EXISTS usage_stats_metric_key_uq
+                ON usage_stats(session_id, metric_key)
+                WHERE metric_key IS NOT NULL
+                """
+            )
             self._connection.commit()
             self._connection.commit()
         except BaseException:
         except BaseException:
             self._connection.rollback()
             self._connection.rollback()
@@ -544,6 +654,26 @@ class SQLiteSessionStore:
             for row in self._connection.execute("PRAGMA table_info(messages)")
             for row in self._connection.execute("PRAGMA table_info(messages)")
         }
         }
 
 
+    def _table_columns(self, table_name: str) -> set[str]:
+        assert self._connection is not None
+        return {
+            row[1]
+            for row in self._connection.execute(f"PRAGMA table_info({table_name})")
+        }
+
+    def _add_column_if_missing(
+        self,
+        table_name: str,
+        column_name: str,
+        declaration: str,
+    ) -> None:
+        assert self._connection is not None
+        if column_name in self._table_columns(table_name):
+            return
+        self._connection.execute(
+            f"ALTER TABLE {table_name} ADD COLUMN {column_name} {declaration}"
+        )
+
     def _touch_session_locked(self, session_id: str, now: str) -> None:
     def _touch_session_locked(self, session_id: str, now: str) -> None:
         self._connect().execute(
         self._connect().execute(
             "UPDATE sessions SET updated_at = ? WHERE id = ?",
             "UPDATE sessions SET updated_at = ? WHERE id = ?",

+ 16 - 3
src/agent_lab/presentation/static/app.js

@@ -1499,12 +1499,16 @@ function formatTime(value) {
 
 
 function renderSessionUsage(usage) {
 function renderSessionUsage(usage) {
   const session = usage.session || {};
   const session = usage.session || {};
+  const sessionElapsed = session.turn_wall_time_ms ?? session.elapsed_ms ?? 0;
   sessionUsageSummary.textContent = "";
   sessionUsageSummary.textContent = "";
   [
   [
     ["Tokens", session.total_tokens || 0],
     ["Tokens", session.total_tokens || 0],
     ["Cached", session.cached_tokens || 0],
     ["Cached", session.cached_tokens || 0],
     ["Prompt", session.prompt_tokens || 0],
     ["Prompt", session.prompt_tokens || 0],
-    ["Elapsed", formatMs(session.elapsed_ms || 0)],
+    ["Calls", session.call_count || 0],
+    ["Fallbacks", session.fallback_count || 0],
+    ["Tools", session.tool_count || 0],
+    ["Elapsed", formatMs(sessionElapsed)],
   ].forEach(([label, value]) => {
   ].forEach(([label, value]) => {
     const item = document.createElement("div");
     const item = document.createElement("div");
     const labelEl = document.createElement("span");
     const labelEl = document.createElement("span");
@@ -1518,12 +1522,21 @@ function renderSessionUsage(usage) {
   renderUsageRows(
   renderUsageRows(
     sessionUsageTurns,
     sessionUsageTurns,
     usage.turns || [],
     usage.turns || [],
-    (turn) => `Turn ${turn.turn_index}: ${turn.total_tokens} tokens, ${turn.elapsed_ms}ms`,
+    (turn) => {
+      const elapsed = turn.turn_wall_time_ms ?? turn.elapsed_ms;
+      return `Turn ${turn.turn_index}: ${turn.total_tokens} tokens, ${formatMs(elapsed)}, ${turn.tool_count || 0} tools, ${turn.fallback_count || 0} fallbacks`;
+    },
   );
   );
   renderUsageRows(
   renderUsageRows(
     sessionUsageCalls,
     sessionUsageCalls,
     usage.calls || [],
     usage.calls || [],
-    (call) => `T${call.turn_index} R${call.round_index}: ${call.total_tokens} tokens, TTFT ${formatMs(call.ttft_ms)}, elapsed ${formatMs(call.elapsed_ms)}`,
+    (call) => {
+      const fallback = call.used_fallback ? " · fallback" : "";
+      const toolLatency = call.tool_latency_ms === null || call.tool_latency_ms === undefined
+        ? ""
+        : ` · tool ${formatMs(call.tool_latency_ms)}`;
+      return `T${call.turn_index} R${call.round_index} · ${call.mode} · ${call.agent}/${call.call_kind}${fallback}: ${call.total_tokens} tokens, TTFT ${formatMs(call.ttft_ms)}, elapsed ${formatMs(call.elapsed_ms)}${toolLatency}`;
+    },
   );
   );
 }
 }
 
 

+ 343 - 1
tests/test_debug_runtime.py

@@ -2,6 +2,7 @@ import asyncio
 import importlib
 import importlib
 import json
 import json
 import logging
 import logging
+from dataclasses import replace
 from collections.abc import AsyncIterator
 from collections.abc import AsyncIterator
 from typing import Any
 from typing import Any
 
 
@@ -9,7 +10,14 @@ import pytest
 
 
 from agent_lab.application.contracts import AgentParams, DebugRunRequest, EventAgentParams
 from agent_lab.application.contracts import AgentParams, DebugRunRequest, EventAgentParams
 from agent_lab.application.event_agent import EventAgentRequest
 from agent_lab.application.event_agent import EventAgentRequest
-from agent_lab.application.events import EventBatchExecutor, ResultPolicy
+from agent_lab.application.events import (
+    EventBatchExecutor,
+    EventBatchResult,
+    EventResult,
+    EventSource,
+    EventStatus,
+    ResultPolicy,
+)
 from agent_lab.application.runtime import DebugRuntime
 from agent_lab.application.runtime import DebugRuntime
 from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.domain.events import ToolCallEvent
 from agent_lab.domain.events import ToolCallEvent
@@ -1435,6 +1443,8 @@ async def test_runtime_emits_round_stats_for_each_chat_call_in_event_handoff():
             2.08,
             2.08,
             2.09,
             2.09,
             2.1,
             2.1,
+            2.11,
+            2.12,
             2.25,
             2.25,
             2.26,
             2.26,
             3.0,
             3.0,
@@ -2345,6 +2355,338 @@ def _tool_call(call_id: str, *, name: str = "safe_tool") -> ToolCallEvent:
     )
     )
 
 
 
 
+class ManualClock:
+    def __init__(self) -> None:
+        self.now = 0.0
+
+    def __call__(self) -> float:
+        return self.now
+
+
+class ProviderTimingChatClient:
+    def __init__(self, clock: ManualClock) -> None:
+        self.clock = clock
+
+    async def stream_chat(
+        self,
+        messages: list[ChatMessage],
+        tools: list[dict],
+        params: AgentParams,
+        tool_choice: dict[str, Any] | None = None,
+    ) -> AsyncIterator[StreamItem]:
+        self.clock.now = 0.010
+        yield StreamItem.raw_response_chunk({"provider": "first"})
+        self.clock.now = 0.050
+        yield StreamItem.message_delta("visible")
+        yield StreamItem.usage_item(
+            TokenUsage(prompt_tokens=2, completion_tokens=3, total_tokens=5)
+        )
+        self.clock.now = 0.060
+
+
+class ComparableMetricsChatClient:
+    def __init__(self) -> None:
+        self.chat_calls = 0
+        self.fallback_calls = 0
+
+    async def stream_chat(
+        self,
+        messages: list[ChatMessage],
+        tools: list[dict],
+        params: AgentParams,
+        tool_choice: dict[str, Any] | None = None,
+    ) -> AsyncIterator[StreamItem]:
+        if tool_choice is not None:
+            self.fallback_calls += 1
+            tool_name = tools[0]["function"]["name"]
+            yield StreamItem.raw_response_chunk({"fallback": "first"})
+            yield StreamItem.provider_tool_call(
+                ToolCallEvent(
+                    id="provider-fallback",
+                    name=tool_name,
+                    arguments={"query": "logical query"},
+                    raw_arguments='{"query":"logical query"}',
+                )
+            )
+            yield StreamItem.usage_item(
+                TokenUsage(prompt_tokens=3, completion_tokens=4, total_tokens=7)
+            )
+            return
+
+        self.chat_calls += 1
+        if self.chat_calls == 1:
+            event = ToolCallEvent(
+                id="event-1",
+                name="comparable.search",
+                arguments={"query": "logical query"},
+                raw_arguments='{"query":"logical query"}',
+            )
+            if tools:
+                yield StreamItem.provider_tool_call(event)
+            else:
+                yield StreamItem.text_event(event)
+            yield StreamItem.usage_item(
+                TokenUsage(prompt_tokens=2, completion_tokens=3, total_tokens=5)
+            )
+            return
+        yield StreamItem.message_delta("final")
+        yield StreamItem.usage_item(
+            TokenUsage(prompt_tokens=4, completion_tokens=6, total_tokens=10)
+        )
+
+
+async def _run_persisted_turn(
+    runtime: DebugRuntime,
+    request: DebugRunRequest,
+) -> list[dict[str, Any]]:
+    queues = runtime.start_session(request)
+    outputs: list[dict[str, Any]] = []
+    while not any(message["type"] == "turn_completed" for message in outputs):
+        outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
+    await runtime.aclose()
+    return outputs
+
+
+def _comparable_registry(*, fallback_required: bool) -> ToolRegistry:
+    return ToolRegistry(
+        [
+            ToolDefinition(
+                name="comparable.search",
+                description="Comparable search.",
+                parameters={
+                    "type": "object",
+                    "properties": {"query": {"type": "string"}},
+                    "required": ["query"],
+                },
+                handler=lambda event: {"query": event.arguments["query"]},
+                argument_resolver=(
+                    (lambda event, context: {})
+                    if fallback_required
+                    else (lambda event, context: {"query": "logical query"})
+                ),
+            )
+        ]
+    )
+
+
+def _comparable_request(*, mode: str, session_id: str) -> DebugRunRequest:
+    return DebugRunRequest(
+        session_id=session_id,
+        user_message="compare invocation",
+        system_prompts=[],
+        pre_messages=[],
+        chat_agent=AgentParams(model="chat-model"),
+        event_agent=EventAgentParams(
+            model="event-model",
+            enabled_tools=["comparable.search"],
+            max_event_loops=1,
+            max_parallel_events=2,
+        ),
+        tool_invocation_mode=mode,
+    )
+
+
+@pytest.mark.asyncio
+async def test_runtime_persists_provider_ttft_but_keeps_visible_round_ttft(tmp_path):
+    clock = ManualClock()
+    store = SQLiteSessionStore(tmp_path / "provider-ttft.sqlite3")
+    runtime = DebugRuntime(
+        ProviderTimingChatClient(clock),
+        session_store=store,
+        clock=clock,
+    )
+
+    outputs = await _run_persisted_turn(
+        runtime,
+        DebugRunRequest(
+            session_id="provider-ttft",
+            user_message="timing",
+            system_prompts=[],
+            pre_messages=[],
+            chat_agent=AgentParams(model="chat-model"),
+            event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
+        ),
+    )
+
+    round_stats = next(message for message in outputs if message["type"] == "round_stats")
+    usage = store.usage_summary("provider-ttft")
+    assert round_stats["ttft_ms"] == 50
+    assert usage["calls"][0]["ttft_ms"] == 10
+    assert usage["calls"][0]["metric_key"] == "chat:1:1"
+    assert usage["turns"][0]["turn_wall_time_ms"] == 60
+
+
+@pytest.mark.asyncio
+async def test_runtime_direct_and_dual_persist_comparable_logical_tool_metrics(tmp_path):
+    summaries: dict[str, dict[str, Any]] = {}
+    for mode in ("chat_agent_tools", "dual_agent"):
+        store = SQLiteSessionStore(tmp_path / f"{mode}.sqlite3")
+        client = ComparableMetricsChatClient()
+        runtime = DebugRuntime(
+            client,
+            registry=_comparable_registry(fallback_required=False),
+            session_store=store,
+        )
+        session_id = f"session-{mode}"
+
+        await _run_persisted_turn(
+            runtime,
+            _comparable_request(mode=mode, session_id=session_id),
+        )
+
+        summaries[mode] = store.usage_summary(session_id)
+        assert client.fallback_calls == 0
+
+    for mode, summary in summaries.items():
+        assert summary["session"]["total_tokens"] == 15
+        assert summary["session"]["tool_count"] == 1
+        assert summary["session"]["fallback_count"] == 0
+        assert {call["mode"] for call in summary["calls"]} == {mode}
+        assert {call["metric_key"] for call in summary["calls"]} == {
+            "chat:1:1",
+            "chat:1:2",
+            "tool:1:1:event-1",
+        }
+        assert not any(
+            call["call_kind"] == "argument_fallback" for call in summary["calls"]
+        )
+
+
+@pytest.mark.asyncio
+async def test_runtime_dual_persists_fallback_model_cost_once(tmp_path):
+    store = SQLiteSessionStore(tmp_path / "dual-fallback.sqlite3")
+    client = ComparableMetricsChatClient()
+    runtime = DebugRuntime(
+        client,
+        registry=_comparable_registry(fallback_required=True),
+        session_store=store,
+    )
+
+    await _run_persisted_turn(
+        runtime,
+        _comparable_request(mode="dual_agent", session_id="dual-fallback"),
+    )
+
+    usage = store.usage_summary("dual-fallback")
+    fallback_calls = [
+        call for call in usage["calls"] if call["call_kind"] == "argument_fallback"
+    ]
+    assert client.fallback_calls == 1
+    assert len(fallback_calls) == 1
+    assert fallback_calls[0]["agent"] == "event_agent"
+    assert fallback_calls[0]["event_id"] == "event-1"
+    assert fallback_calls[0]["event_name"] == "comparable.search"
+    assert fallback_calls[0]["total_tokens"] == 7
+    assert fallback_calls[0]["ttft_ms"] is not None
+    assert fallback_calls[0]["metric_key"] == "fallback:1:1:event-1"
+    assert usage["session"]["total_tokens"] == 22
+    assert usage["session"]["fallback_count"] == 1
+    assert usage["session"]["tool_count"] == 1
+
+
+def test_runtime_persists_only_logical_tool_results(tmp_path):
+    store = SQLiteSessionStore(tmp_path / "logical-results.sqlite3")
+    store.create_session(title="logical", config={}, session_id="logical")
+    store.start_turn("logical", turn_index=1, user_message="logical")
+    primary = EventResult(
+        event_id="event-1",
+        event_name="comparable.search",
+        status=EventStatus.SUCCESS,
+        source=EventSource.TEXT_EVENT,
+        tool_latency_ms=20,
+    )
+    duplicate = replace(
+        primary,
+        event_id="event-2",
+        deduplicated_from="event-1",
+        tool_latency_ms=20,
+    )
+    runtime = DebugRuntime(
+        ComparableMetricsChatClient(),
+        session_store=store,
+    )
+
+    runtime._append_persisted_tool_metrics(
+        "logical",
+        1,
+        1,
+        EventBatchResult(results=(primary, duplicate)),
+    )
+
+    usage = store.usage_summary("logical")
+    assert usage["session"]["tool_count"] == 1
+    assert usage["calls"][0]["metric_key"] == "tool:1:1:event-1"
+
+
+@pytest.mark.asyncio
+async def test_runtime_parallel_tool_latencies_are_not_summed_as_turn_wall_time(tmp_path):
+    both_started = asyncio.Event()
+    started = 0
+
+    async def handler(event: ToolCallEvent) -> dict[str, Any]:
+        nonlocal started
+        started += 1
+        if started == 2:
+            both_started.set()
+        await asyncio.wait_for(both_started.wait(), timeout=1)
+        await asyncio.sleep(0.03)
+        return {"event_id": event.id}
+
+    registry = ToolRegistry(
+        [
+            ToolDefinition(
+                name="parallel.tool",
+                description="Parallel tool.",
+                parameters={"type": "object"},
+                handler=handler,
+            )
+        ]
+    )
+    events = [
+        ToolCallEvent(
+            id=f"parallel-{index}",
+            name="parallel.tool",
+            arguments={"index": index},
+            raw_arguments=f'{{"index":{index}}}',
+        )
+        for index in (1, 2)
+    ]
+    client = ScriptedChatClient(
+        [
+            [
+                StreamItem.provider_tool_call(events[0]),
+                StreamItem.provider_tool_call(events[1]),
+            ],
+            [StreamItem.message_delta("done")],
+        ]
+    )
+    store = SQLiteSessionStore(tmp_path / "parallel.sqlite3")
+    runtime = DebugRuntime(client, registry=registry, session_store=store)
+
+    await _run_persisted_turn(
+        runtime,
+        _direct_request(
+            enabled_tools=["parallel.tool"],
+            session_id="parallel",
+        ).model_copy(
+            update={
+                "event_agent": EventAgentParams(
+                    enabled_tools=["parallel.tool"],
+                    max_event_loops=1,
+                    max_parallel_events=2,
+                )
+            }
+        ),
+    )
+
+    usage = store.usage_summary("parallel")
+    tool_calls = [call for call in usage["calls"] if call["call_kind"] == "tool_execution"]
+    tool_latency_sum = sum(call["tool_latency_ms"] for call in tool_calls)
+    assert len(tool_calls) == 2
+    assert tool_latency_sum >= 40
+    assert usage["session"]["turn_wall_time_ms"] < tool_latency_sum
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_direct_mode_rejects_provider_call_id_reused_across_one_shot_rounds():
 async def test_direct_mode_rejects_provider_call_id_reused_across_one_shot_rounds():
     executed: list[str] = []
     executed: list[str] = []

+ 207 - 1
tests/test_event_agent.py

@@ -8,7 +8,7 @@ from agent_lab.application.contracts import AgentParams, EventAgentParams
 from agent_lab.application.event_agent import EventAgent
 from agent_lab.application.event_agent import EventAgent
 from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
 from agent_lab.domain.events import ToolCallEvent
 from agent_lab.domain.events import ToolCallEvent
-from agent_lab.domain.messages import ChatMessage, StreamItem
+from agent_lab.domain.messages import ChatMessage, StreamItem, TokenUsage
 
 
 
 
 class ToolCallingChatClient:
 class ToolCallingChatClient:
@@ -114,6 +114,48 @@ class WrongNameToolCallingChatClient(ToolCallingChatClient):
         )
         )
 
 
 
 
+class TrailingUsageChatClient:
+    async def stream_chat(
+        self,
+        messages: list[ChatMessage],
+        tools: list[dict],
+        params: AgentParams,
+        tool_choice: dict | None = None,
+    ) -> AsyncIterator[StreamItem]:
+        tool_name = tools[0]["function"]["name"]
+        yield StreamItem.raw_response_chunk({"first": tool_name})
+        yield StreamItem.provider_tool_call(
+            ToolCallEvent(
+                id=f"provider-{tool_name}",
+                name=tool_name,
+                arguments={"query": tool_name},
+                raw_arguments=json.dumps({"query": tool_name}),
+            )
+        )
+        yield StreamItem.usage_item(
+            TokenUsage(
+                prompt_tokens=3,
+                completion_tokens=4,
+                total_tokens=7,
+                cached_tokens=1,
+            )
+        )
+
+
+class FailedResolutionUsageChatClient:
+    async def stream_chat(
+        self,
+        messages: list[ChatMessage],
+        tools: list[dict],
+        params: AgentParams,
+        tool_choice: dict | None = None,
+    ) -> AsyncIterator[StreamItem]:
+        yield StreamItem.message_delta("invalid fallback response")
+        yield StreamItem.usage_item(
+            TokenUsage(prompt_tokens=5, completion_tokens=2, total_tokens=7)
+        )
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_event_agent_uses_deterministic_resolver_before_llm_fallback():
 async def test_event_agent_uses_deterministic_resolver_before_llm_fallback():
     chat_client = ToolCallingChatClient({"message": "LLM generated handoff"})
     chat_client = ToolCallingChatClient({"message": "LLM generated handoff"})
@@ -180,6 +222,35 @@ async def test_event_agent_deterministic_mock_resolver_skips_llm():
     assert chat_client.calls == []
     assert chat_client.calls == []
 
 
 
 
+@pytest.mark.asyncio
+async def test_event_agent_injects_clock_into_event_kernel_handler_metrics():
+    clock_values = iter([5.0, 5.030])
+    registry = ToolRegistry(
+        [
+            ToolDefinition(
+                name="timed.tool",
+                description="Timed tool.",
+                parameters={"type": "object"},
+                handler=lambda event: {"tool": event.name},
+            )
+        ]
+    )
+    event = ToolCallEvent(
+        id="timed-event",
+        name="timed.tool",
+        arguments={},
+        raw_arguments="{}",
+    )
+
+    batch = await EventAgent(
+        enabled_tools=[event.name],
+        registry=registry,
+        monotonic_clock=lambda: next(clock_values),
+    ).handle_many([event], history=[])
+
+    assert batch.results[0].tool_latency_ms == 30
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_event_agent_calls_llm_once_for_incomplete_deterministic_arguments():
 async def test_event_agent_calls_llm_once_for_incomplete_deterministic_arguments():
     chat_client = ToolCallingChatClient({"message": "LLM generated handoff"})
     chat_client = ToolCallingChatClient({"message": "LLM generated handoff"})
@@ -225,6 +296,141 @@ async def test_event_agent_calls_llm_once_for_incomplete_deterministic_arguments
     }
     }
 
 
 
 
+@pytest.mark.asyncio
+async def test_event_agent_consumes_trailing_usage_and_records_provider_ttft():
+    clock_values = iter([10.0, 10.011, 10.050, 10.060, 10.080])
+    registry = ToolRegistry(
+        [
+            ToolDefinition(
+                name="fallback.search",
+                description="Resolve with fallback.",
+                parameters={
+                    "type": "object",
+                    "properties": {"query": {"type": "string"}},
+                    "required": ["query"],
+                },
+                handler=lambda event: {"query": event.arguments["query"]},
+                argument_resolver=lambda event, context: {},
+            )
+        ]
+    )
+    event = ToolCallEvent(
+        id="event-1",
+        name="fallback.search",
+        arguments={},
+        raw_arguments="{}",
+    )
+    agent = EventAgent(
+        enabled_tools=[event.name],
+        registry=registry,
+        chat_client=TrailingUsageChatClient(),
+        monotonic_clock=lambda: next(clock_values),
+    )
+
+    batch = await agent.handle_many([event], history=[])
+
+    assert batch.results[0].status.value == "success"
+    assert agent.fallback_model_calls([event]) == [
+        {
+            "event_id": "event-1",
+            "event_name": "fallback.search",
+            "usage": TokenUsage(
+                prompt_tokens=3,
+                completion_tokens=4,
+                total_tokens=7,
+                cached_tokens=1,
+            ),
+            "ttft_ms": 11,
+            "elapsed_ms": 50,
+        }
+    ]
+
+
+@pytest.mark.asyncio
+async def test_event_agent_records_failed_fallback_resolution_attempt_usage():
+    clock_values = iter([2.0, 2.005, 2.020])
+    registry = ToolRegistry(
+        [
+            ToolDefinition(
+                name="fallback.search",
+                description="Resolve with fallback.",
+                parameters={
+                    "type": "object",
+                    "properties": {"query": {"type": "string"}},
+                    "required": ["query"],
+                },
+                handler=lambda event: {"query": event.arguments["query"]},
+                argument_resolver=lambda event, context: {},
+            )
+        ]
+    )
+    event = ToolCallEvent(
+        id="event-failed",
+        name="fallback.search",
+        arguments={},
+        raw_arguments="{}",
+    )
+    agent = EventAgent(
+        enabled_tools=[event.name],
+        registry=registry,
+        chat_client=FailedResolutionUsageChatClient(),
+        monotonic_clock=lambda: next(clock_values),
+    )
+
+    batch = await agent.handle_many([event], history=[])
+
+    assert batch.results[0].status.value == "invalid_arguments"
+    assert agent.fallback_model_calls([event])[0] == {
+        "event_id": "event-failed",
+        "event_name": "fallback.search",
+        "usage": TokenUsage(prompt_tokens=5, completion_tokens=2, total_tokens=7),
+        "ttft_ms": 5,
+        "elapsed_ms": 20,
+    }
+
+
+@pytest.mark.asyncio
+async def test_event_agent_keeps_parallel_fallback_metrics_isolated_by_event():
+    registry = ToolRegistry(
+        [
+            ToolDefinition(
+                name=name,
+                description="Resolve with fallback.",
+                parameters={
+                    "type": "object",
+                    "properties": {"query": {"type": "string"}},
+                    "required": ["query"],
+                },
+                handler=lambda event: {"query": event.arguments["query"]},
+                argument_resolver=lambda event, context: {},
+            )
+            for name in ("fallback.first", "fallback.second")
+        ]
+    )
+    events = [
+        ToolCallEvent(id=f"event-{index}", name=name, arguments={}, raw_arguments="{}")
+        for index, name in enumerate(
+            ("fallback.first", "fallback.second"),
+            start=1,
+        )
+    ]
+    agent = EventAgent(
+        enabled_tools=[event.name for event in events],
+        registry=registry,
+        chat_client=TrailingUsageChatClient(),
+        params=EventAgentParams(max_parallel_events=2),
+    )
+
+    await agent.handle_many(events, history=[])
+
+    metrics = agent.fallback_model_calls(events)
+    assert [(metric["event_id"], metric["event_name"]) for metric in metrics] == [
+        ("event-1", "fallback.first"),
+        ("event-2", "fallback.second"),
+    ]
+    assert [metric["usage"].total_tokens for metric in metrics] == [7, 7]
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_event_agent_rejects_fallback_provider_call_for_wrong_tool_name():
 async def test_event_agent_rejects_fallback_provider_call_for_wrong_tool_name():
     chat_client = WrongNameToolCallingChatClient({"message": "wrong"})
     chat_client = WrongNameToolCallingChatClient({"message": "wrong"})

+ 66 - 0
tests/test_event_kernel.py

@@ -868,6 +868,72 @@ async def test_kernel_normalizes_handler_exceptions():
     assert result.error == "event handler failed: boom"
     assert result.error == "event handler failed: boom"
 
 
 
 
+@pytest.mark.asyncio
+@pytest.mark.parametrize("handler_kind", ["async", "sync", "failure"])
+async def test_kernel_measures_only_actual_handler_latency(handler_kind: str):
+    clock_values = iter([10.0, 10.025])
+
+    async def async_handler(request: EventRequest) -> dict[str, Any]:
+        return {"query": request.arguments["query"]}
+
+    def sync_handler(request: EventRequest) -> dict[str, Any]:
+        return {"query": request.arguments["query"]}
+
+    def failing_handler(request: EventRequest) -> dict[str, Any]:
+        raise RuntimeError("boom")
+
+    handler = {
+        "async": async_handler,
+        "sync": sync_handler,
+        "failure": failing_handler,
+    }[handler_kind]
+    result = await EventKernel(
+        EventRegistry([_definition(handler=handler)]),
+        monotonic_clock=lambda: next(clock_values),
+    ).execute(
+        EventRequest(
+            id="event-1",
+            name="example.lookup",
+            arguments={"query": "value"},
+            source=EventSource.PROVIDER_RESOLVED,
+        )
+    )
+
+    assert result.tool_latency_ms == 25
+    if handler_kind == "failure":
+        assert result.status is EventStatus.HANDLER_ERROR
+    else:
+        assert result.status is EventStatus.SUCCESS
+
+
+@pytest.mark.asyncio
+async def test_kernel_handler_latency_excludes_argument_fallback_time():
+    clock_values = iter([20.0, 20.012])
+
+    async def fallback(*args: Any) -> ResolvedEventArguments:
+        return ResolvedEventArguments(
+            event_name="example.lookup",
+            arguments={"query": "fallback"},
+            raw_arguments='{"query":"fallback"}',
+        )
+
+    result = await EventKernel(
+        EventRegistry([_definition()]),
+        argument_fallback=fallback,
+        monotonic_clock=lambda: next(clock_values),
+    ).execute(
+        EventRequest(
+            id="event-1",
+            name="example.lookup",
+            arguments={},
+            raw_arguments="{}",
+        )
+    )
+
+    assert result.used_fallback is True
+    assert result.tool_latency_ms == 12
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_kernel_validates_complete_draft_2020_12_schema():
 async def test_kernel_validates_complete_draft_2020_12_schema():
     definition = _definition(
     definition = _definition(

+ 289 - 4
tests/test_sqlite_store.py

@@ -14,7 +14,10 @@ def test_sqlite_store_persists_session_messages_audit_and_usage(tmp_path):
 
 
     session_id = store.create_session(
     session_id = store.create_session(
         title="Debug first turn",
         title="Debug first turn",
-        config={"chat_agent": {"model": "chat-model"}},
+        config={
+            "tool_invocation_mode": "chat_agent_tools",
+            "chat_agent": {"model": "chat-model"},
+        },
     )
     )
     store.start_turn(session_id, turn_index=1, user_message="debug this")
     store.start_turn(session_id, turn_index=1, user_message="debug this")
     store.append_message(
     store.append_message(
@@ -47,7 +50,7 @@ def test_sqlite_store_persists_session_messages_audit_and_usage(tmp_path):
         ttft_ms=120,
         ttft_ms=120,
         elapsed_ms=450,
         elapsed_ms=450,
     )
     )
-    store.complete_turn(session_id, turn_index=1)
+    store.complete_turn(session_id, turn_index=1, wall_time_ms=425)
 
 
     sessions = store.list_sessions()
     sessions = store.list_sessions()
     assert sessions == [
     assert sessions == [
@@ -67,7 +70,26 @@ def test_sqlite_store_persists_session_messages_audit_and_usage(tmp_path):
     ]
     ]
     assert store.list_audit_logs(session_id)[0]["event"] == "chat_agent_request"
     assert store.list_audit_logs(session_id)[0]["event"] == "chat_agent_request"
     usage = store.usage_summary(session_id)
     usage = store.usage_summary(session_id)
-    assert usage["calls"][0]["total_tokens"] == 10
+    assert usage["calls"][0] | {"created_at": "ignored"} == {
+        "id": usage["calls"][0]["id"],
+        "turn_index": 1,
+        "round_index": 1,
+        "prompt_tokens": 3,
+        "completion_tokens": 7,
+        "total_tokens": 10,
+        "cached_tokens": 2,
+        "ttft_ms": 120,
+        "elapsed_ms": 450,
+        "mode": "chat_agent_tools",
+        "agent": "chat_agent",
+        "call_kind": "chat_completion",
+        "event_id": None,
+        "event_name": None,
+        "used_fallback": False,
+        "tool_latency_ms": None,
+        "metric_key": None,
+        "created_at": "ignored",
+    }
     assert usage["turns"] == [
     assert usage["turns"] == [
         {
         {
             "turn_index": 1,
             "turn_index": 1,
@@ -76,9 +98,222 @@ def test_sqlite_store_persists_session_messages_audit_and_usage(tmp_path):
             "total_tokens": 10,
             "total_tokens": 10,
             "cached_tokens": 2,
             "cached_tokens": 2,
             "elapsed_ms": 450,
             "elapsed_ms": 450,
+            "call_count": 1,
+            "fallback_count": 0,
+            "tool_count": 0,
+            "turn_wall_time_ms": 425,
         }
         }
     ]
     ]
-    assert usage["session"]["total_tokens"] == 10
+    assert usage["session"] == {
+        "prompt_tokens": 3,
+        "completion_tokens": 7,
+        "total_tokens": 10,
+        "cached_tokens": 2,
+        "elapsed_ms": 450,
+        "call_count": 1,
+        "fallback_count": 0,
+        "tool_count": 0,
+        "turn_wall_time_ms": 425,
+    }
+
+
+def test_sqlite_store_persists_typed_calls_without_double_counting_metrics(tmp_path):
+    store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
+    session_id = store.create_session(
+        title="comparable metrics",
+        config={"tool_invocation_mode": "dual_agent"},
+    )
+    store.start_turn(session_id, turn_index=1, user_message="search")
+
+    store.append_usage(
+        session_id,
+        turn_index=1,
+        round_index=1,
+        usage=TokenUsage(prompt_tokens=2, completion_tokens=3, total_tokens=5),
+        ttft_ms=10,
+        elapsed_ms=30,
+        metadata={"metric_key": "chat:1:1"},
+    )
+    fallback_metadata = {
+        "agent": "event_agent",
+        "call_kind": "argument_fallback",
+        "event_id": "event-1",
+        "event_name": "mock_search",
+        "used_fallback": True,
+        "metric_key": "fallback:1:1:event-1",
+    }
+    for _ in range(2):
+        store.append_usage(
+            session_id,
+            turn_index=1,
+            round_index=1,
+            usage=TokenUsage(prompt_tokens=4, completion_tokens=6, total_tokens=10),
+            ttft_ms=5,
+            elapsed_ms=20,
+            metadata=fallback_metadata,
+        )
+    store.append_usage(
+        session_id,
+        turn_index=1,
+        round_index=1,
+        usage=TokenUsage(prompt_tokens=100, completion_tokens=100, total_tokens=200),
+        ttft_ms=None,
+        elapsed_ms=80,
+        metadata={
+            "agent": "event_kernel",
+            "call_kind": "tool_execution",
+            "event_id": "event-1",
+            "event_name": "mock_search",
+            "used_fallback": True,
+            "tool_latency_ms": 80,
+            "metric_key": "tool:1:1:event-1",
+        },
+    )
+    store.complete_turn(session_id, turn_index=1, wall_time_ms=95)
+
+    usage = store.usage_summary(session_id)
+
+    assert len(usage["calls"]) == 3
+    assert [call["mode"] for call in usage["calls"]] == ["dual_agent"] * 3
+    assert [call["agent"] for call in usage["calls"]] == [
+        "chat_agent",
+        "event_agent",
+        "event_kernel",
+    ]
+    assert [call["call_kind"] for call in usage["calls"]] == [
+        "chat_completion",
+        "argument_fallback",
+        "tool_execution",
+    ]
+    assert usage["calls"][1]["event_id"] == "event-1"
+    assert usage["calls"][1]["event_name"] == "mock_search"
+    assert usage["calls"][1]["used_fallback"] is True
+    assert usage["calls"][2]["tool_latency_ms"] == 80
+    assert usage["calls"][2]["total_tokens"] == 0
+    assert usage["turns"] == [
+        {
+            "turn_index": 1,
+            "prompt_tokens": 6,
+            "completion_tokens": 9,
+            "total_tokens": 15,
+            "cached_tokens": 0,
+            "elapsed_ms": 130,
+            "call_count": 3,
+            "fallback_count": 1,
+            "tool_count": 1,
+            "turn_wall_time_ms": 95,
+        }
+    ]
+    assert usage["session"] == {
+        "prompt_tokens": 6,
+        "completion_tokens": 9,
+        "total_tokens": 15,
+        "cached_tokens": 0,
+        "elapsed_ms": 130,
+        "call_count": 3,
+        "fallback_count": 1,
+        "tool_count": 1,
+        "turn_wall_time_ms": 95,
+    }
+    assert store.list_sessions()[0]["total_tokens"] == 15
+
+
+def test_sqlite_store_metric_keys_are_unique_within_each_session(tmp_path):
+    store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
+    for session_id in ("session-a", "session-b"):
+        store.create_session(title=session_id, config={}, session_id=session_id)
+        store.start_turn(session_id, turn_index=1, user_message="test")
+        for _ in range(2):
+            store.append_usage(
+                session_id,
+                turn_index=1,
+                round_index=1,
+                usage=TokenUsage(total_tokens=1),
+                ttft_ms=None,
+                elapsed_ms=1,
+                metadata={"metric_key": "chat:1:1"},
+            )
+
+    assert len(store.usage_summary("session-a")["calls"]) == 1
+    assert len(store.usage_summary("session-b")["calls"]) == 1
+
+
+def test_sqlite_store_migrates_legacy_usage_and_turn_metrics_additively(tmp_path):
+    database_path = tmp_path / "legacy-usage.sqlite3"
+    connection = sqlite3.connect(database_path)
+    connection.executescript(
+        """
+        CREATE TABLE sessions (
+            id TEXT PRIMARY KEY,
+            title TEXT NOT NULL,
+            created_at TEXT NOT NULL,
+            updated_at TEXT NOT NULL,
+            config_json TEXT NOT NULL
+        );
+        CREATE TABLE turns (
+            session_id TEXT NOT NULL,
+            turn_index INTEGER NOT NULL,
+            user_message TEXT NOT NULL,
+            started_at TEXT NOT NULL,
+            completed_at TEXT,
+            PRIMARY KEY (session_id, turn_index)
+        );
+        CREATE TABLE usage_stats (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            session_id TEXT NOT NULL,
+            turn_index INTEGER NOT NULL,
+            round_index INTEGER NOT NULL,
+            prompt_tokens INTEGER NOT NULL,
+            completion_tokens INTEGER NOT NULL,
+            total_tokens INTEGER NOT NULL,
+            cached_tokens INTEGER NOT NULL,
+            ttft_ms INTEGER,
+            elapsed_ms INTEGER NOT NULL,
+            created_at TEXT NOT NULL
+        );
+        INSERT INTO sessions VALUES (
+            'legacy', 'Legacy', 'now', 'now',
+            '{"tool_invocation_mode":"chat_agent_tools"}'
+        );
+        INSERT INTO turns VALUES ('legacy', 1, 'old', 'now', 'now');
+        INSERT INTO usage_stats (
+            session_id, turn_index, round_index, prompt_tokens,
+            completion_tokens, total_tokens, cached_tokens,
+            ttft_ms, elapsed_ms, created_at
+        ) VALUES ('legacy', 1, 1, 1, 2, 3, 0, 4, 5, 'now');
+        """
+    )
+    connection.commit()
+    connection.close()
+
+    store = SQLiteSessionStore(database_path)
+    usage = store.usage_summary("legacy")
+
+    assert usage["calls"][0]["agent"] == "chat_agent"
+    assert usage["calls"][0]["call_kind"] == "chat_completion"
+    assert usage["calls"][0]["mode"] == "chat_agent_tools"
+    assert usage["calls"][0]["used_fallback"] is False
+    assert usage["turns"][0]["turn_wall_time_ms"] is None
+    migrated = sqlite3.connect(database_path)
+    usage_columns = {row[1] for row in migrated.execute("PRAGMA table_info(usage_stats)")}
+    turn_columns = {row[1] for row in migrated.execute("PRAGMA table_info(turns)")}
+    indexes = {row[1] for row in migrated.execute("PRAGMA index_list(usage_stats)")}
+    metric_index_sql = migrated.execute(
+        "SELECT sql FROM sqlite_master WHERE name = 'usage_stats_metric_key_uq'"
+    ).fetchone()[0]
+    migrated.close()
+    assert {
+        "agent",
+        "call_kind",
+        "event_id",
+        "event_name",
+        "used_fallback",
+        "tool_latency_ms",
+        "metric_key",
+    } <= usage_columns
+    assert "wall_time_ms" in turn_columns
+    assert "usage_stats_metric_key_uq" in indexes
+    assert "session_id, metric_key" in metric_index_sql
 
 
 
 
 def test_sqlite_store_session_list_totals_are_not_multiplied_by_turns(tmp_path):
 def test_sqlite_store_session_list_totals_are_not_multiplied_by_turns(tmp_path):
@@ -296,6 +531,35 @@ def test_sqlite_store_migration_is_safe_across_concurrent_initialization(
         )
         )
         """
         """
     )
     )
+    connection.execute(
+        """
+        CREATE TABLE turns (
+            session_id TEXT NOT NULL,
+            turn_index INTEGER NOT NULL,
+            user_message TEXT NOT NULL,
+            started_at TEXT NOT NULL,
+            completed_at TEXT,
+            PRIMARY KEY (session_id, turn_index)
+        )
+        """
+    )
+    connection.execute(
+        """
+        CREATE TABLE usage_stats (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            session_id TEXT NOT NULL,
+            turn_index INTEGER NOT NULL,
+            round_index INTEGER NOT NULL,
+            prompt_tokens INTEGER NOT NULL,
+            completion_tokens INTEGER NOT NULL,
+            total_tokens INTEGER NOT NULL,
+            cached_tokens INTEGER NOT NULL,
+            ttft_ms INTEGER,
+            elapsed_ms INTEGER NOT NULL,
+            created_at TEXT NOT NULL
+        )
+        """
+    )
     connection.execute(
     connection.execute(
         """
         """
         INSERT INTO messages
         INSERT INTO messages
@@ -356,3 +620,24 @@ def test_sqlite_store_migration_is_safe_across_concurrent_initialization(
         "PRAGMA table_info(messages)"
         "PRAGMA table_info(messages)"
     ).fetchall()
     ).fetchall()
     assert [column[1] for column in columns].count("tool_calls_json") == 1
     assert [column[1] for column in columns].count("tool_calls_json") == 1
+    migrated = real_connect(database_path)
+    turn_columns = [row[1] for row in migrated.execute("PRAGMA table_info(turns)")]
+    usage_columns = [
+        row[1] for row in migrated.execute("PRAGMA table_info(usage_stats)")
+    ]
+    metric_indexes = [
+        row[1] for row in migrated.execute("PRAGMA index_list(usage_stats)")
+    ]
+    migrated.close()
+    assert turn_columns.count("wall_time_ms") == 1
+    for column_name in (
+        "agent",
+        "call_kind",
+        "event_id",
+        "event_name",
+        "used_fallback",
+        "tool_latency_ms",
+        "metric_key",
+    ):
+        assert usage_columns.count(column_name) == 1
+    assert metric_indexes.count("usage_stats_metric_key_uq") == 1

+ 11 - 0
tests/test_websocket_api.py

@@ -350,6 +350,10 @@ def test_session_api_creates_lists_and_returns_empty_replay_data(tmp_path):
             "total_tokens": 0,
             "total_tokens": 0,
             "cached_tokens": 0,
             "cached_tokens": 0,
             "elapsed_ms": 0,
             "elapsed_ms": 0,
+            "call_count": 0,
+            "fallback_count": 0,
+            "tool_count": 0,
+            "turn_wall_time_ms": None,
         },
         },
     }
     }
 
 
@@ -1639,6 +1643,13 @@ def test_static_app_loads_session_replay_and_usage():
     assert '`/api/sessions/${sessionId}/usage`' in js
     assert '`/api/sessions/${sessionId}/usage`' in js
     assert "function renderAuditReplay(" in js
     assert "function renderAuditReplay(" in js
     assert "function renderSessionUsage(" in js
     assert "function renderSessionUsage(" in js
+    assert "session.turn_wall_time_ms ?? session.elapsed_ms" in js
+    assert "turn.turn_wall_time_ms ?? turn.elapsed_ms" in js
+    assert "call.mode" in js
+    assert "call.agent" in js
+    assert "call.call_kind" in js
+    assert "call.used_fallback" in js
+    assert "call.tool_latency_ms" in js
     assert "sessionUsageSummary" in js
     assert "sessionUsageSummary" in js
     assert "Finish current turn before switching sessions" in js
     assert "Finish current turn before switching sessions" in js
     assert "await loadSessionReplay(session.id, { preserveStatus: true });" in create_session
     assert "await loadSessionReplay(session.id, { preserveStatus: true });" in create_session