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.
- agentloop/__init__.py +136 -0
- agentloop/ablation_protocol.py +229 -0
- agentloop/ablations.py +564 -0
- agentloop/aggregate_cli.py +88 -0
- agentloop/aggregate_stats.py +175 -0
- agentloop/aggregates.py +532 -0
- agentloop/audit.py +39 -0
- agentloop/autoinstrument.py +145 -0
- agentloop/batch_experiments.py +295 -0
- agentloop/batching.py +95 -0
- agentloop/budget_types.py +335 -0
- agentloop/budgets.py +539 -0
- agentloop/calibration.py +872 -0
- agentloop/checkpoint_state.py +306 -0
- agentloop/checkpoint_store.py +86 -0
- agentloop/ci.py +202 -0
- agentloop/cli.py +1314 -0
- agentloop/client.py +228 -0
- agentloop/completion.py +345 -0
- agentloop/completion_types.py +199 -0
- agentloop/config.py +50 -0
- agentloop/context_controls.py +443 -0
- agentloop/context_experiments.py +337 -0
- agentloop/context_types.py +216 -0
- agentloop/costs.py +667 -0
- agentloop/cursor_validation.py +48 -0
- agentloop/decorators.py +445 -0
- agentloop/demo.py +300 -0
- agentloop/doctor.py +341 -0
- agentloop/drift.py +361 -0
- agentloop/drift_cli.py +147 -0
- agentloop/drift_types.py +110 -0
- agentloop/drift_windows.py +340 -0
- agentloop/entrypoint.py +201 -0
- agentloop/estimates.py +133 -0
- agentloop/events.py +75 -0
- agentloop/experiment_artifacts.py +80 -0
- agentloop/experiment_observations.py +169 -0
- agentloop/experiment_reports.py +545 -0
- agentloop/experiment_types.py +355 -0
- agentloop/experiments.py +392 -0
- agentloop/exporters.py +117 -0
- agentloop/finding_benchmark_types.py +131 -0
- agentloop/finding_benchmarks.py +424 -0
- agentloop/findings.py +419 -0
- agentloop/graph.py +464 -0
- agentloop/harness.py +825 -0
- agentloop/harness_bench/__init__.py +1 -0
- agentloop/harness_bench/cli.py +77 -0
- agentloop/harness_bench/drivers.py +96 -0
- agentloop/harness_bench/offline.py +507 -0
- agentloop/harness_evidence.py +585 -0
- agentloop/harness_usage.py +91 -0
- agentloop/html_report.py +860 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/harbor/__init__.py +1 -0
- agentloop/integrations/harbor/atif.py +982 -0
- agentloop/integrations/harbor/atif_validation.py +329 -0
- agentloop/integrations/harbor/cli.py +207 -0
- agentloop/integrations/harbor/manifest.py +86 -0
- agentloop/integrations/harbor/trial_evidence.py +196 -0
- agentloop/integrations/harbor/trials.py +1109 -0
- agentloop/integrations/harbor/verifier.py +118 -0
- agentloop/integrations/langgraph.py +150 -0
- agentloop/integrations/langgraph_harness.py +466 -0
- agentloop/integrations/langgraph_recovery.py +366 -0
- agentloop/integrations/omnigent/__init__.py +1 -0
- agentloop/integrations/omnigent/cli.py +78 -0
- agentloop/integrations/omnigent/evidence.py +93 -0
- agentloop/integrations/omnigent/telemetry.py +422 -0
- agentloop/integrations/openai.py +415 -0
- agentloop/integrations/openai_agents.py +291 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/interoperability/__init__.py +5 -0
- agentloop/interoperability/artifacts.py +112 -0
- agentloop/interoperability/cli.py +59 -0
- agentloop/interoperability/cohort_evidence.py +175 -0
- agentloop/interoperability/cohorts.py +681 -0
- agentloop/interoperability/contracts.py +427 -0
- agentloop/interoperability/coordination.py +554 -0
- agentloop/interoperability/evidence.py +215 -0
- agentloop/interoperability/otlp_jsonl.py +775 -0
- agentloop/interoperability/otlp_validation.py +496 -0
- agentloop/interoperability/registry.py +561 -0
- agentloop/interoperability/validation.py +333 -0
- agentloop/intervention_service.py +110 -0
- agentloop/intervention_store.py +118 -0
- agentloop/interventions.py +190 -0
- agentloop/issues.py +90 -0
- agentloop/judgment_adapters.py +125 -0
- agentloop/judgment_benchmark_types.py +98 -0
- agentloop/judgment_benchmarks.py +606 -0
- agentloop/judgment_types.py +241 -0
- agentloop/judgment_views.py +38 -0
- agentloop/judgments.py +342 -0
- agentloop/loop_guards.py +274 -0
- agentloop/loop_types.py +66 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +342 -0
- agentloop/migrations.py +303 -0
- agentloop/model_routing.py +470 -0
- agentloop/onboarding.py +315 -0
- agentloop/operations.py +44 -0
- agentloop/optimizer.py +199 -0
- agentloop/otel.py +715 -0
- agentloop/otel_ids.py +81 -0
- agentloop/otel_semantics.py +134 -0
- agentloop/parallelism.py +141 -0
- agentloop/patches.py +603 -0
- agentloop/plan_export.py +126 -0
- agentloop/policy_promotion.py +325 -0
- agentloop/promotion_evidence.py +304 -0
- agentloop/promotion_types.py +209 -0
- agentloop/quality.py +446 -0
- agentloop/ranking.py +529 -0
- agentloop/recovery_evidence.py +96 -0
- agentloop/replay.py +753 -0
- agentloop/retention.py +375 -0
- agentloop/retention_types.py +113 -0
- agentloop/reuse_experiments.py +247 -0
- agentloop/routing_checks.py +116 -0
- agentloop/routing_types.py +241 -0
- agentloop/rules.py +396 -0
- agentloop/runtime.py +286 -0
- agentloop/savings.py +158 -0
- agentloop/scheduling_plan.py +99 -0
- agentloop/scheduling_types.py +169 -0
- agentloop/schema.py +257 -0
- agentloop/semantic_rules.py +200 -0
- agentloop/semantic_waste.py +345 -0
- agentloop/semantic_waste_types.py +88 -0
- agentloop/server.py +494 -0
- agentloop/store.py +1429 -0
- agentloop/structural_experiments.py +431 -0
- agentloop/structural_types.py +113 -0
- agentloop/structured_quality.py +1020 -0
- agentloop/studies.py +629 -0
- agentloop/study_statistics.py +58 -0
- agentloop/substitution_evidence.py +199 -0
- agentloop/substitution_export.py +190 -0
- agentloop/substitution_reports.py +407 -0
- agentloop/substitution_types.py +284 -0
- agentloop/substitutions.py +267 -0
- agentloop/timing.py +142 -0
- agentloop/tokens.py +188 -0
- agentloop/tool_scheduling.py +410 -0
- agentloop/trace_sources.py +31 -0
- agentloop/tracer.py +494 -0
- agentloop/value.py +306 -0
- agentloop/version.py +3 -0
- agentloop/workflow_types.py +264 -0
- agentloop/workflows.py +284 -0
- agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
- agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
- agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
- agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +5 -0
- dashboard/app.py +922 -0
- dashboard/streamlit_compat.py +63 -0
- dashboard/trace_upload.py +57 -0
- 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
|