agentloop-profiler 0.1.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.
Files changed (166) hide show
  1. agentloop/__init__.py +136 -0
  2. agentloop/ablation_protocol.py +229 -0
  3. agentloop/ablations.py +564 -0
  4. agentloop/aggregate_cli.py +88 -0
  5. agentloop/aggregate_stats.py +175 -0
  6. agentloop/aggregates.py +532 -0
  7. agentloop/audit.py +39 -0
  8. agentloop/autoinstrument.py +145 -0
  9. agentloop/batch_experiments.py +295 -0
  10. agentloop/batching.py +95 -0
  11. agentloop/budget_types.py +335 -0
  12. agentloop/budgets.py +539 -0
  13. agentloop/calibration.py +872 -0
  14. agentloop/checkpoint_state.py +306 -0
  15. agentloop/checkpoint_store.py +86 -0
  16. agentloop/ci.py +202 -0
  17. agentloop/cli.py +1314 -0
  18. agentloop/client.py +228 -0
  19. agentloop/completion.py +345 -0
  20. agentloop/completion_types.py +199 -0
  21. agentloop/config.py +50 -0
  22. agentloop/context_controls.py +443 -0
  23. agentloop/context_experiments.py +337 -0
  24. agentloop/context_types.py +216 -0
  25. agentloop/costs.py +667 -0
  26. agentloop/cursor_validation.py +48 -0
  27. agentloop/decorators.py +445 -0
  28. agentloop/demo.py +300 -0
  29. agentloop/doctor.py +341 -0
  30. agentloop/drift.py +361 -0
  31. agentloop/drift_cli.py +147 -0
  32. agentloop/drift_types.py +110 -0
  33. agentloop/drift_windows.py +340 -0
  34. agentloop/entrypoint.py +201 -0
  35. agentloop/estimates.py +133 -0
  36. agentloop/events.py +75 -0
  37. agentloop/experiment_artifacts.py +80 -0
  38. agentloop/experiment_observations.py +169 -0
  39. agentloop/experiment_reports.py +545 -0
  40. agentloop/experiment_types.py +355 -0
  41. agentloop/experiments.py +392 -0
  42. agentloop/exporters.py +117 -0
  43. agentloop/finding_benchmark_types.py +131 -0
  44. agentloop/finding_benchmarks.py +424 -0
  45. agentloop/findings.py +419 -0
  46. agentloop/graph.py +464 -0
  47. agentloop/harness.py +825 -0
  48. agentloop/harness_bench/__init__.py +1 -0
  49. agentloop/harness_bench/cli.py +77 -0
  50. agentloop/harness_bench/drivers.py +96 -0
  51. agentloop/harness_bench/offline.py +507 -0
  52. agentloop/harness_evidence.py +585 -0
  53. agentloop/harness_usage.py +91 -0
  54. agentloop/html_report.py +860 -0
  55. agentloop/integrations/__init__.py +1 -0
  56. agentloop/integrations/crewai.py +144 -0
  57. agentloop/integrations/harbor/__init__.py +1 -0
  58. agentloop/integrations/harbor/atif.py +982 -0
  59. agentloop/integrations/harbor/atif_validation.py +329 -0
  60. agentloop/integrations/harbor/cli.py +207 -0
  61. agentloop/integrations/harbor/manifest.py +86 -0
  62. agentloop/integrations/harbor/trial_evidence.py +196 -0
  63. agentloop/integrations/harbor/trials.py +1109 -0
  64. agentloop/integrations/harbor/verifier.py +118 -0
  65. agentloop/integrations/langgraph.py +150 -0
  66. agentloop/integrations/langgraph_harness.py +466 -0
  67. agentloop/integrations/langgraph_recovery.py +366 -0
  68. agentloop/integrations/omnigent/__init__.py +1 -0
  69. agentloop/integrations/omnigent/cli.py +78 -0
  70. agentloop/integrations/omnigent/evidence.py +93 -0
  71. agentloop/integrations/omnigent/telemetry.py +422 -0
  72. agentloop/integrations/openai.py +415 -0
  73. agentloop/integrations/openai_agents.py +291 -0
  74. agentloop/integrations/sdk.py +82 -0
  75. agentloop/integrations/vercel_ai.py +83 -0
  76. agentloop/interoperability/__init__.py +5 -0
  77. agentloop/interoperability/artifacts.py +112 -0
  78. agentloop/interoperability/cli.py +59 -0
  79. agentloop/interoperability/cohort_evidence.py +175 -0
  80. agentloop/interoperability/cohorts.py +681 -0
  81. agentloop/interoperability/contracts.py +427 -0
  82. agentloop/interoperability/coordination.py +554 -0
  83. agentloop/interoperability/evidence.py +215 -0
  84. agentloop/interoperability/otlp_jsonl.py +775 -0
  85. agentloop/interoperability/otlp_validation.py +496 -0
  86. agentloop/interoperability/registry.py +561 -0
  87. agentloop/interoperability/validation.py +333 -0
  88. agentloop/intervention_service.py +110 -0
  89. agentloop/intervention_store.py +118 -0
  90. agentloop/interventions.py +190 -0
  91. agentloop/issues.py +90 -0
  92. agentloop/judgment_adapters.py +125 -0
  93. agentloop/judgment_benchmark_types.py +98 -0
  94. agentloop/judgment_benchmarks.py +606 -0
  95. agentloop/judgment_types.py +241 -0
  96. agentloop/judgment_views.py +38 -0
  97. agentloop/judgments.py +342 -0
  98. agentloop/loop_guards.py +274 -0
  99. agentloop/loop_types.py +66 -0
  100. agentloop/markdown.py +102 -0
  101. agentloop/metrics.py +342 -0
  102. agentloop/migrations.py +303 -0
  103. agentloop/model_routing.py +470 -0
  104. agentloop/onboarding.py +315 -0
  105. agentloop/operations.py +44 -0
  106. agentloop/optimizer.py +199 -0
  107. agentloop/otel.py +715 -0
  108. agentloop/otel_ids.py +81 -0
  109. agentloop/otel_semantics.py +134 -0
  110. agentloop/parallelism.py +141 -0
  111. agentloop/patches.py +603 -0
  112. agentloop/plan_export.py +126 -0
  113. agentloop/policy_promotion.py +325 -0
  114. agentloop/promotion_evidence.py +304 -0
  115. agentloop/promotion_types.py +209 -0
  116. agentloop/quality.py +446 -0
  117. agentloop/ranking.py +529 -0
  118. agentloop/recovery_evidence.py +96 -0
  119. agentloop/replay.py +753 -0
  120. agentloop/retention.py +375 -0
  121. agentloop/retention_types.py +113 -0
  122. agentloop/reuse_experiments.py +247 -0
  123. agentloop/routing_checks.py +116 -0
  124. agentloop/routing_types.py +241 -0
  125. agentloop/rules.py +396 -0
  126. agentloop/runtime.py +286 -0
  127. agentloop/savings.py +158 -0
  128. agentloop/scheduling_plan.py +99 -0
  129. agentloop/scheduling_types.py +169 -0
  130. agentloop/schema.py +257 -0
  131. agentloop/semantic_rules.py +200 -0
  132. agentloop/semantic_waste.py +345 -0
  133. agentloop/semantic_waste_types.py +88 -0
  134. agentloop/server.py +494 -0
  135. agentloop/store.py +1429 -0
  136. agentloop/structural_experiments.py +431 -0
  137. agentloop/structural_types.py +113 -0
  138. agentloop/structured_quality.py +1020 -0
  139. agentloop/studies.py +629 -0
  140. agentloop/study_statistics.py +58 -0
  141. agentloop/substitution_evidence.py +199 -0
  142. agentloop/substitution_export.py +190 -0
  143. agentloop/substitution_reports.py +407 -0
  144. agentloop/substitution_types.py +284 -0
  145. agentloop/substitutions.py +267 -0
  146. agentloop/timing.py +142 -0
  147. agentloop/tokens.py +188 -0
  148. agentloop/tool_scheduling.py +410 -0
  149. agentloop/trace_sources.py +31 -0
  150. agentloop/tracer.py +494 -0
  151. agentloop/value.py +306 -0
  152. agentloop/version.py +3 -0
  153. agentloop/workflow_types.py +264 -0
  154. agentloop/workflows.py +284 -0
  155. agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
  156. agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
  157. agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
  158. agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
  159. agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
  160. agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
  161. agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
  162. dashboard/__init__.py +5 -0
  163. dashboard/app.py +922 -0
  164. dashboard/streamlit_compat.py +63 -0
  165. dashboard/trace_upload.py +57 -0
  166. dashboard/value_view.py +105 -0
agentloop/store.py ADDED
@@ -0,0 +1,1429 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import math
9
+ import os
10
+ import secrets
11
+ import sqlite3
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, Protocol
15
+
16
+ from agentloop.config import (
17
+ get_postgres_dsn,
18
+ get_postgres_password_file,
19
+ postgres_connection_source,
20
+ )
21
+ from agentloop.cursor_validation import InvalidCursorError as InvalidCursorError
22
+ from agentloop.cursor_validation import decode_cursor, encode_cursor
23
+ from agentloop.findings import build_diagnosis
24
+ from agentloop.intervention_store import InterventionStoreMixin
25
+ from agentloop.interventions import InterventionRecord
26
+ from agentloop.migrations import apply_postgres_migrations, apply_sqlite_migrations
27
+ from agentloop.ranking import cluster_ranking, finalize_ranking
28
+ from agentloop.savings import SavingsItem, select_compatible
29
+ from agentloop.tracer import AgentTrace
30
+
31
+ _API_KEY_PREFIX_LENGTH = 10
32
+ _API_KEY_SALT_BYTES = 16
33
+ _API_KEY_HASH_BYTES = 32
34
+ _API_KEY_SCRYPT_N = 2**14
35
+ _API_KEY_SCRYPT_R = 8
36
+ _API_KEY_SCRYPT_P = 1
37
+
38
+ # Finding lifecycle. `detected` is the initial state for every newly diagnosed
39
+ # finding. `superseded` is not reachable through update_finding_status(); it is
40
+ # assigned automatically when a finding an operator hasn't triaged (detected /
41
+ # accepted) disappears from a later diagnosis of the same run. Terminal human
42
+ # decisions (resolved, dismissed) are never overwritten by re-diagnosis. Every
43
+ # state can be `reopened` back to `detected`.
44
+ FINDING_STATUSES = frozenset({"detected", "accepted", "resolved", "dismissed", "superseded"})
45
+ _INACTIVE_FINDING_STATUSES = frozenset({"resolved", "dismissed", "superseded"})
46
+ ALLOWED_FINDING_TRANSITIONS: dict[str, frozenset[str]] = {
47
+ "detected": frozenset({"accepted", "dismissed"}),
48
+ "accepted": frozenset({"resolved", "dismissed"}),
49
+ "resolved": frozenset({"detected"}),
50
+ "dismissed": frozenset({"detected"}),
51
+ "superseded": frozenset({"detected"}),
52
+ }
53
+
54
+ # Compatibility default for the unpaginated list_traces()/list_findings() calls:
55
+ # generous enough that no real deployment notices it, but bounded so a single
56
+ # call can't load an unbounded table into memory. Callers that need more than
57
+ # this should use list_traces_page()/list_findings_page().
58
+ _LEGACY_LIST_SAFETY_LIMIT = 10_000
59
+
60
+ DEFAULT_PAGE_SIZE = 50
61
+ MAX_PAGE_SIZE = 200
62
+
63
+ # optimization_queue() clusters unresolved findings in Python; bound the window
64
+ # to the most recently updated findings per project instead of loading a
65
+ # project's entire finding history.
66
+ _QUEUE_MAX_FINDINGS = 5000
67
+
68
+ _COST_STATUSES = frozenset({"complete", "partial", "unknown", "empty"})
69
+ _MAX_DATABASE_INTEGER = 2_147_483_647
70
+
71
+
72
+ class TraceProjectConflictError(ValueError):
73
+ """Raised when a run ID is already owned by a different project."""
74
+
75
+
76
+ class FindingNotFoundError(LookupError):
77
+ """Raised when a finding does not exist within the given project."""
78
+
79
+
80
+ class FindingTransitionError(ValueError):
81
+ """Raised when a status transition is not allowed from the finding's current status."""
82
+
83
+ def __init__(self, current_status: str, new_status: str):
84
+ self.current_status = current_status
85
+ self.new_status = new_status
86
+ super().__init__(f"cannot transition finding from {current_status!r} to {new_status!r}")
87
+
88
+
89
+ class TraceStore(Protocol):
90
+ def init(self) -> None: ...
91
+
92
+ def get_finding_snapshot(
93
+ self, run_id: str, finding_id: str, project_id: str = "default"
94
+ ) -> dict[str, Any] | None: ...
95
+
96
+ def save_intervention(
97
+ self, record: InterventionRecord | dict[str, Any], project_id: str = "default"
98
+ ) -> dict[str, Any]: ...
99
+
100
+ def get_intervention(
101
+ self, intervention_id: str, project_id: str = "default"
102
+ ) -> dict[str, Any] | None: ...
103
+
104
+ def create_api_key(self, project_id: str, name: str) -> dict[str, Any]: ...
105
+
106
+ def verify_api_key(self, api_key: str) -> dict[str, Any] | None: ...
107
+
108
+ def save_trace(self, trace: AgentTrace, project_id: str = "default") -> None: ...
109
+
110
+ def list_traces(self, project_id: str | None = None) -> list[dict[str, Any]]: ...
111
+
112
+ def list_traces_page(
113
+ self,
114
+ project_id: str | None = None,
115
+ limit: int = DEFAULT_PAGE_SIZE,
116
+ cursor: str | None = None,
117
+ ) -> dict[str, Any]: ...
118
+
119
+ def get_trace(self, run_id: str, project_id: str | None = None) -> AgentTrace | None: ...
120
+
121
+ def save_diagnosis(self, diagnosis: dict[str, Any], project_id: str = "default") -> None: ...
122
+
123
+ def list_findings(
124
+ self, project_id: str | None = None, status: str | None = None
125
+ ) -> list[dict[str, Any]]: ...
126
+
127
+ def list_findings_page(
128
+ self,
129
+ project_id: str | None = None,
130
+ status: str | None = None,
131
+ limit: int = DEFAULT_PAGE_SIZE,
132
+ cursor: str | None = None,
133
+ ) -> dict[str, Any]: ...
134
+
135
+ def update_finding_status(
136
+ self, project_id: str, run_id: str, finding_id: str, new_status: str
137
+ ) -> dict[str, Any]: ...
138
+
139
+ def optimization_queue(self, project_id: str | None = None) -> list[dict[str, Any]]: ...
140
+
141
+ def record_usage(self, project_id: str, run_id: str, report: dict[str, Any]) -> None: ...
142
+
143
+ def usage_summary(self, project_id: str | None = None) -> dict[str, Any]: ...
144
+
145
+
146
+ def hash_api_key(api_key: str) -> str:
147
+ salt = secrets.token_bytes(_API_KEY_SALT_BYTES)
148
+ digest = hashlib.scrypt(
149
+ api_key.encode("utf-8"),
150
+ salt=salt,
151
+ n=_API_KEY_SCRYPT_N,
152
+ r=_API_KEY_SCRYPT_R,
153
+ p=_API_KEY_SCRYPT_P,
154
+ dklen=_API_KEY_HASH_BYTES,
155
+ )
156
+ return "scrypt$v1${salt}${digest}".format(
157
+ salt=_encode_hash_bytes(salt),
158
+ digest=_encode_hash_bytes(digest),
159
+ )
160
+
161
+
162
+ def verify_api_key_hash(api_key: str, encoded_hash: str) -> bool:
163
+ if not api_key.startswith("al_") or len(api_key) > 512:
164
+ return False
165
+ parts = encoded_hash.split("$")
166
+ if len(parts) != 4 or parts[:2] != ["scrypt", "v1"]:
167
+ return False
168
+ try:
169
+ salt = _decode_hash_bytes(parts[2])
170
+ expected = _decode_hash_bytes(parts[3])
171
+ except (binascii.Error, UnicodeEncodeError, ValueError):
172
+ return False
173
+ if len(salt) != _API_KEY_SALT_BYTES or len(expected) != _API_KEY_HASH_BYTES:
174
+ return False
175
+ actual = hashlib.scrypt(
176
+ api_key.encode("utf-8"),
177
+ salt=salt,
178
+ n=_API_KEY_SCRYPT_N,
179
+ r=_API_KEY_SCRYPT_R,
180
+ p=_API_KEY_SCRYPT_P,
181
+ dklen=_API_KEY_HASH_BYTES,
182
+ )
183
+ return hmac.compare_digest(actual, expected)
184
+
185
+
186
+ def _encode_hash_bytes(value: bytes) -> str:
187
+ return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
188
+
189
+
190
+ def _decode_hash_bytes(value: str) -> bytes:
191
+ padded = value + "=" * (-len(value) % 4)
192
+ return base64.b64decode(padded.encode("ascii"), altchars=b"-_", validate=True)
193
+
194
+
195
+ def new_api_key() -> str:
196
+ return "al_" + secrets.token_urlsafe(32)
197
+
198
+
199
+ def _validate_page_limit(limit: int) -> int:
200
+ if not isinstance(limit, int) or limit < 1 or limit > MAX_PAGE_SIZE:
201
+ raise ValueError(f"page limit must be between 1 and {MAX_PAGE_SIZE}, got {limit!r}")
202
+ return limit
203
+
204
+
205
+ def _validate_new_status(new_status: str) -> str:
206
+ if new_status not in FINDING_STATUSES:
207
+ raise ValueError(
208
+ f"unknown finding status {new_status!r}; expected one of {sorted(FINDING_STATUSES)}"
209
+ )
210
+ return new_status
211
+
212
+
213
+ def _sources_allowing(new_status: str) -> list[str]:
214
+ return [
215
+ source for source, targets in ALLOWED_FINDING_TRANSITIONS.items() if new_status in targets
216
+ ]
217
+
218
+
219
+ # The widest source set any target status has (detected <- resolved/dismissed/superseded).
220
+ # update_finding_status() pads its source list to this fixed width so the SQL text is
221
+ # static regardless of new_status, rather than building a variable-length `IN (...)`.
222
+ _MAX_TRANSITION_SOURCES = max(len(_sources_allowing(status)) for status in FINDING_STATUSES)
223
+
224
+
225
+ def _padded_sources(new_status: str) -> list[str]:
226
+ sources = _sources_allowing(new_status)
227
+ return sources + [""] * (_MAX_TRANSITION_SOURCES - len(sources))
228
+
229
+
230
+ def _finding_row(row: dict[str, Any]) -> dict[str, Any]:
231
+ raw_payload = row.pop("payload_json")
232
+ payload = json.loads(raw_payload) if isinstance(raw_payload, str) else raw_payload
233
+ row["patchable"] = bool(row["patchable"])
234
+ row["estimated_latency_savings_ms"] = (
235
+ None
236
+ if row["estimated_latency_savings_ms"] is None
237
+ else float(row["estimated_latency_savings_ms"] or 0)
238
+ )
239
+ row["estimated_cost_savings_usd"] = (
240
+ None
241
+ if row["estimated_cost_savings_usd"] is None
242
+ else float(row["estimated_cost_savings_usd"])
243
+ )
244
+ row["finding"] = payload
245
+ return row
246
+
247
+
248
+ def _report_cost_completeness(report: dict[str, Any]) -> tuple[str, int, int]:
249
+ """Return validated status and priced/unavailable call counts for storage."""
250
+
251
+ breakdown = report.get("cost_breakdown")
252
+ breakdown = breakdown if isinstance(breakdown, dict) else {}
253
+ total, count_is_valid = _normalized_model_call_count(report)
254
+ if not count_is_valid:
255
+ return "unknown", 0, 0
256
+ if _normalized_estimated_cost(report) is None and total:
257
+ return "unknown", 0, total
258
+ status = report.get("cost_status")
259
+ status = status if status in _COST_STATUSES else None
260
+
261
+ try:
262
+ priced = max(0, int(breakdown.get("priced_model_call_count", 0) or 0))
263
+ unavailable = max(0, int(breakdown.get("unavailable_model_call_count", 0) or 0))
264
+ except (OverflowError, TypeError, ValueError):
265
+ priced = unavailable = 0
266
+
267
+ if priced + unavailable != total:
268
+ if status == "unknown":
269
+ priced, unavailable = 0, total
270
+ elif status == "partial" and total > 1:
271
+ priced, unavailable = total - 1, 1
272
+ elif status == "complete":
273
+ priced, unavailable = total, 0
274
+ else:
275
+ priced, unavailable = 0, total
276
+ return _aggregate_cost_status(priced, unavailable), priced, unavailable
277
+
278
+
279
+ def _normalized_model_call_count(report: dict[str, Any]) -> tuple[int, bool]:
280
+ raw = report.get("model_call_count", 0)
281
+ if isinstance(raw, bool) or not isinstance(raw, (int, float)):
282
+ return 0, False
283
+ if isinstance(raw, float) and (not math.isfinite(raw) or not raw.is_integer()):
284
+ return 0, False
285
+ count = int(raw)
286
+ return (count, True) if 0 <= count <= _MAX_DATABASE_INTEGER else (0, False)
287
+
288
+
289
+ def _normalized_estimated_cost(report: dict[str, Any]) -> float | None:
290
+ raw = report.get("estimated_cost_usd")
291
+ if isinstance(raw, bool) or not isinstance(raw, (int, float)):
292
+ return None
293
+ try:
294
+ cost = float(raw)
295
+ except OverflowError:
296
+ return None
297
+ return cost if math.isfinite(cost) and cost >= 0 else None
298
+
299
+
300
+ def _aggregate_cost_status(priced: int, unavailable: int, *, incomplete_run_count: int = 0) -> str:
301
+ if incomplete_run_count:
302
+ return "unknown" if priced == 0 else "partial"
303
+ total = priced + unavailable
304
+ if total == 0:
305
+ return "empty"
306
+ if unavailable == 0:
307
+ return "complete"
308
+ if priced == 0:
309
+ return "unknown"
310
+ return "partial"
311
+
312
+
313
+ def _merge_cost_status(left: str | None, right: str) -> str:
314
+ if left is None or left == "empty" or left == right:
315
+ return right
316
+ if right == "empty":
317
+ return left
318
+ return "partial"
319
+
320
+
321
+ def _max_severity(left: str, right: str) -> str:
322
+ rank = {"high": 3, "medium": 2, "low": 1}
323
+ return left if rank.get(left, 0) >= rank.get(right, 0) else right
324
+
325
+
326
+ def _build_optimization_queue(
327
+ findings: list[dict[str, Any]], project_id: str | None
328
+ ) -> list[dict[str, Any]]:
329
+ """Cluster unresolved findings into a priority-ranked optimization queue.
330
+
331
+ Within one run, findings of the same type and title that share affected
332
+ spans are the same underlying problem reported per occurrence — summing
333
+ their savings would double-count. Each cluster's savings therefore come
334
+ from the compatible (span-disjoint) selection per run, summed across runs,
335
+ and ranking retains the same compatible-span accounting.
336
+ """
337
+ clusters: dict[tuple[str, str], dict[str, Any]] = {}
338
+ cluster_items: dict[tuple[str, str], dict[str, list[SavingsItem]]] = {}
339
+ members = {}
340
+ run_scopes, run_ids = {}, {}
341
+ for finding in findings:
342
+ key = (finding["type"], finding["title"])
343
+ members.setdefault(key, []).append(finding)
344
+ cluster = clusters.setdefault(
345
+ key,
346
+ {
347
+ "queue_id": "queue_"
348
+ + hashlib.sha256("|".join(key).encode("utf-8")).hexdigest()[:12],
349
+ "type": finding["type"],
350
+ "title": finding["title"],
351
+ "status": "detected",
352
+ "severity": finding["severity"],
353
+ "occurrence_count": 0,
354
+ "run_count": 0,
355
+ "affected_runs": [],
356
+ "affected_run_refs": [],
357
+ "patchable_count": 0,
358
+ "estimated_latency_savings_ms": 0.0,
359
+ "estimated_cost_savings_usd": 0.0,
360
+ "cost_status": None,
361
+ "latest_created_at": finding["created_at"],
362
+ "project_id": project_id,
363
+ "quality_risk": "unknown",
364
+ "requires_scorer": True,
365
+ "safe_to_auto_patch": False,
366
+ },
367
+ )
368
+ cluster["occurrence_count"] += 1
369
+ if finding["estimated_latency_savings_ms"] is None:
370
+ cluster["unmodeled_latency_count"] = cluster.get("unmodeled_latency_count", 0) + 1
371
+ scope = (finding.get("project_id"), finding["run_id"])
372
+ if scope not in run_scopes.setdefault(key, set()):
373
+ run_scopes[key].add(scope)
374
+ cluster["affected_run_refs"].append({"project_id": scope[0], "run_id": scope[1]})
375
+ cluster["run_count"] += 1
376
+ if finding["run_id"] not in run_ids.setdefault(key, set()):
377
+ run_ids[key].add(finding["run_id"])
378
+ cluster["affected_runs"].append(finding["run_id"])
379
+ cluster["patchable_count"] += 1 if finding["patchable"] else 0
380
+ cluster["severity"] = _max_severity(cluster["severity"], finding["severity"])
381
+ cluster["latest_created_at"] = max(
382
+ str(cluster["latest_created_at"]), str(finding["created_at"])
383
+ )
384
+ raw_payload = finding.get("finding")
385
+ payload = raw_payload if isinstance(raw_payload, dict) else {}
386
+ metadata = payload.get("metadata")
387
+ finding_status = metadata.get("cost_status") if isinstance(metadata, dict) else None
388
+ if finding_status not in _COST_STATUSES:
389
+ finding_status = (
390
+ "complete" if finding["estimated_cost_savings_usd"] is not None else "unknown"
391
+ )
392
+ cluster["cost_status"] = _merge_cost_status(cluster["cost_status"], finding_status)
393
+ cluster_items.setdefault(key, {}).setdefault(scope, []).append(
394
+ SavingsItem(
395
+ spans=frozenset(payload.get("affected_spans") or []),
396
+ latency_ms=float(finding["estimated_latency_savings_ms"] or 0.0),
397
+ cost_usd=float(finding["estimated_cost_savings_usd"] or 0.0),
398
+ )
399
+ )
400
+
401
+ selection_cache = {}
402
+ for key, cluster in clusters.items():
403
+ cluster["affected_runs"].sort()
404
+ cluster["affected_run_refs"].sort(
405
+ key=lambda value: (str(value["project_id"]), value["run_id"])
406
+ )
407
+ latency = 0.0
408
+ cost = 0.0
409
+ for scope, run_items in cluster_items[key].items():
410
+ selection = select_compatible(run_items)
411
+ selection_cache.setdefault(key, {})[scope] = (run_items, selection)
412
+ latency += selection.latency_ms
413
+ cost += selection.cost_usd
414
+ cluster["estimated_latency_savings_ms"] = (
415
+ None if cluster.get("unmodeled_latency_count") else round(latency, 3)
416
+ )
417
+ cluster["cost_status"] = cluster["cost_status"] or "complete"
418
+ cluster["estimated_cost_savings_usd"] = (
419
+ round(cost, 6) if cluster["cost_status"] in {"complete", "empty"} else None
420
+ )
421
+
422
+ queue = list(clusters.values())
423
+ for item in queue:
424
+ key = (item["type"], item["title"])
425
+ item["ranking"] = cluster_ranking(members[key], selection_cache=selection_cache[key])
426
+ item["quality_risk"] = item["ranking"]["components"]["quality_risk"]["value"] or "unknown"
427
+ item["requires_scorer"] = True
428
+ item["safe_to_auto_patch"] = False
429
+ return finalize_ranking(queue)
430
+
431
+
432
+ @dataclass
433
+ class SQLiteTraceStore(InterventionStoreMixin):
434
+ path: str = "runs/agentloop.db"
435
+
436
+ def _connect(self) -> sqlite3.Connection:
437
+ Path(self.path).parent.mkdir(parents=True, exist_ok=True)
438
+ conn = sqlite3.connect(self.path, timeout=30)
439
+ conn.row_factory = sqlite3.Row
440
+ return conn
441
+
442
+ def init(self) -> None:
443
+ with self._connect() as conn:
444
+ apply_sqlite_migrations(conn)
445
+
446
+ def create_api_key(self, project_id: str, name: str) -> dict[str, Any]:
447
+ self.init()
448
+ api_key = new_api_key()
449
+ prefix = api_key[:_API_KEY_PREFIX_LENGTH]
450
+ with self._connect() as conn:
451
+ conn.execute("INSERT OR IGNORE INTO projects(project_id) VALUES (?)", (project_id,))
452
+ conn.execute(
453
+ "INSERT INTO api_keys(key_hash, project_id, name, prefix) VALUES (?, ?, ?, ?)",
454
+ (hash_api_key(api_key), project_id, name, prefix),
455
+ )
456
+ return {"api_key": api_key, "project_id": project_id, "name": name, "prefix": prefix}
457
+
458
+ def verify_api_key(self, api_key: str) -> dict[str, Any] | None:
459
+ self.init()
460
+ prefix = api_key[:_API_KEY_PREFIX_LENGTH]
461
+ with self._connect() as conn:
462
+ rows = conn.execute(
463
+ "SELECT key_hash, project_id, name, prefix FROM api_keys WHERE prefix = ?",
464
+ (prefix,),
465
+ ).fetchall()
466
+ row = next(
467
+ (
468
+ candidate
469
+ for candidate in rows
470
+ if verify_api_key_hash(api_key, candidate["key_hash"])
471
+ ),
472
+ None,
473
+ )
474
+ if row is None:
475
+ return None
476
+ conn.execute(
477
+ "UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE key_hash = ?",
478
+ (row["key_hash"],),
479
+ )
480
+ return {key: row[key] for key in ("project_id", "name", "prefix")}
481
+
482
+ def _upsert_trace(
483
+ self, conn: sqlite3.Connection, trace: AgentTrace, project_id: str
484
+ ) -> dict[str, Any]:
485
+ report = trace.report()
486
+ cost_status, priced_calls, unavailable_calls = _report_cost_completeness(report)
487
+ model_call_count, _ = _normalized_model_call_count(report)
488
+ estimated_cost_usd = _normalized_estimated_cost(report)
489
+ conn.execute("INSERT OR IGNORE INTO projects(project_id) VALUES (?)", (project_id,))
490
+ cursor = conn.execute(
491
+ """
492
+ INSERT INTO traces(
493
+ run_id, project_id, name, started_at, payload_json, total_runtime_ms,
494
+ estimated_cost_usd, cost_status, priced_model_call_count,
495
+ unavailable_model_call_count, model_call_count, tool_call_count, retry_count
496
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
497
+ ON CONFLICT(run_id) DO UPDATE SET
498
+ project_id=excluded.project_id,
499
+ name=excluded.name,
500
+ started_at=excluded.started_at,
501
+ payload_json=excluded.payload_json,
502
+ total_runtime_ms=excluded.total_runtime_ms,
503
+ estimated_cost_usd=excluded.estimated_cost_usd,
504
+ cost_status=excluded.cost_status,
505
+ priced_model_call_count=excluded.priced_model_call_count,
506
+ unavailable_model_call_count=excluded.unavailable_model_call_count,
507
+ model_call_count=excluded.model_call_count,
508
+ tool_call_count=excluded.tool_call_count,
509
+ retry_count=excluded.retry_count
510
+ WHERE traces.project_id = excluded.project_id
511
+ """,
512
+ (
513
+ trace.run_id,
514
+ project_id,
515
+ trace.name,
516
+ trace.started_at,
517
+ json.dumps(trace.to_dict()),
518
+ report.get("total_runtime_ms", 0),
519
+ estimated_cost_usd,
520
+ cost_status,
521
+ priced_calls,
522
+ unavailable_calls,
523
+ model_call_count,
524
+ report.get("tool_call_count", 0),
525
+ report.get("retry_count", 0),
526
+ ),
527
+ )
528
+ if cursor.rowcount == 0:
529
+ raise TraceProjectConflictError(
530
+ f"run_id {trace.run_id!r} already belongs to another project"
531
+ )
532
+ return report
533
+
534
+ def _upsert_usage(
535
+ self, conn: sqlite3.Connection, project_id: str, run_id: str, report: dict[str, Any]
536
+ ) -> None:
537
+ cost_status, priced_calls, unavailable_calls = _report_cost_completeness(report)
538
+ model_call_count, _ = _normalized_model_call_count(report)
539
+ estimated_cost_usd = _normalized_estimated_cost(report)
540
+ conn.execute(
541
+ """
542
+ INSERT INTO usage_events(
543
+ project_id, run_id, total_runtime_ms, estimated_cost_usd, input_tokens,
544
+ output_tokens, cost_status, priced_model_call_count,
545
+ unavailable_model_call_count, model_call_count, tool_call_count, retry_count
546
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
547
+ ON CONFLICT(project_id, run_id) DO UPDATE SET
548
+ total_runtime_ms=excluded.total_runtime_ms,
549
+ estimated_cost_usd=excluded.estimated_cost_usd,
550
+ input_tokens=excluded.input_tokens,
551
+ output_tokens=excluded.output_tokens,
552
+ cost_status=excluded.cost_status,
553
+ priced_model_call_count=excluded.priced_model_call_count,
554
+ unavailable_model_call_count=excluded.unavailable_model_call_count,
555
+ model_call_count=excluded.model_call_count,
556
+ tool_call_count=excluded.tool_call_count,
557
+ retry_count=excluded.retry_count,
558
+ created_at=CURRENT_TIMESTAMP
559
+ """,
560
+ (
561
+ project_id,
562
+ run_id,
563
+ report.get("total_runtime_ms", 0),
564
+ estimated_cost_usd,
565
+ report.get("input_tokens", 0),
566
+ report.get("output_tokens", 0),
567
+ cost_status,
568
+ priced_calls,
569
+ unavailable_calls,
570
+ model_call_count,
571
+ report.get("tool_call_count", 0),
572
+ report.get("retry_count", 0),
573
+ ),
574
+ )
575
+
576
+ def _upsert_findings(
577
+ self, conn: sqlite3.Connection, diagnosis: dict[str, Any], project_id: str
578
+ ) -> None:
579
+ conn.execute("INSERT OR IGNORE INTO projects(project_id) VALUES (?)", (project_id,))
580
+ run_id = diagnosis["run_id"]
581
+ new_finding_ids: list[str] = []
582
+ for finding in diagnosis.get("findings", []):
583
+ savings = finding.get("savings", {})
584
+ rewrite = finding.get("rewrite", {})
585
+ new_finding_ids.append(finding["finding_id"])
586
+ conn.execute(
587
+ """
588
+ INSERT INTO trace_findings(
589
+ project_id, run_id, finding_id, type, severity, title, confidence,
590
+ patchable, estimated_latency_savings_ms, estimated_cost_savings_usd,
591
+ payload_json
592
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
593
+ ON CONFLICT(project_id, run_id, finding_id) DO UPDATE SET
594
+ type=excluded.type,
595
+ severity=excluded.severity,
596
+ title=excluded.title,
597
+ confidence=excluded.confidence,
598
+ patchable=excluded.patchable,
599
+ estimated_latency_savings_ms=excluded.estimated_latency_savings_ms,
600
+ estimated_cost_savings_usd=excluded.estimated_cost_savings_usd,
601
+ payload_json=excluded.payload_json,
602
+ status=CASE WHEN trace_findings.status = 'superseded' THEN 'detected'
603
+ ELSE trace_findings.status END,
604
+ updated_at=CURRENT_TIMESTAMP
605
+ """,
606
+ (
607
+ project_id,
608
+ run_id,
609
+ finding["finding_id"],
610
+ finding["type"],
611
+ finding["severity"],
612
+ finding["title"],
613
+ finding["confidence"],
614
+ 1 if rewrite.get("patchable") else 0,
615
+ savings.get("estimated_latency_savings_ms", 0),
616
+ savings.get("estimated_cost_savings_usd", 0),
617
+ json.dumps(finding),
618
+ ),
619
+ )
620
+ if not diagnosis.get("analysis_complete", True):
621
+ return # Failed rules cannot establish that old findings disappeared.
622
+ # Findings from a prior diagnosis of this run that did not reappear are
623
+ # superseded rather than deleted, unless a human already made a
624
+ # terminal decision (resolved/dismissed) about them — that decision is
625
+ # preserved regardless of what the analyzer currently detects. The
626
+ # kept-finding-id set is arbitrary-length, so it is passed as a single
627
+ # bound JSON array (via json_each) instead of building a variable-width
628
+ # `IN (...)` clause, keeping the SQL text static.
629
+ conn.execute(
630
+ """
631
+ UPDATE trace_findings
632
+ SET status = 'superseded', updated_at = CURRENT_TIMESTAMP
633
+ WHERE project_id = ? AND run_id = ?
634
+ AND status NOT IN ('resolved', 'dismissed', 'superseded')
635
+ AND finding_id NOT IN (SELECT value FROM json_each(?))
636
+ """,
637
+ (project_id, run_id, json.dumps(new_finding_ids)),
638
+ )
639
+
640
+ def save_trace(self, trace: AgentTrace, project_id: str = "default") -> None:
641
+ self.init()
642
+ with self._connect() as conn:
643
+ report = self._upsert_trace(conn, trace, project_id)
644
+ self._upsert_usage(conn, project_id, trace.run_id, report)
645
+ self._upsert_findings(conn, build_diagnosis(trace), project_id)
646
+
647
+ def list_traces(self, project_id: str | None = None) -> list[dict[str, Any]]:
648
+ self.init()
649
+ with self._connect() as conn:
650
+ if project_id:
651
+ rows = conn.execute(
652
+ "SELECT run_id, project_id, name, started_at, total_runtime_ms, estimated_cost_usd, "
653
+ "cost_status, priced_model_call_count, unavailable_model_call_count, created_at "
654
+ "FROM traces WHERE project_id = ? ORDER BY created_at DESC, run_id DESC LIMIT ?",
655
+ (project_id, _LEGACY_LIST_SAFETY_LIMIT),
656
+ ).fetchall()
657
+ else:
658
+ rows = conn.execute(
659
+ "SELECT run_id, project_id, name, started_at, total_runtime_ms, estimated_cost_usd, "
660
+ "cost_status, priced_model_call_count, unavailable_model_call_count, created_at "
661
+ "FROM traces ORDER BY created_at DESC, run_id DESC LIMIT ?",
662
+ (_LEGACY_LIST_SAFETY_LIMIT,),
663
+ ).fetchall()
664
+ return [dict(row) for row in rows]
665
+
666
+ def list_traces_page(
667
+ self,
668
+ project_id: str | None = None,
669
+ limit: int = DEFAULT_PAGE_SIZE,
670
+ cursor: str | None = None,
671
+ ) -> dict[str, Any]:
672
+ after_created_at, after_run_id = (
673
+ decode_cursor(cursor, expected_parts=2) if cursor else (None, None)
674
+ )
675
+ limit = _validate_page_limit(limit)
676
+ self.init()
677
+ with self._connect() as conn:
678
+ rows = conn.execute(
679
+ """
680
+ SELECT run_id, project_id, name, started_at, total_runtime_ms, estimated_cost_usd,
681
+ cost_status, priced_model_call_count, unavailable_model_call_count, created_at
682
+ FROM traces
683
+ WHERE project_id = COALESCE(?, project_id)
684
+ AND (? = 0 OR (created_at, run_id) < (?, ?))
685
+ ORDER BY created_at DESC, run_id DESC
686
+ LIMIT ?
687
+ """,
688
+ (project_id, 1 if cursor else 0, after_created_at, after_run_id, limit + 1),
689
+ ).fetchall()
690
+ items = [dict(row) for row in rows[:limit]]
691
+ next_cursor = None
692
+ if len(rows) > limit:
693
+ last = items[-1]
694
+ next_cursor = encode_cursor([last["created_at"], last["run_id"]])
695
+ return {"items": items, "next_cursor": next_cursor}
696
+
697
+ def get_trace(self, run_id: str, project_id: str | None = None) -> AgentTrace | None:
698
+ self.init()
699
+ with self._connect() as conn:
700
+ if project_id:
701
+ row = conn.execute(
702
+ "SELECT payload_json FROM traces WHERE run_id = ? AND project_id = ?",
703
+ (run_id, project_id),
704
+ ).fetchone()
705
+ else:
706
+ row = conn.execute(
707
+ "SELECT payload_json FROM traces WHERE run_id = ?", (run_id,)
708
+ ).fetchone()
709
+ if row is None:
710
+ return None
711
+ return AgentTrace.from_dict(json.loads(row["payload_json"]))
712
+
713
+ def save_diagnosis(self, diagnosis: dict[str, Any], project_id: str = "default") -> None:
714
+ self.init()
715
+ with self._connect() as conn:
716
+ self._upsert_findings(conn, diagnosis, project_id)
717
+
718
+ def list_findings(
719
+ self, project_id: str | None = None, status: str | None = None
720
+ ) -> list[dict[str, Any]]:
721
+ self.init()
722
+ with self._connect() as conn:
723
+ rows = conn.execute(
724
+ """
725
+ SELECT project_id, run_id, finding_id, type, severity, title, confidence,
726
+ patchable, status, estimated_latency_savings_ms, estimated_cost_savings_usd,
727
+ payload_json, created_at, updated_at
728
+ FROM trace_findings
729
+ WHERE project_id = COALESCE(?, project_id)
730
+ AND status = COALESCE(?, status)
731
+ ORDER BY
732
+ CASE severity WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
733
+ COALESCE(estimated_cost_savings_usd, -1) DESC,
734
+ COALESCE(estimated_latency_savings_ms, -1) DESC,
735
+ updated_at DESC
736
+ LIMIT ?
737
+ """,
738
+ (project_id or None, status or None, _LEGACY_LIST_SAFETY_LIMIT),
739
+ ).fetchall()
740
+ return [_finding_row(dict(row)) for row in rows]
741
+
742
+ def list_findings_page(
743
+ self,
744
+ project_id: str | None = None,
745
+ status: str | None = None,
746
+ limit: int = DEFAULT_PAGE_SIZE,
747
+ cursor: str | None = None,
748
+ ) -> dict[str, Any]:
749
+ """Recency-ordered, keyset-paginated finding listing.
750
+
751
+ Ordered by (updated_at, run_id, finding_id) DESC rather than the
752
+ priority order list_findings() uses — priority mixes several columns
753
+ with independent directions, which keyset pagination can't compare
754
+ against a single cursor tuple.
755
+ """
756
+ after_updated_at, after_run_id, after_finding_id = (
757
+ decode_cursor(cursor, expected_parts=3) if cursor else (None, None, None)
758
+ )
759
+ limit = _validate_page_limit(limit)
760
+ self.init()
761
+ with self._connect() as conn:
762
+ rows = conn.execute(
763
+ """
764
+ SELECT project_id, run_id, finding_id, type, severity, title, confidence,
765
+ patchable, status, estimated_latency_savings_ms, estimated_cost_savings_usd,
766
+ payload_json, created_at, updated_at
767
+ FROM trace_findings
768
+ WHERE project_id = COALESCE(?, project_id)
769
+ AND status = COALESCE(?, status)
770
+ AND (? = 0 OR (updated_at, run_id, finding_id) < (?, ?, ?))
771
+ ORDER BY updated_at DESC, run_id DESC, finding_id DESC
772
+ LIMIT ?
773
+ """,
774
+ (
775
+ project_id,
776
+ status,
777
+ 1 if cursor else 0,
778
+ after_updated_at,
779
+ after_run_id,
780
+ after_finding_id,
781
+ limit + 1,
782
+ ),
783
+ ).fetchall()
784
+ items = [_finding_row(dict(row)) for row in rows[:limit]]
785
+ next_cursor = None
786
+ if len(rows) > limit:
787
+ last = items[-1]
788
+ next_cursor = encode_cursor([last["updated_at"], last["run_id"], last["finding_id"]])
789
+ return {"items": items, "next_cursor": next_cursor}
790
+
791
+ def update_finding_status(
792
+ self, project_id: str, run_id: str, finding_id: str, new_status: str
793
+ ) -> dict[str, Any]:
794
+ _validate_new_status(new_status)
795
+ sources = _padded_sources(new_status)
796
+ self.init()
797
+ with self._connect() as conn:
798
+ cursor = conn.execute(
799
+ """
800
+ UPDATE trace_findings
801
+ SET status = ?, updated_at = CURRENT_TIMESTAMP
802
+ WHERE project_id = ? AND run_id = ? AND finding_id = ?
803
+ AND status IN (?, ?, ?)
804
+ """,
805
+ (new_status, project_id, run_id, finding_id, *sources),
806
+ )
807
+ if cursor.rowcount == 0:
808
+ existing = conn.execute(
809
+ "SELECT status FROM trace_findings WHERE project_id = ? AND run_id = ? AND finding_id = ?",
810
+ (project_id, run_id, finding_id),
811
+ ).fetchone()
812
+ if existing is None:
813
+ raise FindingNotFoundError(
814
+ f"finding {finding_id!r} not found for run {run_id!r} in project {project_id!r}"
815
+ )
816
+ raise FindingTransitionError(existing["status"], new_status)
817
+ row = conn.execute(
818
+ """
819
+ SELECT project_id, run_id, finding_id, type, severity, title, confidence,
820
+ patchable, status, estimated_latency_savings_ms, estimated_cost_savings_usd,
821
+ payload_json, created_at, updated_at
822
+ FROM trace_findings WHERE project_id = ? AND run_id = ? AND finding_id = ?
823
+ """,
824
+ (project_id, run_id, finding_id),
825
+ ).fetchone()
826
+ return _finding_row(dict(row))
827
+
828
+ def optimization_queue(self, project_id: str | None = None) -> list[dict[str, Any]]:
829
+ self.init()
830
+ with self._connect() as conn:
831
+ rows = conn.execute(
832
+ """
833
+ SELECT project_id, run_id, finding_id, type, severity, title, confidence,
834
+ patchable, status, estimated_latency_savings_ms, estimated_cost_savings_usd,
835
+ payload_json, created_at, updated_at
836
+ FROM trace_findings
837
+ WHERE project_id = COALESCE(?, project_id)
838
+ AND status NOT IN ('resolved', 'dismissed', 'superseded')
839
+ ORDER BY updated_at DESC
840
+ LIMIT ?
841
+ """,
842
+ (project_id or None, _QUEUE_MAX_FINDINGS),
843
+ ).fetchall()
844
+ findings = [_finding_row(dict(row)) for row in rows]
845
+ return _build_optimization_queue(findings, project_id)
846
+
847
+ def record_usage(self, project_id: str, run_id: str, report: dict[str, Any]) -> None:
848
+ with self._connect() as conn:
849
+ self._upsert_usage(conn, project_id, run_id, report)
850
+
851
+ def usage_summary(self, project_id: str | None = None) -> dict[str, Any]:
852
+ self.init()
853
+ with self._connect() as conn:
854
+ row = conn.execute(
855
+ """
856
+ SELECT
857
+ COUNT(*) AS run_count,
858
+ COALESCE(SUM(total_runtime_ms), 0) AS total_runtime_ms,
859
+ COALESCE(SUM(estimated_cost_usd), 0) AS estimated_cost_usd,
860
+ COALESCE(SUM(priced_model_call_count), 0) AS priced_model_call_count,
861
+ COALESCE(SUM(unavailable_model_call_count), 0) AS unavailable_model_call_count,
862
+ COALESCE(SUM(CASE WHEN cost_status IN ('partial', 'unknown') THEN 1 ELSE 0 END), 0) AS incomplete_cost_run_count,
863
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
864
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
865
+ COALESCE(SUM(model_call_count), 0) AS model_call_count,
866
+ COALESCE(SUM(tool_call_count), 0) AS tool_call_count,
867
+ COALESCE(SUM(retry_count), 0) AS retry_count
868
+ FROM usage_events
869
+ WHERE project_id = COALESCE(?, project_id)
870
+ """,
871
+ (project_id or None,),
872
+ ).fetchone()
873
+ result = dict(row)
874
+ incomplete_run_count = int(result.pop("incomplete_cost_run_count"))
875
+ result["cost_status"] = _aggregate_cost_status(
876
+ int(result["priced_model_call_count"]),
877
+ int(result["unavailable_model_call_count"]),
878
+ incomplete_run_count=incomplete_run_count,
879
+ )
880
+ if result["cost_status"] not in {"complete", "empty"}:
881
+ result["estimated_cost_usd"] = None
882
+ if project_id:
883
+ result["project_id"] = project_id
884
+ return result
885
+
886
+
887
+ @dataclass
888
+ class PostgresTraceStore(InterventionStoreMixin):
889
+ _postgres_interventions = True
890
+ dsn: str | None = None
891
+ password_file: str | None = None
892
+
893
+ def _connect(self):
894
+ try:
895
+ import psycopg
896
+ except ImportError as exc:
897
+ raise RuntimeError(
898
+ "Install postgres support with: uv sync --locked --extra postgres"
899
+ ) from exc
900
+ if self.dsn:
901
+ return psycopg.connect(self.dsn)
902
+ connect_kwargs: dict[str, str] = {}
903
+ if self.password_file:
904
+ connect_kwargs["password"] = _read_postgres_password(self.password_file)
905
+ return psycopg.connect("", **connect_kwargs)
906
+
907
+ def init(self) -> None:
908
+ with self._connect() as conn:
909
+ apply_postgres_migrations(conn)
910
+
911
+ def create_api_key(self, project_id: str, name: str) -> dict[str, Any]:
912
+ self.init()
913
+ api_key = new_api_key()
914
+ prefix = api_key[:_API_KEY_PREFIX_LENGTH]
915
+ with self._connect() as conn:
916
+ conn.execute(
917
+ "INSERT INTO projects(project_id) VALUES (%s) ON CONFLICT DO NOTHING", (project_id,)
918
+ )
919
+ conn.execute(
920
+ "INSERT INTO api_keys(key_hash, project_id, name, prefix) VALUES (%s, %s, %s, %s)",
921
+ (hash_api_key(api_key), project_id, name, prefix),
922
+ )
923
+ return {"api_key": api_key, "project_id": project_id, "name": name, "prefix": prefix}
924
+
925
+ def verify_api_key(self, api_key: str) -> dict[str, Any] | None:
926
+ self.init()
927
+ prefix = api_key[:_API_KEY_PREFIX_LENGTH]
928
+ with self._connect() as conn:
929
+ rows = conn.execute(
930
+ "SELECT key_hash, project_id, name, prefix FROM api_keys WHERE prefix = %s",
931
+ (prefix,),
932
+ ).fetchall()
933
+ row = next(
934
+ (candidate for candidate in rows if verify_api_key_hash(api_key, candidate[0])),
935
+ None,
936
+ )
937
+ if row is None:
938
+ return None
939
+ conn.execute(
940
+ "UPDATE api_keys SET last_used_at = now() WHERE key_hash = %s",
941
+ (row[0],),
942
+ )
943
+ return {"project_id": row[1], "name": row[2], "prefix": row[3]}
944
+
945
+ def _upsert_trace(self, conn, trace: AgentTrace, project_id: str) -> dict[str, Any]:
946
+ report = trace.report()
947
+ cost_status, priced_calls, unavailable_calls = _report_cost_completeness(report)
948
+ model_call_count, _ = _normalized_model_call_count(report)
949
+ estimated_cost_usd = _normalized_estimated_cost(report)
950
+ conn.execute(
951
+ "INSERT INTO projects(project_id) VALUES (%s) ON CONFLICT DO NOTHING", (project_id,)
952
+ )
953
+ cursor = conn.execute(
954
+ """
955
+ INSERT INTO traces(run_id, project_id, name, started_at, payload_json, total_runtime_ms,
956
+ estimated_cost_usd, cost_status, priced_model_call_count,
957
+ unavailable_model_call_count, model_call_count, tool_call_count, retry_count)
958
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
959
+ ON CONFLICT(run_id) DO UPDATE SET project_id=excluded.project_id, name=excluded.name, started_at=excluded.started_at,
960
+ payload_json=excluded.payload_json, total_runtime_ms=excluded.total_runtime_ms, estimated_cost_usd=excluded.estimated_cost_usd,
961
+ cost_status=excluded.cost_status, priced_model_call_count=excluded.priced_model_call_count,
962
+ unavailable_model_call_count=excluded.unavailable_model_call_count,
963
+ model_call_count=excluded.model_call_count, tool_call_count=excluded.tool_call_count, retry_count=excluded.retry_count
964
+ WHERE traces.project_id = excluded.project_id
965
+ """,
966
+ (
967
+ trace.run_id,
968
+ project_id,
969
+ trace.name,
970
+ trace.started_at,
971
+ json.dumps(trace.to_dict()),
972
+ report.get("total_runtime_ms", 0),
973
+ estimated_cost_usd,
974
+ cost_status,
975
+ priced_calls,
976
+ unavailable_calls,
977
+ model_call_count,
978
+ report.get("tool_call_count", 0),
979
+ report.get("retry_count", 0),
980
+ ),
981
+ )
982
+ if cursor.rowcount == 0:
983
+ raise TraceProjectConflictError(
984
+ f"run_id {trace.run_id!r} already belongs to another project"
985
+ )
986
+ return report
987
+
988
+ def _upsert_usage(self, conn, project_id: str, run_id: str, report: dict[str, Any]) -> None:
989
+ cost_status, priced_calls, unavailable_calls = _report_cost_completeness(report)
990
+ model_call_count, _ = _normalized_model_call_count(report)
991
+ estimated_cost_usd = _normalized_estimated_cost(report)
992
+ conn.execute(
993
+ """
994
+ INSERT INTO usage_events(project_id, run_id, total_runtime_ms, estimated_cost_usd,
995
+ input_tokens, output_tokens, cost_status, priced_model_call_count,
996
+ unavailable_model_call_count, model_call_count, tool_call_count, retry_count)
997
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
998
+ ON CONFLICT(project_id, run_id) DO UPDATE SET
999
+ total_runtime_ms=excluded.total_runtime_ms,
1000
+ estimated_cost_usd=excluded.estimated_cost_usd,
1001
+ input_tokens=excluded.input_tokens,
1002
+ output_tokens=excluded.output_tokens,
1003
+ cost_status=excluded.cost_status,
1004
+ priced_model_call_count=excluded.priced_model_call_count,
1005
+ unavailable_model_call_count=excluded.unavailable_model_call_count,
1006
+ model_call_count=excluded.model_call_count,
1007
+ tool_call_count=excluded.tool_call_count,
1008
+ retry_count=excluded.retry_count,
1009
+ created_at=now()
1010
+ """,
1011
+ (
1012
+ project_id,
1013
+ run_id,
1014
+ report.get("total_runtime_ms", 0),
1015
+ estimated_cost_usd,
1016
+ report.get("input_tokens", 0),
1017
+ report.get("output_tokens", 0),
1018
+ cost_status,
1019
+ priced_calls,
1020
+ unavailable_calls,
1021
+ model_call_count,
1022
+ report.get("tool_call_count", 0),
1023
+ report.get("retry_count", 0),
1024
+ ),
1025
+ )
1026
+
1027
+ def _upsert_findings(self, conn, diagnosis: dict[str, Any], project_id: str) -> None:
1028
+ conn.execute(
1029
+ "INSERT INTO projects(project_id) VALUES (%s) ON CONFLICT DO NOTHING", (project_id,)
1030
+ )
1031
+ run_id = diagnosis["run_id"]
1032
+ new_finding_ids: list[str] = []
1033
+ for finding in diagnosis.get("findings", []):
1034
+ savings = finding.get("savings", {})
1035
+ rewrite = finding.get("rewrite", {})
1036
+ new_finding_ids.append(finding["finding_id"])
1037
+ conn.execute(
1038
+ """
1039
+ 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)
1040
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
1041
+ ON CONFLICT(project_id, run_id, finding_id) DO UPDATE SET
1042
+ type=excluded.type, severity=excluded.severity, title=excluded.title, confidence=excluded.confidence,
1043
+ patchable=excluded.patchable, estimated_latency_savings_ms=excluded.estimated_latency_savings_ms,
1044
+ estimated_cost_savings_usd=excluded.estimated_cost_savings_usd, payload_json=excluded.payload_json,
1045
+ status=CASE WHEN trace_findings.status = 'superseded' THEN 'detected' ELSE trace_findings.status END,
1046
+ updated_at=now()
1047
+ """,
1048
+ (
1049
+ project_id,
1050
+ run_id,
1051
+ finding["finding_id"],
1052
+ finding["type"],
1053
+ finding["severity"],
1054
+ finding["title"],
1055
+ finding["confidence"],
1056
+ bool(rewrite.get("patchable")),
1057
+ savings.get("estimated_latency_savings_ms", 0),
1058
+ savings.get("estimated_cost_savings_usd", 0),
1059
+ json.dumps(finding),
1060
+ ),
1061
+ )
1062
+ if not diagnosis.get("analysis_complete", True):
1063
+ return
1064
+ conn.execute(
1065
+ """
1066
+ UPDATE trace_findings
1067
+ SET status = 'superseded', updated_at = now()
1068
+ WHERE project_id = %s AND run_id = %s
1069
+ AND status NOT IN ('resolved', 'dismissed', 'superseded')
1070
+ AND finding_id != ALL(%s)
1071
+ """,
1072
+ (project_id, run_id, new_finding_ids),
1073
+ )
1074
+
1075
+ def save_trace(self, trace: AgentTrace, project_id: str = "default") -> None:
1076
+ self.init()
1077
+ with self._connect() as conn:
1078
+ report = self._upsert_trace(conn, trace, project_id)
1079
+ self._upsert_usage(conn, project_id, trace.run_id, report)
1080
+ self._upsert_findings(conn, build_diagnosis(trace), project_id)
1081
+
1082
+ def list_traces(self, project_id: str | None = None) -> list[dict[str, Any]]:
1083
+ self.init()
1084
+ with self._connect() as conn:
1085
+ if project_id:
1086
+ rows = conn.execute(
1087
+ "SELECT run_id, project_id, name, started_at, total_runtime_ms, estimated_cost_usd, cost_status, priced_model_call_count, unavailable_model_call_count, created_at::text FROM traces WHERE project_id = %s ORDER BY created_at DESC, run_id DESC LIMIT %s",
1088
+ (project_id, _LEGACY_LIST_SAFETY_LIMIT),
1089
+ ).fetchall()
1090
+ else:
1091
+ rows = conn.execute(
1092
+ "SELECT run_id, project_id, name, started_at, total_runtime_ms, estimated_cost_usd, cost_status, priced_model_call_count, unavailable_model_call_count, created_at::text FROM traces ORDER BY created_at DESC, run_id DESC LIMIT %s",
1093
+ (_LEGACY_LIST_SAFETY_LIMIT,),
1094
+ ).fetchall()
1095
+ keys = [
1096
+ "run_id",
1097
+ "project_id",
1098
+ "name",
1099
+ "started_at",
1100
+ "total_runtime_ms",
1101
+ "estimated_cost_usd",
1102
+ "cost_status",
1103
+ "priced_model_call_count",
1104
+ "unavailable_model_call_count",
1105
+ "created_at",
1106
+ ]
1107
+ return [dict(zip(keys, row, strict=True)) for row in rows]
1108
+
1109
+ def list_traces_page(
1110
+ self,
1111
+ project_id: str | None = None,
1112
+ limit: int = DEFAULT_PAGE_SIZE,
1113
+ cursor: str | None = None,
1114
+ ) -> dict[str, Any]:
1115
+ after_created_at, after_run_id = (
1116
+ decode_cursor(cursor, expected_parts=2) if cursor else (None, None)
1117
+ )
1118
+ limit = _validate_page_limit(limit)
1119
+ keys = [
1120
+ "run_id",
1121
+ "project_id",
1122
+ "name",
1123
+ "started_at",
1124
+ "total_runtime_ms",
1125
+ "estimated_cost_usd",
1126
+ "cost_status",
1127
+ "priced_model_call_count",
1128
+ "unavailable_model_call_count",
1129
+ "created_at",
1130
+ ]
1131
+ self.init()
1132
+ with self._connect() as conn:
1133
+ rows = conn.execute(
1134
+ """
1135
+ SELECT run_id, project_id, name, started_at, total_runtime_ms, estimated_cost_usd,
1136
+ cost_status, priced_model_call_count, unavailable_model_call_count, created_at::text
1137
+ FROM traces
1138
+ WHERE project_id = COALESCE(%s, project_id)
1139
+ AND (%s = 0 OR (created_at, run_id) < (%s, %s))
1140
+ ORDER BY created_at DESC, run_id DESC
1141
+ LIMIT %s
1142
+ """,
1143
+ (project_id, 1 if cursor else 0, after_created_at, after_run_id, limit + 1),
1144
+ ).fetchall()
1145
+ items = [dict(zip(keys, row, strict=True)) for row in rows[:limit]]
1146
+ next_cursor = None
1147
+ if len(rows) > limit:
1148
+ last = items[-1]
1149
+ next_cursor = encode_cursor([last["created_at"], last["run_id"]])
1150
+ return {"items": items, "next_cursor": next_cursor}
1151
+
1152
+ def get_trace(self, run_id: str, project_id: str | None = None) -> AgentTrace | None:
1153
+ self.init()
1154
+ with self._connect() as conn:
1155
+ if project_id:
1156
+ row = conn.execute(
1157
+ "SELECT payload_json FROM traces WHERE run_id = %s AND project_id = %s",
1158
+ (run_id, project_id),
1159
+ ).fetchone()
1160
+ else:
1161
+ row = conn.execute(
1162
+ "SELECT payload_json FROM traces WHERE run_id = %s", (run_id,)
1163
+ ).fetchone()
1164
+ if row is None:
1165
+ return None
1166
+ payload = row[0]
1167
+ if isinstance(payload, str):
1168
+ payload = json.loads(payload)
1169
+ return AgentTrace.from_dict(payload)
1170
+
1171
+ def save_diagnosis(self, diagnosis: dict[str, Any], project_id: str = "default") -> None:
1172
+ self.init()
1173
+ with self._connect() as conn:
1174
+ self._upsert_findings(conn, diagnosis, project_id)
1175
+
1176
+ def list_findings(
1177
+ self, project_id: str | None = None, status: str | None = None
1178
+ ) -> list[dict[str, Any]]:
1179
+ self.init()
1180
+ with self._connect() as conn:
1181
+ rows = conn.execute(
1182
+ """
1183
+ SELECT project_id, run_id, finding_id, type, severity, title, confidence,
1184
+ patchable, status, estimated_latency_savings_ms, estimated_cost_savings_usd,
1185
+ payload_json, created_at::text, updated_at::text
1186
+ FROM trace_findings
1187
+ WHERE project_id = COALESCE(%s, project_id)
1188
+ AND status = COALESCE(%s, status)
1189
+ ORDER BY
1190
+ CASE severity WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
1191
+ COALESCE(estimated_cost_savings_usd, -1) DESC,
1192
+ COALESCE(estimated_latency_savings_ms, -1) DESC,
1193
+ updated_at DESC
1194
+ LIMIT %s
1195
+ """,
1196
+ (project_id or None, status or None, _LEGACY_LIST_SAFETY_LIMIT),
1197
+ ).fetchall()
1198
+ keys = [
1199
+ "project_id",
1200
+ "run_id",
1201
+ "finding_id",
1202
+ "type",
1203
+ "severity",
1204
+ "title",
1205
+ "confidence",
1206
+ "patchable",
1207
+ "status",
1208
+ "estimated_latency_savings_ms",
1209
+ "estimated_cost_savings_usd",
1210
+ "payload_json",
1211
+ "created_at",
1212
+ "updated_at",
1213
+ ]
1214
+ return [_finding_row(dict(zip(keys, row, strict=True))) for row in rows]
1215
+
1216
+ def list_findings_page(
1217
+ self,
1218
+ project_id: str | None = None,
1219
+ status: str | None = None,
1220
+ limit: int = DEFAULT_PAGE_SIZE,
1221
+ cursor: str | None = None,
1222
+ ) -> dict[str, Any]:
1223
+ after_updated_at, after_run_id, after_finding_id = (
1224
+ decode_cursor(cursor, expected_parts=3) if cursor else (None, None, None)
1225
+ )
1226
+ limit = _validate_page_limit(limit)
1227
+ keys = [
1228
+ "project_id",
1229
+ "run_id",
1230
+ "finding_id",
1231
+ "type",
1232
+ "severity",
1233
+ "title",
1234
+ "confidence",
1235
+ "patchable",
1236
+ "status",
1237
+ "estimated_latency_savings_ms",
1238
+ "estimated_cost_savings_usd",
1239
+ "payload_json",
1240
+ "created_at",
1241
+ "updated_at",
1242
+ ]
1243
+ self.init()
1244
+ with self._connect() as conn:
1245
+ rows = conn.execute(
1246
+ """
1247
+ SELECT project_id, run_id, finding_id, type, severity, title, confidence,
1248
+ patchable, status, estimated_latency_savings_ms, estimated_cost_savings_usd,
1249
+ payload_json, created_at::text, updated_at::text
1250
+ FROM trace_findings
1251
+ WHERE project_id = COALESCE(%s, project_id)
1252
+ AND status = COALESCE(%s, status)
1253
+ AND (%s = 0 OR (updated_at, run_id, finding_id) < (%s, %s, %s))
1254
+ ORDER BY updated_at DESC, run_id DESC, finding_id DESC
1255
+ LIMIT %s
1256
+ """,
1257
+ (
1258
+ project_id,
1259
+ status,
1260
+ 1 if cursor else 0,
1261
+ after_updated_at,
1262
+ after_run_id,
1263
+ after_finding_id,
1264
+ limit + 1,
1265
+ ),
1266
+ ).fetchall()
1267
+ items = [_finding_row(dict(zip(keys, row, strict=True))) for row in rows[:limit]]
1268
+ next_cursor = None
1269
+ if len(rows) > limit:
1270
+ last = items[-1]
1271
+ next_cursor = encode_cursor([last["updated_at"], last["run_id"], last["finding_id"]])
1272
+ return {"items": items, "next_cursor": next_cursor}
1273
+
1274
+ def update_finding_status(
1275
+ self, project_id: str, run_id: str, finding_id: str, new_status: str
1276
+ ) -> dict[str, Any]:
1277
+ _validate_new_status(new_status)
1278
+ allowed_sources = _sources_allowing(new_status)
1279
+ keys = [
1280
+ "project_id",
1281
+ "run_id",
1282
+ "finding_id",
1283
+ "type",
1284
+ "severity",
1285
+ "title",
1286
+ "confidence",
1287
+ "patchable",
1288
+ "status",
1289
+ "estimated_latency_savings_ms",
1290
+ "estimated_cost_savings_usd",
1291
+ "payload_json",
1292
+ "created_at",
1293
+ "updated_at",
1294
+ ]
1295
+ self.init()
1296
+ with self._connect() as conn:
1297
+ cursor = conn.execute(
1298
+ """
1299
+ UPDATE trace_findings
1300
+ SET status = %s, updated_at = now()
1301
+ WHERE project_id = %s AND run_id = %s AND finding_id = %s
1302
+ AND status = ANY(%s)
1303
+ """,
1304
+ (new_status, project_id, run_id, finding_id, allowed_sources),
1305
+ )
1306
+ if cursor.rowcount == 0:
1307
+ existing = conn.execute(
1308
+ "SELECT status FROM trace_findings WHERE project_id = %s AND run_id = %s AND finding_id = %s",
1309
+ (project_id, run_id, finding_id),
1310
+ ).fetchone()
1311
+ if existing is None:
1312
+ raise FindingNotFoundError(
1313
+ f"finding {finding_id!r} not found for run {run_id!r} in project {project_id!r}"
1314
+ )
1315
+ raise FindingTransitionError(existing[0], new_status)
1316
+ row = conn.execute(
1317
+ """
1318
+ SELECT project_id, run_id, finding_id, type, severity, title, confidence,
1319
+ patchable, status, estimated_latency_savings_ms, estimated_cost_savings_usd,
1320
+ payload_json, created_at::text, updated_at::text
1321
+ FROM trace_findings WHERE project_id = %s AND run_id = %s AND finding_id = %s
1322
+ """,
1323
+ (project_id, run_id, finding_id),
1324
+ ).fetchone()
1325
+ return _finding_row(dict(zip(keys, row, strict=True)))
1326
+
1327
+ def optimization_queue(self, project_id: str | None = None) -> list[dict[str, Any]]:
1328
+ self.init()
1329
+ with self._connect() as conn:
1330
+ rows = conn.execute(
1331
+ """
1332
+ SELECT project_id, run_id, finding_id, type, severity, title, confidence,
1333
+ patchable, status, estimated_latency_savings_ms, estimated_cost_savings_usd,
1334
+ payload_json, created_at::text, updated_at::text
1335
+ FROM trace_findings
1336
+ WHERE project_id = COALESCE(%s, project_id)
1337
+ AND status NOT IN ('resolved', 'dismissed', 'superseded')
1338
+ ORDER BY updated_at DESC
1339
+ LIMIT %s
1340
+ """,
1341
+ (project_id or None, _QUEUE_MAX_FINDINGS),
1342
+ ).fetchall()
1343
+ keys = [
1344
+ "project_id",
1345
+ "run_id",
1346
+ "finding_id",
1347
+ "type",
1348
+ "severity",
1349
+ "title",
1350
+ "confidence",
1351
+ "patchable",
1352
+ "status",
1353
+ "estimated_latency_savings_ms",
1354
+ "estimated_cost_savings_usd",
1355
+ "payload_json",
1356
+ "created_at",
1357
+ "updated_at",
1358
+ ]
1359
+ findings = [_finding_row(dict(zip(keys, row, strict=True))) for row in rows]
1360
+ return _build_optimization_queue(findings, project_id)
1361
+
1362
+ def record_usage(self, project_id: str, run_id: str, report: dict[str, Any]) -> None:
1363
+ with self._connect() as conn:
1364
+ self._upsert_usage(conn, project_id, run_id, report)
1365
+
1366
+ def usage_summary(self, project_id: str | None = None) -> dict[str, Any]:
1367
+ self.init()
1368
+ with self._connect() as conn:
1369
+ if project_id:
1370
+ row = conn.execute(
1371
+ "SELECT COUNT(*), COALESCE(SUM(total_runtime_ms),0), COALESCE(SUM(estimated_cost_usd),0), COALESCE(SUM(priced_model_call_count),0), COALESCE(SUM(unavailable_model_call_count),0), COALESCE(SUM(CASE WHEN cost_status IN ('partial', 'unknown') THEN 1 ELSE 0 END),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",
1372
+ (project_id,),
1373
+ ).fetchone()
1374
+ else:
1375
+ row = conn.execute(
1376
+ "SELECT COUNT(*), COALESCE(SUM(total_runtime_ms),0), COALESCE(SUM(estimated_cost_usd),0), COALESCE(SUM(priced_model_call_count),0), COALESCE(SUM(unavailable_model_call_count),0), COALESCE(SUM(CASE WHEN cost_status IN ('partial', 'unknown') THEN 1 ELSE 0 END),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"
1377
+ ).fetchone()
1378
+ keys = [
1379
+ "run_count",
1380
+ "total_runtime_ms",
1381
+ "estimated_cost_usd",
1382
+ "priced_model_call_count",
1383
+ "unavailable_model_call_count",
1384
+ "incomplete_cost_run_count",
1385
+ "input_tokens",
1386
+ "output_tokens",
1387
+ "model_call_count",
1388
+ "tool_call_count",
1389
+ "retry_count",
1390
+ ]
1391
+ result = dict(zip(keys, row, strict=True))
1392
+ incomplete_run_count = int(result.pop("incomplete_cost_run_count"))
1393
+ result["cost_status"] = _aggregate_cost_status(
1394
+ int(result["priced_model_call_count"]),
1395
+ int(result["unavailable_model_call_count"]),
1396
+ incomplete_run_count=incomplete_run_count,
1397
+ )
1398
+ if result["cost_status"] not in {"complete", "empty"}:
1399
+ result["estimated_cost_usd"] = None
1400
+ if project_id:
1401
+ result["project_id"] = project_id
1402
+ return result
1403
+
1404
+
1405
+ def get_store() -> TraceStore:
1406
+ backend = os.getenv("AGENTLOOP_STORE_BACKEND", "sqlite").lower()
1407
+ if backend == "postgres":
1408
+ dsn = get_postgres_dsn()
1409
+ if not postgres_connection_source():
1410
+ raise RuntimeError(
1411
+ "AGENTLOOP_STORE_BACKEND=postgres requires AGENTLOOP_DATABASE_URL, "
1412
+ "DATABASE_URL, or libpq PGHOST/PGSERVICE configuration"
1413
+ )
1414
+ return PostgresTraceStore(
1415
+ dsn=dsn,
1416
+ password_file=None if dsn else get_postgres_password_file(),
1417
+ )
1418
+ return SQLiteTraceStore(path=os.getenv("AGENTLOOP_SQLITE_PATH", "runs/agentloop.db"))
1419
+
1420
+
1421
+ def _read_postgres_password(path: str) -> str:
1422
+ """Read a file-backed Postgres password without including it in errors or logs."""
1423
+ try:
1424
+ password = Path(path).read_text(encoding="utf-8").rstrip("\r\n")
1425
+ except (OSError, UnicodeError) as exc:
1426
+ raise RuntimeError(f"Unable to read Postgres password file: {path}") from exc
1427
+ if not password:
1428
+ raise RuntimeError(f"Postgres password file is empty: {path}")
1429
+ return password