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.
- agentdynamics/__init__.py +12 -0
- agentdynamics/__main__.py +322 -0
- agentdynamics/analysis.py +755 -0
- agentdynamics/autotrace.py +697 -0
- agentdynamics/bootstrap/sitecustomize.py +27 -0
- agentdynamics/collectors/__init__.py +0 -0
- agentdynamics/collectors/aegis_audit.py +72 -0
- agentdynamics/collectors/claude_code.py +257 -0
- agentdynamics/collectors/generic.py +103 -0
- agentdynamics/collectors/inbox.py +95 -0
- agentdynamics/collectors/langfuse.py +98 -0
- agentdynamics/collectors/langsmith.py +258 -0
- agentdynamics/collectors/otlp.py +349 -0
- agentdynamics/collectors/spans.py +202 -0
- agentdynamics/config.py +139 -0
- agentdynamics/engine.py +376 -0
- agentdynamics/flows.py +142 -0
- agentdynamics/govern.py +231 -0
- agentdynamics/integrations/__init__.py +1 -0
- agentdynamics/integrations/aegis.py +352 -0
- agentdynamics/phases.py +82 -0
- agentdynamics/pricing.py +69 -0
- agentdynamics/privacy.py +41 -0
- agentdynamics/sdk.py +105 -0
- agentdynamics/server.py +949 -0
- agentdynamics/slo.py +84 -0
- agentdynamics/store.py +228 -0
- agentdynamics/web/app.js +1069 -0
- agentdynamics/web/charts.js +185 -0
- agentdynamics/web/index.html +40 -0
- agentdynamics/web/style.css +244 -0
- agentdynamics-0.4.0.dist-info/METADATA +195 -0
- agentdynamics-0.4.0.dist-info/RECORD +37 -0
- agentdynamics-0.4.0.dist-info/WHEEL +5 -0
- agentdynamics-0.4.0.dist-info/entry_points.txt +2 -0
- agentdynamics-0.4.0.dist-info/licenses/LICENSE +202 -0
- agentdynamics-0.4.0.dist-info/top_level.txt +1 -0
agentdynamics/server.py
ADDED
|
@@ -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()
|