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.
- agentloop/__init__.py +68 -0
- agentloop/audit.py +34 -0
- agentloop/autoinstrument.py +102 -0
- agentloop/ci.py +135 -0
- agentloop/cli.py +876 -0
- agentloop/client.py +170 -0
- agentloop/config.py +50 -0
- agentloop/costs.py +15 -0
- agentloop/decorators.py +160 -0
- agentloop/demo.py +283 -0
- agentloop/doctor.py +344 -0
- agentloop/events.py +49 -0
- agentloop/exporters.py +60 -0
- agentloop/findings.py +215 -0
- agentloop/graph.py +312 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/langgraph.py +140 -0
- agentloop/integrations/openai.py +369 -0
- agentloop/integrations/openai_agents.py +212 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/issues.py +88 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +123 -0
- agentloop/optimizer.py +323 -0
- agentloop/otel.py +302 -0
- agentloop/otel_ids.py +81 -0
- agentloop/patches.py +583 -0
- agentloop/plan_export.py +72 -0
- agentloop/quality.py +222 -0
- agentloop/replay.py +424 -0
- agentloop/runtime.py +244 -0
- agentloop/savings.py +135 -0
- agentloop/server.py +292 -0
- agentloop/store.py +840 -0
- agentloop/timing.py +124 -0
- agentloop/tracer.py +356 -0
- agentloop/value.py +243 -0
- agentloop/version.py +3 -0
- agentloop_profiler-0.4.0.dist-info/METADATA +598 -0
- agentloop_profiler-0.4.0.dist-info/RECORD +50 -0
- agentloop_profiler-0.4.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.4.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.4.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.4.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +54 -0
- agentloop_profiler-0.4.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +1 -0
- dashboard/app.py +698 -0
- 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
|