trace-agent-server 0.0.1__py3-none-any.whl

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,851 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from trace_agent_server.models import Artifact, Decision, Event, Run
6
+ from trace_agent_server.services.run_summary import (
7
+ build_command_chain,
8
+ build_file_activity,
9
+ build_patch_activity,
10
+ build_timeline_groups,
11
+ build_tool_chain,
12
+ )
13
+
14
+
15
+ def _event_map(events: list[Event]) -> dict[int, Event]:
16
+ return {event.id: event for event in events}
17
+
18
+
19
+ def _first_seq(event_ids: list[int], lookup: dict[int, Event]) -> int:
20
+ seqs = [lookup[event_id].seq for event_id in event_ids if event_id in lookup]
21
+ return min(seqs) if seqs else 10**9
22
+
23
+
24
+ def _target(
25
+ *,
26
+ label: str,
27
+ status: str | None,
28
+ summary: str | None,
29
+ event_ids: list[int],
30
+ step_id: str | None,
31
+ duration_ms: int | None = None,
32
+ metadata: dict[str, Any] | None = None,
33
+ ) -> dict[str, Any]:
34
+ return {
35
+ "label": label,
36
+ "status": status,
37
+ "summary": summary,
38
+ "event_ids": event_ids,
39
+ "step_id": step_id,
40
+ "duration_ms": duration_ms,
41
+ "metadata": metadata or {},
42
+ }
43
+
44
+
45
+ def _normalize_label(value: str | None) -> str:
46
+ return (value or "").strip().lower()
47
+
48
+
49
+ def _pair_summary(kind: str, relation: str, left_label: str | None, right_label: str | None) -> str:
50
+ if relation == "shared":
51
+ return f"Shared {kind}: {left_label or right_label}."
52
+ if relation == "left_only":
53
+ return f"Only left run used {kind}: {left_label}."
54
+ if relation == "right_only":
55
+ return f"Only right run used {kind}: {right_label}."
56
+ if left_label == right_label:
57
+ return f"Same {kind} with a different outcome: {left_label}."
58
+ return f"{kind.title()} diverged: {left_label or 'none'} vs {right_label or 'none'}."
59
+
60
+
61
+ def _align_sequence(
62
+ left_items: list[dict[str, Any]],
63
+ right_items: list[dict[str, Any]],
64
+ *,
65
+ kind: str,
66
+ label_key: str,
67
+ step_key: str = "step_id",
68
+ status_key: str = "status",
69
+ summary_keys: tuple[str, ...],
70
+ metadata_builder,
71
+ ) -> list[dict[str, Any]]:
72
+ pairs: list[dict[str, Any]] = []
73
+ left_index = 0
74
+ right_index = 0
75
+
76
+ while left_index < len(left_items) or right_index < len(right_items):
77
+ left_item = left_items[left_index] if left_index < len(left_items) else None
78
+ right_item = right_items[right_index] if right_index < len(right_items) else None
79
+
80
+ if left_item is None and right_item is not None:
81
+ right_label = str(right_item.get(label_key) or "unknown")
82
+ pairs.append(
83
+ {
84
+ "relation": "right_only",
85
+ "kind": kind,
86
+ "summary": _pair_summary(kind, "right_only", None, right_label),
87
+ "left": None,
88
+ "right": _target(
89
+ label=right_label,
90
+ status=right_item.get(status_key),
91
+ summary=next((right_item.get(key) for key in summary_keys if right_item.get(key)), None),
92
+ event_ids=list(right_item.get("event_ids", [right_item.get("event_id")]).copy()),
93
+ step_id=right_item.get(step_key),
94
+ duration_ms=right_item.get("duration_ms"),
95
+ metadata=metadata_builder(right_item),
96
+ ),
97
+ }
98
+ )
99
+ right_index += 1
100
+ continue
101
+
102
+ if right_item is None and left_item is not None:
103
+ left_label = str(left_item.get(label_key) or "unknown")
104
+ pairs.append(
105
+ {
106
+ "relation": "left_only",
107
+ "kind": kind,
108
+ "summary": _pair_summary(kind, "left_only", left_label, None),
109
+ "left": _target(
110
+ label=left_label,
111
+ status=left_item.get(status_key),
112
+ summary=next((left_item.get(key) for key in summary_keys if left_item.get(key)), None),
113
+ event_ids=list(left_item.get("event_ids", [left_item.get("event_id")]).copy()),
114
+ step_id=left_item.get(step_key),
115
+ duration_ms=left_item.get("duration_ms"),
116
+ metadata=metadata_builder(left_item),
117
+ ),
118
+ "right": None,
119
+ }
120
+ )
121
+ left_index += 1
122
+ continue
123
+
124
+ assert left_item is not None and right_item is not None
125
+ left_label = str(left_item.get(label_key) or "unknown")
126
+ right_label = str(right_item.get(label_key) or "unknown")
127
+ left_step = left_item.get(step_key)
128
+ right_step = right_item.get(step_key)
129
+
130
+ match = False
131
+ if left_step and right_step and left_step == right_step:
132
+ match = True
133
+ elif _normalize_label(left_label) == _normalize_label(right_label):
134
+ match = True
135
+ elif left_index + 1 < len(left_items) and _normalize_label(str(left_items[left_index + 1].get(label_key) or "")) == _normalize_label(right_label):
136
+ pairs.append(
137
+ {
138
+ "relation": "left_only",
139
+ "kind": kind,
140
+ "summary": _pair_summary(kind, "left_only", left_label, None),
141
+ "left": _target(
142
+ label=left_label,
143
+ status=left_item.get(status_key),
144
+ summary=next((left_item.get(key) for key in summary_keys if left_item.get(key)), None),
145
+ event_ids=list(left_item.get("event_ids", [left_item.get("event_id")]).copy()),
146
+ step_id=left_item.get(step_key),
147
+ duration_ms=left_item.get("duration_ms"),
148
+ metadata=metadata_builder(left_item),
149
+ ),
150
+ "right": None,
151
+ }
152
+ )
153
+ left_index += 1
154
+ continue
155
+ elif right_index + 1 < len(right_items) and _normalize_label(left_label) == _normalize_label(str(right_items[right_index + 1].get(label_key) or "")):
156
+ pairs.append(
157
+ {
158
+ "relation": "right_only",
159
+ "kind": kind,
160
+ "summary": _pair_summary(kind, "right_only", None, right_label),
161
+ "left": None,
162
+ "right": _target(
163
+ label=right_label,
164
+ status=right_item.get(status_key),
165
+ summary=next((right_item.get(key) for key in summary_keys if right_item.get(key)), None),
166
+ event_ids=list(right_item.get("event_ids", [right_item.get("event_id")]).copy()),
167
+ step_id=right_item.get(step_key),
168
+ duration_ms=right_item.get("duration_ms"),
169
+ metadata=metadata_builder(right_item),
170
+ ),
171
+ }
172
+ )
173
+ right_index += 1
174
+ continue
175
+
176
+ relation = "shared"
177
+ if not match or left_item.get(status_key) != right_item.get(status_key):
178
+ relation = "different"
179
+
180
+ pairs.append(
181
+ {
182
+ "relation": relation,
183
+ "kind": kind,
184
+ "summary": _pair_summary(kind, relation, left_label, right_label),
185
+ "left": _target(
186
+ label=left_label,
187
+ status=left_item.get(status_key),
188
+ summary=next((left_item.get(key) for key in summary_keys if left_item.get(key)), None),
189
+ event_ids=list(left_item.get("event_ids", [left_item.get("event_id")]).copy()),
190
+ step_id=left_item.get(step_key),
191
+ duration_ms=left_item.get("duration_ms"),
192
+ metadata=metadata_builder(left_item),
193
+ ),
194
+ "right": _target(
195
+ label=right_label,
196
+ status=right_item.get(status_key),
197
+ summary=next((right_item.get(key) for key in summary_keys if right_item.get(key)), None),
198
+ event_ids=list(right_item.get("event_ids", [right_item.get("event_id")]).copy()),
199
+ step_id=right_item.get(step_key),
200
+ duration_ms=right_item.get("duration_ms"),
201
+ metadata=metadata_builder(right_item),
202
+ ),
203
+ }
204
+ )
205
+ left_index += 1
206
+ right_index += 1
207
+
208
+ return pairs
209
+
210
+
211
+ def _final_file_state(file_activity: list[dict[str, Any]], patch_activity: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
212
+ state: dict[str, dict[str, Any]] = {}
213
+ for item in file_activity:
214
+ if item["event_type"] != "file.written":
215
+ continue
216
+ state[item["path"]] = {
217
+ **item,
218
+ "source": "file",
219
+ }
220
+ for item in patch_activity:
221
+ state[item["path"]] = {
222
+ "step_id": item["step_id"],
223
+ "event_id": item["event_id"],
224
+ "path": item["path"],
225
+ "status": item["status"],
226
+ "change_type": "update",
227
+ "summary": item["summary"],
228
+ "content_hash": item.get("after_hash"),
229
+ "before_hash": item.get("before_hash"),
230
+ "after_hash": item.get("after_hash"),
231
+ "diff_summary": item.get("diff_summary"),
232
+ "line_additions": item.get("line_additions"),
233
+ "line_deletions": item.get("line_deletions"),
234
+ "timestamp": item["timestamp"],
235
+ "source": "patch",
236
+ }
237
+ return state
238
+
239
+
240
+ def _compare_file_state(left_state: dict[str, dict[str, Any]], right_state: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
241
+ pairs: list[dict[str, Any]] = []
242
+ paths = sorted(set(left_state) | set(right_state))
243
+ for path in paths:
244
+ left_item = left_state.get(path)
245
+ right_item = right_state.get(path)
246
+ relation = "shared"
247
+ if left_item is None:
248
+ relation = "right_only"
249
+ elif right_item is None:
250
+ relation = "left_only"
251
+ elif (left_item.get("after_hash") or left_item.get("content_hash")) != (right_item.get("after_hash") or right_item.get("content_hash")):
252
+ relation = "different"
253
+
254
+ pairs.append(
255
+ {
256
+ "relation": relation,
257
+ "kind": "file",
258
+ "summary": _pair_summary("file", relation, path if left_item else None, path if right_item else None),
259
+ "left": None
260
+ if left_item is None
261
+ else _target(
262
+ label=path,
263
+ status=left_item.get("status"),
264
+ summary=left_item.get("diff_summary") or left_item.get("summary"),
265
+ event_ids=[left_item.get("event_id")],
266
+ step_id=left_item.get("step_id"),
267
+ metadata={
268
+ "path": path,
269
+ "change_type": left_item.get("change_type"),
270
+ "after_hash": left_item.get("after_hash"),
271
+ "before_hash": left_item.get("before_hash"),
272
+ "line_additions": left_item.get("line_additions"),
273
+ "line_deletions": left_item.get("line_deletions"),
274
+ "source": left_item.get("source"),
275
+ },
276
+ ),
277
+ "right": None
278
+ if right_item is None
279
+ else _target(
280
+ label=path,
281
+ status=right_item.get("status"),
282
+ summary=right_item.get("diff_summary") or right_item.get("summary"),
283
+ event_ids=[right_item.get("event_id")],
284
+ step_id=right_item.get("step_id"),
285
+ metadata={
286
+ "path": path,
287
+ "change_type": right_item.get("change_type"),
288
+ "after_hash": right_item.get("after_hash"),
289
+ "before_hash": right_item.get("before_hash"),
290
+ "line_additions": right_item.get("line_additions"),
291
+ "line_deletions": right_item.get("line_deletions"),
292
+ "source": right_item.get("source"),
293
+ },
294
+ ),
295
+ }
296
+ )
297
+ return pairs
298
+
299
+
300
+ def _compare_artifacts(left_artifacts: list[Artifact], right_artifacts: list[Artifact]) -> list[dict[str, Any]]:
301
+ left_map = {f"{artifact.kind}:{artifact.label}": artifact for artifact in left_artifacts}
302
+ right_map = {f"{artifact.kind}:{artifact.label}": artifact for artifact in right_artifacts}
303
+ pairs: list[dict[str, Any]] = []
304
+ for key in sorted(set(left_map) | set(right_map)):
305
+ left_artifact = left_map.get(key)
306
+ right_artifact = right_map.get(key)
307
+ relation = "shared"
308
+ if left_artifact is None:
309
+ relation = "right_only"
310
+ elif right_artifact is None:
311
+ relation = "left_only"
312
+ elif left_artifact.content_hash != right_artifact.content_hash:
313
+ relation = "different"
314
+
315
+ label = key.split(":", 1)[1]
316
+ pairs.append(
317
+ {
318
+ "relation": relation,
319
+ "kind": "artifact",
320
+ "summary": _pair_summary("artifact", relation, label if left_artifact else None, label if right_artifact else None),
321
+ "left": None
322
+ if left_artifact is None
323
+ else _target(
324
+ label=left_artifact.label,
325
+ status="created",
326
+ summary=left_artifact.summary,
327
+ event_ids=[left_artifact.source_event_id] if left_artifact.source_event_id else [],
328
+ step_id=None,
329
+ metadata={"kind": left_artifact.kind, "content_hash": left_artifact.content_hash},
330
+ ),
331
+ "right": None
332
+ if right_artifact is None
333
+ else _target(
334
+ label=right_artifact.label,
335
+ status="created",
336
+ summary=right_artifact.summary,
337
+ event_ids=[right_artifact.source_event_id] if right_artifact.source_event_id else [],
338
+ step_id=None,
339
+ metadata={"kind": right_artifact.kind, "content_hash": right_artifact.content_hash},
340
+ ),
341
+ }
342
+ )
343
+ return pairs
344
+
345
+
346
+ def _timeline_sequence(
347
+ tool_chain: list[dict[str, Any]],
348
+ command_chain: list[dict[str, Any]],
349
+ file_activity: list[dict[str, Any]],
350
+ patch_activity: list[dict[str, Any]],
351
+ lookup: dict[int, Event],
352
+ ) -> list[dict[str, Any]]:
353
+ items: list[dict[str, Any]] = []
354
+ for item in tool_chain:
355
+ items.append(
356
+ {
357
+ "kind": "tool",
358
+ "label": item["tool_name"],
359
+ "status": item["status"],
360
+ "summary": item["error_summary"] or item["output_summary"] or item["args_summary"],
361
+ "event_ids": item["event_ids"],
362
+ "step_id": item["step_id"],
363
+ "duration_ms": item["duration_ms"],
364
+ "metadata": {"tool_name": item["tool_name"]},
365
+ "_seq": _first_seq(item["event_ids"], lookup),
366
+ }
367
+ )
368
+ for item in command_chain:
369
+ items.append(
370
+ {
371
+ "kind": "command",
372
+ "label": item["command"],
373
+ "status": item["status"],
374
+ "summary": item["error_summary"] or item["output_summary"],
375
+ "event_ids": item["event_ids"],
376
+ "step_id": item["step_id"],
377
+ "duration_ms": item["duration_ms"],
378
+ "metadata": {"cwd": item["cwd"], "exit_code": item["exit_code"]},
379
+ "_seq": _first_seq(item["event_ids"], lookup),
380
+ }
381
+ )
382
+ for item in file_activity:
383
+ items.append(
384
+ {
385
+ "kind": "file",
386
+ "label": item["path"],
387
+ "status": item["status"],
388
+ "summary": item["summary"],
389
+ "event_ids": [item["event_id"]],
390
+ "step_id": item["step_id"],
391
+ "duration_ms": None,
392
+ "metadata": {"event_type": item["event_type"], "change_type": item["change_type"]},
393
+ "_seq": lookup[item["event_id"]].seq,
394
+ }
395
+ )
396
+ for item in patch_activity:
397
+ items.append(
398
+ {
399
+ "kind": "file",
400
+ "label": item["path"],
401
+ "status": item["status"],
402
+ "summary": item["summary"],
403
+ "event_ids": [item["event_id"]],
404
+ "step_id": item["step_id"],
405
+ "duration_ms": None,
406
+ "metadata": {"event_type": "patch.applied", "change_type": "update"},
407
+ "_seq": lookup[item["event_id"]].seq,
408
+ }
409
+ )
410
+ items.sort(key=lambda item: item["_seq"])
411
+ return items
412
+
413
+
414
+ def _align_timeline(left_items: list[dict[str, Any]], right_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
415
+ pairs: list[dict[str, Any]] = []
416
+ left_index = 0
417
+ right_index = 0
418
+
419
+ while left_index < len(left_items) or right_index < len(right_items):
420
+ left_item = left_items[left_index] if left_index < len(left_items) else None
421
+ right_item = right_items[right_index] if right_index < len(right_items) else None
422
+
423
+ if left_item is None and right_item is not None:
424
+ pairs.append(
425
+ {
426
+ "relation": "right_only",
427
+ "kind": right_item["kind"],
428
+ "summary": f"Only right run has {right_item['kind']}: {right_item['label']}.",
429
+ "left": None,
430
+ "right": _target(
431
+ label=right_item["label"],
432
+ status=right_item["status"],
433
+ summary=right_item["summary"],
434
+ event_ids=right_item["event_ids"],
435
+ step_id=right_item["step_id"],
436
+ duration_ms=right_item["duration_ms"],
437
+ metadata=right_item["metadata"],
438
+ ),
439
+ }
440
+ )
441
+ right_index += 1
442
+ continue
443
+
444
+ if right_item is None and left_item is not None:
445
+ pairs.append(
446
+ {
447
+ "relation": "left_only",
448
+ "kind": left_item["kind"],
449
+ "summary": f"Only left run has {left_item['kind']}: {left_item['label']}.",
450
+ "left": _target(
451
+ label=left_item["label"],
452
+ status=left_item["status"],
453
+ summary=left_item["summary"],
454
+ event_ids=left_item["event_ids"],
455
+ step_id=left_item["step_id"],
456
+ duration_ms=left_item["duration_ms"],
457
+ metadata=left_item["metadata"],
458
+ ),
459
+ "right": None,
460
+ }
461
+ )
462
+ left_index += 1
463
+ continue
464
+
465
+ assert left_item is not None and right_item is not None
466
+ if left_item["kind"] == right_item["kind"] and _normalize_label(left_item["label"]) == _normalize_label(right_item["label"]):
467
+ relation = "shared" if left_item["status"] == right_item["status"] else "different"
468
+ pairs.append(
469
+ {
470
+ "relation": relation,
471
+ "kind": left_item["kind"],
472
+ "summary": _pair_summary(left_item["kind"], relation, left_item["label"], right_item["label"]),
473
+ "left": _target(
474
+ label=left_item["label"],
475
+ status=left_item["status"],
476
+ summary=left_item["summary"],
477
+ event_ids=left_item["event_ids"],
478
+ step_id=left_item["step_id"],
479
+ duration_ms=left_item["duration_ms"],
480
+ metadata=left_item["metadata"],
481
+ ),
482
+ "right": _target(
483
+ label=right_item["label"],
484
+ status=right_item["status"],
485
+ summary=right_item["summary"],
486
+ event_ids=right_item["event_ids"],
487
+ step_id=right_item["step_id"],
488
+ duration_ms=right_item["duration_ms"],
489
+ metadata=right_item["metadata"],
490
+ ),
491
+ }
492
+ )
493
+ left_index += 1
494
+ right_index += 1
495
+ continue
496
+
497
+ if left_item["_seq"] <= right_item["_seq"]:
498
+ pairs.append(
499
+ {
500
+ "relation": "left_only",
501
+ "kind": left_item["kind"],
502
+ "summary": f"Only left run has {left_item['kind']}: {left_item['label']}.",
503
+ "left": _target(
504
+ label=left_item["label"],
505
+ status=left_item["status"],
506
+ summary=left_item["summary"],
507
+ event_ids=left_item["event_ids"],
508
+ step_id=left_item["step_id"],
509
+ duration_ms=left_item["duration_ms"],
510
+ metadata=left_item["metadata"],
511
+ ),
512
+ "right": None,
513
+ }
514
+ )
515
+ left_index += 1
516
+ else:
517
+ pairs.append(
518
+ {
519
+ "relation": "right_only",
520
+ "kind": right_item["kind"],
521
+ "summary": f"Only right run has {right_item['kind']}: {right_item['label']}.",
522
+ "left": None,
523
+ "right": _target(
524
+ label=right_item["label"],
525
+ status=right_item["status"],
526
+ summary=right_item["summary"],
527
+ event_ids=right_item["event_ids"],
528
+ step_id=right_item["step_id"],
529
+ duration_ms=right_item["duration_ms"],
530
+ metadata=right_item["metadata"],
531
+ ),
532
+ }
533
+ )
534
+ right_index += 1
535
+
536
+ return pairs
537
+
538
+
539
+ def _overview_diff(left_run: Run, right_run: Run, left_events: list[Event], right_events: list[Event]) -> dict[str, Any]:
540
+ left_token_total = sum(int((event.event_metadata.get("usage") or {}).get("total_tokens") or 0) for event in left_events if event.type == "model.responded")
541
+ right_token_total = sum(int((event.event_metadata.get("usage") or {}).get("total_tokens") or 0) for event in right_events if event.type == "model.responded")
542
+
543
+ duration_ms_delta = None
544
+ if left_run.ended_at is not None and right_run.ended_at is not None:
545
+ left_duration = int((left_run.ended_at - left_run.started_at).total_seconds() * 1000)
546
+ right_duration = int((right_run.ended_at - right_run.started_at).total_seconds() * 1000)
547
+ duration_ms_delta = right_duration - left_duration
548
+
549
+ return {
550
+ "tool_count_delta": right_run.tool_count - left_run.tool_count,
551
+ "command_count_delta": sum(1 for event in right_events if event.type in {"command.succeeded", "command.failed"})
552
+ - sum(1 for event in left_events if event.type in {"command.succeeded", "command.failed"}),
553
+ "error_count_delta": right_run.error_count - left_run.error_count,
554
+ "retry_count_delta": right_run.retry_count - left_run.retry_count,
555
+ "artifact_count_delta": right_run.artifact_count - left_run.artifact_count,
556
+ "token_total_delta": right_token_total - left_token_total,
557
+ "duration_ms_delta": duration_ms_delta,
558
+ }
559
+
560
+
561
+ def _detect_retry_without_adaptation(tool_chain: list[dict[str, Any]]) -> list[int]:
562
+ last_failed_by_tool: dict[str, dict[str, Any]] = {}
563
+ for item in tool_chain:
564
+ if item["status"] == "failed":
565
+ last_failed_by_tool[item["tool_name"]] = item
566
+ continue
567
+ previous = last_failed_by_tool.get(item["tool_name"])
568
+ if previous and previous.get("args_summary") == item.get("args_summary"):
569
+ return previous["event_ids"] + item["event_ids"]
570
+ return []
571
+
572
+
573
+ def _derive_root_cause(
574
+ *,
575
+ left_run: Run,
576
+ right_run: Run,
577
+ divergence: dict[str, Any] | None,
578
+ tool_diff: list[dict[str, Any]],
579
+ file_diff: list[dict[str, Any]],
580
+ artifact_diff: list[dict[str, Any]],
581
+ left_tool_chain: list[dict[str, Any]],
582
+ right_tool_chain: list[dict[str, Any]],
583
+ left_lookup: dict[int, Event],
584
+ right_lookup: dict[int, Event],
585
+ ) -> dict[str, Any]:
586
+ if divergence is None:
587
+ return {
588
+ "label": "no_observable_root_cause",
589
+ "summary": "No observable divergence was found between the selected runs.",
590
+ "first_causal_event_left": None,
591
+ "first_causal_event_right": None,
592
+ "supporting_event_ids": {"left": [], "right": []},
593
+ "impact_summary": "Both runs stayed aligned across the observed execution path.",
594
+ "confidence": "low",
595
+ }
596
+
597
+ left_event_ids = divergence.get("left_event_ids", [])
598
+ right_event_ids = divergence.get("right_event_ids", [])
599
+ mismatch_kind = divergence["first_mismatch_kind"]
600
+
601
+ left_events = [left_lookup[event_id] for event_id in left_event_ids if event_id in left_lookup]
602
+ right_events = [right_lookup[event_id] for event_id in right_event_ids if event_id in right_lookup]
603
+
604
+ if any(event.type == "run.failed" and event.error_code == "provider_error" for event in left_events + right_events):
605
+ return {
606
+ "label": "provider_failure",
607
+ "summary": "The provider failed before the runs could stay aligned.",
608
+ "first_causal_event_left": left_event_ids[0] if left_event_ids else None,
609
+ "first_causal_event_right": right_event_ids[0] if right_event_ids else None,
610
+ "supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
611
+ "impact_summary": "The provider error interrupted the execution path and changed the final status.",
612
+ "confidence": "high",
613
+ }
614
+
615
+ if mismatch_kind == "command":
616
+ return {
617
+ "label": "command_failure",
618
+ "summary": "A command diverged first, and one run failed or changed outcome at that command step.",
619
+ "first_causal_event_left": left_event_ids[0] if left_event_ids else None,
620
+ "first_causal_event_right": right_event_ids[0] if right_event_ids else None,
621
+ "supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
622
+ "impact_summary": f"Run status diverged to {left_run.status} vs {right_run.status} after command execution changed.",
623
+ "confidence": "high",
624
+ }
625
+
626
+ if mismatch_kind == "tool":
627
+ retry_left = _detect_retry_without_adaptation(left_tool_chain)
628
+ retry_right = _detect_retry_without_adaptation(right_tool_chain)
629
+ if retry_left or retry_right:
630
+ return {
631
+ "label": "retry_without_adaptation",
632
+ "summary": "One run retried the same tool without changing the observable input or path.",
633
+ "first_causal_event_left": (retry_left or left_event_ids or [None])[0],
634
+ "first_causal_event_right": (retry_right or right_event_ids or [None])[0],
635
+ "supporting_event_ids": {"left": retry_left or left_event_ids, "right": retry_right or right_event_ids},
636
+ "impact_summary": "The extra retry delayed or derailed the run without introducing a new strategy.",
637
+ "confidence": "high",
638
+ }
639
+
640
+ first_tool_diff = next((item for item in tool_diff if item["relation"] == "different"), None)
641
+ if first_tool_diff and first_tool_diff.get("left") and first_tool_diff.get("right"):
642
+ left_label = first_tool_diff["left"]["label"]
643
+ right_label = first_tool_diff["right"]["label"]
644
+ if left_label != right_label:
645
+ return {
646
+ "label": "bad_tool_selection",
647
+ "summary": "The runs chose different tools at the same point in the execution.",
648
+ "first_causal_event_left": left_event_ids[0] if left_event_ids else None,
649
+ "first_causal_event_right": right_event_ids[0] if right_event_ids else None,
650
+ "supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
651
+ "impact_summary": f"The tool choice changed the rest of the execution path: {left_label} vs {right_label}.",
652
+ "confidence": "high",
653
+ }
654
+
655
+ return {
656
+ "label": "dead_end_branch",
657
+ "summary": "The runs diverged on tool execution and one branch stopped matching the successful path.",
658
+ "first_causal_event_left": left_event_ids[0] if left_event_ids else None,
659
+ "first_causal_event_right": right_event_ids[0] if right_event_ids else None,
660
+ "supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
661
+ "impact_summary": "The tool path changed before the final result diverged.",
662
+ "confidence": "medium",
663
+ }
664
+
665
+ different_file = next((item for item in file_diff if item["relation"] == "different"), None)
666
+ if mismatch_kind == "file" or different_file is not None:
667
+ if different_file is None and (
668
+ not any(item["relation"] in {"left_only", "right_only"} for item in file_diff)
669
+ and (
670
+ any(event.type == "file.read" for event in left_events) or any(event.type == "file.read" for event in right_events)
671
+ )
672
+ ):
673
+ return {
674
+ "label": "missing_context",
675
+ "summary": "One run observed file context that the other run did not capture before diverging.",
676
+ "first_causal_event_left": left_event_ids[0] if left_event_ids else None,
677
+ "first_causal_event_right": right_event_ids[0] if right_event_ids else None,
678
+ "supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
679
+ "impact_summary": "The missing file read changed the context available to the run before the result diverged.",
680
+ "confidence": "medium",
681
+ }
682
+ return {
683
+ "label": "file_change_divergence",
684
+ "summary": "The runs produced different file changes or patched different content.",
685
+ "first_causal_event_left": left_event_ids[0] if left_event_ids else None,
686
+ "first_causal_event_right": right_event_ids[0] if right_event_ids else None,
687
+ "supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
688
+ "impact_summary": "The file state diverged before the final output or artifact changed.",
689
+ "confidence": "high",
690
+ }
691
+
692
+ different_artifact = next((item for item in artifact_diff if item["relation"] == "different"), None)
693
+ if different_artifact is not None:
694
+ return {
695
+ "label": "artifact_mismatch",
696
+ "summary": "The runs ended with different persisted artifacts despite sharing part of the execution path.",
697
+ "first_causal_event_left": left_event_ids[0] if left_event_ids else None,
698
+ "first_causal_event_right": right_event_ids[0] if right_event_ids else None,
699
+ "supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
700
+ "impact_summary": "The observable output differed at the artifact layer.",
701
+ "confidence": "medium",
702
+ }
703
+
704
+ return {
705
+ "label": "no_observable_root_cause",
706
+ "summary": "The runs diverged, but the first causal reason is not observable from the stored events.",
707
+ "first_causal_event_left": left_event_ids[0] if left_event_ids else None,
708
+ "first_causal_event_right": right_event_ids[0] if right_event_ids else None,
709
+ "supporting_event_ids": {"left": left_event_ids, "right": right_event_ids},
710
+ "impact_summary": "The result changed after an observable divergence, but not enough evidence was captured to classify it.",
711
+ "confidence": "low",
712
+ }
713
+
714
+
715
+ def _first_divergence(pairs: list[dict[str, Any]], left_lookup: dict[int, Event], right_lookup: dict[int, Event]) -> dict[str, Any] | None:
716
+ candidates = [pair for pair in pairs if pair["relation"] != "shared"]
717
+ if not candidates:
718
+ return None
719
+
720
+ def sort_key(pair: dict[str, Any]) -> int:
721
+ seqs: list[int] = []
722
+ if pair.get("left"):
723
+ seqs.append(_first_seq(pair["left"]["event_ids"], left_lookup))
724
+ if pair.get("right"):
725
+ seqs.append(_first_seq(pair["right"]["event_ids"], right_lookup))
726
+ return min(seqs) if seqs else 10**9
727
+
728
+ first_pair = min(candidates, key=sort_key)
729
+ return {
730
+ "first_mismatch_kind": first_pair["kind"],
731
+ "left_event_ids": first_pair["left"]["event_ids"] if first_pair.get("left") else [],
732
+ "right_event_ids": first_pair["right"]["event_ids"] if first_pair.get("right") else [],
733
+ "summary": first_pair["summary"],
734
+ }
735
+
736
+
737
+ def compare_runs(
738
+ *,
739
+ left_run: Run,
740
+ right_run: Run,
741
+ left_events: list[Event],
742
+ right_events: list[Event],
743
+ left_artifacts: list[Artifact],
744
+ right_artifacts: list[Artifact],
745
+ left_decisions: list[Decision],
746
+ right_decisions: list[Decision],
747
+ ) -> dict[str, Any]:
748
+ left_lookup = _event_map(left_events)
749
+ right_lookup = _event_map(right_events)
750
+
751
+ left_tool_chain = build_tool_chain(left_events)
752
+ right_tool_chain = build_tool_chain(right_events)
753
+ left_command_chain = build_command_chain(left_events)
754
+ right_command_chain = build_command_chain(right_events)
755
+ left_file_activity = build_file_activity(left_events)
756
+ right_file_activity = build_file_activity(right_events)
757
+ left_patch_activity = build_patch_activity(left_events)
758
+ right_patch_activity = build_patch_activity(right_events)
759
+
760
+ tool_diff = _align_sequence(
761
+ left_tool_chain,
762
+ right_tool_chain,
763
+ kind="tool",
764
+ label_key="tool_name",
765
+ summary_keys=("error_summary", "output_summary", "args_summary"),
766
+ metadata_builder=lambda item: {
767
+ "tool_name": item.get("tool_name"),
768
+ "error_code": item.get("error_code"),
769
+ "artifact_count": item.get("artifact_count"),
770
+ },
771
+ )
772
+ command_diff = _align_sequence(
773
+ left_command_chain,
774
+ right_command_chain,
775
+ kind="command",
776
+ label_key="command",
777
+ summary_keys=("error_summary", "output_summary"),
778
+ metadata_builder=lambda item: {
779
+ "cwd": item.get("cwd"),
780
+ "exit_code": item.get("exit_code"),
781
+ "error_code": item.get("error_code"),
782
+ },
783
+ )
784
+ decision_diff = _align_sequence(
785
+ [
786
+ {
787
+ "step_id": decision.id,
788
+ "label": decision.label,
789
+ "status": decision.outcome,
790
+ "summary": decision.rationale_summary,
791
+ "event_ids": decision.evidence_event_ids,
792
+ "duration_ms": None,
793
+ "kind": decision.kind,
794
+ }
795
+ for decision in left_decisions
796
+ ],
797
+ [
798
+ {
799
+ "step_id": decision.id,
800
+ "label": decision.label,
801
+ "status": decision.outcome,
802
+ "summary": decision.rationale_summary,
803
+ "event_ids": decision.evidence_event_ids,
804
+ "duration_ms": None,
805
+ "kind": decision.kind,
806
+ }
807
+ for decision in right_decisions
808
+ ],
809
+ kind="decision",
810
+ label_key="label",
811
+ summary_keys=("summary",),
812
+ metadata_builder=lambda item: {"kind": item.get("kind")},
813
+ )
814
+
815
+ file_diff = _compare_file_state(
816
+ _final_file_state(left_file_activity, left_patch_activity),
817
+ _final_file_state(right_file_activity, right_patch_activity),
818
+ )
819
+ artifact_diff = _compare_artifacts(left_artifacts, right_artifacts)
820
+
821
+ aligned_timeline = _align_timeline(
822
+ _timeline_sequence(left_tool_chain, left_command_chain, left_file_activity, left_patch_activity, left_lookup),
823
+ _timeline_sequence(right_tool_chain, right_command_chain, right_file_activity, right_patch_activity, right_lookup),
824
+ )
825
+ divergence = _first_divergence(aligned_timeline, left_lookup, right_lookup)
826
+ root_cause = _derive_root_cause(
827
+ left_run=left_run,
828
+ right_run=right_run,
829
+ divergence=divergence,
830
+ tool_diff=tool_diff,
831
+ file_diff=file_diff,
832
+ artifact_diff=artifact_diff,
833
+ left_tool_chain=left_tool_chain,
834
+ right_tool_chain=right_tool_chain,
835
+ left_lookup=left_lookup,
836
+ right_lookup=right_lookup,
837
+ )
838
+
839
+ return {
840
+ "overview_diff": _overview_diff(left_run, right_run, left_events, right_events),
841
+ "divergence": divergence,
842
+ "aligned_timeline": aligned_timeline,
843
+ "tool_diff": tool_diff,
844
+ "command_diff": command_diff,
845
+ "file_diff": file_diff,
846
+ "artifact_diff": artifact_diff,
847
+ "decision_diff": decision_diff,
848
+ "root_cause": root_cause,
849
+ "left_timeline_groups": build_timeline_groups(left_events),
850
+ "right_timeline_groups": build_timeline_groups(right_events),
851
+ }