agentdynamics 0.4.0__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,376 @@
1
+ """Ingestion + analysis engine.
2
+
3
+ Pipeline:
4
+ push (OTLP, LangSmith-compatible API, SDK, webhook) ─┐
5
+ pull (LangSmith API, Langfuse API, inbox file tailer) ─┼─> spans_raw (durable, idempotent upserts by span id)
6
+ files (Claude Code transcripts, SDK run files) ───────┘ │
7
+ ▼
8
+ trace assembly (late spans simply re-assemble their trace) -> normalized runs
9
+ ▼
10
+ per-run analysis (cached; only dirty runs recomputed) -> cross-run finalize
11
+ (baselines, scores, events) -> SQLite -> API / console / alerts / Prometheus
12
+ """
13
+ import json
14
+ import os
15
+ import threading
16
+ import time
17
+ import traceback
18
+ import urllib.request
19
+
20
+ from . import analysis, config as cfgmod, pricing, store
21
+ from .collectors import aegis_audit, claude_code, generic, inbox, langfuse, langsmith, otlp, spans as spanmod
22
+ from .privacy import Redactor
23
+
24
+
25
+ class SourceStatus:
26
+ def __init__(self, name, kind, detail=""):
27
+ self.d = {"name": name, "type": kind, "detail": detail, "status": "idle", "last_ok": None, "last_error": None,
28
+ "last_error_at": None, "items": 0, "last_batch": 0}
29
+
30
+ def ok(self, n=0):
31
+ self.d.update(status="ok", last_ok=time.time(), last_batch=n)
32
+ self.d["items"] += n
33
+
34
+ def fail(self, err):
35
+ self.d.update(status="error", last_error=str(err)[:400], last_error_at=time.time())
36
+
37
+
38
+ class Engine:
39
+ def __init__(self, data_dir, claude_root=claude_code.DEFAULT_ROOT, cfg=None):
40
+ self.data_dir = data_dir
41
+ os.makedirs(data_dir, exist_ok=True)
42
+ self.cfg = cfg or cfgmod.load(data_dir)
43
+ self.claude_root = claude_root
44
+ self.runs_dir = os.path.join(data_dir, "runs")
45
+ os.makedirs(self.runs_dir, exist_ok=True)
46
+ pricing.load_overrides(data_dir)
47
+ self.db_path = os.path.join(data_dir, "agentdynamics.db")
48
+ self.con = store.connect(self.db_path)
49
+ self.lock = threading.RLock()
50
+ self.redactor = Redactor(self.cfg["privacy"])
51
+ self._files = {} # path -> (mtime, size, run)
52
+ self._runs = {} # run id -> run (trace/SDK/transcript)
53
+ self._tasks = {} # run id -> [task]
54
+ self._span_since = 0.0
55
+ self._first = True
56
+ self._wake = threading.Event()
57
+ self.last_refresh = None
58
+ self.last_duration = None
59
+ self.stats = {"spans_ingested": 0, "refreshes": 0, "alerts_sent": 0}
60
+ self.sources = {}
61
+ if claude_root:
62
+ self.sources["claude_code"] = SourceStatus("claude_code", "file", claude_root)
63
+ self.sources["sdk"] = SourceStatus("sdk", "push", "/api/ingest")
64
+ self.sources["otlp"] = SourceStatus("otlp", "push", "/v1/traces (OTLP/HTTP json+protobuf)")
65
+ self.sources["langsmith"] = SourceStatus("langsmith", "push", "/langsmith (LangSmith-compatible API)")
66
+ self.sources["aegis"] = SourceStatus("aegis", "push", "Aegis audit records (/api/ingest/records, inbox)")
67
+ self.pullers = []
68
+ for i, sc in enumerate(self.cfg.get("sources") or []):
69
+ self._add_source(i, sc)
70
+
71
+ # ------------------------------------------------------------------ config-driven sources
72
+ def _add_source(self, i, sc):
73
+ t = sc.get("type")
74
+ name = sc.get("name") or f"{t}-{i}"
75
+ if t == "claude_code":
76
+ if sc.get("path"):
77
+ self.claude_root = sc["path"]
78
+ self.sources["claude_code"] = SourceStatus("claude_code", "file", sc["path"])
79
+ return
80
+ st = SourceStatus(name, t, sc.get("project") or sc.get("host") or sc.get("path") or "")
81
+ self.sources[name] = st
82
+ self.pullers.append((name, t, sc, st))
83
+
84
+ def _run_puller(self, name, t, sc, st):
85
+ state = store.get_state(self.con, name)
86
+ try:
87
+ if t == "langsmith_api":
88
+ runs = langsmith.LangSmithPuller(sc, state).pull()
89
+ self.ingest_langsmith(runs, [], count_source=False)
90
+ st.ok(len(runs))
91
+ elif t == "langfuse_api":
92
+ traces = langfuse.LangfusePuller(sc, state).pull()
93
+ items = [s for tr in traces for s in langfuse.trace_to_spans(tr)]
94
+ self.ingest_spans(items, "langfuse", count_source=False)
95
+ st.ok(len(traces))
96
+ elif t == "inbox":
97
+ recs = inbox.Inbox(sc["path"], state).poll()
98
+ self.ingest_records(recs)
99
+ st.ok(len(recs))
100
+ else:
101
+ raise ValueError(f"unknown source type '{t}'")
102
+ except Exception as ex: # a failing connector must never stop the others
103
+ st.fail(ex)
104
+ finally:
105
+ with self.lock:
106
+ store.set_state(self.con, name, state)
107
+
108
+ def start_pullers(self):
109
+ for name, t, sc, st in self.pullers:
110
+ interval = float(sc.get("interval", 60 if t != "inbox" else 5))
111
+
112
+ def loop(name=name, t=t, sc=sc, st=st, interval=interval):
113
+ while True:
114
+ self._run_puller(name, t, sc, st)
115
+ self._wake.set()
116
+ time.sleep(interval)
117
+ threading.Thread(target=loop, daemon=True, name=f"src-{name}").start()
118
+
119
+ # ------------------------------------------------------------------ push ingestion
120
+ def ingest_spans(self, spans_, source, count_source=True):
121
+ """Canonical spans (OTLP, Langfuse, inbox) -> durable store."""
122
+ items = [(s["trace_id"], s["span_id"], "canonical", s) for s in spans_ if s.get("trace_id") and s.get("span_id")]
123
+ with self.lock:
124
+ store.upsert_spans(self.con, source, items)
125
+ self.stats["spans_ingested"] += len(items)
126
+ if count_source and source in self.sources:
127
+ self.sources[source].ok(len(items))
128
+ self._wake.set()
129
+ return len(items)
130
+
131
+ def ingest_otlp(self, body, content_type):
132
+ return self.ingest_spans(otlp.parse_request(body, content_type), "otlp")
133
+
134
+ def ingest_langsmith(self, posts, patches, feedback=(), count_source=True):
135
+ """LangSmith runs: posts create, patches update. Merged per run id, idempotently."""
136
+ with self.lock:
137
+ ids = [str(r["id"]) for r in list(posts) + list(patches) if r.get("id")]
138
+ ids += [str(f.get("run_id")) for f in feedback if f.get("run_id")]
139
+ existing = store.get_span_docs(self.con, "langsmith", ids)
140
+ merged = {}
141
+ for r in list(posts) + list(patches):
142
+ rid = str(r.get("id"))
143
+ base = merged.get(rid) or existing.get(rid) or {}
144
+ merged[rid] = langsmith.merge(base, r)
145
+ for f in feedback:
146
+ rid = str(f.get("run_id"))
147
+ # feedback can arrive before the (asynchronously batched) run: keep a stub that the run merges into
148
+ base = merged.get(rid) or existing.get(rid) or {"id": rid, "trace_id": rid, "_stub": True}
149
+ merged[rid] = langsmith.merge(base, {"feedback": [{"key": f.get("key"), "score": f.get("score")}]})
150
+ items = [(str(d.get("trace_id") or rid), rid, "langsmith", d) for rid, d in merged.items()]
151
+ store.upsert_spans(self.con, "langsmith", items)
152
+ self.stats["spans_ingested"] += len(items)
153
+ if count_source:
154
+ self.sources["langsmith"].ok(len(items))
155
+ self._wake.set()
156
+ return len(items)
157
+
158
+ def ingest(self, payload):
159
+ """Generic run JSON (SDK / webhook)."""
160
+ run = generic.normalize(payload) # validate before writing
161
+ path = os.path.join(self.runs_dir, f"{run['id'].replace(':', '_').replace('/', '_')}.json")
162
+ with open(path, "w", encoding="utf-8") as f:
163
+ json.dump(payload, f)
164
+ self.sources["sdk"].ok(1)
165
+ self._wake.set()
166
+ return run["id"]
167
+
168
+ def ingest_records(self, recs):
169
+ """Auto-detected records from files/log pipelines."""
170
+ by = {"otlp": [], "langsmith": [], "langfuse": [], "span": [], "aegis": []}
171
+ for fmt, r in recs:
172
+ if fmt == "generic":
173
+ self.ingest(r)
174
+ elif fmt in by:
175
+ by[fmt].append(r)
176
+ n = 0
177
+ for doc in by["otlp"]:
178
+ n += self.ingest_spans([otlp.map_span(a, b, c) for a, b, c in otlp.decode_json(doc)], "otlp", count_source=False)
179
+ if by["langsmith"]:
180
+ n += self.ingest_langsmith(by["langsmith"], [], count_source=False)
181
+ for tr in by["langfuse"]:
182
+ n += self.ingest_spans(langfuse.trace_to_spans(tr), "langfuse", count_source=False)
183
+ for s in by["span"]:
184
+ n += self.ingest_spans([s], s.get("source") or "inbox", count_source=False)
185
+ if by["aegis"]:
186
+ n += self.ingest_aegis(by["aegis"])
187
+ return n
188
+
189
+ def ingest_aegis(self, records):
190
+ """Aegis audit records (JSONL audit log lines), grouped into runs by correlation id."""
191
+ items = [(aegis_audit.group_key(r), aegis_audit.span_id(r), "aegis", r) for r in records if aegis_audit.is_record(r)]
192
+ with self.lock:
193
+ store.upsert_spans(self.con, "aegis", items)
194
+ self.stats["spans_ingested"] += len(items)
195
+ if "aegis" in self.sources:
196
+ self.sources["aegis"].ok(len(items))
197
+ self._wake.set()
198
+ return len(items)
199
+
200
+ # ------------------------------------------------------------------ health rules config
201
+ @property
202
+ def rules_path(self):
203
+ return os.path.join(self.data_dir, "rules.json")
204
+
205
+ def rules(self):
206
+ if os.path.exists(self.rules_path):
207
+ with open(self.rules_path, encoding="utf-8") as f:
208
+ saved = json.load(f)
209
+ known = {r["id"] for r in saved}
210
+ return saved + [r for r in analysis.DEFAULT_RULES if r["id"] not in known] # new built-in rules appear automatically
211
+ return analysis.DEFAULT_RULES
212
+
213
+ def save_rules(self, rules):
214
+ with open(self.rules_path, "w", encoding="utf-8") as f:
215
+ json.dump(rules, f, indent=2)
216
+ self.refresh(force=True)
217
+
218
+ # ------------------------------------------------------------------ refresh
219
+ def _scan_files(self):
220
+ """Returns (changed runs, removed run ids) for file-based sources."""
221
+ found = []
222
+ if self.claude_root and os.path.isdir(self.claude_root):
223
+ found += [("cc", p) for p in claude_code.discover(self.claude_root)]
224
+ found += [("sdk", os.path.join(self.runs_dir, fn)) for fn in os.listdir(self.runs_dir) if fn.endswith(".json")]
225
+ changed, seen = [], set()
226
+ for kind, p in found:
227
+ try:
228
+ st = os.stat(p)
229
+ except OSError:
230
+ continue
231
+ seen.add(p)
232
+ c = self._files.get(p)
233
+ if c and c[0] == st.st_mtime and c[1] == st.st_size:
234
+ continue
235
+ try:
236
+ if kind == "cc":
237
+ run = claude_code.parse_file(p, self.claude_root)
238
+ else:
239
+ with open(p, encoding="utf-8") as f:
240
+ run = generic.normalize(json.load(f))
241
+ run["file"] = p
242
+ except Exception as ex: # a malformed file must not take down monitoring
243
+ (self.sources.get("claude_code") if kind == "cc" else self.sources["sdk"]).fail(f"{os.path.basename(p)}: {ex}")
244
+ continue
245
+ old = self._files.get(p)
246
+ self._files[p] = (st.st_mtime, st.st_size, run)
247
+ if old and old[2]["id"] != run["id"]:
248
+ changed.append(("remove", old[2]["id"]))
249
+ changed.append(("upsert", run))
250
+ removed = [self._files.pop(p)[2]["id"] for p in list(self._files) if p not in seen]
251
+ if "claude_code" in self.sources and any(k == "cc" for k, _ in found):
252
+ self.sources["claude_code"].d.update(status="ok", last_ok=time.time(), items=sum(1 for k, _ in found if k == "cc"))
253
+ return changed, removed
254
+
255
+ def _assemble_traces(self):
256
+ since = self._span_since
257
+ now = time.time()
258
+ touched = store.all_traces(self.con) if since == 0 else store.traces_updated_since(self.con, since - 2)
259
+ runs = []
260
+ sdk_ids = {f[2]["id"] for f in self._files.values()}
261
+ for source, trace_id in touched:
262
+ docs = store.trace_spans(self.con, source, trace_id)
263
+ if source == "aegis":
264
+ if trace_id in sdk_ids:
265
+ continue # already recorded in-process by agentdynamics.integrations.aegis
266
+ run = generic.normalize(aegis_audit.build_payload(trace_id, [d for _, d in docs]))
267
+ run["source"] = "aegis"
268
+ runs.append(run)
269
+ continue
270
+ canon = [c for c in (langsmith.to_span(d) if fmt == "langsmith" else d for fmt, d in docs) if c]
271
+ run = spanmod.build_run(trace_id, canon, source)
272
+ if run:
273
+ runs.append(run)
274
+ self._span_since = now
275
+ return runs
276
+
277
+ def refresh(self, force=False):
278
+ with self.lock:
279
+ t0 = time.time()
280
+ if force:
281
+ self._files.clear()
282
+ self._span_since = 0
283
+ self._runs.clear()
284
+ self._tasks.clear()
285
+ changed, removed = self._scan_files()
286
+ dirty = {}
287
+ for op, x in changed:
288
+ if op == "remove":
289
+ removed.append(x)
290
+ else:
291
+ dirty[x["id"]] = x
292
+ for run in self._assemble_traces():
293
+ dirty[run["id"]] = run
294
+ # retention
295
+ days = float(self.cfg["retention"].get("days") or 0)
296
+ if days > 0:
297
+ cutoff = time.time() - days * 86400
298
+ store.purge_spans_before(self.con, cutoff)
299
+ for rid, r in list(self._runs.items()) + list(dirty.items()):
300
+ if (r.get("ended") or r.get("started") or time.time()) < cutoff:
301
+ removed.append(rid)
302
+ dirty.pop(rid, None)
303
+ dirty = {k: v for k, v in dirty.items() if v["steps"]}
304
+ if not dirty and not removed and not force and not self._first:
305
+ return False
306
+ for rid in removed:
307
+ self._runs.pop(rid, None)
308
+ self._tasks.pop(rid, None)
309
+ for rid, run in dirty.items():
310
+ for s in run["steps"]:
311
+ s.pop("flags", None)
312
+ s.pop("attributed_cost", None)
313
+ self._runs[rid] = run
314
+ self._tasks[rid] = analysis.run_tasks(run)
315
+ runs = list(self._runs.values())
316
+ tasks, baselines, events = analysis.finalize(runs, self._tasks, self.rules())
317
+ insights = analysis.process_insights(tasks)
318
+ meta = {"refreshed": time.time(), "runs": len(runs), "tasks": len(tasks), "insights": insights}
319
+ store.write_runs(self.con, list(dirty.values()), removed, self.redactor)
320
+ store.write_analysis(self.con, tasks, baselines, events, meta, self.redactor)
321
+ self._dispatch_alerts(events)
322
+ self._first = False
323
+ self.last_refresh = time.time()
324
+ self.last_duration = round(self.last_refresh - t0, 2)
325
+ self.stats["refreshes"] += 1
326
+ return True
327
+
328
+ def watch(self, interval=None):
329
+ interval = interval or float(self.cfg["analysis"].get("interval", 15))
330
+
331
+ def loop():
332
+ while True:
333
+ self._wake.wait(interval)
334
+ if self._wake.is_set():
335
+ time.sleep(1.0) # debounce bursts of pushed spans
336
+ self._wake.clear()
337
+ try:
338
+ self.refresh()
339
+ except Exception:
340
+ traceback.print_exc()
341
+ threading.Thread(target=loop, daemon=True, name="analyzer").start()
342
+ self.start_pullers()
343
+
344
+ # ------------------------------------------------------------------ alerting
345
+ SEV = {"info": 1, "warning": 2, "critical": 3}
346
+
347
+ def _dispatch_alerts(self, events):
348
+ hooks = self.cfg["alerts"].get("webhooks") or []
349
+ sent = {r[0] for r in self.con.execute("SELECT event_id FROM alerts_sent")}
350
+ new = [e for e in events if e["id"] not in sent]
351
+ if not new:
352
+ return
353
+ with self.con:
354
+ self.con.executemany("INSERT OR IGNORE INTO alerts_sent (event_id, ts) VALUES (?, ?)", [(e["id"], time.time()) for e in new])
355
+ if self._first or not hooks: # never flood a channel with history on first start
356
+ return
357
+ recent = [e for e in new if (e.get("ts") or 0) > time.time() - 3600]
358
+ for h in hooks:
359
+ lvl = self.SEV.get(h.get("min_severity", "warning"), 2)
360
+ batch = [e for e in recent if self.SEV.get(e["severity"], 1) >= lvl]
361
+ if batch:
362
+ threading.Thread(target=self._post_hook, args=(h, batch), daemon=True).start()
363
+
364
+ def _post_hook(self, hook, events):
365
+ if hook.get("format", "json") == "slack":
366
+ lines = [f"*{e['severity'].upper()}* · {e['rule']} · {e['project']} / {e['task_type']}\n{e['message']}" for e in events[:10]]
367
+ body = {"text": "AgentDynamics alerts\n" + "\n\n".join(lines)}
368
+ else:
369
+ body = {"source": "agentdynamics", "events": events}
370
+ try:
371
+ req = urllib.request.Request(hook["url"], data=json.dumps(body, default=str).encode(), method="POST",
372
+ headers={"Content-Type": "application/json"})
373
+ urllib.request.urlopen(req, timeout=10).read()
374
+ self.stats["alerts_sent"] += len(events)
375
+ except Exception as ex:
376
+ print(f"[agentdynamics] alert webhook failed: {ex}")
agentdynamics/flows.py ADDED
@@ -0,0 +1,142 @@
1
+ """Workflow (agent-graph) analytics: process mining over node paths.
2
+
3
+ Works for LangGraph / multi-agent traces (nodes = graph nodes or agents) and for coding agents
4
+ without an explicit graph (nodes = process phases: explore -> edit -> verify ...).
5
+ """
6
+ import statistics
7
+ from collections import Counter, defaultdict
8
+
9
+ from .analysis import apdex_score, pct
10
+ from .store import rows
11
+
12
+
13
+ def _stats(tasks):
14
+ n = len(tasks)
15
+ cost = [t["cost"] + (t["subagent_cost"] or 0) for t in tasks]
16
+ wall = [t["wall_s"] or 0 for t in tasks]
17
+ ok = sum(1 for t in tasks if t["outcome"] == "completed")
18
+ return {
19
+ "runs": n, "success_rate": round(ok / n, 3) if n else None,
20
+ "failed": sum(1 for t in tasks if t["outcome"] == "failed"),
21
+ "cost": round(sum(cost), 4), "avg_cost": round(sum(cost) / n, 5) if n else 0,
22
+ "p50_s": round(pct(wall, 0.5) or 0, 2), "p95_s": round(pct(wall, 0.95) or 0, 2),
23
+ "avg_steps": round(statistics.mean([t["steps_total"] or 0 for t in tasks]), 1) if n else 0,
24
+ "loop_rate": round(sum(1 for t in tasks if (t["max_node_visits"] or 0) >= 5) / n, 3) if n else 0,
25
+ "apdex": apdex_score([t for t in tasks if t["apdex"]]),
26
+ "tokens": sum(t["total_tokens"] for t in tasks),
27
+ }
28
+
29
+
30
+ def workflows(api, q):
31
+ ts = api.tasks(q)
32
+ by = defaultdict(list)
33
+ for t in ts:
34
+ by[t["workflow"] or t["task_type"]].append(t)
35
+ out = []
36
+ for wf, g in by.items():
37
+ paths = Counter(tuple(t["path"] or []) for t in g)
38
+ s = _stats(g)
39
+ s.update({"workflow": wf, "framework": Counter(t["framework"] for t in g).most_common(1)[0][0],
40
+ "variants": len(paths), "top_path": list(paths.most_common(1)[0][0]) if paths else [],
41
+ "nodes": max((t["nodes"] or 0) for t in g), "graph": any((t["nodes"] or 0) > 0 for t in g),
42
+ "projects": sorted({t["project"] for t in g})[:5]})
43
+ out.append(s)
44
+ out.sort(key=lambda x: -x["runs"])
45
+ return {"workflows": out}
46
+
47
+
48
+ def workflow_detail(api, name, q):
49
+ ts = [t for t in api.tasks(q) if (t["workflow"] or t["task_type"]) == name]
50
+ if not ts:
51
+ return None
52
+ graph = any((t["nodes"] or 0) > 0 for t in ts)
53
+ # --- transitions (directly-follows graph) with failure overlay
54
+ edges = Counter()
55
+ edge_fail = Counter()
56
+ node_runs = Counter()
57
+ for t in ts:
58
+ p = ["__start__"] + list(t["path"] or []) + ["__end__"]
59
+ failed = t["outcome"] in ("failed", "interrupted", "rework")
60
+ for a, b in zip(p, p[1:]):
61
+ edges[(a, b)] += 1
62
+ if failed:
63
+ edge_fail[(a, b)] += 1
64
+ for n in set(t["path"] or []):
65
+ node_runs[n] += 1
66
+ # --- node / phase statistics from steps
67
+ ids = [t["id"] for t in ts]
68
+ steps = []
69
+ for i in range(0, len(ids), 400):
70
+ chunk = ids[i:i + 400]
71
+ steps += rows(api.con, f"""SELECT task_id, kind, name, node, phase, span_kind, duration_ms, cost, attributed_cost, is_error,
72
+ input_tokens, output_tokens, cache_read, cache_write, agent FROM steps WHERE task_id IN ({','.join('?' * len(chunk))})""", chunk)
73
+ nodes = defaultdict(lambda: {"executions": 0, "errors": 0, "ms": [], "llm_calls": 0, "tool_calls": 0, "cost": 0.0, "tokens": 0})
74
+ for s in steps:
75
+ if graph:
76
+ key = s["node"] or (s["name"] if s["span_kind"] == "agent" else None)
77
+ if not key:
78
+ continue
79
+ n = nodes[key]
80
+ if s["kind"] == "span" and (s["name"] == s["node"] or s["span_kind"] in ("node", "agent")):
81
+ n["executions"] += 1
82
+ n["errors"] += s["is_error"] or 0
83
+ if s["duration_ms"] is not None:
84
+ n["ms"].append(s["duration_ms"])
85
+ elif s["kind"] == "llm":
86
+ n["llm_calls"] += 1
87
+ n["cost"] += s["cost"] or 0
88
+ n["tokens"] += sum((s[k] or 0) for k in ("input_tokens", "output_tokens", "cache_read", "cache_write"))
89
+ n["errors"] += s["is_error"] or 0
90
+ elif s["kind"] == "tool":
91
+ n["tool_calls"] += 1
92
+ n["errors"] += s["is_error"] or 0
93
+ else:
94
+ if s["kind"] != "tool":
95
+ continue
96
+ n = nodes[s["phase"] or "other"]
97
+ n["executions"] += 1
98
+ n["tool_calls"] += 1
99
+ n["errors"] += s["is_error"] or 0
100
+ n["cost"] += s["attributed_cost"] or 0
101
+ if s["duration_ms"] is not None:
102
+ n["ms"].append(s["duration_ms"])
103
+ total_cost = sum(n["cost"] for n in nodes.values()) or 1
104
+ node_list = []
105
+ for k, n in nodes.items():
106
+ ms = n.pop("ms")
107
+ node_list.append({"node": k, **n, "runs": node_runs.get(k, 0), "per_run": round(n["executions"] / len(ts), 2),
108
+ "p50_ms": round(pct(ms, 0.5)) if ms else None, "p95_ms": round(pct(ms, 0.95)) if ms else None,
109
+ "error_rate": round(n["errors"] / max(1, n["executions"] + n["llm_calls"] + (0 if graph else 0)), 3),
110
+ "cost_share": round(n["cost"] / total_cost, 3)})
111
+ node_list.sort(key=lambda x: -x["executions"])
112
+ # --- path variants
113
+ by_path = defaultdict(list)
114
+ for t in ts:
115
+ by_path[tuple(t["path"] or [])].append(t)
116
+ variants = []
117
+ for p, g in sorted(by_path.items(), key=lambda kv: -len(kv[1]))[:15]:
118
+ st = _stats(g)
119
+ variants.append({"path": list(p), "runs": len(g), "share": round(len(g) / len(ts), 3), "success_rate": st["success_rate"],
120
+ "avg_cost": st["avg_cost"], "p50_s": st["p50_s"], "example": g[0]["id"]})
121
+ # --- handoffs between agents
122
+ handoffs = Counter()
123
+ by_task = defaultdict(list)
124
+ for s in steps:
125
+ if s["kind"] in ("llm", "tool") and s["agent"]:
126
+ by_task[s["task_id"]].append(s["agent"])
127
+ for seq in by_task.values():
128
+ c = [a for i, a in enumerate(seq) if i == 0 or a != seq[i - 1]]
129
+ for a, b in zip(c, c[1:]):
130
+ handoffs[(a, b)] += 1
131
+ loops = Counter(min(t["max_node_visits"] or 0, 10) for t in ts)
132
+ crit = Counter(t["critical_node"] for t in ts if t["critical_node"])
133
+ return {
134
+ "workflow": name, "graph": graph, "summary": _stats(ts), "nodes": node_list,
135
+ "edges": [{"from": a, "to": b, "count": c, "fail": edge_fail.get((a, b), 0)} for (a, b), c in edges.items()],
136
+ "variants": variants, "variant_count": len(by_path),
137
+ "handoffs": [{"from": a, "to": b, "count": c} for (a, b), c in handoffs.most_common(20)],
138
+ "loops": {str(k): v for k, v in sorted(loops.items())},
139
+ "critical": [{"node": k, "runs": v} for k, v in crit.most_common(8)],
140
+ "failures": [api._task_brief(t) | {"root_error": None} for t in sorted(ts, key=lambda x: -(x["started"] or 0))
141
+ if t["outcome"] in ("failed", "interrupted")][:10],
142
+ }