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,949 @@
1
+ """HTTP API + static web console (stdlib only)."""
2
+ import gzip
3
+ import hmac
4
+ import json
5
+ import mimetypes
6
+ import os
7
+ import statistics
8
+ import threading
9
+ import time
10
+ import traceback
11
+ import zlib
12
+ from collections import Counter, defaultdict
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from urllib.parse import parse_qs, unquote, urlparse
15
+
16
+ from .analysis import REFUSAL as REFUSE, TRUNCATION as TRUNC, apdex_score, pct
17
+ from .store import connect_reader, rows
18
+
19
+ WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
20
+ DAY = 86400
21
+
22
+
23
+ def mcp_group(name):
24
+ if name and name.startswith("mcp__"):
25
+ parts = name.split("__")
26
+ return f"MCP · {parts[1]}" if len(parts) > 2 else name
27
+ return name
28
+
29
+
30
+ class Api:
31
+ def __init__(self, engine):
32
+ self.e = engine
33
+ self._local = threading.local()
34
+
35
+ @property
36
+ def con(self):
37
+ """One read-only connection per server thread (WAL lets readers run while the engine writes)."""
38
+ c = getattr(self._local, "con", None)
39
+ if c is None:
40
+ c = self._local.con = connect_reader(self.e.db_path)
41
+ return c
42
+
43
+ # ------------------------------------------------------------ filtering
44
+ def where(self, q, alias="t", subagents_default="0"):
45
+ clauses, args = [], []
46
+ if q.get("project"):
47
+ clauses.append(f"{alias}.project = ?")
48
+ args.append(q["project"])
49
+ if q.get("days"):
50
+ clauses.append(f"{alias}.started >= ?")
51
+ args.append(time.time() - float(q["days"]) * DAY)
52
+ if q.get("source"):
53
+ clauses.append(f"{alias}.source = ?")
54
+ args.append(q["source"])
55
+ if q.get("sub", subagents_default) == "0":
56
+ clauses.append(f"{alias}.is_subagent = 0")
57
+ if q.get("type"):
58
+ clauses.append(f"{alias}.task_type = ?")
59
+ args.append(q["type"])
60
+ for f in ("environment", "workflow", "framework"):
61
+ if q.get(f):
62
+ clauses.append(f"{alias}.{f} = ?")
63
+ args.append(q[f])
64
+ clauses.append(f"({alias}.llm_calls > 0 OR {alias}.tool_calls > 0)")
65
+ return (" WHERE " + " AND ".join(clauses)) if clauses else "", args
66
+
67
+ def tasks(self, q, extra="", extra_args=(), cols="t.*"):
68
+ w, a = self.where(q)
69
+ return rows(self.con, f"SELECT {cols} FROM tasks t{w}{extra}", a + list(extra_args))
70
+
71
+ # ------------------------------------------------------------ helpers
72
+ @staticmethod
73
+ def kpis(ts):
74
+ n = len(ts)
75
+ cost = sum(t["cost"] + (t["subagent_cost"] or 0) for t in ts)
76
+ calls = sum(t["tool_calls"] for t in ts)
77
+ code = [t for t in ts if t["code_changed"]]
78
+ scores = [t["score"] for t in ts if t["score"] is not None]
79
+ ok = [t for t in ts if t["outcome"] == "completed"]
80
+ return {
81
+ "tasks": n,
82
+ "sessions": len({t["run_id"] for t in ts}),
83
+ "cost": round(cost, 4),
84
+ "tokens": sum(t["total_tokens"] for t in ts),
85
+ "output_tokens": sum(t["output_tokens"] for t in ts),
86
+ "avg_cost": round(cost / n, 4) if n else 0,
87
+ "median_cost": round(pct([t["cost"] + (t["subagent_cost"] or 0) for t in ts], 0.5) or 0, 4),
88
+ "avg_duration": round(statistics.mean([t["duration_s"] for t in ts]), 1) if n else 0,
89
+ "apdex": apdex_score([t for t in ts if t["apdex"]]),
90
+ "success_rate": round(len(ok) / n, 3) if n else None,
91
+ "tool_calls": calls,
92
+ "tool_error_rate": round(sum(t["tool_errors"] for t in ts) / calls, 4) if calls else 0,
93
+ "waste_cost": round(sum(t["waste_cost"] or 0 for t in ts), 4),
94
+ "verification_rate": round(sum(1 for t in code if t["verified"]) / len(code), 3) if code else None,
95
+ "avg_score": round(statistics.mean(scores), 1) if scores else None,
96
+ "cache_hit": round(sum(t["cache_read"] for t in ts) / max(1, sum(t["cache_read"] + t["cache_write"] + t["input_tokens"] for t in ts)), 3),
97
+ "rework_rate": round(sum(1 for t in ts if t["outcome"] in ("rework", "interrupted")) / n, 3) if n else None,
98
+ # agent-flow KPIs
99
+ "failed_rate": round(sum(1 for t in ts if t["outcome"] == "failed") / n, 3) if n else None,
100
+ "avg_steps": round(statistics.mean([t["steps_total"] or 0 for t in ts]), 1) if n else 0,
101
+ "loop_rate": round(sum(1 for t in ts if (t["max_node_visits"] or 0) >= 5) / n, 3) if n else 0,
102
+ "truncations": sum(t["truncations"] or 0 for t in ts),
103
+ "refusals": sum(t["refusals"] or 0 for t in ts),
104
+ "rate_limited": sum(t["rate_limited"] or 0 for t in ts),
105
+ "llm_errors": sum(t["llm_errors"] or 0 for t in ts),
106
+ "handoffs": sum(t["handoffs"] or 0 for t in ts),
107
+ "ttft_p50": pct([t["ttft_ms"] for t in ts if t["ttft_ms"]], 0.5),
108
+ "p95_wall": round(pct([t["wall_s"] or 0 for t in ts], 0.95) or 0, 1),
109
+ "feedback_avg": round(statistics.mean(fb), 3) if (fb := [t["feedback_score"] for t in ts if t["feedback_score"] is not None]) else None,
110
+ "unpriced": sum(t["unpriced"] or 0 for t in ts),
111
+ }
112
+
113
+ @staticmethod
114
+ def health(apdex):
115
+ if apdex is None:
116
+ return "unknown"
117
+ return "normal" if apdex >= 0.85 else "warning" if apdex >= 0.7 else "critical"
118
+
119
+ def daily(self, ts, days=None):
120
+ by = defaultdict(list)
121
+ for t in ts:
122
+ if t["started"]:
123
+ by[time.strftime("%Y-%m-%d", time.localtime(t["started"]))].append(t)
124
+ out = []
125
+ for d in sorted(by):
126
+ g = by[d]
127
+ out.append({"day": d, "tasks": len(g), "cost": round(sum(t["cost"] + (t["subagent_cost"] or 0) for t in g), 4),
128
+ "tokens": sum(t["total_tokens"] for t in g), "apdex": apdex_score([t for t in g if t["apdex"]]),
129
+ "errors": sum(t["tool_errors"] for t in g),
130
+ "score": round(statistics.mean([t["score"] for t in g if t["score"] is not None] or [0]), 1)})
131
+ return out
132
+
133
+ # ------------------------------------------------------------ endpoints
134
+ def filters(self, q):
135
+ live = "WHERE llm_calls>0 OR tool_calls>0"
136
+ projects = rows(self.con, f"SELECT project, COUNT(*) n, SUM(cost) cost FROM tasks {live} GROUP BY project ORDER BY cost DESC")
137
+ types = rows(self.con, "SELECT DISTINCT task_type FROM tasks ORDER BY task_type")
138
+ sources = rows(self.con, "SELECT DISTINCT source FROM tasks")
139
+ envs = rows(self.con, f"SELECT environment, COUNT(*) n FROM tasks {live} GROUP BY environment ORDER BY n DESC")
140
+ fws = rows(self.con, f"SELECT DISTINCT framework FROM tasks {live}")
141
+ meta = {r["k"]: json.loads(r["v"]) for r in self.con.execute("SELECT k, v FROM meta")}
142
+ return {"projects": projects, "types": [r["task_type"] for r in types], "sources": [r["source"] for r in sources],
143
+ "environments": [r["environment"] for r in envs], "frameworks": [r["framework"] for r in fws],
144
+ "auth": self.e.cfg["auth"]["enabled"],
145
+ "refreshed": meta.get("refreshed"), "refresh_seconds": self.e.last_duration,
146
+ "runs": meta.get("runs"), "task_count": meta.get("tasks")}
147
+
148
+ def overview(self, q):
149
+ ts = self.tasks(q)
150
+ subq = dict(q, sub="1")
151
+ all_ts = self.tasks(subq)
152
+ k = self.kpis(ts)
153
+ k["cost_incl_subagents"] = round(sum(t["cost"] for t in all_ts), 4)
154
+ events = self.events(q)["events"]
155
+ sev = Counter(e["severity"] for e in events)
156
+ by_type = defaultdict(list)
157
+ for t in ts:
158
+ by_type[t["task_type"]].append(t)
159
+ types = []
160
+ for ty, g in by_type.items():
161
+ ap = apdex_score([t for t in g if t["apdex"]])
162
+ types.append({"type": ty, "tasks": len(g), "cost": round(sum(t["cost"] + (t["subagent_cost"] or 0) for t in g), 3),
163
+ "apdex": ap, "health": self.health(ap)})
164
+ types.sort(key=lambda x: -x["cost"])
165
+ models = rows(self.con, f"""SELECT s.model, COUNT(*) calls, SUM(s.cost) cost FROM steps s JOIN tasks t ON t.id = s.task_id
166
+ {self.where(subq)[0]} AND s.kind='llm' AND s.model != '<synthetic>' GROUP BY s.model ORDER BY cost DESC""", self.where(subq)[1])
167
+ phase = Counter()
168
+ for t in ts:
169
+ phase.update(t["phase_cost"] or {})
170
+ top = sorted(ts, key=lambda t: -(t["cost"] + (t["subagent_cost"] or 0)))[:8]
171
+ return {"kpis": k, "daily": self.daily(ts), "events": events[:12], "severity": dict(sev), "types": types,
172
+ "models": models, "phase_cost": {p: round(v, 4) for p, v in phase.items()},
173
+ "outcomes": dict(Counter(t["outcome"] for t in ts)),
174
+ "apdex_mix": dict(Counter(t["apdex"] for t in ts if t["apdex"])),
175
+ "top_tasks": [self._task_brief(t) for t in top]}
176
+
177
+ @staticmethod
178
+ def _task_brief(t):
179
+ keys = ["id", "run_id", "project", "task_type", "prompt", "started", "duration_s", "cost", "subagent_cost", "outcome",
180
+ "apdex", "score", "tool_calls", "tool_errors", "llm_calls", "total_tokens", "cost_vs_baseline", "is_subagent",
181
+ "waste_cost", "verified", "models", "max_context", "workflow", "environment", "framework", "wall_s",
182
+ "steps_total", "max_node_visits", "feedback_score"]
183
+ d = {k: t.get(k) for k in keys}
184
+ d["prompt"] = (d["prompt"] or "")[:160]
185
+ return d
186
+
187
+ def types(self, q):
188
+ ts = self.tasks(q)
189
+ base = {r["task_type"]: r["data"] for r in rows(self.con, "SELECT * FROM baselines")}
190
+ by = defaultdict(list)
191
+ for t in ts:
192
+ by[t["task_type"]].append(t)
193
+ out = []
194
+ for ty, g in by.items():
195
+ k = self.kpis(g)
196
+ costs = [t["cost"] + (t["subagent_cost"] or 0) for t in g]
197
+ k.update({"type": ty, "health": self.health(k["apdex"]), "baseline": base.get(ty) or base.get("__all__"),
198
+ "p90_cost": round(pct(costs, 0.9), 4), "p90_duration": round(pct([t["duration_s"] for t in g], 0.9), 1),
199
+ "daily": [{"day": d["day"], "tasks": d["tasks"], "cost": d["cost"]} for d in self.daily(g)]})
200
+ out.append(k)
201
+ ev = Counter(r["task_type"] for r in rows(self.con, "SELECT task_type FROM events"))
202
+ for k in out:
203
+ k["events"] = ev.get(k["type"], 0)
204
+ out.sort(key=lambda x: -x["cost"])
205
+ return {"types": out}
206
+
207
+ def task_list(self, q):
208
+ extra, args = [], []
209
+ for f in ("outcome", "apdex"):
210
+ if q.get(f):
211
+ extra.append(f"t.{f} = ?")
212
+ args.append(q[f])
213
+ if q.get("run"):
214
+ extra.append("t.run_id = ?")
215
+ args.append(q["run"])
216
+ if q.get("q"):
217
+ extra.append("t.prompt LIKE ?")
218
+ args.append(f"%{q['q']}%")
219
+ if q.get("flag") == "unverified":
220
+ extra.append("t.unverified_edits = 1")
221
+ if q.get("flag") == "waste":
222
+ extra.append("t.waste_cost > 0")
223
+ sort = {"cost": "(t.cost + t.subagent_cost) DESC", "recent": "t.started DESC", "score": "t.score ASC",
224
+ "duration": "t.duration_s DESC", "baseline": "t.cost_vs_baseline DESC", "waste": "t.waste_cost DESC"}.get(q.get("sort"), "t.started DESC")
225
+ ex = (" AND " + " AND ".join(extra)) if extra else ""
226
+ ts = self.tasks(q, f"{ex} ORDER BY {sort} LIMIT {int(q.get('limit', 300))}", args)
227
+ return {"tasks": [self._task_brief(t) for t in ts]}
228
+
229
+ def task(self, tid):
230
+ t = rows(self.con, "SELECT * FROM tasks WHERE id = ?", (tid,))
231
+ if not t:
232
+ return None
233
+ t = t[0]
234
+ steps = rows(self.con, "SELECT * FROM steps WHERE task_id = ? ORDER BY seq", (tid,))
235
+ events = rows(self.con, "SELECT * FROM events WHERE task_id = ?", (tid,))
236
+ run = rows(self.con, "SELECT * FROM runs WHERE id = ?", (t["run_id"],))
237
+ base = rows(self.con, "SELECT data FROM baselines WHERE task_type IN (?, '__all__') ORDER BY task_type = '__all__'", (t["task_type"],))
238
+ children = rows(self.con, "SELECT id, run_id, prompt, cost, duration_s, tool_calls, tool_errors, outcome, score FROM tasks WHERE parent_task_id = ?", (tid,))
239
+ for c in children:
240
+ c["prompt"] = (c["prompt"] or "")[:200]
241
+ c["title"] = (rows(self.con, "SELECT agent_name, title FROM runs WHERE id=?", (c["run_id"],)) or [{}])[0]
242
+ sibs = rows(self.con, "SELECT id, idx, prompt, outcome FROM tasks WHERE run_id = ? ORDER BY idx", (t["run_id"],))
243
+ pos = next((i for i, s in enumerate(sibs) if s["id"] == tid), 0)
244
+ nav = {"prev": sibs[pos - 1]["id"] if pos > 0 else None, "next": sibs[pos + 1]["id"] if pos + 1 < len(sibs) else None,
245
+ "count": len(sibs), "pos": pos + 1}
246
+ return {"task": t, "steps": steps, "events": events, "run": run[0] if run else None,
247
+ "baseline": base[0]["data"] if base else None, "children": children, "nav": nav}
248
+
249
+ def sessions(self, q):
250
+ w, a = self.where(q)
251
+ ts = rows(self.con, f"SELECT * FROM tasks t{w}", a)
252
+ by = defaultdict(list)
253
+ for t in ts:
254
+ by[t["run_id"]].append(t)
255
+ runs = {r["id"]: r for r in rows(self.con, "SELECT * FROM runs")}
256
+ ev = Counter(r["run_id"] for r in rows(self.con, "SELECT run_id FROM events"))
257
+ out = []
258
+ for rid, g in by.items():
259
+ r = runs.get(rid, {})
260
+ k = self.kpis(g)
261
+ out.append({"id": rid, "title": r.get("title") or r.get("agent_name") or (g[0]["prompt"] or "")[:80],
262
+ "project": r.get("project"), "cwd": r.get("cwd"), "started": r.get("started"), "ended": r.get("ended"),
263
+ "source": r.get("source"), "models": ",".join(sorted({m for t in g for m in (t["models"] or "").split(",") if m})),
264
+ "events": ev.get(rid, 0), **k})
265
+ out.sort(key=lambda x: -(x["started"] or 0))
266
+ return {"sessions": out}
267
+
268
+ def flowmap(self, q):
269
+ cond, args = self.where(dict(q, sub="1"))
270
+ extra = ""
271
+ if q.get("task"):
272
+ ids = [q["task"]] + [r["id"] for r in rows(self.con, "SELECT id FROM tasks WHERE parent_task_id = ?", (q["task"],))]
273
+ extra = f" AND t.id IN ({','.join('?' * len(ids))})"
274
+ args = args + ids
275
+ if q.get("run"):
276
+ extra += " AND (t.run_id = ? OR t.run_id LIKE ?)"
277
+ args = args + [q["run"], q["run"] + ":%"]
278
+ steps = rows(self.con, f"""SELECT s.kind, s.name, s.model, s.duration_ms, s.cost, s.attributed_cost, s.is_error, s.phase,
279
+ s.output_chars, s.context_tokens, s.input_tokens, s.output_tokens, s.cache_read, s.cache_write, t.is_subagent, t.id tid,
280
+ s.agent, t.workflow, t.source
281
+ FROM steps s JOIN tasks t ON t.id = s.task_id{cond}{extra}""", args)
282
+ nodes, edges = {}, {}
283
+
284
+ def node(nid, kind, label):
285
+ if nid not in nodes:
286
+ nodes[nid] = {"id": nid, "kind": kind, "label": label, "calls": 0, "errors": 0, "ms": [], "cost": 0.0,
287
+ "tokens": 0, "phases": Counter(), "members": Counter()}
288
+ return nodes[nid]
289
+
290
+ def edge(a, b):
291
+ k = (a, b)
292
+ if k not in edges:
293
+ edges[k] = {"from": a, "to": b, "calls": 0, "errors": 0, "ms": []}
294
+ return edges[k]
295
+
296
+ user = node("user", "user", "User")
297
+ main = node("agent:main", "agent", "Main agent")
298
+ subs = None
299
+ for s in steps:
300
+ agent = main
301
+ if s["is_subagent"]:
302
+ subs = subs or node("agent:sub", "agent", "Subagents")
303
+ agent = subs
304
+ elif s["source"] != "claude-code" and s["kind"] != "prompt":
305
+ # traced apps: one node per agent (multi-agent) or per workflow entry point
306
+ label = s["agent"] or s["workflow"] or "agent"
307
+ agent = node(f"agent:{label}", "agent", label)
308
+ if s["kind"] == "prompt" and s["source"] != "claude-code" and not s["is_subagent"]:
309
+ label = s["workflow"] or "agent"
310
+ e = edge("user", f"agent:{label}")
311
+ node(f"agent:{label}", "agent", label)
312
+ e["calls"] += 1
313
+ user["calls"] += 1
314
+ continue
315
+ if s["kind"] == "prompt":
316
+ if s["is_subagent"]:
317
+ e = edge("agent:main", "agent:sub")
318
+ else:
319
+ e = edge("user", "agent:main")
320
+ user["calls"] += 1
321
+ e["calls"] += 1
322
+ elif s["kind"] == "llm":
323
+ if s["model"] == "<synthetic>":
324
+ continue
325
+ m = node(f"model:{s['model']}", "model", s["model"] or "?")
326
+ m["calls"] += 1
327
+ m["cost"] += s["cost"] or 0
328
+ m["tokens"] += (s["input_tokens"] or 0) + (s["output_tokens"] or 0) + (s["cache_read"] or 0) + (s["cache_write"] or 0)
329
+ if s["duration_ms"] is not None:
330
+ m["ms"].append(s["duration_ms"])
331
+ agent["calls"] += 1
332
+ agent["cost"] += s["cost"] or 0
333
+ e = edge(agent["id"], m["id"])
334
+ e["calls"] += 1
335
+ if s["duration_ms"] is not None:
336
+ e["ms"].append(s["duration_ms"])
337
+ elif s["kind"] == "tool":
338
+ g = mcp_group(s["name"])
339
+ tn = node(f"tool:{g}", "tool", g)
340
+ tn["calls"] += 1
341
+ tn["errors"] += s["is_error"] or 0
342
+ tn["cost"] += s["attributed_cost"] or 0
343
+ tn["phases"][s["phase"]] += 1
344
+ tn["members"][s["name"]] += 1
345
+ if s["duration_ms"] is not None:
346
+ tn["ms"].append(s["duration_ms"])
347
+ e = edge(agent["id"], tn["id"])
348
+ e["calls"] += 1
349
+ e["errors"] += s["is_error"] or 0
350
+ if s["duration_ms"] is not None:
351
+ e["ms"].append(s["duration_ms"])
352
+ elif s["kind"] == "notice" and s["name"] == "api_error":
353
+ agent["errors"] += 1
354
+
355
+ def fin(d):
356
+ ms = d.pop("ms")
357
+ d["avg_ms"] = round(statistics.mean(ms)) if ms else None
358
+ d["p95_ms"] = round(pct(ms, 0.95)) if ms else None
359
+ d["error_rate"] = round(d["errors"] / d["calls"], 3) if d["calls"] else 0
360
+ for k in ("phases", "members"):
361
+ if k in d:
362
+ d[k] = dict(d[k].most_common(8))
363
+ return d
364
+
365
+ # agent-to-agent handoffs for multi-agent traces
366
+ prev = {}
367
+ for s in steps:
368
+ if s["kind"] in ("llm", "tool") and s["agent"] and s["source"] != "claude-code":
369
+ p = prev.get(s["tid"])
370
+ if p and p != s["agent"]:
371
+ edge(f"agent:{p}", f"agent:{s['agent']}")["calls"] += 1
372
+ prev[s["tid"]] = s["agent"]
373
+ has_main = main["calls"] > 0 or any(k[0] == "user" and k[1] == "agent:main" for k in edges)
374
+ if not has_main:
375
+ nodes.pop("agent:main", None)
376
+ return {"nodes": [fin(n) for n in nodes.values() if n["calls"] or n["id"] == "user" or (n["kind"] == "agent" and n["id"] in {e[1] for e in edges})],
377
+ "edges": [fin(e) for e in edges.values()]}
378
+
379
+ def tools(self, q):
380
+ cond, args = self.where(dict(q, sub=q.get("sub", "1")))
381
+ st = rows(self.con, f"""SELECT s.name, s.phase, s.duration_ms, s.is_error, s.output_chars, s.attributed_cost, s.flags, s.error, s.task_id
382
+ FROM steps s JOIN tasks t ON t.id = s.task_id{cond} AND s.kind = 'tool'""", args)
383
+ by = defaultdict(list)
384
+ for s in st:
385
+ by[s["name"]].append(s)
386
+ out = []
387
+ for name, g in by.items():
388
+ ms = [s["duration_ms"] for s in g if s["duration_ms"] is not None]
389
+ errs = [s for s in g if s["is_error"]]
390
+ flags = Counter(f for s in g for f in (s["flags"] or []))
391
+ out.append({"name": name, "group": mcp_group(name), "phase": Counter(s["phase"] for s in g).most_common(1)[0][0],
392
+ "calls": len(g), "errors": len(errs), "error_rate": round(len(errs) / len(g), 3),
393
+ "avg_ms": round(statistics.mean(ms)) if ms else None, "p95_ms": round(pct(ms, 0.95)) if ms else None,
394
+ "avg_output_chars": round(statistics.mean([s["output_chars"] or 0 for s in g])),
395
+ "output_tokens_est": round(sum(s["output_chars"] or 0 for s in g) / 4),
396
+ "cost": round(sum(s["attributed_cost"] or 0 for s in g), 4), "flags": dict(flags),
397
+ "sample_errors": [{"error": (s["error"] or "")[:200], "task_id": s["task_id"]} for s in errs[-4:]]})
398
+ out.sort(key=lambda x: -x["calls"])
399
+ return {"tools": out}
400
+
401
+ def models(self, q):
402
+ cond, args = self.where(dict(q, sub=q.get("sub", "1")))
403
+ st = rows(self.con, f"""SELECT s.model, s.ts, s.duration_ms, s.cost, s.input_tokens, s.output_tokens, s.cache_read, s.cache_write,
404
+ s.context_tokens, s.thinking_tokens, s.effort, s.stop_reason, s.is_error, s.rate_limited, s.ttft_ms FROM steps s JOIN tasks t ON t.id = s.task_id{cond} AND s.kind = 'llm'
405
+ AND s.model != '<synthetic>'""", args)
406
+ by = defaultdict(list)
407
+ for s in st:
408
+ by[s["model"]].append(s)
409
+ out = []
410
+ daily = defaultdict(lambda: defaultdict(float))
411
+ for m, g in by.items():
412
+ ms = [s["duration_ms"] for s in g if s["duration_ms"] is not None]
413
+ inp = sum(s["input_tokens"] + s["cache_read"] + s["cache_write"] for s in g)
414
+ for s in g:
415
+ if s["ts"]:
416
+ daily[time.strftime("%Y-%m-%d", time.localtime(s["ts"]))][m] += s["cost"] or 0
417
+ out.append({"model": m, "calls": len(g), "cost": round(sum(s["cost"] for s in g), 4),
418
+ "input_tokens": sum(s["input_tokens"] for s in g), "output_tokens": sum(s["output_tokens"] for s in g),
419
+ "cache_read": sum(s["cache_read"] for s in g), "cache_write": sum(s["cache_write"] for s in g),
420
+ "thinking_tokens": sum(s["thinking_tokens"] or 0 for s in g),
421
+ "cache_hit": round(sum(s["cache_read"] for s in g) / inp, 3) if inp else None,
422
+ "avg_ms": round(statistics.mean(ms)) if ms else None, "p95_ms": round(pct(ms, 0.95)) if ms else None,
423
+ "avg_context": round(statistics.mean([s["context_tokens"] for s in g])),
424
+ "max_context": max(s["context_tokens"] for s in g),
425
+ "avg_output": round(statistics.mean([s["output_tokens"] for s in g])),
426
+ "effort": dict(Counter(s["effort"] for s in g if s["effort"])),
427
+ "errors": sum(1 for s in g if s["is_error"]),
428
+ "rate_limited": sum(1 for s in g if s["rate_limited"]),
429
+ "truncation_rate": round(sum(1 for s in g if s["stop_reason"] in TRUNC) / len(g), 4),
430
+ "refusals": sum(1 for s in g if s["stop_reason"] in REFUSE),
431
+ "ttft_p50": pct([s["ttft_ms"] for s in g if s["ttft_ms"]], 0.5),
432
+ "ttft_p95": pct([s["ttft_ms"] for s in g if s["ttft_ms"]], 0.95),
433
+ "tps_p50": pct([s["output_tokens"] / (s["duration_ms"] / 1000) for s in g
434
+ if s["duration_ms"] and s["output_tokens"] > 20], 0.5)})
435
+ out.sort(key=lambda x: -x["cost"])
436
+ return {"models": out, "daily": [{"day": d, **{k: round(v, 4) for k, v in daily[d].items()}} for d in sorted(daily)]}
437
+
438
+ def events(self, q):
439
+ clauses, args = ["1=1"], []
440
+ for f in ("severity", "rule_id", "project", "task_type"):
441
+ if q.get(f):
442
+ clauses.append(f"{f} = ?")
443
+ args.append(q[f])
444
+ if q.get("days"):
445
+ clauses.append("ts >= ?")
446
+ args.append(time.time() - float(q["days"]) * DAY)
447
+ ev = rows(self.con, f"SELECT e.*, t.prompt FROM events e LEFT JOIN tasks t ON t.id = e.task_id WHERE {' AND '.join(clauses).replace('project', 'e.project').replace('task_type', 'e.task_type')} ORDER BY e.ts DESC LIMIT 500", args)
448
+ for e in ev:
449
+ e["prompt"] = (e["prompt"] or "")[:140]
450
+ rule_counts = rows(self.con, "SELECT rule_id, rule, severity, COUNT(*) n FROM events GROUP BY rule_id ORDER BY n DESC")
451
+ return {"events": ev, "rules": rule_counts}
452
+
453
+ def process(self, q):
454
+ ts = self.tasks(q)
455
+ meta = {r["k"]: json.loads(r["v"]) for r in self.con.execute("SELECT k, v FROM meta")}
456
+ from .analysis import process_insights
457
+ insights = process_insights(ts) if (q.get("project") or q.get("days") or q.get("type")) else meta.get("insights", [])
458
+ dims = ["efficiency", "focus", "reliability", "verification", "context", "autonomy", "compliance", "overall"]
459
+ avg = {}
460
+ for d in dims:
461
+ v = [t["scores"].get(d) for t in ts if t["scores"] and t["scores"].get(d) is not None]
462
+ avg[d] = round(statistics.mean(v), 1) if v else None
463
+ by = defaultdict(list)
464
+ for t in ts:
465
+ by[t["task_type"]].append(t)
466
+ per_type = []
467
+ for ty, g in by.items():
468
+ row = {"type": ty, "n": len(g)}
469
+ for d in dims:
470
+ v = [t["scores"].get(d) for t in g if t["scores"] and t["scores"].get(d) is not None]
471
+ row[d] = round(statistics.mean(v), 1) if v else None
472
+ ph = Counter()
473
+ for t in g:
474
+ ph.update(t["phase_cost"] or {})
475
+ tot = sum(ph.values()) or 1
476
+ row["phase_mix"] = {k: round(v / tot, 3) for k, v in ph.items()}
477
+ per_type.append(row)
478
+ per_type.sort(key=lambda r: -r["n"])
479
+ worst = sorted([t for t in ts if t["score"] is not None], key=lambda t: t["score"])[:10]
480
+ return {"insights": insights, "avg": avg, "per_type": per_type, "daily": self.daily(ts),
481
+ "worst": [self._task_brief(t) | {"scores": t["scores"]} for t in worst]}
482
+
483
+ GROUPS = {"task_type": "t.task_type", "project": "t.project", "outcome": "t.outcome", "apdex": "t.apdex",
484
+ "day": "date(t.started, 'unixepoch', 'localtime')", "week": "strftime('%Y-W%W', t.started, 'unixepoch', 'localtime')",
485
+ "models": "t.models", "source": "t.source", "prompt_kind": "t.prompt_kind", "hour": "strftime('%H', t.started, 'unixepoch', 'localtime')"}
486
+ METRICS = {"tasks": "COUNT(*)", "cost": "SUM(t.cost + t.subagent_cost)", "avg_cost": "AVG(t.cost + t.subagent_cost)",
487
+ "tokens": "SUM(t.total_tokens)", "output_tokens": "SUM(t.output_tokens)", "avg_duration": "AVG(t.duration_s)",
488
+ "avg_score": "AVG(t.score)", "tool_calls": "SUM(t.tool_calls)", "tool_errors": "SUM(t.tool_errors)",
489
+ "error_rate": "1.0 * SUM(t.tool_errors) / MAX(1, SUM(t.tool_calls))", "waste": "SUM(t.waste_cost)",
490
+ "rework_rate": "AVG(CASE WHEN t.outcome IN ('rework','interrupted') THEN 1.0 ELSE 0 END)",
491
+ "verification_rate": "AVG(CASE WHEN t.code_changed = 1 THEN t.verified END)", "avg_context": "AVG(t.max_context)",
492
+ "cache_hit": "1.0 * SUM(t.cache_read) / MAX(1, SUM(t.cache_read + t.cache_write + t.input_tokens))"}
493
+
494
+ def analytics(self, q):
495
+ g = self.GROUPS.get(q.get("group", "task_type"), "t.task_type")
496
+ metrics = [m for m in (q.get("metrics") or "tasks,cost,avg_cost,avg_score").split(",") if m in self.METRICS]
497
+ w, a = self.where(q)
498
+ sel = ", ".join(f"{self.METRICS[m]} AS {m}" for m in metrics)
499
+ order = "grp" if q.get("group") in ("day", "week", "hour") else f"{metrics[0]} DESC"
500
+ data = rows(self.con, f"SELECT {g} AS grp, {sel} FROM tasks t{w} GROUP BY grp ORDER BY {order} LIMIT 200", a)
501
+ return {"rows": data, "metrics": metrics, "group": q.get("group", "task_type"),
502
+ "available": {"groups": list(self.GROUPS), "metrics": list(self.METRICS)}}
503
+
504
+ def compare(self, q):
505
+ dim = q.get("dim", "models")
506
+ col = {"models": "models", "task_type": "task_type", "project": "project", "source": "source",
507
+ "policy": "policy_version", "workflow": "workflow"}.get(dim)
508
+ ts = self.tasks(q)
509
+
510
+ def pick(val):
511
+ if dim == "period":
512
+ lo, hi = val.split("..")
513
+ lo_t = time.mktime(time.strptime(lo, "%Y-%m-%d"))
514
+ hi_t = time.mktime(time.strptime(hi, "%Y-%m-%d")) + DAY
515
+ return [t for t in ts if t["started"] and lo_t <= t["started"] < hi_t]
516
+ return [t for t in ts if (t[col] or "") == val]
517
+
518
+ res = {}
519
+ for side in ("a", "b"):
520
+ if q.get(side):
521
+ g = pick(q[side])
522
+ k = self.kpis(g)
523
+ ph = Counter()
524
+ for t in g:
525
+ ph.update(t["phase_cost"] or {})
526
+ tot = sum(ph.values()) or 1
527
+ k["phase_mix"] = {p: round(v / tot, 3) for p, v in ph.items()}
528
+ sc = defaultdict(list)
529
+ for t in g:
530
+ for d, v in (t["scores"] or {}).items():
531
+ if v is not None:
532
+ sc[d].append(v)
533
+ k["scores"] = {d: round(statistics.mean(v), 1) for d, v in sc.items()}
534
+ res[side] = k
535
+ options = sorted({t[col] for t in ts if t.get(col)}) if col else []
536
+ return {"dim": dim, "options": options, **res}
537
+
538
+ # ------------------------------------------------------------ governance (Aegis)
539
+ def _governed(self, q):
540
+ ts = [t for t in self.tasks(dict(q, sub="1")) if t["governed"]]
541
+ return ts
542
+
543
+ def _steps_for(self, ids, cols="*", extra=""):
544
+ out = []
545
+ for i in range(0, len(ids), 400):
546
+ chunk = ids[i:i + 400]
547
+ out += rows(self.con, f"SELECT {cols} FROM steps WHERE task_id IN ({','.join('?' * len(chunk))}){extra}", chunk)
548
+ return out
549
+
550
+ def governance(self, q):
551
+ ts = self._governed(q)
552
+ ids = [t["id"] for t in ts]
553
+ by_task = {t["id"]: t for t in ts}
554
+ steps = self._steps_for(ids, "task_id, seq, kind, name, ts, denied, rule, guard, agent, grant_depth, error, text, "
555
+ "attributed_cost, cost")
556
+ decisions = [s for s in steps if s["rule"] and s["kind"] in ("tool", "llm", "span")]
557
+ denied = [s for s in steps if s["denied"]]
558
+ tool_dec = [s for s in decisions if s["kind"] == "tool"]
559
+ revokes = [s for s in steps if s["kind"] == "notice" and s["name"] == "revoked"]
560
+ n = len(ts)
561
+ k = {
562
+ "governed_tasks": n, "decisions": len(decisions), "denials": len(denied),
563
+ "denial_rate": round(sum(1 for s in tool_dec if s["denied"]) / len(tool_dec), 4) if tool_dec else 0,
564
+ "budget_stops": sum(1 for s in denied if str(s["rule"]).startswith("budget.")),
565
+ "spend_denials": sum(1 for s in denied if s["kind"] == "llm"),
566
+ "revocations": len(revokes),
567
+ "blocked_cost": round(sum(s["attributed_cost"] or 0 for s in denied), 4),
568
+ "probing_tasks": sum(1 for t in ts if (t["repeated_denials"] or 0) >= 3),
569
+ "compliance": round(statistics.mean([t["scores"].get("compliance") for t in ts if t["scores"] and t["scores"].get("compliance") is not None]), 1)
570
+ if any(t["scores"] and t["scores"].get("compliance") is not None for t in ts) else None,
571
+ "policies": len({t["policy_version"] for t in ts if t["policy_version"]}),
572
+ }
573
+ by_rule = Counter(s["rule"] for s in denied)
574
+ by_tool = Counter(s["name"] for s in denied)
575
+ by_agent = Counter(s["agent"] or "?" for s in denied)
576
+ daily = defaultdict(Counter)
577
+ for s in denied:
578
+ if s["ts"]:
579
+ daily[time.strftime("%Y-%m-%d", time.localtime(s["ts"]))][(s["rule"] or "").split(".")[0]] += 1
580
+ recent = sorted(denied, key=lambda s: -(s["ts"] or 0))[:25]
581
+ return {
582
+ "kpis": k,
583
+ "by_rule": [{"rule": r, "n": c} for r, c in by_rule.most_common(15)],
584
+ "by_tool": [{"tool": r, "n": c} for r, c in by_tool.most_common(15)],
585
+ "by_agent": [{"agent": r, "n": c} for r, c in by_agent.most_common(10)],
586
+ "daily": [{"day": d, **c} for d, c in sorted(daily.items())],
587
+ "guards": sorted({g for c in daily.values() for g in c}),
588
+ "recent": [{"task_id": s["task_id"], "tool": s["name"], "rule": s["rule"], "agent": s["agent"], "ts": s["ts"],
589
+ "error": (s["error"] or "")[:200], "kind": s["kind"],
590
+ "prompt": (by_task.get(s["task_id"], {}).get("prompt") or "")[:120],
591
+ "workflow": by_task.get(s["task_id"], {}).get("workflow")} for s in recent],
592
+ "revocations": [{"task_id": s["task_id"], "ts": s["ts"], "agent": s["agent"], "text": s["text"]}
593
+ for s in sorted(revokes, key=lambda s: -(s["ts"] or 0))[:15]],
594
+ "policies": self._policy_rows(ts, steps),
595
+ }
596
+
597
+ def _policy_docs(self):
598
+ out = {}
599
+ for r in rows(self.con, "SELECT policy_version, policy, started FROM runs WHERE policy IS NOT NULL ORDER BY started"):
600
+ if isinstance(r["policy"], dict):
601
+ out[r["policy_version"]] = r["policy"]
602
+ return out
603
+
604
+ def _policy_rows(self, ts, steps):
605
+ docs = self._policy_docs()
606
+ by_pol = defaultdict(list)
607
+ for t in ts:
608
+ if t["policy_version"]:
609
+ by_pol[t["policy_version"]].append(t)
610
+ used_by_task = defaultdict(set)
611
+ for s in steps:
612
+ if s["kind"] == "tool" and not s["denied"] and s["name"] not in ("model.spend", "agent.spawn"):
613
+ used_by_task[s["task_id"]].add(s["name"])
614
+ out = []
615
+ for label, g in sorted(by_pol.items(), key=lambda kv: -len(kv[1])):
616
+ doc = (docs.get(label) or {}).get("doc") or {}
617
+ granted = sorted(e["name"] for e in (doc.get("tools") or {}).get("allow", []) if e["name"] != "agent.spawn")
618
+ used = sorted(set().union(*[used_by_task[t["id"]] for t in g]) if g else set())
619
+ budget = doc.get("budget") or {}
620
+ p95 = {"usd": pct([t["cost"] + (t["subagent_cost"] or 0) for t in g], 0.95),
621
+ "tokens": pct([t["total_tokens"] for t in g], 0.95),
622
+ "wall_clock_s": pct([t["wall_s"] or 0 for t in g], 0.95),
623
+ "tool_calls": pct([t["tool_calls"] for t in g], 0.95)}
624
+ headroom = {k: (round(budget[k] / p95[k], 1) if budget.get(k) and p95[k] else None) for k in p95}
625
+ out.append({"policy": label, "name": (docs.get(label) or {}).get("name"), "tasks": len(g),
626
+ "success_rate": round(sum(1 for t in g if t["outcome"] == "completed") / len(g), 3),
627
+ "denials": sum(t["policy_denials"] + t["spend_denials"] for t in g),
628
+ "revocations": sum(t["revocations"] for t in g),
629
+ "granted": granted, "used": used, "unused": sorted(set(granted) - set(used)),
630
+ "budget": budget, "p95": p95, "headroom": headroom,
631
+ "workflows": sorted({t["workflow"] for t in g if t["workflow"]})})
632
+ return out
633
+
634
+ def export_policy(self, q):
635
+ """Observe -> govern: a tightened policy for a workflow, derived from its governed runs."""
636
+ from .govern import render, synthesize
637
+ ts = self._governed(q)
638
+ if q.get("policy"):
639
+ ts = [t for t in ts if t["policy_version"] == q["policy"]]
640
+ if not ts:
641
+ return {"error": "no governed runs match (need runs recorded with agentdynamics.integrations.aegis)"}
642
+ label = q.get("policy") or Counter(t["policy_version"] for t in ts if t["policy_version"]).most_common(1)[0][0]
643
+ base = (self._policy_docs().get(label) or {}).get("doc") if label else None
644
+ if q.get("base_doc"):
645
+ base = q["base_doc"]
646
+ steps = self._steps_for([t["id"] for t in ts], "task_id, kind, name, denied, rule, agent, grant_depth, args_json")
647
+ doc, changes, stats = synthesize(base, [t for t in ts if not t["is_subagent"]], steps,
648
+ headroom=float(q.get("headroom") or 1.5))
649
+ scope = ", ".join(f"{k}={q[k]}" for k in ("project", "workflow", "environment", "days") if q.get(k))
650
+ return {"yaml": render(doc, changes, stats, f"Base: {label or 'none'}. Scope: {scope or 'all governed runs'}."),
651
+ "policy": doc, "changes": changes, "stats": stats, "base": label}
652
+
653
+ # ------------------------------------------------------------ enterprise endpoints
654
+ def workflows(self, q):
655
+ from .flows import workflows
656
+ return workflows(self, q)
657
+
658
+ def workflow(self, q):
659
+ from .flows import workflow_detail
660
+ return workflow_detail(self, q.get("name", ""), q)
661
+
662
+ def slos(self, q):
663
+ from . import slo
664
+ ts = self.tasks(dict(q, days=""))
665
+ return {"slos": [slo.evaluate(ts, s) for s in slo.load(self.e.data_dir)]}
666
+
667
+ def sources(self, q):
668
+ src = [dict(s.d) for s in self.e.sources.values()]
669
+ spans = rows(self.con, "SELECT source, COUNT(*) spans, COUNT(DISTINCT trace_id) traces, MAX(updated) last FROM spans_raw GROUP BY source")
670
+ by = {r["source"]: r for r in spans}
671
+ for s in src:
672
+ if s["name"] in by:
673
+ s.update({"spans": by[s["name"]]["spans"], "traces": by[s["name"]]["traces"], "last_data": by[s["name"]]["last"]})
674
+ if s["status"] == "idle":
675
+ s["status"] = "ok" # has stored data from before this process started
676
+ runs = rows(self.con, "SELECT source, framework, COUNT(*) n, MAX(ended) last FROM runs GROUP BY source, framework")
677
+ return {"sources": src, "runs_by_source": runs, "stats": self.e.stats, "auth": self.e.cfg["auth"]["enabled"]}
678
+
679
+ def connect(self, q):
680
+ from .__main__ import SNIPPETS
681
+ url = (q.get("url") or "http://127.0.0.1:8787").rstrip("/")
682
+ return {"snippets": [{"id": k, "title": t, "body": b.format(url=url)} for k, (t, b) in SNIPPETS.items()],
683
+ "auth": self.e.cfg["auth"]["enabled"]}
684
+
685
+ def config(self, q):
686
+ from .config import public_view
687
+ return {"config": public_view(self.e.cfg), "data_dir": self.e.data_dir, "db": self.e.db_path}
688
+
689
+ def healthz(self):
690
+ ok = self.e.last_refresh is not None
691
+ return {"status": "ok" if ok else "starting", "last_refresh": self.e.last_refresh, "refresh_seconds": self.e.last_duration,
692
+ "sources": {k: v.d["status"] for k, v in self.e.sources.items()}}
693
+
694
+ def prometheus(self):
695
+ lines = []
696
+
697
+ def esc(v):
698
+ return str(v).replace("\\", "").replace('"', "").replace("\n", " ")
699
+
700
+ def m(name, help_, typ, samples):
701
+ lines.append(f"# HELP {name} {help_}")
702
+ lines.append(f"# TYPE {name} {typ}")
703
+ for labels, v in samples:
704
+ lab = ",".join(f'{k}="{esc(val)}"' for k, val in labels.items())
705
+ lines.append(f"{name}{{{lab}}} {v}")
706
+
707
+ t = rows(self.con, "SELECT project, environment, task_type, outcome, COUNT(*) n, SUM(cost+subagent_cost) cost, SUM(tool_calls) calls, "
708
+ "SUM(tool_errors) errs, SUM(total_tokens) tok FROM tasks WHERE llm_calls>0 OR tool_calls>0 "
709
+ "GROUP BY project, environment, task_type, outcome")
710
+
711
+ def lab(r):
712
+ return {"project": r["project"], "environment": r["environment"], "task_type": r["task_type"], "outcome": r["outcome"]}
713
+
714
+ m("agentdynamics_tasks_total", "Tasks observed", "counter", [(lab(r), r["n"]) for r in t])
715
+ m("agentdynamics_cost_usd_total", "Model spend in USD (list price)", "counter", [(lab(r), round(r["cost"] or 0, 6)) for r in t])
716
+ m("agentdynamics_tokens_total", "Tokens processed", "counter", [(lab(r), r["tok"] or 0) for r in t])
717
+ m("agentdynamics_tool_calls_total", "Tool calls", "counter", [(lab(r), r["calls"] or 0) for r in t])
718
+ m("agentdynamics_tool_errors_total", "Failed tool calls", "counter", [(lab(r), r["errs"] or 0) for r in t])
719
+ ap = rows(self.con, "SELECT task_type, apdex, COUNT(*) n FROM tasks WHERE apdex IS NOT NULL GROUP BY task_type, apdex")
720
+ by = defaultdict(Counter)
721
+ for r in ap:
722
+ by[r["task_type"]][r["apdex"]] += r["n"]
723
+ m("agentdynamics_apdex", "Agent Apdex by task type", "gauge",
724
+ [({"task_type": k}, round((c["satisfied"] + c["tolerating"] / 2) / max(1, sum(c.values())), 4)) for k, c in by.items()])
725
+ ev = rows(self.con, "SELECT rule_id, severity, COUNT(*) n FROM events GROUP BY rule_id, severity")
726
+ m("agentdynamics_health_events", "Open health-rule violations", "gauge", [({"rule": r["rule_id"], "severity": r["severity"]}, r["n"]) for r in ev])
727
+ m("agentdynamics_spans_ingested_total", "Spans/runs accepted by push and pull ingestion", "counter", [({}, self.e.stats["spans_ingested"])])
728
+ m("agentdynamics_last_refresh_timestamp_seconds", "Last successful analysis", "gauge", [({}, self.e.last_refresh or 0)])
729
+ m("agentdynamics_refresh_duration_seconds", "Duration of last analysis", "gauge", [({}, self.e.last_duration or 0)])
730
+ return "\n".join(lines) + "\n"
731
+
732
+
733
+ # ---------------------------------------------------------------- HTTP layer
734
+
735
+ ROLE_FOR = {"ingest": 1, "read": 2, "admin": 3}
736
+ # ingest keys only write telemetry, read keys only read, admin can do everything
737
+ CAN = {1: {"ingest"}, 2: {"read"}, 3: {"ingest", "read", "admin"}}
738
+ MAX_BODY = 64 * 1024 * 1024
739
+
740
+
741
+ class Handler(BaseHTTPRequestHandler):
742
+ api = None
743
+ protocol_version = "HTTP/1.1"
744
+
745
+ def log_message(self, fmt, *args):
746
+ pass
747
+
748
+ def _send(self, code, body, ctype="application/json", extra_headers=None):
749
+ if isinstance(body, bytes):
750
+ data = body
751
+ elif isinstance(body, str):
752
+ data = body.encode()
753
+ else:
754
+ data = json.dumps(body, default=str).encode()
755
+ self.send_response(code)
756
+ self.send_header("Content-Type", ctype)
757
+ self.send_header("Content-Length", str(len(data)))
758
+ self.send_header("Cache-Control", "no-store")
759
+ self.send_header("X-Content-Type-Options", "nosniff")
760
+ for k, v in (extra_headers or {}).items():
761
+ self.send_header(k, v)
762
+ self.end_headers()
763
+ self.wfile.write(data)
764
+
765
+ # --- auth: API keys with roles ingest < read < admin
766
+ def _role(self):
767
+ auth = self.api.e.cfg["auth"]
768
+ if not auth.get("enabled"):
769
+ return 3
770
+ key = self.headers.get("x-api-key") or ""
771
+ h = self.headers.get("Authorization") or ""
772
+ if h.lower().startswith("bearer "):
773
+ key = h[7:].strip()
774
+ for k in auth.get("keys") or []:
775
+ if key and hmac.compare_digest(key, str(k.get("key", ""))):
776
+ return ROLE_FOR.get(k.get("role"), 0)
777
+ return 0
778
+
779
+ def _require(self, need):
780
+ r = self._role()
781
+ if need in CAN.get(r, set()):
782
+ return True
783
+ self._send(401 if r == 0 else 403, {"error": "unauthorized" if r == 0 else f"requires '{need}' role"},
784
+ extra_headers={"WWW-Authenticate": "Bearer"} if r == 0 else None)
785
+ return False
786
+
787
+ def _body(self):
788
+ n = int(self.headers.get("Content-Length") or 0)
789
+ if n > MAX_BODY:
790
+ raise ValueError("payload too large")
791
+ body = self.rfile.read(n) if n else b""
792
+ enc = (self.headers.get("Content-Encoding") or "").lower()
793
+ if enc == "gzip":
794
+ body = gzip.decompress(body)
795
+ elif enc == "deflate":
796
+ body = zlib.decompress(body)
797
+ elif enc == "zstd":
798
+ from .collectors.langsmith import zstd_available, zstd_decompress
799
+ if not zstd_available():
800
+ raise ValueError("Content-Encoding zstd needs `pip install zstandard`")
801
+ body = zstd_decompress(body)
802
+ if len(body) > MAX_BODY:
803
+ raise ValueError("payload too large")
804
+ elif enc and enc != "identity":
805
+ raise ValueError(f"unsupported Content-Encoding {enc}")
806
+ return body
807
+
808
+ def do_GET(self):
809
+ u = urlparse(self.path)
810
+ q = {k: v[0] for k, v in parse_qs(u.query).items() if v and v[0] != ""}
811
+ p = u.path
812
+ api = self.api
813
+ try:
814
+ if p == "/healthz":
815
+ return self._send(200, api.healthz())
816
+ if p in ("/langsmith/info", "/langsmith/api/v1/info"):
817
+ from .collectors.langsmith import info
818
+ return self._send(200, info())
819
+ if p.startswith("/api/") or p == "/metrics":
820
+ if not self._require("read"):
821
+ return
822
+ if p == "/metrics":
823
+ return self._send(200, api.prometheus(), "text/plain; version=0.0.4")
824
+ routes = {"/api/filters": api.filters, "/api/overview": api.overview, "/api/types": api.types,
825
+ "/api/tasks": api.task_list, "/api/sessions": api.sessions, "/api/flowmap": api.flowmap,
826
+ "/api/tools": api.tools, "/api/models": api.models, "/api/events": api.events,
827
+ "/api/process": api.process, "/api/analytics": api.analytics, "/api/compare": api.compare,
828
+ "/api/workflows": api.workflows, "/api/workflow": api.workflow, "/api/slos": api.slos,
829
+ "/api/sources": api.sources, "/api/config": api.config, "/api/connect": api.connect,
830
+ "/api/governance": api.governance, "/api/governance/policy": api.export_policy}
831
+ if p in routes:
832
+ r = routes[p](q)
833
+ return self._send(200 if r is not None else 404, r if r is not None else {"error": "not found"})
834
+ if p.startswith("/api/task/"):
835
+ r = api.task(unquote(p[len("/api/task/"):]))
836
+ return self._send(200 if r else 404, r or {"error": "not found"})
837
+ if p == "/api/rules":
838
+ return self._send(200, {"rules": api.e.rules()})
839
+ if p == "/api/whoami":
840
+ return self._send(200, {"role": {3: "admin", 2: "read", 1: "ingest"}.get(self._role())})
841
+ if p.startswith("/api/") or p.startswith("/langsmith/"):
842
+ return self._send(404 if p.startswith("/api/") else 200, {"error": "not found"} if p.startswith("/api/") else {})
843
+ except Exception as ex: # surface errors to the console instead of a dropped connection
844
+ traceback.print_exc()
845
+ return self._send(500, {"error": str(ex)})
846
+ # static console
847
+ rel = "index.html" if p in ("/", "") else p.lstrip("/")
848
+ path = os.path.normpath(os.path.join(WEB_DIR, rel))
849
+ if not path.startswith(WEB_DIR) or not os.path.isfile(path):
850
+ path = os.path.join(WEB_DIR, "index.html")
851
+ with open(path, "rb") as f:
852
+ self._send(200, f.read(), mimetypes.guess_type(path)[0] or "application/octet-stream")
853
+
854
+ def do_PATCH(self):
855
+ u = urlparse(self.path)
856
+ try:
857
+ if u.path.startswith("/langsmith/") and "/runs/" in u.path:
858
+ if not self._require("ingest"):
859
+ return
860
+ rid = u.path.rstrip("/").rsplit("/", 1)[-1]
861
+ patch = json.loads(self._body() or b"{}")
862
+ patch["id"] = rid
863
+ self.api.e.ingest_langsmith([], [patch])
864
+ return self._send(200, {})
865
+ except Exception as ex:
866
+ return self._send(400, {"error": str(ex)})
867
+ self._send(404, {"error": "not found"})
868
+
869
+ def do_POST(self):
870
+ u = urlparse(self.path)
871
+ p = u.path
872
+ e = self.api.e
873
+ try:
874
+ # ---- telemetry ingestion (role: ingest)
875
+ if p in ("/v1/traces", "/otlp/v1/traces"):
876
+ if not self._require("ingest"):
877
+ return
878
+ ctype = self.headers.get("Content-Type") or ""
879
+ n = e.ingest_otlp(self._body(), ctype)
880
+ if "protobuf" in ctype:
881
+ return self._send(200, b"", "application/x-protobuf") # empty ExportTraceServiceResponse
882
+ return self._send(200, {"partialSuccess": {}, "accepted": n})
883
+ if p.startswith("/langsmith/"):
884
+ if not self._require("ingest"):
885
+ return
886
+ from .collectors import langsmith as ls
887
+ sub = p[len("/langsmith"):].replace("/api/v1", "")
888
+ body = self._body()
889
+ if sub == "/runs/batch":
890
+ posts, patches = ls.parse_batch(body)
891
+ e.ingest_langsmith(posts, patches)
892
+ elif sub == "/runs/multipart":
893
+ posts, patches, fb = ls.parse_multipart(body, self.headers.get("Content-Type") or "")
894
+ e.ingest_langsmith(posts, patches, fb)
895
+ elif sub == "/runs":
896
+ e.ingest_langsmith([json.loads(body or b"{}")], [])
897
+ elif sub == "/feedback":
898
+ e.ingest_langsmith([], [], [json.loads(body or b"{}")])
899
+ else:
900
+ return self._send(200, {}) # accept and ignore other LangSmith calls (datasets, sessions...)
901
+ return self._send(202, {})
902
+ if p == "/api/ingest":
903
+ if not self._require("ingest"):
904
+ return
905
+ payload = json.loads(self._body() or b"{}")
906
+ runs = payload if isinstance(payload, list) else [payload]
907
+ return self._send(200, {"ok": True, "ids": [e.ingest(r) for r in runs]})
908
+ if p == "/api/ingest/records":
909
+ # log pipelines (Fluent Bit / Vector / Logstash HTTP outputs): JSON array or NDJSON of any supported format
910
+ if not self._require("ingest"):
911
+ return
912
+ from .collectors.inbox import detect, unwrap
913
+ body = self._body().strip()
914
+ recs = json.loads(body) if body.startswith(b"[") else [json.loads(x) for x in body.splitlines() if x.strip()]
915
+ recs = [unwrap(r) for r in recs]
916
+ n = e.ingest_records([(detect(r), r) for r in recs if detect(r)])
917
+ return self._send(200, {"ok": True, "accepted": n, "received": len(recs)})
918
+ # ---- operations
919
+ if p == "/api/refresh":
920
+ if not self._require("read"):
921
+ return
922
+ changed = e.refresh(force=True)
923
+ return self._send(200, {"ok": True, "changed": changed, "seconds": e.last_duration})
924
+ if p == "/api/rules":
925
+ if not self._require("admin"):
926
+ return
927
+ e.save_rules(json.loads(self._body())["rules"])
928
+ return self._send(200, {"ok": True})
929
+ if p == "/api/slos":
930
+ if not self._require("admin"):
931
+ return
932
+ from . import slo
933
+ slo.save(e.data_dir, json.loads(self._body())["slos"])
934
+ return self._send(200, {"ok": True})
935
+ except Exception as ex:
936
+ traceback.print_exc()
937
+ return self._send(400, {"error": str(ex)})
938
+ self._send(404, {"error": "not found"})
939
+
940
+
941
+ def serve(engine, host="127.0.0.1", port=8787):
942
+ Handler.api = Api(engine)
943
+ httpd = ThreadingHTTPServer((host, port), Handler)
944
+ httpd.daemon_threads = True
945
+ print(f"AgentDynamics console: http://{host}:{port}")
946
+ print(f" OTLP/HTTP traces : http://{host}:{port}/v1/traces")
947
+ print(f" LangSmith API : http://{host}:{port}/langsmith (set LANGSMITH_ENDPOINT to this)")
948
+ print(f" auth : {'on' if engine.cfg['auth']['enabled'] else 'off (local mode)'}")
949
+ httpd.serve_forever()