| 1 | """Agent CLI stream parsing behind an adapter seam.
|
| 2 |
|
| 3 | Each agent CLI emits a different event stream shape. The shared engine in
|
| 4 | analyze_stream handles the lane-level concerns (turn timeline, failure trace,
|
| 5 | recovery cost); a StreamAdapter supplies the per-CLI knowledge:
|
| 6 |
|
| 7 | - how turn boundaries appear, and whether a turn's tool calls are carried
|
| 8 | inside the turn event (Claude-style: assistant message holds tool_use
|
| 9 | blocks, results follow) or precede the boundary (Codex-style:
|
| 10 | turn.completed arrives after the turn's command items);
|
| 11 | - whether the absence of compaction markers is a measured zero (Claude
|
| 12 | streams emit explicit compact boundaries) or unmeasured (ADR-0002).
|
| 13 |
|
| 14 | Adding a new agent CLI means adding an adapter and a golden transcript
|
| 15 | fixture under tests/ — never editing the shared engine heuristics.
|
| 16 |
|
| 17 | All values are None when the stream does not expose the underlying signal.
|
| 18 | Never fabricate zeros here: a zero must mean "measured as zero" (ADR-0002).
|
| 19 | """
|
| 20 |
|
| 21 | from __future__ import annotations
|
| 22 |
|
| 23 | from dataclasses import dataclass
|
| 24 | from typing import Any, Iterable
|
| 25 |
|
| 26 | from oakbench.classify import (
|
| 27 | UNKNOWN_COMMAND_PATTERN,
|
| 28 | classify_command,
|
| 29 | unwrap_shell_command,
|
| 30 | vcs_info_family,
|
| 31 | )
|
| 32 |
|
| 33 |
|
| 34 | @dataclass
|
| 35 | class StreamAnalysis:
|
| 36 | turn_source: str = "unmeasured"
|
| 37 | turns: list[dict[str, Any]] | None = None
|
| 38 | assistant_turns_total: int | None = None
|
| 39 | tool_calls_per_turn_avg: float | None = None
|
| 40 | max_tool_calls_per_turn: int | None = None
|
| 41 | cumulative_input_tokens: int | None = None
|
| 42 | peak_input_tokens: int | None = None
|
| 43 | context_truncation_events: int | None = None
|
| 44 | failed_tool_calls_total: int | None = None
|
| 45 | failed_vcs_commands_total: int | None = None
|
| 46 | failed_test_commands_total: int | None = None
|
| 47 | same_command_retries_total: int | None = None
|
| 48 | unknown_command_failures_total: int | None = None
|
| 49 | turns_to_recovery: int | None = None
|
| 50 | tokens_to_recovery: int | None = None
|
| 51 | vcs_info_followup_calls_total: int | None = None
|
| 52 | failed_command_examples: list[str] | None = None
|
| 53 | parallel_batches_total: int | None = None
|
| 54 | max_concurrency: int | None = None
|
| 55 |
|
| 56 |
|
| 57 | def walk_json(value: Any) -> Iterable[Any]:
|
| 58 | yield value
|
| 59 | if isinstance(value, dict):
|
| 60 | for child in value.values():
|
| 61 | yield from walk_json(child)
|
| 62 | elif isinstance(value, list):
|
| 63 | for child in value:
|
| 64 | yield from walk_json(child)
|
| 65 |
|
| 66 |
|
| 67 | def number(value: Any) -> int | None:
|
| 68 | if value is None or isinstance(value, bool):
|
| 69 | return None
|
| 70 | try:
|
| 71 | parsed = int(value)
|
| 72 | except (TypeError, ValueError):
|
| 73 | return None
|
| 74 | return parsed if parsed >= 0 else None
|
| 75 |
|
| 76 |
|
| 77 | def first_number(mapping: dict[str, Any], keys: tuple[str, ...]) -> int | None:
|
| 78 | for key in keys:
|
| 79 | if key in mapping:
|
| 80 | value = number(mapping[key])
|
| 81 | if value is not None:
|
| 82 | return value
|
| 83 | return None
|
| 84 |
|
| 85 |
|
| 86 | def usage_from_subtree(value: Any) -> dict[str, int | None] | None:
|
| 87 | input_keys = ("input_tokens", "prompt_tokens", "inputTokenCount", "promptTokens")
|
| 88 | output_keys = ("output_tokens", "completion_tokens", "outputTokenCount", "completionTokens")
|
| 89 | cache_read_keys = ("cache_read_input_tokens", "cache_read_tokens", "cached_tokens", "cached_input_tokens")
|
| 90 | for item in walk_json(value):
|
| 91 | if not isinstance(item, dict):
|
| 92 | continue
|
| 93 | if set(item).intersection(input_keys + output_keys):
|
| 94 | return {
|
| 95 | "input_tokens": first_number(item, input_keys),
|
| 96 | "output_tokens": first_number(item, output_keys),
|
| 97 | "cache_read_tokens": first_number(item, cache_read_keys),
|
| 98 | }
|
| 99 | return None
|
| 100 |
|
| 101 |
|
| 102 | def is_assistant_turn_event(event: Any) -> bool:
|
| 103 | if not isinstance(event, dict):
|
| 104 | return False
|
| 105 | event_type = str(event.get("type") or "").lower()
|
| 106 | if event_type == "assistant":
|
| 107 | return True
|
| 108 | if event_type in {"turn.completed", "turn_completed"}:
|
| 109 | return True
|
| 110 | if event_type == "item.completed":
|
| 111 | item = event.get("item")
|
| 112 | if isinstance(item, dict) and str(item.get("type") or "") in {"agent_message", "assistant_message"}:
|
| 113 | return True
|
| 114 | return False
|
| 115 |
|
| 116 |
|
| 117 | def assistant_event_tool_use_count(event: Any) -> int:
|
| 118 | return len(assistant_event_tool_use_refs(event))
|
| 119 |
|
| 120 |
|
| 121 | def assistant_event_tool_use_refs(event: Any) -> list[str]:
|
| 122 | refs: list[str] = []
|
| 123 | anonymous = 0
|
| 124 | for item in walk_json(event):
|
| 125 | if not isinstance(item, dict) or str(item.get("type") or "") != "tool_use":
|
| 126 | continue
|
| 127 | tool_id = item.get("id")
|
| 128 | if tool_id:
|
| 129 | refs.append(str(tool_id))
|
| 130 | else:
|
| 131 | refs.append(f"anonymous:{anonymous}")
|
| 132 | anonymous += 1
|
| 133 | return refs
|
| 134 |
|
| 135 |
|
| 136 | def cursor_tool_call(event: Any) -> tuple[str, str | None, bool | None] | None:
|
| 137 | if not isinstance(event, dict) or str(event.get("type") or "").lower() != "tool_call":
|
| 138 | return None
|
| 139 | tool_call = event.get("tool_call")
|
| 140 | if not isinstance(tool_call, dict):
|
| 141 | return None
|
| 142 | tool_name = next((key for key in tool_call if key.endswith("ToolCall")), None)
|
| 143 | if tool_name is None:
|
| 144 | return None
|
| 145 | payload = tool_call.get(tool_name)
|
| 146 | if not isinstance(payload, dict):
|
| 147 | return tool_name, None, None
|
| 148 |
|
| 149 | command = None
|
| 150 | args = payload.get("args")
|
| 151 | if isinstance(args, dict) and isinstance(args.get("command"), str):
|
| 152 | command = unwrap_shell_command(args["command"])
|
| 153 |
|
| 154 | failed: bool | None = None
|
| 155 | result = payload.get("result")
|
| 156 | if isinstance(result, dict):
|
| 157 | if "error" in result or "failure" in result:
|
| 158 | failed = True
|
| 159 | elif "success" in result:
|
| 160 | failed = False
|
| 161 | shell_result = result.get("success") or result.get("failure")
|
| 162 | if isinstance(shell_result, dict):
|
| 163 | if command is None and isinstance(shell_result.get("command"), str):
|
| 164 | command = unwrap_shell_command(shell_result["command"])
|
| 165 | exit_code = shell_result.get("exitCode")
|
| 166 | if isinstance(exit_code, int) and not isinstance(exit_code, bool):
|
| 167 | failed = exit_code != 0
|
| 168 | return tool_name, command, failed
|
| 169 |
|
| 170 |
|
| 171 | def iter_tool_observations(event: Any) -> Iterable[tuple[bool, str]]:
|
| 172 | """Yield (failed, command_or_name) for adapter-visible tool executions with a known outcome."""
|
| 173 | cursor_call = cursor_tool_call(event)
|
| 174 | if cursor_call is not None:
|
| 175 | tool_name, command, failed = cursor_call
|
| 176 | if str(event.get("subtype") or "").lower() == "completed" and failed is not None:
|
| 177 | yield failed, command or tool_name
|
| 178 | return
|
| 179 |
|
| 180 | for item in walk_json(event):
|
| 181 | if not isinstance(item, dict):
|
| 182 | continue
|
| 183 | item_type = str(item.get("type") or "").lower()
|
| 184 | if item_type == "tool_result":
|
| 185 | yield bool(item.get("is_error")), str(item.get("tool_use_id") or "tool_result")
|
| 186 | continue
|
| 187 | exit_code = item.get("exit_code")
|
| 188 | if exit_code is None and item_type == "command_execution":
|
| 189 | status = str(item.get("status") or "").lower()
|
| 190 | if status == "failed":
|
| 191 | exit_code = 1
|
| 192 | elif status == "completed":
|
| 193 | exit_code = 0
|
| 194 | if exit_code is not None and not isinstance(exit_code, bool) and isinstance(exit_code, int):
|
| 195 | command = item.get("command") or item.get("cmd") or item_type or "command"
|
| 196 | yield exit_code != 0, unwrap_shell_command(str(command))
|
| 197 |
|
| 198 |
|
| 199 | def event_output_text(event: Any) -> str:
|
| 200 | texts: list[str] = []
|
| 201 | for item in walk_json(event):
|
| 202 | if not isinstance(item, dict):
|
| 203 | continue
|
| 204 | for key in ("aggregated_output", "output", "stderr", "error", "content", "text"):
|
| 205 | value = item.get(key)
|
| 206 | if isinstance(value, str):
|
| 207 | texts.append(value)
|
| 208 | return "\n".join(texts)
|
| 209 |
|
| 210 |
|
| 211 | class StreamAdapter:
|
| 212 | """Per-CLI stream knowledge. The base class implements the shared heuristics."""
|
| 213 |
|
| 214 | name = "generic"
|
| 215 | # Claude streams emit explicit compaction markers, so absence there is a
|
| 216 | # measured zero. Streams without such markers leave truncation unmeasured.
|
| 217 | missing_compaction_is_measured_zero = False
|
| 218 | # OpenAI-style usage reports cached tokens as a SUBSET of input_tokens;
|
| 219 | # Anthropic-style reports them separately. Adding cache reads to input is
|
| 220 | # double counting for the former.
|
| 221 | cache_read_included_in_input = False
|
| 222 |
|
| 223 | def is_turn_event(self, event: Any) -> bool:
|
| 224 | return is_assistant_turn_event(event)
|
| 225 |
|
| 226 | def is_session_usage_event(self, event: Any) -> bool:
|
| 227 | """Events that carry session-level usage without marking a model turn."""
|
| 228 | return False
|
| 229 |
|
| 230 | def turn_event_key(self, event: Any) -> str | None:
|
| 231 | """Stable key for streams that repeat one logical turn across events."""
|
| 232 | return None
|
| 233 |
|
| 234 | def stream_carries_tool_use_blocks(self, events: list[Any]) -> bool:
|
| 235 | # Claude-style streams carry a turn's tool_use blocks inside the
|
| 236 | # assistant event itself (results follow); Codex-style streams emit
|
| 237 | # turn boundaries after the turn's command items. Decide per stream,
|
| 238 | # not per event — mixing the two heuristics double-counts text-only turns.
|
| 239 | return any(
|
| 240 | self.is_turn_event(event) and assistant_event_tool_use_count(event) > 0 for event in events
|
| 241 | )
|
| 242 |
|
| 243 | def turn_tool_calls(self, event: Any, tools_since_last_turn: int, carries_tool_use: bool) -> int:
|
| 244 | if carries_tool_use:
|
| 245 | return assistant_event_tool_use_count(event)
|
| 246 | return tools_since_last_turn
|
| 247 |
|
| 248 |
|
| 249 | class ClaudeStreamAdapter(StreamAdapter):
|
| 250 | name = "claude_print_stream_json"
|
| 251 | missing_compaction_is_measured_zero = True
|
| 252 |
|
| 253 | def turn_event_key(self, event: Any) -> str | None:
|
| 254 | if not isinstance(event, dict):
|
| 255 | return None
|
| 256 | message = event.get("message")
|
| 257 | if not isinstance(message, dict):
|
| 258 | return None
|
| 259 | message_id = message.get("id")
|
| 260 | return str(message_id) if message_id else None
|
| 261 |
|
| 262 |
|
| 263 | class CodexStreamAdapter(StreamAdapter):
|
| 264 | """codex exec --json, verified against codex-cli 0.139 transcripts.
|
| 265 |
|
| 266 | Codex's `turn.completed` covers the WHOLE session (one per run) and its
|
| 267 | `input_tokens` already includes the cached subset (OpenAI semantics).
|
| 268 | Model round-trips are the `agent_message` items, which carry no usage —
|
| 269 | so turns come from agent_message and usage from the session event.
|
| 270 | """
|
| 271 |
|
| 272 | name = "codex_exec_json"
|
| 273 | cache_read_included_in_input = True
|
| 274 |
|
| 275 | def is_turn_event(self, event: Any) -> bool:
|
| 276 | if not isinstance(event, dict):
|
| 277 | return False
|
| 278 | if str(event.get("type") or "").lower() != "item.completed":
|
| 279 | return False
|
| 280 | item = event.get("item")
|
| 281 | return isinstance(item, dict) and str(item.get("type") or "") in {"agent_message", "assistant_message"}
|
| 282 |
|
| 283 | def is_session_usage_event(self, event: Any) -> bool:
|
| 284 | if not isinstance(event, dict):
|
| 285 | return False
|
| 286 | return str(event.get("type") or "").lower() in {"turn.completed", "turn_completed"}
|
| 287 |
|
| 288 |
|
| 289 | class CursorStreamAdapter(StreamAdapter):
|
| 290 | name = "cursor_agent_stream_json"
|
| 291 |
|
| 292 |
|
| 293 | ADAPTERS: dict[str, StreamAdapter] = {
|
| 294 | adapter.name: adapter
|
| 295 | for adapter in (ClaudeStreamAdapter(), CodexStreamAdapter(), CursorStreamAdapter())
|
| 296 | }
|
| 297 |
|
| 298 |
|
| 299 | def adapter_for(adapter_name: str) -> StreamAdapter:
|
| 300 | return ADAPTERS.get(adapter_name, StreamAdapter())
|
| 301 |
|
| 302 |
|
| 303 | def analyze_stream(events: list[Any], adapter_name: str) -> StreamAnalysis:
|
| 304 | adapter = adapter_for(adapter_name)
|
| 305 | analysis = StreamAnalysis()
|
| 306 | if not events:
|
| 307 | return analysis
|
| 308 |
|
| 309 | carries_tool_use = adapter.stream_carries_tool_use_blocks(events)
|
| 310 |
|
| 311 | turns: list[dict[str, Any]] = []
|
| 312 | session_usages: list[dict[str, int | None]] = []
|
| 313 | # Ordered trace mixing turn boundaries and tool outcomes so recovery cost
|
| 314 | # can be measured as turns between first failure and next success.
|
| 315 | trace: list[tuple[str, Any]] = []
|
| 316 | truncations = 0
|
| 317 | tools_since_last_turn = 0
|
| 318 | tool_use_seen = False
|
| 319 | failed_examples: list[str] = []
|
| 320 | failed_total = 0
|
| 321 | failed_vcs = 0
|
| 322 | failed_test = 0
|
| 323 | unknown_failures = 0
|
| 324 | failed_commands_seen: dict[str, int] = {}
|
| 325 | same_command_retries = 0
|
| 326 | # Compact-output cost: a successful read-only VCS command immediately
|
| 327 | # re-asked in the same family with different arguments means the first
|
| 328 | # output lacked the information the agent needed. Claude-style streams
|
| 329 | # report results by tool_use_id, so the actual command text comes from a
|
| 330 | # pre-pass mapping ids to tool_use input commands; when no observation
|
| 331 | # resolves to real command text the metric stays None (ADR-0002), never a
|
| 332 | # fabricated zero.
|
| 333 | vcs_info_followups = 0
|
| 334 | previous_ok_info: tuple[str, str] | None = None
|
| 335 | commands_resolvable = False
|
| 336 | id_to_command: dict[str, str] = {}
|
| 337 | turn_key_to_index: dict[str, int] = {}
|
| 338 | turn_tool_refs: dict[str, set[str]] = {}
|
| 339 | for event in events:
|
| 340 | for item in walk_json(event):
|
| 341 | if not isinstance(item, dict) or str(item.get("type") or "") != "tool_use":
|
| 342 | continue
|
| 343 | tool_id = item.get("id")
|
| 344 | command = (item.get("input") or {}).get("command") if isinstance(item.get("input"), dict) else None
|
| 345 | if tool_id and isinstance(command, str):
|
| 346 | id_to_command[str(tool_id)] = unwrap_shell_command(command)
|
| 347 |
|
| 348 | for event in events:
|
| 349 | if isinstance(event, dict):
|
| 350 | event_type = str(event.get("type") or "").lower()
|
| 351 | subtype = str(event.get("subtype") or "").lower()
|
| 352 | if "compact" in event_type or "compact" in subtype or "truncat" in event_type:
|
| 353 | truncations += 1
|
| 354 |
|
| 355 | observed_tools = list(iter_tool_observations(event))
|
| 356 | for failed, command in observed_tools:
|
| 357 | tool_use_seen = True
|
| 358 | tools_since_last_turn += 1
|
| 359 | trace.append(("tool_fail" if failed else "tool_ok", command))
|
| 360 | resolved_command = id_to_command.get(command, command)
|
| 361 | if " " in resolved_command:
|
| 362 | commands_resolvable = True
|
| 363 | if not failed:
|
| 364 | family = vcs_info_family(resolved_command)
|
| 365 | if (
|
| 366 | family is not None
|
| 367 | and previous_ok_info is not None
|
| 368 | and previous_ok_info[0] == family
|
| 369 | and previous_ok_info[1] != resolved_command
|
| 370 | ):
|
| 371 | vcs_info_followups += 1
|
| 372 | previous_ok_info = (family, resolved_command) if family is not None else None
|
| 373 | else:
|
| 374 | previous_ok_info = None
|
| 375 | if failed:
|
| 376 | failed_total += 1
|
| 377 | kinds = classify_command(resolved_command)
|
| 378 | if "vcs" in kinds:
|
| 379 | failed_vcs += 1
|
| 380 | if "test" in kinds:
|
| 381 | failed_test += 1
|
| 382 | if UNKNOWN_COMMAND_PATTERN.search(event_output_text(event)):
|
| 383 | unknown_failures += 1
|
| 384 | if len(failed_examples) < 5:
|
| 385 | failed_examples.append(resolved_command[:160])
|
| 386 | previous = failed_commands_seen.get(resolved_command, 0)
|
| 387 | if previous:
|
| 388 | same_command_retries += 1
|
| 389 | failed_commands_seen[resolved_command] = previous + 1
|
| 390 |
|
| 391 | if adapter.is_session_usage_event(event):
|
| 392 | usage = usage_from_subtree(event)
|
| 393 | if usage is not None:
|
| 394 | session_usages.append(usage)
|
| 395 |
|
| 396 | if adapter.is_turn_event(event):
|
| 397 | turn_key = adapter.turn_event_key(event)
|
| 398 | tool_refs = assistant_event_tool_use_refs(event)
|
| 399 | if turn_key is not None:
|
| 400 | if turn_key in turn_key_to_index:
|
| 401 | turn_index = turn_key_to_index[turn_key]
|
| 402 | if carries_tool_use:
|
| 403 | refs = turn_tool_refs.setdefault(turn_key, set())
|
| 404 | refs.update(tool_refs)
|
| 405 | turns[turn_index]["tool_calls_observed"] = len(refs)
|
| 406 | usage = usage_from_subtree(event)
|
| 407 | if usage is not None:
|
| 408 | turn = turns[turn_index]
|
| 409 | if turn["input_tokens"] is None:
|
| 410 | turn["input_tokens"] = usage.get("input_tokens")
|
| 411 | if turn["output_tokens"] is None:
|
| 412 | turn["output_tokens"] = usage.get("output_tokens")
|
| 413 | if turn["cache_read_tokens"] is None:
|
| 414 | turn["cache_read_tokens"] = usage.get("cache_read_tokens")
|
| 415 | continue
|
| 416 | turn_key_to_index[turn_key] = len(turns)
|
| 417 | if carries_tool_use:
|
| 418 | turn_tool_refs[turn_key] = set(tool_refs)
|
| 419 | usage = usage_from_subtree(event)
|
| 420 | turns.append(
|
| 421 | {
|
| 422 | "turn": len(turns),
|
| 423 | "input_tokens": usage.get("input_tokens") if usage else None,
|
| 424 | "output_tokens": usage.get("output_tokens") if usage else None,
|
| 425 | "cache_read_tokens": usage.get("cache_read_tokens") if usage else None,
|
| 426 | "tool_calls_observed": len(tool_refs)
|
| 427 | if carries_tool_use
|
| 428 | else adapter.turn_tool_calls(event, tools_since_last_turn, carries_tool_use),
|
| 429 | }
|
| 430 | )
|
| 431 | tools_since_last_turn = 0
|
| 432 | trace.append(("turn", len(turns) - 1))
|
| 433 |
|
| 434 | if turns:
|
| 435 | analysis.turn_source = "adapter_stream"
|
| 436 | analysis.turns = turns
|
| 437 | analysis.assistant_turns_total = len(turns)
|
| 438 | tool_counts = [int(turn["tool_calls_observed"]) for turn in turns]
|
| 439 | if tool_use_seen or any(tool_counts):
|
| 440 | analysis.tool_calls_per_turn_avg = round(sum(tool_counts) / len(turns), 3)
|
| 441 | analysis.max_tool_calls_per_turn = max(tool_counts)
|
| 442 | def input_total(usage: dict[str, Any]) -> int:
|
| 443 | base = usage["input_tokens"] or 0
|
| 444 | if adapter.cache_read_included_in_input:
|
| 445 | return base
|
| 446 | return base + (usage["cache_read_tokens"] or 0)
|
| 447 |
|
| 448 | input_totals = [input_total(turn) for turn in turns if turn["input_tokens"] is not None]
|
| 449 | if not input_totals and session_usages:
|
| 450 | # Session-level usage (codex-style): already cumulative across the
|
| 451 | # run's model calls; peak per round-trip is not adapter-visible.
|
| 452 | input_totals = [
|
| 453 | input_total(usage) for usage in session_usages if usage.get("input_tokens") is not None
|
| 454 | ]
|
| 455 | if input_totals:
|
| 456 | analysis.cumulative_input_tokens = sum(input_totals)
|
| 457 | elif input_totals:
|
| 458 | analysis.cumulative_input_tokens = sum(input_totals)
|
| 459 | analysis.peak_input_tokens = max(input_totals)
|
| 460 | if truncations or adapter.missing_compaction_is_measured_zero:
|
| 461 | analysis.context_truncation_events = truncations
|
| 462 |
|
| 463 | if tool_use_seen:
|
| 464 | analysis.failed_tool_calls_total = failed_total
|
| 465 | analysis.failed_vcs_commands_total = failed_vcs
|
| 466 | analysis.failed_test_commands_total = failed_test
|
| 467 | analysis.same_command_retries_total = same_command_retries
|
| 468 | analysis.unknown_command_failures_total = unknown_failures
|
| 469 | analysis.vcs_info_followup_calls_total = vcs_info_followups if commands_resolvable else None
|
| 470 | analysis.failed_command_examples = failed_examples
|
| 471 |
|
| 472 | first_failure_index = next((i for i, (kind, _) in enumerate(trace) if kind == "tool_fail"), None)
|
| 473 | if first_failure_index is not None:
|
| 474 | recovery_turns = 0
|
| 475 | recovered = False
|
| 476 | recovery_turn_ids: list[int] = []
|
| 477 | for kind, payload in trace[first_failure_index + 1 :]:
|
| 478 | if kind == "turn":
|
| 479 | recovery_turns += 1
|
| 480 | recovery_turn_ids.append(int(payload))
|
| 481 | elif kind == "tool_ok":
|
| 482 | recovered = True
|
| 483 | break
|
| 484 | if recovered:
|
| 485 | analysis.turns_to_recovery = recovery_turns
|
| 486 | if turns and recovery_turn_ids:
|
| 487 | outputs = [
|
| 488 | turns[turn_id]["output_tokens"]
|
| 489 | for turn_id in recovery_turn_ids
|
| 490 | if turn_id < len(turns) and turns[turn_id]["output_tokens"] is not None
|
| 491 | ]
|
| 492 | if outputs:
|
| 493 | analysis.tokens_to_recovery = sum(outputs)
|
| 494 |
|
| 495 | return analysis
|