@matt82198/aesop 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,430 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Wave Quality Scorecard Generator — deterministic metrics from telemetry.
4
+
5
+ Generates quality scorecards answering "how healthy are our waves?" from on-disk telemetry.
6
+
7
+ Usage:
8
+ wave_scorecard.py [--json|--md] [--waves N] [--state-root PATH]
9
+
10
+ Outputs:
11
+ - ASCII (default): human-readable text format
12
+ - --json: machine-readable JSON
13
+ - --md: markdown table for side-by-side comparison
14
+ - --waves N: show last N waves (default: all)
15
+
16
+ Metrics (where sources exist):
17
+ - Items dispatched/succeeded (from ledger)
18
+ - Repair rounds used (from ledger)
19
+ - First-try-green rate (from ledger verdict timing)
20
+ - Defect-escape count (from git history if available)
21
+ - Tokens + estimated cost by phase/model (from ledger)
22
+ - Agent success by agent_type (from ledger)
23
+ - Retry frequency (from ledger entries)
24
+
25
+ Sources:
26
+ - AESOP_STATE_ROOT/ledger/OUTCOMES-LEDGER.md: fleet outcome telemetry
27
+ - tools/defect_escape.py: Haiku code quality metrics
28
+ - Tools report on n/a (missing source) rather than inventing.
29
+ """
30
+
31
+ import sys
32
+ import json
33
+ import os
34
+ from pathlib import Path
35
+ from collections import defaultdict
36
+
37
+ # Ensure tools and state_store are importable (sys.path fix for bootstrapping)
38
+ repo_root = Path(__file__).parent.parent
39
+ if str(repo_root) not in sys.path:
40
+ sys.path.insert(0, str(repo_root))
41
+
42
+ try:
43
+ from common import get_state_dir
44
+ except ImportError:
45
+ from tools.common import get_state_dir
46
+
47
+ from state_store.read_api import ReadAPI
48
+
49
+
50
+ def parse_ledger_rows():
51
+ """Parse OUTCOMES-LEDGER.md and return structured rows via StateAPI.
52
+
53
+ Returns:
54
+ list of dicts with keys: iso_ts, agent_type, model, duration_sec,
55
+ tokens_in, tokens_out, verdict, phase, wave
56
+ """
57
+ state_dir = get_state_dir()
58
+ api = ReadAPI(state_dir)
59
+ return api.read_ledger_rows()
60
+
61
+
62
+ def compute_wave_metrics(rows):
63
+ """Compute quality metrics grouped by wave.
64
+
65
+ Args:
66
+ rows: list of ledger rows
67
+
68
+ Returns:
69
+ dict mapping wave_num to metrics dict
70
+ """
71
+ metrics_by_wave = defaultdict(lambda: {
72
+ 'entries': 0,
73
+ 'ok_count': 0,
74
+ 'failed_count': 0,
75
+ 'empty_count': 0,
76
+ 'hung_count': 0,
77
+ 'tokens_in': 0,
78
+ 'tokens_out': 0,
79
+ 'duration_sec': 0,
80
+ 'by_phase': defaultdict(lambda: {'entries': 0, 'tokens_out': 0, 'ok_count': 0, 'failed_count': 0}),
81
+ 'by_model': defaultdict(lambda: {'entries': 0, 'tokens_out': 0, 'ok_count': 0, 'failed_count': 0}),
82
+ 'by_agent_type': defaultdict(lambda: {'entries': 0, 'ok_count': 0, 'failed_count': 0}),
83
+ 'repair_rounds': 0,
84
+ })
85
+
86
+ for row in rows:
87
+ wave = row['wave']
88
+ if wave is None:
89
+ wave = 0 # Unknown wave
90
+
91
+ m = metrics_by_wave[wave]
92
+
93
+ m['entries'] += 1
94
+ m['tokens_in'] += row['tokens_in']
95
+ m['tokens_out'] += row['tokens_out']
96
+ m['duration_sec'] += row['duration_sec']
97
+
98
+ # Count verdicts
99
+ verdict = row['verdict']
100
+ if verdict == 'OK':
101
+ m['ok_count'] += 1
102
+ elif verdict == 'FAILED':
103
+ m['failed_count'] += 1
104
+ elif verdict == 'EMPTY':
105
+ m['empty_count'] += 1
106
+ elif verdict == 'HUNG':
107
+ m['hung_count'] += 1
108
+
109
+ # Track by phase
110
+ phase = row['phase'] or 'unknown'
111
+ phase_data = m['by_phase'][phase]
112
+ phase_data['entries'] += 1
113
+ phase_data['tokens_out'] += row['tokens_out']
114
+ if verdict == 'OK':
115
+ phase_data['ok_count'] += 1
116
+ else:
117
+ phase_data['failed_count'] += 1
118
+
119
+ # Count repair rounds
120
+ if phase == 'repair':
121
+ m['repair_rounds'] += 1
122
+
123
+ # Track by model
124
+ model = row['model']
125
+ model_data = m['by_model'][model]
126
+ model_data['entries'] += 1
127
+ model_data['tokens_out'] += row['tokens_out']
128
+ if verdict == 'OK':
129
+ model_data['ok_count'] += 1
130
+ else:
131
+ model_data['failed_count'] += 1
132
+
133
+ # Track by agent type
134
+ agent_type = row['agent_type']
135
+ agent_data = m['by_agent_type'][agent_type]
136
+ agent_data['entries'] += 1
137
+ if verdict == 'OK':
138
+ agent_data['ok_count'] += 1
139
+ else:
140
+ agent_data['failed_count'] += 1
141
+
142
+ # Convert defaultdicts to regular dicts for JSON serialization
143
+ result = {}
144
+ for wave, metrics in metrics_by_wave.items():
145
+ result[wave] = {
146
+ 'entries': metrics['entries'],
147
+ 'ok_count': metrics['ok_count'],
148
+ 'failed_count': metrics['failed_count'],
149
+ 'empty_count': metrics['empty_count'],
150
+ 'hung_count': metrics['hung_count'],
151
+ 'tokens_in': metrics['tokens_in'],
152
+ 'tokens_out': metrics['tokens_out'],
153
+ 'duration_sec': metrics['duration_sec'],
154
+ 'by_phase': dict(metrics['by_phase']),
155
+ 'by_model': dict(metrics['by_model']),
156
+ 'by_agent_type': dict(metrics['by_agent_type']),
157
+ 'repair_rounds': metrics['repair_rounds'],
158
+ }
159
+
160
+ return result
161
+
162
+
163
+ def compute_derived_metrics(wave_metrics):
164
+ """Compute derived metrics from wave metrics.
165
+
166
+ Args:
167
+ wave_metrics: dict from compute_wave_metrics()
168
+
169
+ Returns:
170
+ dict with derived metrics
171
+ """
172
+ derived = {}
173
+
174
+ for wave, metrics in wave_metrics.items():
175
+ total = metrics['entries']
176
+ if total == 0:
177
+ derived[wave] = {
178
+ 'success_rate': 'n/a (no entries)',
179
+ 'first_try_green_rate': 'n/a',
180
+ 'repair_efficiency': 'n/a',
181
+ 'cost_estimate': 'n/a',
182
+ }
183
+ continue
184
+
185
+ ok = metrics['ok_count']
186
+ success_rate = (ok / total) * 100 if total > 0 else 0
187
+
188
+ # First-try-green: rough estimate based on whether repairs happened
189
+ repair_rounds = metrics['repair_rounds']
190
+ if repair_rounds > 0:
191
+ # Rough estimate: if there were repairs, not all were first-try-green
192
+ first_try_estimate = ((total - repair_rounds) / total) * 100
193
+ else:
194
+ first_try_estimate = success_rate
195
+
196
+ # Repair efficiency: OK repairs / total repairs
197
+ if repair_rounds > 0:
198
+ repair_ok = metrics['by_phase'].get('repair', {}).get('ok_count', 0)
199
+ repair_efficiency = (repair_ok / repair_rounds) * 100
200
+ else:
201
+ repair_efficiency = None
202
+
203
+ # Cost estimate (rough): assume haiku ~$0.001 per 1M tokens
204
+ tokens_out = metrics['tokens_out']
205
+ cost_estimate = (tokens_out / 1_000_000) * 0.001
206
+
207
+ derived[wave] = {
208
+ 'success_rate': f"{success_rate:.1f}%",
209
+ 'first_try_green_rate': f"{first_try_estimate:.1f}%",
210
+ 'repair_efficiency': f"{repair_efficiency:.1f}%" if repair_efficiency is not None else "n/a",
211
+ 'cost_estimate': f"${cost_estimate:.4f}",
212
+ }
213
+
214
+ return derived
215
+
216
+
217
+ def format_ascii(wave_metrics, derived_metrics):
218
+ """Format metrics as ASCII text.
219
+
220
+ Args:
221
+ wave_metrics: dict from compute_wave_metrics()
222
+ derived_metrics: dict from compute_derived_metrics()
223
+
224
+ Returns:
225
+ formatted string
226
+ """
227
+ if not wave_metrics:
228
+ return "No data available from ledger (n/a: no entries)\n"
229
+
230
+ output = []
231
+ output.append("\n=== Wave Quality Scorecards ===\n")
232
+
233
+ # Sort by wave number
234
+ for wave in sorted(wave_metrics.keys()):
235
+ metrics = wave_metrics[wave]
236
+ derived = derived_metrics.get(wave, {})
237
+
238
+ output.append(f"Wave {wave}:")
239
+ output.append(f" Entries: {metrics['entries']}")
240
+ output.append(f" OK: {metrics['ok_count']} | Failed: {metrics['failed_count']} | Empty: {metrics['empty_count']} | Hung: {metrics['hung_count']}")
241
+ output.append(f" Success Rate: {derived.get('success_rate', 'n/a')}")
242
+ output.append(f" First-Try-Green: {derived.get('first_try_green_rate', 'n/a')}")
243
+ output.append(f" Repair Rounds: {metrics['repair_rounds']}")
244
+ output.append(f" Repair Efficiency: {derived.get('repair_efficiency', 'n/a')}")
245
+ output.append(f" Tokens In: {metrics['tokens_in']} | Out: {metrics['tokens_out']}")
246
+ output.append(f" Estimated Cost: {derived.get('cost_estimate', 'n/a')}")
247
+ output.append(f" Duration: {metrics['duration_sec']}s")
248
+
249
+ # Tokens by phase
250
+ if metrics['by_phase']:
251
+ output.append(" Tokens by Phase:")
252
+ for phase, phase_data in sorted(metrics['by_phase'].items()):
253
+ output.append(f" {phase}: {phase_data['entries']} entries, {phase_data['tokens_out']} tokens, {phase_data['ok_count']} OK")
254
+
255
+ # Tokens by model
256
+ if metrics['by_model']:
257
+ output.append(" Tokens by Model:")
258
+ for model, model_data in sorted(metrics['by_model'].items()):
259
+ output.append(f" {model}: {model_data['entries']} entries, {model_data['tokens_out']} tokens, {model_data['ok_count']} OK")
260
+
261
+ # By agent type
262
+ if metrics['by_agent_type']:
263
+ output.append(" Success by Agent Type:")
264
+ for agent_type, agent_data in sorted(metrics['by_agent_type'].items()):
265
+ rate = (agent_data['ok_count'] / agent_data['entries'] * 100) if agent_data['entries'] > 0 else 0
266
+ output.append(f" {agent_type}: {agent_data['ok_count']}/{agent_data['entries']} OK ({rate:.1f}%)")
267
+
268
+ output.append("")
269
+
270
+ return "\n".join(output)
271
+
272
+
273
+ def format_json(wave_metrics, derived_metrics):
274
+ """Format metrics as JSON.
275
+
276
+ Args:
277
+ wave_metrics: dict from compute_wave_metrics()
278
+ derived_metrics: dict from compute_derived_metrics()
279
+
280
+ Returns:
281
+ JSON string
282
+ """
283
+ result = {
284
+ 'scorecards': {},
285
+ }
286
+
287
+ for wave in sorted(wave_metrics.keys()):
288
+ metrics = wave_metrics[wave]
289
+ derived = derived_metrics.get(wave, {})
290
+
291
+ result['scorecards'][wave] = {
292
+ 'entries': metrics['entries'],
293
+ 'verdicts': {
294
+ 'ok': metrics['ok_count'],
295
+ 'failed': metrics['failed_count'],
296
+ 'empty': metrics['empty_count'],
297
+ 'hung': metrics['hung_count'],
298
+ },
299
+ 'success_rate': derived.get('success_rate', 'n/a'),
300
+ 'first_try_green_rate': derived.get('first_try_green_rate', 'n/a'),
301
+ 'repair_rounds': metrics['repair_rounds'],
302
+ 'repair_efficiency': derived.get('repair_efficiency', 'n/a'),
303
+ 'tokens': {
304
+ 'in': metrics['tokens_in'],
305
+ 'out': metrics['tokens_out'],
306
+ },
307
+ 'cost_estimate': derived.get('cost_estimate', 'n/a'),
308
+ 'duration_sec': metrics['duration_sec'],
309
+ 'by_phase': metrics['by_phase'],
310
+ 'by_model': metrics['by_model'],
311
+ 'by_agent_type': metrics['by_agent_type'],
312
+ }
313
+
314
+ if not result['scorecards']:
315
+ result['error'] = "No data available from ledger"
316
+
317
+ return json.dumps(result, indent=2)
318
+
319
+
320
+ def format_markdown(wave_metrics, derived_metrics):
321
+ """Format metrics as markdown table (side-by-side).
322
+
323
+ Args:
324
+ wave_metrics: dict from compute_wave_metrics()
325
+ derived_metrics: dict from compute_derived_metrics()
326
+
327
+ Returns:
328
+ markdown string
329
+ """
330
+ if not wave_metrics:
331
+ return "No data available from ledger (n/a: no entries)\n"
332
+
333
+ output = []
334
+ output.append("\n## Wave Quality Scorecards\n")
335
+
336
+ # Sort by wave number
337
+ sorted_waves = sorted(wave_metrics.keys())
338
+
339
+ # Header
340
+ header = "| Metric" + "".join(f" | Wave {w}" for w in sorted_waves) + " |"
341
+ separator = "|--------" + "".join("|--------" for _ in sorted_waves) + "|"
342
+
343
+ output.append(header)
344
+ output.append(separator)
345
+
346
+ # Metrics rows
347
+ metrics_to_show = [
348
+ ('entries', 'Entries'),
349
+ ('ok_count', 'OK Count'),
350
+ ('failed_count', 'Failed Count'),
351
+ ('repair_rounds', 'Repair Rounds'),
352
+ ('tokens_out', 'Tokens Out'),
353
+ ]
354
+
355
+ for metric_key, metric_name in metrics_to_show:
356
+ row = f"| {metric_name}"
357
+ for wave in sorted_waves:
358
+ metrics = wave_metrics[wave]
359
+ value = metrics.get(metric_key, 0)
360
+ row += f" | {value}"
361
+ row += " |"
362
+ output.append(row)
363
+
364
+ # Derived metrics rows
365
+ derived_to_show = [
366
+ ('success_rate', 'Success Rate'),
367
+ ('first_try_green_rate', 'First-Try-Green'),
368
+ ('repair_efficiency', 'Repair Efficiency'),
369
+ ('cost_estimate', 'Est. Cost'),
370
+ ]
371
+
372
+ for derived_key, derived_name in derived_to_show:
373
+ row = f"| {derived_name}"
374
+ for wave in sorted_waves:
375
+ derived = derived_metrics.get(wave, {})
376
+ value = derived.get(derived_key, 'n/a')
377
+ row += f" | {value}"
378
+ row += " |"
379
+ output.append(row)
380
+
381
+ output.append("")
382
+ return "\n".join(output)
383
+
384
+
385
+ def main():
386
+ """Main entry point."""
387
+ import argparse
388
+
389
+ parser = argparse.ArgumentParser(
390
+ description="Wave Quality Scorecard Generator",
391
+ epilog="Output formats: ASCII (default), --json (machine-readable), --md (markdown table)"
392
+ )
393
+ parser.add_argument("--json", action="store_true", help="Output JSON")
394
+ parser.add_argument("--md", action="store_true", help="Output markdown table")
395
+ parser.add_argument("--waves", type=int, help="Show last N waves (default: all)")
396
+ parser.add_argument("--state-root", help="Path to state directory (overrides AESOP_STATE_ROOT)")
397
+
398
+ args = parser.parse_args()
399
+
400
+ # Override state root if provided
401
+ if args.state_root:
402
+ os.environ['AESOP_STATE_ROOT'] = args.state_root
403
+
404
+ # Parse ledger
405
+ rows = parse_ledger_rows()
406
+
407
+ # Filter to last N waves if requested
408
+ if args.waves:
409
+ if rows:
410
+ max_wave = max((r['wave'] for r in rows if r['wave'] is not None), default=0)
411
+ min_wave = max(0, max_wave - args.waves + 1)
412
+ rows = [r for r in rows if r['wave'] is None or r['wave'] >= min_wave]
413
+
414
+ # Compute metrics
415
+ wave_metrics = compute_wave_metrics(rows)
416
+ derived_metrics = compute_derived_metrics(wave_metrics)
417
+
418
+ # Format output
419
+ if args.json:
420
+ print(format_json(wave_metrics, derived_metrics))
421
+ elif args.md:
422
+ print(format_markdown(wave_metrics, derived_metrics))
423
+ else:
424
+ print(format_ascii(wave_metrics, derived_metrics))
425
+
426
+ return 0
427
+
428
+
429
+ if __name__ == '__main__':
430
+ sys.exit(main())
package/ui/config.py CHANGED
@@ -52,7 +52,7 @@ def reload():
52
52
  with open(derived_config_file) as f:
53
53
  derived_config = json.load(f)
54
54
  if "aesop_root" in derived_config:
55
- AESOP_ROOT = Path(derived_config["aesop_root"])
55
+ AESOP_ROOT = Path(derived_config["aesop_root"]).expanduser()
56
56
  except Exception:
57
57
  # Silently ignore config errors here; will attempt full load below
58
58
  pass
package/ui/cost.py CHANGED
@@ -155,13 +155,16 @@ def get_cost_summary():
155
155
 
156
156
  Extended fields (additively):
157
157
  - per_week_costs: dict of "YYYY-Www" -> week cost/token totals and model mix
158
+ - per_wave_costs: dict of "wave-N" -> wave cost/token totals and model mix
159
+ - per_agent_costs: dict of agent_type -> agent cost/token totals and model mix
158
160
  - verdict_weighted_cost: cost-per-outcome metrics (cost per OK, weighted by verdict distribution)
159
161
  - model_mix_trend: per-day model usage distribution (%)
160
162
 
161
163
  Returns:
162
164
  dict: CostSummary with models, daily_totals, overall_scorecard,
163
165
  skipped_lines, has_pricing, estimates_by_model, per_week_costs,
164
- verdict_weighted_cost, model_mix_trend (or error field if invalid).
166
+ per_wave_costs, per_agent_costs, verdict_weighted_cost, model_mix_trend
167
+ (or error field if invalid).
165
168
  """
166
169
  import sys
167
170
  from datetime import datetime, timedelta
@@ -188,6 +191,8 @@ def get_cost_summary():
188
191
  "has_pricing": False,
189
192
  "estimates_by_model": {},
190
193
  "per_week_costs": {},
194
+ "per_wave_costs": {},
195
+ "per_agent_costs": {},
191
196
  "verdict_weighted_cost": {
192
197
  "cost_per_ok": 0.0,
193
198
  "cost_per_failed": 0.0,
@@ -294,14 +299,28 @@ def get_cost_summary():
294
299
  result["skipped_lines"] += 1
295
300
  continue
296
301
 
297
- # Store ledger entry for per-week calculation
302
+ # Extract optional wave and phase fields (if present)
303
+ wave = None
304
+ phase = None
305
+ try:
306
+ if len(parts) > 8:
307
+ phase = parts[8].strip() if len(parts) > 8 else None
308
+ if len(parts) > 9:
309
+ wave = parts[9].strip() if len(parts) > 9 else None
310
+ except (IndexError, ValueError):
311
+ pass
312
+
313
+ # Store ledger entry for per-week/per-wave/per-agent calculation
298
314
  ledger_entries.append({
299
315
  "timestamp": timestamp,
300
316
  "date_str": date_str,
301
317
  "model": model,
318
+ "agent_type": agent_type,
302
319
  "tokens_in": tokens_in,
303
320
  "tokens_out": tokens_out,
304
- "verdict": verdict
321
+ "verdict": verdict,
322
+ "wave": wave,
323
+ "phase": phase
305
324
  })
306
325
 
307
326
  # Aggregate by model
@@ -367,6 +386,8 @@ def get_cost_summary():
367
386
 
368
387
  # Calculate per-week costs and model mix trend
369
388
  _calculate_weekly_costs(result, pricing_map, ledger_entries)
389
+ _calculate_per_wave_costs(result, pricing_map, ledger_entries)
390
+ _calculate_per_agent_costs(result, pricing_map, ledger_entries)
370
391
  _calculate_verdict_weighted_cost(result, pricing_map)
371
392
  _calculate_model_mix_trend(result)
372
393
 
@@ -551,6 +572,158 @@ def _calculate_model_mix_trend(result):
551
572
  result["model_mix_trend"] = model_mix_trend
552
573
 
553
574
 
575
+ def _calculate_per_wave_costs(result, pricing_map, ledger_entries):
576
+ """Calculate per-wave cost rollup from ledger entries.
577
+
578
+ Groups ledger entries by wave number and aggregates per-model tokens for each wave.
579
+ If pricing is available, includes cost estimates.
580
+
581
+ Modifies result["per_wave_costs"] in-place with structure:
582
+ {
583
+ "wave-N": {
584
+ "tokens_in": int,
585
+ "tokens_out": int,
586
+ "model_tokens": {"model": int, ...},
587
+ "cost": float (if pricing available)
588
+ }
589
+ }
590
+ """
591
+ if not ledger_entries:
592
+ return
593
+
594
+ # Group ledger entries by wave and aggregate per-model within each wave
595
+ waves = {}
596
+ for entry in ledger_entries:
597
+ if not entry.get("wave"):
598
+ # Skip entries without wave info
599
+ continue
600
+
601
+ wave_key = f"wave-{entry['wave']}"
602
+
603
+ if wave_key not in waves:
604
+ waves[wave_key] = {
605
+ "tokens_in": 0,
606
+ "tokens_out": 0,
607
+ "model_tokens": {},
608
+ "cost": 0.0
609
+ }
610
+
611
+ # Aggregate this entry's tokens into the wave
612
+ waves[wave_key]["tokens_in"] += entry["tokens_in"]
613
+ waves[wave_key]["tokens_out"] += entry["tokens_out"]
614
+
615
+ # Track per-model tokens within this wave
616
+ model = entry["model"]
617
+ if model not in waves[wave_key]["model_tokens"]:
618
+ waves[wave_key]["model_tokens"][model] = 0
619
+ waves[wave_key]["model_tokens"][model] += entry["tokens_in"] + entry["tokens_out"]
620
+
621
+ # If pricing available, calculate cost per wave based on that wave's model mix
622
+ if pricing_map:
623
+ for wave_key, wave_data in waves.items():
624
+ total_cost = 0.0
625
+ for model, total_tokens in wave_data["model_tokens"].items():
626
+ if model in pricing_map:
627
+ pricing = pricing_map[model]
628
+ input_price = pricing.get("input_per_mtok", 0.0)
629
+ output_price = pricing.get("output_per_mtok", 0.0)
630
+ # Find entries for this model in this wave
631
+ model_entries_in_wave = [
632
+ e for e in ledger_entries
633
+ if e.get("wave") == wave_key.replace("wave-", "") and e["model"] == model
634
+ ]
635
+ if model_entries_in_wave:
636
+ wave_model_tokens_in = sum(e["tokens_in"] for e in model_entries_in_wave)
637
+ wave_model_tokens_out = sum(e["tokens_out"] for e in model_entries_in_wave)
638
+ model_cost = (wave_model_tokens_in * input_price + wave_model_tokens_out * output_price) / 1_000_000
639
+ else:
640
+ # Fallback: if we can't find the entries, use 1:2 ratio estimate
641
+ tokens_in_estimate = total_tokens * 0.333
642
+ tokens_out_estimate = total_tokens * 0.667
643
+ model_cost = (tokens_in_estimate * input_price + tokens_out_estimate * output_price) / 1_000_000
644
+ total_cost += model_cost
645
+ wave_data["cost"] = total_cost
646
+
647
+ result["per_wave_costs"] = waves
648
+
649
+
650
+ def _calculate_per_agent_costs(result, pricing_map, ledger_entries):
651
+ """Calculate per-agent-type cost rollup from ledger entries.
652
+
653
+ Groups ledger entries by agent_type and aggregates per-model tokens for each agent type.
654
+ If pricing is available, includes cost estimates.
655
+
656
+ Modifies result["per_agent_costs"] in-place with structure:
657
+ {
658
+ "agent_type": {
659
+ "tokens_in": int,
660
+ "tokens_out": int,
661
+ "model_tokens": {"model": int, ...},
662
+ "runs": int,
663
+ "verdicts": {"OK": int, "FAILED": int, ...},
664
+ "cost": float (if pricing available)
665
+ }
666
+ }
667
+ """
668
+ if not ledger_entries:
669
+ return
670
+
671
+ # Group ledger entries by agent_type and aggregate per-model within each agent type
672
+ agents = {}
673
+ for entry in ledger_entries:
674
+ agent_type = entry.get("agent_type", "unknown")
675
+
676
+ if agent_type not in agents:
677
+ agents[agent_type] = {
678
+ "tokens_in": 0,
679
+ "tokens_out": 0,
680
+ "model_tokens": {},
681
+ "runs": 0,
682
+ "verdicts": {"OK": 0, "FAILED": 0, "EMPTY": 0, "HUNG": 0},
683
+ "cost": 0.0
684
+ }
685
+
686
+ # Aggregate this entry's tokens into the agent type
687
+ agents[agent_type]["tokens_in"] += entry["tokens_in"]
688
+ agents[agent_type]["tokens_out"] += entry["tokens_out"]
689
+ agents[agent_type]["runs"] += 1
690
+ agents[agent_type]["verdicts"][entry["verdict"]] += 1
691
+
692
+ # Track per-model tokens within this agent type
693
+ model = entry["model"]
694
+ if model not in agents[agent_type]["model_tokens"]:
695
+ agents[agent_type]["model_tokens"][model] = 0
696
+ agents[agent_type]["model_tokens"][model] += entry["tokens_in"] + entry["tokens_out"]
697
+
698
+ # If pricing available, calculate cost per agent type based on that type's model mix
699
+ if pricing_map:
700
+ for agent_type, agent_data in agents.items():
701
+ total_cost = 0.0
702
+ for model, total_tokens in agent_data["model_tokens"].items():
703
+ if model in pricing_map:
704
+ pricing = pricing_map[model]
705
+ input_price = pricing.get("input_per_mtok", 0.0)
706
+ output_price = pricing.get("output_per_mtok", 0.0)
707
+ # Find entries for this model from this agent type
708
+ model_entries_for_agent = [
709
+ e for e in ledger_entries
710
+ if e.get("agent_type") == agent_type and e["model"] == model
711
+ ]
712
+ if model_entries_for_agent:
713
+ agent_model_tokens_in = sum(e["tokens_in"] for e in model_entries_for_agent)
714
+ agent_model_tokens_out = sum(e["tokens_out"] for e in model_entries_for_agent)
715
+ model_cost = (agent_model_tokens_in * input_price + agent_model_tokens_out * output_price) / 1_000_000
716
+ else:
717
+ # Fallback: if we can't find the entries, use 1:2 ratio estimate
718
+ tokens_in_estimate = total_tokens * 0.333
719
+ tokens_out_estimate = total_tokens * 0.667
720
+ model_cost = (tokens_in_estimate * input_price + tokens_out_estimate * output_price) / 1_000_000
721
+ total_cost += model_cost
722
+ agent_data["cost"] = total_cost
723
+
724
+ result["per_agent_costs"] = agents
725
+
726
+
554
727
  def _load_pricing_config():
555
728
  """Load pricing map from aesop.config.json at call time.
556
729