agentloop-profiler 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.

Potentially problematic release.


This version of agentloop-profiler might be problematic. Click here for more details.

Files changed (50) hide show
  1. agentloop/__init__.py +68 -0
  2. agentloop/audit.py +34 -0
  3. agentloop/autoinstrument.py +102 -0
  4. agentloop/ci.py +135 -0
  5. agentloop/cli.py +876 -0
  6. agentloop/client.py +170 -0
  7. agentloop/config.py +50 -0
  8. agentloop/costs.py +15 -0
  9. agentloop/decorators.py +160 -0
  10. agentloop/demo.py +283 -0
  11. agentloop/doctor.py +344 -0
  12. agentloop/events.py +49 -0
  13. agentloop/exporters.py +60 -0
  14. agentloop/findings.py +215 -0
  15. agentloop/graph.py +312 -0
  16. agentloop/integrations/__init__.py +1 -0
  17. agentloop/integrations/crewai.py +144 -0
  18. agentloop/integrations/langgraph.py +140 -0
  19. agentloop/integrations/openai.py +369 -0
  20. agentloop/integrations/openai_agents.py +212 -0
  21. agentloop/integrations/sdk.py +82 -0
  22. agentloop/integrations/vercel_ai.py +83 -0
  23. agentloop/issues.py +88 -0
  24. agentloop/markdown.py +102 -0
  25. agentloop/metrics.py +123 -0
  26. agentloop/optimizer.py +323 -0
  27. agentloop/otel.py +302 -0
  28. agentloop/otel_ids.py +81 -0
  29. agentloop/patches.py +583 -0
  30. agentloop/plan_export.py +72 -0
  31. agentloop/quality.py +222 -0
  32. agentloop/replay.py +424 -0
  33. agentloop/runtime.py +244 -0
  34. agentloop/savings.py +135 -0
  35. agentloop/server.py +292 -0
  36. agentloop/store.py +840 -0
  37. agentloop/timing.py +124 -0
  38. agentloop/tracer.py +356 -0
  39. agentloop/value.py +243 -0
  40. agentloop/version.py +3 -0
  41. agentloop_profiler-0.4.0.dist-info/METADATA +598 -0
  42. agentloop_profiler-0.4.0.dist-info/RECORD +50 -0
  43. agentloop_profiler-0.4.0.dist-info/WHEEL +5 -0
  44. agentloop_profiler-0.4.0.dist-info/entry_points.txt +2 -0
  45. agentloop_profiler-0.4.0.dist-info/licenses/LICENSE +201 -0
  46. agentloop_profiler-0.4.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +54 -0
  47. agentloop_profiler-0.4.0.dist-info/top_level.txt +2 -0
  48. dashboard/__init__.py +1 -0
  49. dashboard/app.py +698 -0
  50. dashboard/value_view.py +101 -0
agentloop/store.py ADDED
@@ -0,0 +1,840 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import os
9
+ import secrets
10
+ import sqlite3
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, Protocol
14
+
15
+ from agentloop.config import (
16
+ get_postgres_dsn,
17
+ get_postgres_password_file,
18
+ postgres_connection_source,
19
+ )
20
+ from agentloop.findings import build_diagnosis
21
+ from agentloop.savings import SavingsItem, select_compatible
22
+ from agentloop.tracer import AgentTrace
23
+
24
+ _API_KEY_PREFIX_LENGTH = 10
25
+ _API_KEY_SALT_BYTES = 16
26
+ _API_KEY_HASH_BYTES = 32
27
+ _API_KEY_SCRYPT_N = 2**14
28
+ _API_KEY_SCRYPT_R = 8
29
+ _API_KEY_SCRYPT_P = 1
30
+
31
+
32
+ class TraceProjectConflictError(ValueError):
33
+ """Raised when a run ID is already owned by a different project."""
34
+
35
+
36
+ class TraceStore(Protocol):
37
+ def init(self) -> None: ...
38
+
39
+ def create_api_key(self, project_id: str, name: str) -> dict[str, Any]: ...
40
+
41
+ def verify_api_key(self, api_key: str) -> dict[str, Any] | None: ...
42
+
43
+ def save_trace(self, trace: AgentTrace, project_id: str = "default") -> None: ...
44
+
45
+ def list_traces(self, project_id: str | None = None) -> list[dict[str, Any]]: ...
46
+
47
+ def get_trace(self, run_id: str, project_id: str | None = None) -> AgentTrace | None: ...
48
+
49
+ def save_diagnosis(self, diagnosis: dict[str, Any], project_id: str = "default") -> None: ...
50
+
51
+ def list_findings(
52
+ self, project_id: str | None = None, status: str | None = None
53
+ ) -> list[dict[str, Any]]: ...
54
+
55
+ def optimization_queue(self, project_id: str | None = None) -> list[dict[str, Any]]: ...
56
+
57
+ def record_usage(self, project_id: str, run_id: str, report: dict[str, Any]) -> None: ...
58
+
59
+ def usage_summary(self, project_id: str | None = None) -> dict[str, Any]: ...
60
+
61
+
62
+ def hash_api_key(api_key: str) -> str:
63
+ salt = secrets.token_bytes(_API_KEY_SALT_BYTES)
64
+ digest = hashlib.scrypt(
65
+ api_key.encode("utf-8"),
66
+ salt=salt,
67
+ n=_API_KEY_SCRYPT_N,
68
+ r=_API_KEY_SCRYPT_R,
69
+ p=_API_KEY_SCRYPT_P,
70
+ dklen=_API_KEY_HASH_BYTES,
71
+ )
72
+ return "scrypt$v1${salt}${digest}".format(
73
+ salt=_encode_hash_bytes(salt),
74
+ digest=_encode_hash_bytes(digest),
75
+ )
76
+
77
+
78
+ def verify_api_key_hash(api_key: str, encoded_hash: str) -> bool:
79
+ if not api_key.startswith("al_") or len(api_key) > 512:
80
+ return False
81
+ parts = encoded_hash.split("$")
82
+ if len(parts) != 4 or parts[:2] != ["scrypt", "v1"]:
83
+ return False
84
+ try:
85
+ salt = _decode_hash_bytes(parts[2])
86
+ expected = _decode_hash_bytes(parts[3])
87
+ except (binascii.Error, UnicodeEncodeError, ValueError):
88
+ return False
89
+ if len(salt) != _API_KEY_SALT_BYTES or len(expected) != _API_KEY_HASH_BYTES:
90
+ return False
91
+ actual = hashlib.scrypt(
92
+ api_key.encode("utf-8"),
93
+ salt=salt,
94
+ n=_API_KEY_SCRYPT_N,
95
+ r=_API_KEY_SCRYPT_R,
96
+ p=_API_KEY_SCRYPT_P,
97
+ dklen=_API_KEY_HASH_BYTES,
98
+ )
99
+ return hmac.compare_digest(actual, expected)
100
+
101
+
102
+ def _encode_hash_bytes(value: bytes) -> str:
103
+ return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
104
+
105
+
106
+ def _decode_hash_bytes(value: str) -> bytes:
107
+ padded = value + "=" * (-len(value) % 4)
108
+ return base64.b64decode(padded.encode("ascii"), altchars=b"-_", validate=True)
109
+
110
+
111
+ def new_api_key() -> str:
112
+ return "al_" + secrets.token_urlsafe(32)
113
+
114
+
115
+ def _finding_row(row: dict[str, Any]) -> dict[str, Any]:
116
+ raw_payload = row.pop("payload_json")
117
+ payload = json.loads(raw_payload) if isinstance(raw_payload, str) else raw_payload
118
+ row["patchable"] = bool(row["patchable"])
119
+ row["estimated_latency_savings_ms"] = float(row["estimated_latency_savings_ms"] or 0)
120
+ row["estimated_cost_savings_usd"] = float(row["estimated_cost_savings_usd"] or 0)
121
+ row["finding"] = payload
122
+ return row
123
+
124
+
125
+ def _max_severity(left: str, right: str) -> str:
126
+ rank = {"high": 3, "medium": 2, "low": 1}
127
+ return left if rank.get(left, 0) >= rank.get(right, 0) else right
128
+
129
+
130
+ def _priority_score(item: dict[str, Any]) -> float:
131
+ severity_weight = {"high": 1000.0, "medium": 500.0, "low": 100.0}.get(item["severity"], 0.0)
132
+ patch_weight = 100.0 if item["patchable_count"] else 0.0
133
+ latency_weight = float(item["estimated_latency_savings_ms"]) / 100.0
134
+ cost_weight = float(item["estimated_cost_savings_usd"]) * 1000.0
135
+ occurrence_weight = float(item["occurrence_count"]) * 25.0
136
+ return round(
137
+ severity_weight + patch_weight + latency_weight + cost_weight + occurrence_weight, 3
138
+ )
139
+
140
+
141
+ def _build_optimization_queue(
142
+ findings: list[dict[str, Any]], project_id: str | None
143
+ ) -> list[dict[str, Any]]:
144
+ """Cluster unresolved findings into a priority-ranked optimization queue.
145
+
146
+ Within one run, findings of the same type and title that share affected
147
+ spans are the same underlying problem reported per occurrence — summing
148
+ their savings would double-count. Each cluster's savings therefore come
149
+ from the compatible (span-disjoint) selection per run, summed across runs,
150
+ and ``priority_score`` consumes those deduplicated totals.
151
+ """
152
+ clusters: dict[tuple[str, str], dict[str, Any]] = {}
153
+ cluster_items: dict[tuple[str, str], dict[str, list[SavingsItem]]] = {}
154
+ for finding in findings:
155
+ key = (finding["type"], finding["title"])
156
+ cluster = clusters.setdefault(
157
+ key,
158
+ {
159
+ "queue_id": "queue_"
160
+ + hashlib.sha256("|".join(key).encode("utf-8")).hexdigest()[:12],
161
+ "type": finding["type"],
162
+ "title": finding["title"],
163
+ "status": "detected",
164
+ "severity": finding["severity"],
165
+ "occurrence_count": 0,
166
+ "run_count": 0,
167
+ "affected_runs": [],
168
+ "patchable_count": 0,
169
+ "estimated_latency_savings_ms": 0.0,
170
+ "estimated_cost_savings_usd": 0.0,
171
+ "latest_created_at": finding["created_at"],
172
+ "project_id": project_id,
173
+ "quality_risk": _quality_risk(finding["type"]),
174
+ "requires_scorer": _quality_risk(finding["type"]) in {"high", "medium"},
175
+ "safe_to_auto_patch": False,
176
+ },
177
+ )
178
+ cluster["occurrence_count"] += 1
179
+ if finding["run_id"] not in cluster["affected_runs"]:
180
+ cluster["affected_runs"].append(finding["run_id"])
181
+ cluster["run_count"] += 1
182
+ cluster["patchable_count"] += 1 if finding["patchable"] else 0
183
+ cluster["severity"] = _max_severity(cluster["severity"], finding["severity"])
184
+ cluster["latest_created_at"] = max(
185
+ str(cluster["latest_created_at"]), str(finding["created_at"])
186
+ )
187
+ payload = finding.get("finding") or {}
188
+ cluster_items.setdefault(key, {}).setdefault(finding["run_id"], []).append(
189
+ SavingsItem(
190
+ spans=frozenset(payload.get("affected_spans") or []),
191
+ latency_ms=float(finding["estimated_latency_savings_ms"] or 0.0),
192
+ cost_usd=float(finding["estimated_cost_savings_usd"] or 0.0),
193
+ )
194
+ )
195
+
196
+ for key, cluster in clusters.items():
197
+ latency = 0.0
198
+ cost = 0.0
199
+ for run_items in cluster_items[key].values():
200
+ selection = select_compatible(run_items)
201
+ latency += selection.latency_ms
202
+ cost += selection.cost_usd
203
+ cluster["estimated_latency_savings_ms"] = round(latency, 3)
204
+ cluster["estimated_cost_savings_usd"] = round(cost, 6)
205
+
206
+ queue = list(clusters.values())
207
+ for item in queue:
208
+ item["safe_to_auto_patch"] = item["patchable_count"] > 0 and item["quality_risk"] == "low"
209
+ item["priority_score"] = _priority_score(item)
210
+ return sorted(queue, key=lambda item: item["priority_score"], reverse=True)
211
+
212
+
213
+ def _quality_risk(finding_type: str) -> str:
214
+ if finding_type in {"route_to_smaller_model", "split_large_step", "batch_model_calls"}:
215
+ return "high"
216
+ if finding_type in {
217
+ "cache_context",
218
+ "add_schema_validation",
219
+ "runaway_loop",
220
+ "tool_oscillation",
221
+ }:
222
+ return "medium"
223
+ return "low"
224
+
225
+
226
+ @dataclass
227
+ class SQLiteTraceStore:
228
+ path: str = "runs/agentloop.db"
229
+
230
+ def _connect(self) -> sqlite3.Connection:
231
+ Path(self.path).parent.mkdir(parents=True, exist_ok=True)
232
+ conn = sqlite3.connect(self.path)
233
+ conn.row_factory = sqlite3.Row
234
+ return conn
235
+
236
+ def init(self) -> None:
237
+ with self._connect() as conn:
238
+ conn.executescript(
239
+ """
240
+ CREATE TABLE IF NOT EXISTS projects (
241
+ project_id TEXT PRIMARY KEY,
242
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
243
+ );
244
+ CREATE TABLE IF NOT EXISTS api_keys (
245
+ key_hash TEXT PRIMARY KEY,
246
+ project_id TEXT NOT NULL,
247
+ name TEXT NOT NULL,
248
+ prefix TEXT NOT NULL,
249
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
250
+ last_used_at TEXT,
251
+ FOREIGN KEY(project_id) REFERENCES projects(project_id)
252
+ );
253
+ CREATE TABLE IF NOT EXISTS traces (
254
+ run_id TEXT PRIMARY KEY,
255
+ project_id TEXT NOT NULL,
256
+ name TEXT NOT NULL,
257
+ started_at TEXT,
258
+ payload_json TEXT NOT NULL,
259
+ total_runtime_ms REAL DEFAULT 0,
260
+ estimated_cost_usd REAL DEFAULT 0,
261
+ model_call_count INTEGER DEFAULT 0,
262
+ tool_call_count INTEGER DEFAULT 0,
263
+ retry_count INTEGER DEFAULT 0,
264
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
265
+ FOREIGN KEY(project_id) REFERENCES projects(project_id)
266
+ );
267
+ CREATE TABLE IF NOT EXISTS usage_events (
268
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
269
+ project_id TEXT NOT NULL,
270
+ run_id TEXT NOT NULL,
271
+ total_runtime_ms REAL DEFAULT 0,
272
+ estimated_cost_usd REAL DEFAULT 0,
273
+ input_tokens INTEGER DEFAULT 0,
274
+ output_tokens INTEGER DEFAULT 0,
275
+ model_call_count INTEGER DEFAULT 0,
276
+ tool_call_count INTEGER DEFAULT 0,
277
+ retry_count INTEGER DEFAULT 0,
278
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
279
+ );
280
+ CREATE TABLE IF NOT EXISTS trace_findings (
281
+ project_id TEXT NOT NULL,
282
+ run_id TEXT NOT NULL,
283
+ finding_id TEXT NOT NULL,
284
+ type TEXT NOT NULL,
285
+ severity TEXT NOT NULL,
286
+ title TEXT NOT NULL,
287
+ confidence TEXT NOT NULL,
288
+ patchable INTEGER DEFAULT 0,
289
+ status TEXT DEFAULT 'detected',
290
+ estimated_latency_savings_ms REAL DEFAULT 0,
291
+ estimated_cost_savings_usd REAL DEFAULT 0,
292
+ payload_json TEXT NOT NULL,
293
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
294
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
295
+ PRIMARY KEY(project_id, run_id, finding_id),
296
+ FOREIGN KEY(project_id) REFERENCES projects(project_id),
297
+ FOREIGN KEY(run_id) REFERENCES traces(run_id)
298
+ );
299
+ """
300
+ )
301
+ conn.execute("INSERT OR IGNORE INTO projects(project_id) VALUES (?)", ("default",))
302
+
303
+ def create_api_key(self, project_id: str, name: str) -> dict[str, Any]:
304
+ self.init()
305
+ api_key = new_api_key()
306
+ prefix = api_key[:_API_KEY_PREFIX_LENGTH]
307
+ with self._connect() as conn:
308
+ conn.execute("INSERT OR IGNORE INTO projects(project_id) VALUES (?)", (project_id,))
309
+ conn.execute(
310
+ "INSERT INTO api_keys(key_hash, project_id, name, prefix) VALUES (?, ?, ?, ?)",
311
+ (hash_api_key(api_key), project_id, name, prefix),
312
+ )
313
+ return {"api_key": api_key, "project_id": project_id, "name": name, "prefix": prefix}
314
+
315
+ def verify_api_key(self, api_key: str) -> dict[str, Any] | None:
316
+ self.init()
317
+ prefix = api_key[:_API_KEY_PREFIX_LENGTH]
318
+ with self._connect() as conn:
319
+ rows = conn.execute(
320
+ "SELECT key_hash, project_id, name, prefix FROM api_keys WHERE prefix = ?",
321
+ (prefix,),
322
+ ).fetchall()
323
+ row = next(
324
+ (
325
+ candidate
326
+ for candidate in rows
327
+ if verify_api_key_hash(api_key, candidate["key_hash"])
328
+ ),
329
+ None,
330
+ )
331
+ if row is None:
332
+ return None
333
+ conn.execute(
334
+ "UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE key_hash = ?",
335
+ (row["key_hash"],),
336
+ )
337
+ return {key: row[key] for key in ("project_id", "name", "prefix")}
338
+
339
+ def save_trace(self, trace: AgentTrace, project_id: str = "default") -> None:
340
+ self.init()
341
+ report = trace.report()
342
+ with self._connect() as conn:
343
+ conn.execute("INSERT OR IGNORE INTO projects(project_id) VALUES (?)", (project_id,))
344
+ cursor = conn.execute(
345
+ """
346
+ INSERT INTO traces(
347
+ run_id, project_id, name, started_at, payload_json, total_runtime_ms,
348
+ estimated_cost_usd, model_call_count, tool_call_count, retry_count
349
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
350
+ ON CONFLICT(run_id) DO UPDATE SET
351
+ project_id=excluded.project_id,
352
+ name=excluded.name,
353
+ started_at=excluded.started_at,
354
+ payload_json=excluded.payload_json,
355
+ total_runtime_ms=excluded.total_runtime_ms,
356
+ estimated_cost_usd=excluded.estimated_cost_usd,
357
+ model_call_count=excluded.model_call_count,
358
+ tool_call_count=excluded.tool_call_count,
359
+ retry_count=excluded.retry_count
360
+ WHERE traces.project_id = excluded.project_id
361
+ """,
362
+ (
363
+ trace.run_id,
364
+ project_id,
365
+ trace.name,
366
+ trace.started_at,
367
+ json.dumps(trace.to_dict()),
368
+ report.get("total_runtime_ms", 0),
369
+ report.get("estimated_cost_usd", 0),
370
+ report.get("model_call_count", 0),
371
+ report.get("tool_call_count", 0),
372
+ report.get("retry_count", 0),
373
+ ),
374
+ )
375
+ if cursor.rowcount == 0:
376
+ raise TraceProjectConflictError(
377
+ f"run_id {trace.run_id!r} already belongs to another project"
378
+ )
379
+ self.record_usage(project_id, trace.run_id, report)
380
+ self.save_diagnosis(build_diagnosis(trace), project_id=project_id)
381
+
382
+ def list_traces(self, project_id: str | None = None) -> list[dict[str, Any]]:
383
+ self.init()
384
+ with self._connect() as conn:
385
+ if project_id:
386
+ rows = conn.execute(
387
+ "SELECT run_id, project_id, name, started_at, total_runtime_ms, estimated_cost_usd, created_at FROM traces WHERE project_id = ? ORDER BY created_at DESC",
388
+ (project_id,),
389
+ ).fetchall()
390
+ else:
391
+ rows = conn.execute(
392
+ "SELECT run_id, project_id, name, started_at, total_runtime_ms, estimated_cost_usd, created_at FROM traces ORDER BY created_at DESC"
393
+ ).fetchall()
394
+ return [dict(row) for row in rows]
395
+
396
+ def get_trace(self, run_id: str, project_id: str | None = None) -> AgentTrace | None:
397
+ self.init()
398
+ with self._connect() as conn:
399
+ if project_id:
400
+ row = conn.execute(
401
+ "SELECT payload_json FROM traces WHERE run_id = ? AND project_id = ?",
402
+ (run_id, project_id),
403
+ ).fetchone()
404
+ else:
405
+ row = conn.execute(
406
+ "SELECT payload_json FROM traces WHERE run_id = ?", (run_id,)
407
+ ).fetchone()
408
+ if row is None:
409
+ return None
410
+ return AgentTrace.from_dict(json.loads(row["payload_json"]))
411
+
412
+ def save_diagnosis(self, diagnosis: dict[str, Any], project_id: str = "default") -> None:
413
+ self.init()
414
+ with self._connect() as conn:
415
+ conn.execute("INSERT OR IGNORE INTO projects(project_id) VALUES (?)", (project_id,))
416
+ conn.execute(
417
+ "DELETE FROM trace_findings WHERE project_id = ? AND run_id = ?",
418
+ (project_id, diagnosis["run_id"]),
419
+ )
420
+ for finding in diagnosis.get("findings", []):
421
+ savings = finding.get("savings", {})
422
+ rewrite = finding.get("rewrite", {})
423
+ conn.execute(
424
+ """
425
+ INSERT INTO trace_findings(
426
+ project_id, run_id, finding_id, type, severity, title, confidence,
427
+ patchable, estimated_latency_savings_ms, estimated_cost_savings_usd,
428
+ payload_json
429
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
430
+ ON CONFLICT(project_id, run_id, finding_id) DO UPDATE SET
431
+ type=excluded.type,
432
+ severity=excluded.severity,
433
+ title=excluded.title,
434
+ confidence=excluded.confidence,
435
+ patchable=excluded.patchable,
436
+ estimated_latency_savings_ms=excluded.estimated_latency_savings_ms,
437
+ estimated_cost_savings_usd=excluded.estimated_cost_savings_usd,
438
+ payload_json=excluded.payload_json,
439
+ updated_at=CURRENT_TIMESTAMP
440
+ """,
441
+ (
442
+ project_id,
443
+ diagnosis["run_id"],
444
+ finding["finding_id"],
445
+ finding["type"],
446
+ finding["severity"],
447
+ finding["title"],
448
+ finding["confidence"],
449
+ 1 if rewrite.get("patchable") else 0,
450
+ savings.get("estimated_latency_savings_ms", 0),
451
+ savings.get("estimated_cost_savings_usd", 0),
452
+ json.dumps(finding),
453
+ ),
454
+ )
455
+
456
+ def list_findings(
457
+ self, project_id: str | None = None, status: str | None = None
458
+ ) -> list[dict[str, Any]]:
459
+ self.init()
460
+ with self._connect() as conn:
461
+ rows = conn.execute(
462
+ """
463
+ SELECT project_id, run_id, finding_id, type, severity, title, confidence,
464
+ patchable, status, estimated_latency_savings_ms, estimated_cost_savings_usd,
465
+ payload_json, created_at, updated_at
466
+ FROM trace_findings
467
+ WHERE project_id = COALESCE(?, project_id)
468
+ AND status = COALESCE(?, status)
469
+ ORDER BY
470
+ CASE severity WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
471
+ estimated_cost_savings_usd DESC,
472
+ estimated_latency_savings_ms DESC,
473
+ updated_at DESC
474
+ """,
475
+ (project_id or None, status or None),
476
+ ).fetchall()
477
+ return [_finding_row(dict(row)) for row in rows]
478
+
479
+ def optimization_queue(self, project_id: str | None = None) -> list[dict[str, Any]]:
480
+ findings = [
481
+ finding
482
+ for finding in self.list_findings(project_id=project_id)
483
+ if finding["status"] != "resolved"
484
+ ]
485
+ return _build_optimization_queue(findings, project_id)
486
+
487
+ def record_usage(self, project_id: str, run_id: str, report: dict[str, Any]) -> None:
488
+ with self._connect() as conn:
489
+ conn.execute(
490
+ """
491
+ INSERT INTO usage_events(
492
+ project_id, run_id, total_runtime_ms, estimated_cost_usd, input_tokens,
493
+ output_tokens, model_call_count, tool_call_count, retry_count
494
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
495
+ """,
496
+ (
497
+ project_id,
498
+ run_id,
499
+ report.get("total_runtime_ms", 0),
500
+ report.get("estimated_cost_usd", 0),
501
+ report.get("input_tokens", 0),
502
+ report.get("output_tokens", 0),
503
+ report.get("model_call_count", 0),
504
+ report.get("tool_call_count", 0),
505
+ report.get("retry_count", 0),
506
+ ),
507
+ )
508
+
509
+ def usage_summary(self, project_id: str | None = None) -> dict[str, Any]:
510
+ self.init()
511
+ with self._connect() as conn:
512
+ row = conn.execute(
513
+ """
514
+ SELECT
515
+ COUNT(*) AS run_count,
516
+ COALESCE(SUM(total_runtime_ms), 0) AS total_runtime_ms,
517
+ COALESCE(SUM(estimated_cost_usd), 0) AS estimated_cost_usd,
518
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
519
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
520
+ COALESCE(SUM(model_call_count), 0) AS model_call_count,
521
+ COALESCE(SUM(tool_call_count), 0) AS tool_call_count,
522
+ COALESCE(SUM(retry_count), 0) AS retry_count
523
+ FROM usage_events
524
+ WHERE project_id = COALESCE(?, project_id)
525
+ """,
526
+ (project_id or None,),
527
+ ).fetchone()
528
+ result = dict(row)
529
+ if project_id:
530
+ result["project_id"] = project_id
531
+ return result
532
+
533
+
534
+ @dataclass
535
+ class PostgresTraceStore:
536
+ dsn: str | None = None
537
+ password_file: str | None = None
538
+
539
+ def _connect(self):
540
+ try:
541
+ import psycopg
542
+ except ImportError as exc:
543
+ raise RuntimeError(
544
+ "Install postgres support with: uv sync --locked --extra postgres"
545
+ ) from exc
546
+ if self.dsn:
547
+ return psycopg.connect(self.dsn)
548
+ connect_kwargs: dict[str, str] = {}
549
+ if self.password_file:
550
+ connect_kwargs["password"] = _read_postgres_password(self.password_file)
551
+ return psycopg.connect("", **connect_kwargs)
552
+
553
+ def init(self) -> None:
554
+ with self._connect() as conn:
555
+ conn.execute(
556
+ "CREATE TABLE IF NOT EXISTS projects (project_id TEXT PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT now())"
557
+ )
558
+ conn.execute(
559
+ "CREATE TABLE IF NOT EXISTS api_keys (key_hash TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(project_id), name TEXT NOT NULL, prefix TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now(), last_used_at TIMESTAMPTZ)"
560
+ )
561
+ conn.execute(
562
+ "CREATE TABLE IF NOT EXISTS traces (run_id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(project_id), name TEXT NOT NULL, started_at TEXT, payload_json JSONB NOT NULL, total_runtime_ms DOUBLE PRECISION DEFAULT 0, estimated_cost_usd DOUBLE PRECISION DEFAULT 0, model_call_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, retry_count INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now())"
563
+ )
564
+ conn.execute(
565
+ "CREATE TABLE IF NOT EXISTS usage_events (id BIGSERIAL PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, total_runtime_ms DOUBLE PRECISION DEFAULT 0, estimated_cost_usd DOUBLE PRECISION DEFAULT 0, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, model_call_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, retry_count INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now())"
566
+ )
567
+ conn.execute(
568
+ "CREATE TABLE IF NOT EXISTS trace_findings (project_id TEXT NOT NULL REFERENCES projects(project_id), run_id TEXT NOT NULL REFERENCES traces(run_id), finding_id TEXT NOT NULL, type TEXT NOT NULL, severity TEXT NOT NULL, title TEXT NOT NULL, confidence TEXT NOT NULL, patchable BOOLEAN DEFAULT false, status TEXT DEFAULT 'detected', estimated_latency_savings_ms DOUBLE PRECISION DEFAULT 0, estimated_cost_savings_usd DOUBLE PRECISION DEFAULT 0, payload_json JSONB NOT NULL, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(), PRIMARY KEY(project_id, run_id, finding_id))"
569
+ )
570
+ conn.execute(
571
+ "INSERT INTO projects(project_id) VALUES (%s) ON CONFLICT DO NOTHING", ("default",)
572
+ )
573
+
574
+ def create_api_key(self, project_id: str, name: str) -> dict[str, Any]:
575
+ self.init()
576
+ api_key = new_api_key()
577
+ prefix = api_key[:_API_KEY_PREFIX_LENGTH]
578
+ with self._connect() as conn:
579
+ conn.execute(
580
+ "INSERT INTO projects(project_id) VALUES (%s) ON CONFLICT DO NOTHING", (project_id,)
581
+ )
582
+ conn.execute(
583
+ "INSERT INTO api_keys(key_hash, project_id, name, prefix) VALUES (%s, %s, %s, %s)",
584
+ (hash_api_key(api_key), project_id, name, prefix),
585
+ )
586
+ return {"api_key": api_key, "project_id": project_id, "name": name, "prefix": prefix}
587
+
588
+ def verify_api_key(self, api_key: str) -> dict[str, Any] | None:
589
+ self.init()
590
+ prefix = api_key[:_API_KEY_PREFIX_LENGTH]
591
+ with self._connect() as conn:
592
+ rows = conn.execute(
593
+ "SELECT key_hash, project_id, name, prefix FROM api_keys WHERE prefix = %s",
594
+ (prefix,),
595
+ ).fetchall()
596
+ row = next(
597
+ (candidate for candidate in rows if verify_api_key_hash(api_key, candidate[0])),
598
+ None,
599
+ )
600
+ if row is None:
601
+ return None
602
+ conn.execute(
603
+ "UPDATE api_keys SET last_used_at = now() WHERE key_hash = %s",
604
+ (row[0],),
605
+ )
606
+ return {"project_id": row[1], "name": row[2], "prefix": row[3]}
607
+
608
+ def save_trace(self, trace: AgentTrace, project_id: str = "default") -> None:
609
+ self.init()
610
+ report = trace.report()
611
+ with self._connect() as conn:
612
+ conn.execute(
613
+ "INSERT INTO projects(project_id) VALUES (%s) ON CONFLICT DO NOTHING", (project_id,)
614
+ )
615
+ cursor = conn.execute(
616
+ """
617
+ INSERT INTO traces(run_id, project_id, name, started_at, payload_json, total_runtime_ms, estimated_cost_usd, model_call_count, tool_call_count, retry_count)
618
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
619
+ ON CONFLICT(run_id) DO UPDATE SET project_id=excluded.project_id, name=excluded.name, started_at=excluded.started_at,
620
+ payload_json=excluded.payload_json, total_runtime_ms=excluded.total_runtime_ms, estimated_cost_usd=excluded.estimated_cost_usd,
621
+ model_call_count=excluded.model_call_count, tool_call_count=excluded.tool_call_count, retry_count=excluded.retry_count
622
+ WHERE traces.project_id = excluded.project_id
623
+ """,
624
+ (
625
+ trace.run_id,
626
+ project_id,
627
+ trace.name,
628
+ trace.started_at,
629
+ json.dumps(trace.to_dict()),
630
+ report.get("total_runtime_ms", 0),
631
+ report.get("estimated_cost_usd", 0),
632
+ report.get("model_call_count", 0),
633
+ report.get("tool_call_count", 0),
634
+ report.get("retry_count", 0),
635
+ ),
636
+ )
637
+ if cursor.rowcount == 0:
638
+ raise TraceProjectConflictError(
639
+ f"run_id {trace.run_id!r} already belongs to another project"
640
+ )
641
+ self.record_usage(project_id, trace.run_id, report)
642
+ self.save_diagnosis(build_diagnosis(trace), project_id=project_id)
643
+
644
+ def list_traces(self, project_id: str | None = None) -> list[dict[str, Any]]:
645
+ self.init()
646
+ with self._connect() as conn:
647
+ if project_id:
648
+ rows = conn.execute(
649
+ "SELECT run_id, project_id, name, started_at, total_runtime_ms, estimated_cost_usd, created_at::text FROM traces WHERE project_id = %s ORDER BY created_at DESC",
650
+ (project_id,),
651
+ ).fetchall()
652
+ else:
653
+ rows = conn.execute(
654
+ "SELECT run_id, project_id, name, started_at, total_runtime_ms, estimated_cost_usd, created_at::text FROM traces ORDER BY created_at DESC"
655
+ ).fetchall()
656
+ keys = [
657
+ "run_id",
658
+ "project_id",
659
+ "name",
660
+ "started_at",
661
+ "total_runtime_ms",
662
+ "estimated_cost_usd",
663
+ "created_at",
664
+ ]
665
+ return [dict(zip(keys, row)) for row in rows]
666
+
667
+ def get_trace(self, run_id: str, project_id: str | None = None) -> AgentTrace | None:
668
+ self.init()
669
+ with self._connect() as conn:
670
+ if project_id:
671
+ row = conn.execute(
672
+ "SELECT payload_json FROM traces WHERE run_id = %s AND project_id = %s",
673
+ (run_id, project_id),
674
+ ).fetchone()
675
+ else:
676
+ row = conn.execute(
677
+ "SELECT payload_json FROM traces WHERE run_id = %s", (run_id,)
678
+ ).fetchone()
679
+ if row is None:
680
+ return None
681
+ payload = row[0]
682
+ if isinstance(payload, str):
683
+ payload = json.loads(payload)
684
+ return AgentTrace.from_dict(payload)
685
+
686
+ def save_diagnosis(self, diagnosis: dict[str, Any], project_id: str = "default") -> None:
687
+ self.init()
688
+ with self._connect() as conn:
689
+ conn.execute(
690
+ "INSERT INTO projects(project_id) VALUES (%s) ON CONFLICT DO NOTHING", (project_id,)
691
+ )
692
+ conn.execute(
693
+ "DELETE FROM trace_findings WHERE project_id = %s AND run_id = %s",
694
+ (project_id, diagnosis["run_id"]),
695
+ )
696
+ for finding in diagnosis.get("findings", []):
697
+ savings = finding.get("savings", {})
698
+ rewrite = finding.get("rewrite", {})
699
+ conn.execute(
700
+ """
701
+ INSERT INTO trace_findings(project_id, run_id, finding_id, type, severity, title, confidence, patchable, estimated_latency_savings_ms, estimated_cost_savings_usd, payload_json)
702
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
703
+ ON CONFLICT(project_id, run_id, finding_id) DO UPDATE SET
704
+ type=excluded.type, severity=excluded.severity, title=excluded.title, confidence=excluded.confidence,
705
+ patchable=excluded.patchable, estimated_latency_savings_ms=excluded.estimated_latency_savings_ms,
706
+ estimated_cost_savings_usd=excluded.estimated_cost_savings_usd, payload_json=excluded.payload_json,
707
+ updated_at=now()
708
+ """,
709
+ (
710
+ project_id,
711
+ diagnosis["run_id"],
712
+ finding["finding_id"],
713
+ finding["type"],
714
+ finding["severity"],
715
+ finding["title"],
716
+ finding["confidence"],
717
+ bool(rewrite.get("patchable")),
718
+ savings.get("estimated_latency_savings_ms", 0),
719
+ savings.get("estimated_cost_savings_usd", 0),
720
+ json.dumps(finding),
721
+ ),
722
+ )
723
+
724
+ def list_findings(
725
+ self, project_id: str | None = None, status: str | None = None
726
+ ) -> list[dict[str, Any]]:
727
+ self.init()
728
+ with self._connect() as conn:
729
+ rows = conn.execute(
730
+ """
731
+ SELECT project_id, run_id, finding_id, type, severity, title, confidence,
732
+ patchable, status, estimated_latency_savings_ms, estimated_cost_savings_usd,
733
+ payload_json, created_at::text, updated_at::text
734
+ FROM trace_findings
735
+ WHERE project_id = COALESCE(%s, project_id)
736
+ AND status = COALESCE(%s, status)
737
+ ORDER BY
738
+ CASE severity WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
739
+ estimated_cost_savings_usd DESC,
740
+ estimated_latency_savings_ms DESC,
741
+ updated_at DESC
742
+ """,
743
+ (project_id or None, status or None),
744
+ ).fetchall()
745
+ keys = [
746
+ "project_id",
747
+ "run_id",
748
+ "finding_id",
749
+ "type",
750
+ "severity",
751
+ "title",
752
+ "confidence",
753
+ "patchable",
754
+ "status",
755
+ "estimated_latency_savings_ms",
756
+ "estimated_cost_savings_usd",
757
+ "payload_json",
758
+ "created_at",
759
+ "updated_at",
760
+ ]
761
+ return [_finding_row(dict(zip(keys, row))) for row in rows]
762
+
763
+ def optimization_queue(self, project_id: str | None = None) -> list[dict[str, Any]]:
764
+ findings = [
765
+ finding
766
+ for finding in self.list_findings(project_id=project_id)
767
+ if finding["status"] != "resolved"
768
+ ]
769
+ return _build_optimization_queue(findings, project_id)
770
+
771
+ def record_usage(self, project_id: str, run_id: str, report: dict[str, Any]) -> None:
772
+ with self._connect() as conn:
773
+ conn.execute(
774
+ "INSERT INTO usage_events(project_id, run_id, total_runtime_ms, estimated_cost_usd, input_tokens, output_tokens, model_call_count, tool_call_count, retry_count) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
775
+ (
776
+ project_id,
777
+ run_id,
778
+ report.get("total_runtime_ms", 0),
779
+ report.get("estimated_cost_usd", 0),
780
+ report.get("input_tokens", 0),
781
+ report.get("output_tokens", 0),
782
+ report.get("model_call_count", 0),
783
+ report.get("tool_call_count", 0),
784
+ report.get("retry_count", 0),
785
+ ),
786
+ )
787
+
788
+ def usage_summary(self, project_id: str | None = None) -> dict[str, Any]:
789
+ self.init()
790
+ with self._connect() as conn:
791
+ if project_id:
792
+ row = conn.execute(
793
+ "SELECT COUNT(*), COALESCE(SUM(total_runtime_ms),0), COALESCE(SUM(estimated_cost_usd),0), COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), COALESCE(SUM(model_call_count),0), COALESCE(SUM(tool_call_count),0), COALESCE(SUM(retry_count),0) FROM usage_events WHERE project_id = %s",
794
+ (project_id,),
795
+ ).fetchone()
796
+ else:
797
+ row = conn.execute(
798
+ "SELECT COUNT(*), COALESCE(SUM(total_runtime_ms),0), COALESCE(SUM(estimated_cost_usd),0), COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), COALESCE(SUM(model_call_count),0), COALESCE(SUM(tool_call_count),0), COALESCE(SUM(retry_count),0) FROM usage_events"
799
+ ).fetchone()
800
+ keys = [
801
+ "run_count",
802
+ "total_runtime_ms",
803
+ "estimated_cost_usd",
804
+ "input_tokens",
805
+ "output_tokens",
806
+ "model_call_count",
807
+ "tool_call_count",
808
+ "retry_count",
809
+ ]
810
+ result = dict(zip(keys, row))
811
+ if project_id:
812
+ result["project_id"] = project_id
813
+ return result
814
+
815
+
816
+ def get_store() -> TraceStore:
817
+ backend = os.getenv("AGENTLOOP_STORE_BACKEND", "sqlite").lower()
818
+ if backend == "postgres":
819
+ dsn = get_postgres_dsn()
820
+ if not postgres_connection_source():
821
+ raise RuntimeError(
822
+ "AGENTLOOP_STORE_BACKEND=postgres requires AGENTLOOP_DATABASE_URL, "
823
+ "DATABASE_URL, or libpq PGHOST/PGSERVICE configuration"
824
+ )
825
+ return PostgresTraceStore(
826
+ dsn=dsn,
827
+ password_file=None if dsn else get_postgres_password_file(),
828
+ )
829
+ return SQLiteTraceStore(path=os.getenv("AGENTLOOP_SQLITE_PATH", "runs/agentloop.db"))
830
+
831
+
832
+ def _read_postgres_password(path: str) -> str:
833
+ """Read a file-backed Postgres password without including it in errors or logs."""
834
+ try:
835
+ password = Path(path).read_text(encoding="utf-8").rstrip("\r\n")
836
+ except (OSError, UnicodeError) as exc:
837
+ raise RuntimeError(f"Unable to read Postgres password file: {path}") from exc
838
+ if not password:
839
+ raise RuntimeError(f"Postgres password file is empty: {path}")
840
+ return password