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/slo.py ADDED
@@ -0,0 +1,84 @@
1
+ """Service level objectives with error budgets and burn rates."""
2
+ import json
3
+ import os
4
+ import time
5
+ from collections import defaultdict
6
+
7
+ from .analysis import apdex_score, pct
8
+
9
+ DEFAULT_SLOS = [
10
+ {"id": "success", "name": "Task success rate", "metric": "success_rate", "op": ">=", "target": 0.95, "window_days": 7, "scope": {}},
11
+ {"id": "apdex", "name": "Agent Apdex", "metric": "apdex", "op": ">=", "target": 0.85, "window_days": 7, "scope": {}},
12
+ {"id": "latency", "name": "p95 task latency (s)", "metric": "p95_seconds", "op": "<=", "target": 900, "window_days": 7, "scope": {}},
13
+ {"id": "cost", "name": "Median cost per task ($)", "metric": "median_cost", "op": "<=", "target": 1.0, "window_days": 7, "scope": {}},
14
+ {"id": "tool_errors", "name": "Tool error rate", "metric": "tool_error_rate", "op": "<=", "target": 0.05, "window_days": 7, "scope": {}},
15
+ ]
16
+ RATIO = {"success_rate", "apdex"} # "good event" ratios get an error budget
17
+
18
+
19
+ def path(data_dir):
20
+ return os.path.join(data_dir, "slos.json")
21
+
22
+
23
+ def load(data_dir):
24
+ p = path(data_dir)
25
+ if os.path.exists(p):
26
+ with open(p, encoding="utf-8") as f:
27
+ return json.load(f)
28
+ return DEFAULT_SLOS
29
+
30
+
31
+ def save(data_dir, slos):
32
+ with open(path(data_dir), "w", encoding="utf-8") as f:
33
+ json.dump(slos, f, indent=2)
34
+
35
+
36
+ def metric(tasks, m):
37
+ if not tasks:
38
+ return None
39
+ if m == "success_rate":
40
+ return sum(1 for t in tasks if t["outcome"] == "completed") / len(tasks)
41
+ if m == "apdex":
42
+ return apdex_score([t for t in tasks if t["apdex"]])
43
+ if m == "p95_seconds":
44
+ return pct([t["wall_s"] or 0 for t in tasks], 0.95)
45
+ if m == "median_cost":
46
+ return pct([t["cost"] + (t["subagent_cost"] or 0) for t in tasks], 0.5)
47
+ if m == "tool_error_rate":
48
+ calls = sum(t["tool_calls"] for t in tasks)
49
+ return sum(t["tool_errors"] for t in tasks) / calls if calls else 0
50
+ return None
51
+
52
+
53
+ def evaluate(all_tasks, slo, now=None):
54
+ now = now or time.time()
55
+ sc = slo.get("scope") or {}
56
+ ts = [t for t in all_tasks if all((t.get(k) or "") == v for k, v in sc.items() if v)]
57
+ win = [t for t in ts if (t["started"] or 0) >= now - slo["window_days"] * 86400]
58
+ val = metric(win, slo["metric"])
59
+ good = (val >= slo["target"]) if slo["op"] == ">=" else (val <= slo["target"]) if val is not None else None
60
+ res = {**slo, "value": val, "n": len(win), "met": good if val is not None else None}
61
+ if slo["metric"] in RATIO and val is not None:
62
+ allowed = 1 - slo["target"]
63
+ bad = 1 - val
64
+ res["budget_remaining"] = round(1 - bad / allowed, 3) if allowed > 0 else None
65
+ last = [t for t in win if (t["started"] or 0) >= now - 86400]
66
+ v24 = metric(last, slo["metric"])
67
+ res["burn_rate_24h"] = round((1 - v24) / allowed, 2) if (v24 is not None and allowed > 0) else None
68
+ daily = defaultdict(list)
69
+ for t in win:
70
+ daily[time.strftime("%Y-%m-%d", time.localtime(t["started"]))].append(t)
71
+ res["daily"] = [{"day": d, "value": metric(g, slo["metric"]), "n": len(g)} for d, g in sorted(daily.items())]
72
+ if res["daily"]:
73
+ ok_days = [d for d in res["daily"] if d["value"] is not None and
74
+ ((d["value"] >= slo["target"]) if slo["op"] == ">=" else (d["value"] <= slo["target"]))]
75
+ res["days_met"] = f"{len(ok_days)}/{len(res['daily'])}"
76
+ status = "unknown" if val is None else "ok"
77
+ if val is not None and not good:
78
+ status = "breached"
79
+ elif res.get("budget_remaining") is not None and res["budget_remaining"] < 0.25:
80
+ status = "at risk"
81
+ elif res.get("burn_rate_24h") is not None and res["burn_rate_24h"] > 2:
82
+ status = "at risk"
83
+ res["status"] = status
84
+ return res
agentdynamics/store.py ADDED
@@ -0,0 +1,228 @@
1
+ """SQLite storage (WAL mode).
2
+
3
+ Two kinds of tables:
4
+ * durable - spans_raw (pushed/pulled telemetry), source_state, alerts_sent. These are a system of record.
5
+ * derived - runs, steps, tasks, events, baselines, meta. Rebuildable from sources; dropped on schema change.
6
+
7
+ The storage layer is intentionally thin so it can be swapped for Postgres/ClickHouse at larger scale.
8
+ """
9
+ import json
10
+ import sqlite3
11
+ import time
12
+
13
+ SCHEMA_VERSION = 5
14
+
15
+ RUN_COLS = ["id", "source", "project", "environment", "framework", "workflow", "cwd", "title", "agent_name", "parent_id",
16
+ "parent_task_id", "is_subagent", "thread_id", "user_id", "tags", "root_status", "complete", "version", "git_branch",
17
+ "entrypoint", "started", "ended", "file", "policy_version", "policy"]
18
+ TASK_COLS = ["id", "run_id", "idx", "project", "environment", "source", "framework", "workflow", "is_subagent", "parent_task_id",
19
+ "prompt_kind", "prompt", "task_type", "started", "ended", "wall_s", "duration_s", "llm_calls", "tool_calls", "tool_errors",
20
+ "tool_error_rate", "input_tokens", "output_tokens", "cache_read", "cache_write", "thinking_tokens", "total_tokens", "cost",
21
+ "subagent_cost", "subagents", "max_context", "cache_hit", "models", "interrupts", "compactions", "api_errors",
22
+ "parallelism", "files_read", "files_edited", "edits", "max_edits_one_file", "churn_file", "redundant_reads",
23
+ "duplicate_calls", "max_error_streak", "large_outputs", "explore_ratio", "steps_to_first_edit", "code_changed",
24
+ "verified", "unverified_edits", "waste_cost", "final_stop", "final_text", "ended_on_error", "next_prompt", "rework",
25
+ "outcome", "cost_vs_baseline", "duration_vs_baseline", "score", "apdex",
26
+ # agent-flow metrics
27
+ "steps_total", "llm_errors", "truncations", "refusals", "rate_limited", "ttft_ms", "out_tps", "retrievals",
28
+ "empty_retrievals", "nodes", "max_node_visits", "loop_node", "handoffs", "pingpong", "hitl", "feedback_score",
29
+ "unpriced", "critical_node", "critical_share", "root_error",
30
+ # governance (Aegis)
31
+ "governed", "policy_version", "policy_denials", "spend_denials", "budget_denials", "repeated_denials",
32
+ "revocations", "blocked_cost"]
33
+ TASK_JSON = ["phase_calls", "phase_cost", "scores", "path", "denied_rules"]
34
+ STEP_COLS = ["run_id", "seq", "task_id", "kind", "name", "model", "phase", "target", "ts", "start_ts", "end_ts",
35
+ "duration_ms", "cost", "attributed_cost", "input_tokens", "output_tokens", "cache_read", "cache_write",
36
+ "context_tokens", "thinking_tokens", "is_error", "output_chars", "text", "input_preview", "error",
37
+ "subagent_id", "stop_reason", "tool_calls", "effort",
38
+ "span_id", "parent_span_id", "depth", "node", "agent", "span_kind", "ttft_ms", "docs", "hitl", "rate_limited",
39
+ "denied", "rule", "guard", "grant_depth", "args_json"]
40
+ EVENT_COLS = ["id", "ts", "rule_id", "rule", "severity", "task_id", "run_id", "project", "task_type", "message", "value"]
41
+
42
+ DERIVED = ["runs", "tasks", "steps", "events", "baselines", "meta"]
43
+
44
+ SCHEMA = f"""
45
+ CREATE TABLE IF NOT EXISTS runs ({", ".join(RUN_COLS)}, PRIMARY KEY(id)) WITHOUT ROWID;
46
+ CREATE TABLE IF NOT EXISTS tasks ({", ".join(TASK_COLS + TASK_JSON)}, PRIMARY KEY(id)) WITHOUT ROWID;
47
+ CREATE TABLE IF NOT EXISTS steps ({", ".join(STEP_COLS)}, flags);
48
+ CREATE TABLE IF NOT EXISTS events ({", ".join(EVENT_COLS)});
49
+ CREATE TABLE IF NOT EXISTS baselines (task_type PRIMARY KEY, data);
50
+ CREATE TABLE IF NOT EXISTS meta (k PRIMARY KEY, v);
51
+ CREATE INDEX IF NOT EXISTS steps_task ON steps(task_id);
52
+ CREATE INDEX IF NOT EXISTS steps_run ON steps(run_id);
53
+ CREATE INDEX IF NOT EXISTS steps_node ON steps(node);
54
+ CREATE INDEX IF NOT EXISTS tasks_run ON tasks(run_id);
55
+ CREATE INDEX IF NOT EXISTS tasks_started ON tasks(started);
56
+
57
+ CREATE TABLE IF NOT EXISTS spans_raw (source, trace_id, span_id, fmt, doc, updated REAL, PRIMARY KEY(source, span_id)) WITHOUT ROWID;
58
+ CREATE INDEX IF NOT EXISTS spans_trace ON spans_raw(source, trace_id);
59
+ CREATE INDEX IF NOT EXISTS spans_updated ON spans_raw(updated);
60
+ CREATE TABLE IF NOT EXISTS source_state (name PRIMARY KEY, data);
61
+ CREATE TABLE IF NOT EXISTS alerts_sent (event_id PRIMARY KEY, ts REAL);
62
+ """
63
+
64
+
65
+ def connect(path):
66
+ con = sqlite3.connect(path, check_same_thread=False, timeout=30)
67
+ con.row_factory = sqlite3.Row
68
+ con.execute("PRAGMA journal_mode=WAL")
69
+ con.execute("PRAGMA synchronous=NORMAL")
70
+ ver = con.execute("PRAGMA user_version").fetchone()[0]
71
+ if ver != SCHEMA_VERSION:
72
+ for t in DERIVED:
73
+ con.execute(f"DROP TABLE IF EXISTS {t}")
74
+ con.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
75
+ con.executescript(SCHEMA)
76
+ return con
77
+
78
+
79
+ def connect_reader(path):
80
+ con = sqlite3.connect(path, check_same_thread=False, timeout=30)
81
+ con.row_factory = sqlite3.Row
82
+ con.execute("PRAGMA query_only=ON")
83
+ return con
84
+
85
+
86
+ def _ins(con, table, cols, rows_, verb="INSERT"):
87
+ q = f"{verb} INTO {table} ({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))})"
88
+ con.executemany(q, rows_)
89
+
90
+
91
+ def _redact_step(s, red):
92
+ if red is None:
93
+ return s
94
+ s = dict(s)
95
+ for f in ("text", "input_preview", "error"):
96
+ if s.get(f):
97
+ s[f] = red.text(s[f])
98
+ if s.get("target") and red.rx is not None:
99
+ s["target"] = red.rx.sub("[REDACTED]", s["target"]) if red.store_content else red.text(s["target"])
100
+ return s
101
+
102
+
103
+ def write_runs(con, runs, removed_ids=(), red=None):
104
+ """Replace runs + steps for the given runs (incremental)."""
105
+ ids = [r["id"] for r in runs] + list(removed_ids)
106
+ with con:
107
+ for i in range(0, len(ids), 500):
108
+ chunk = ids[i:i + 500]
109
+ ph = ",".join("?" * len(chunk))
110
+ con.execute(f"DELETE FROM steps WHERE run_id IN ({ph})", chunk)
111
+ con.execute(f"DELETE FROM runs WHERE id IN ({ph})", chunk)
112
+ rrows = []
113
+ for r in runs:
114
+ row = [r.get(c) for c in RUN_COLS]
115
+ row[RUN_COLS.index("tags")] = json.dumps(r.get("tags") or [])
116
+ row[RUN_COLS.index("policy")] = json.dumps(r["policy"]) if r.get("policy") else None
117
+ row[RUN_COLS.index("title")] = red.text(r.get("title")) if red else r.get("title")
118
+ rrows.append(row)
119
+ _ins(con, "runs", RUN_COLS, rrows)
120
+ srows = []
121
+ for r in runs:
122
+ for s in r["steps"]:
123
+ s2 = _redact_step(s, red)
124
+ row = [r["id"] if c == "run_id" else s2.get(c) for c in STEP_COLS]
125
+ for b in ("is_error", "hitl", "rate_limited", "denied"):
126
+ row[STEP_COLS.index(b)] = 1 if s2.get(b) else 0
127
+ srows.append(row + [json.dumps(s2.get("flags") or [])])
128
+ _ins(con, "steps", STEP_COLS + ["flags"], srows)
129
+
130
+
131
+ def update_step_analysis(con, runs):
132
+ """Persist analysis-time step fields (task id, flags, attributed cost) for the given runs."""
133
+ rows_ = [(s.get("task_id"), json.dumps(s.get("flags") or []), s.get("attributed_cost"), r["id"], s["seq"])
134
+ for r in runs for s in r["steps"]]
135
+ with con:
136
+ con.executemany("UPDATE steps SET task_id=?, flags=?, attributed_cost=? WHERE run_id=? AND seq=?", rows_)
137
+
138
+
139
+ def write_analysis(con, tasks, baselines, events, meta, red=None):
140
+ with con:
141
+ for t in ("tasks", "events", "baselines", "meta"):
142
+ con.execute(f"DELETE FROM {t}")
143
+ trows = []
144
+ for t in tasks:
145
+ row = [t.get(c) for c in TASK_COLS] + [json.dumps(t.get(c) if t.get(c) is not None else ([] if c == "path" else {}))
146
+ for c in TASK_JSON]
147
+ if red:
148
+ for f in ("prompt", "final_text", "next_prompt", "root_error"):
149
+ i = TASK_COLS.index(f)
150
+ row[i] = red.text(row[i])
151
+ trows.append(row)
152
+ _ins(con, "tasks", TASK_COLS + TASK_JSON, trows)
153
+ erows = []
154
+ for e in events:
155
+ row = [e.get(c) for c in EVENT_COLS]
156
+ if red and red.rx is not None: # messages can quote user text (e.g. the correcting follow-up)
157
+ i = EVENT_COLS.index("message")
158
+ row[i] = red.rx.sub("[REDACTED]", row[i] or "")
159
+ erows.append(row)
160
+ _ins(con, "events", EVENT_COLS, erows)
161
+ _ins(con, "baselines", ["task_type", "data"], [[k, json.dumps(v)] for k, v in baselines.items()])
162
+ _ins(con, "meta", ["k", "v"], [[k, json.dumps(v)] for k, v in meta.items()])
163
+
164
+
165
+ # ---------------------------------------------------------------- durable span store
166
+
167
+ def upsert_spans(con, source, items):
168
+ """items: list of (trace_id, span_id, fmt, doc_dict). Returns set of touched trace ids."""
169
+ now = time.time()
170
+ with con:
171
+ con.executemany("INSERT INTO spans_raw (source, trace_id, span_id, fmt, doc, updated) VALUES (?,?,?,?,?,?) "
172
+ "ON CONFLICT(source, span_id) DO UPDATE SET trace_id=excluded.trace_id, fmt=excluded.fmt, "
173
+ "doc=excluded.doc, updated=excluded.updated",
174
+ [(source, t, s, f, json.dumps(d, default=str), now) for t, s, f, d in items])
175
+ return {t for t, _, _, _ in items}
176
+
177
+
178
+ def get_span_docs(con, source, span_ids):
179
+ out = {}
180
+ for i in range(0, len(span_ids), 500):
181
+ chunk = span_ids[i:i + 500]
182
+ for r in con.execute(f"SELECT span_id, doc FROM spans_raw WHERE source=? AND span_id IN ({','.join('?' * len(chunk))})",
183
+ [source] + chunk):
184
+ out[r["span_id"]] = json.loads(r["doc"])
185
+ return out
186
+
187
+
188
+ def trace_spans(con, source, trace_id):
189
+ return [(r["fmt"], json.loads(r["doc"])) for r in con.execute(
190
+ "SELECT fmt, doc FROM spans_raw WHERE source=? AND trace_id=?", (source, trace_id))]
191
+
192
+
193
+ def traces_updated_since(con, since):
194
+ return [(r["source"], r["trace_id"]) for r in con.execute(
195
+ "SELECT DISTINCT source, trace_id FROM spans_raw WHERE updated > ?", (since,))]
196
+
197
+
198
+ def all_traces(con):
199
+ return [(r["source"], r["trace_id"]) for r in con.execute("SELECT DISTINCT source, trace_id FROM spans_raw")]
200
+
201
+
202
+ def purge_spans_before(con, cutoff):
203
+ with con:
204
+ return con.execute("DELETE FROM spans_raw WHERE updated < ?", (cutoff,)).rowcount
205
+
206
+
207
+ def get_state(con, name):
208
+ r = con.execute("SELECT data FROM source_state WHERE name=?", (name,)).fetchone()
209
+ return json.loads(r["data"]) if r else {}
210
+
211
+
212
+ def set_state(con, name, data):
213
+ with con:
214
+ con.execute("INSERT OR REPLACE INTO source_state (name, data) VALUES (?, ?)", (name, json.dumps(data)))
215
+
216
+
217
+ def rows(con, q, args=()):
218
+ out = []
219
+ for r in con.execute(q, args).fetchall():
220
+ d = dict(r)
221
+ for k in TASK_JSON + ["flags", "data", "tags", "policy"]:
222
+ if k in d and isinstance(d[k], str):
223
+ try:
224
+ d[k] = json.loads(d[k])
225
+ except ValueError:
226
+ pass
227
+ out.append(d)
228
+ return out