agentloop-profiler 0.4.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of agentloop-profiler might be problematic. Click here for more details.
- {agentloop_profiler-0.4.0/agentloop_profiler.egg-info → agentloop_profiler-0.5.0}/PKG-INFO +2 -2
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/README.md +1 -1
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/cli.py +76 -5
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/client.py +33 -4
- agentloop_profiler-0.5.0/agentloop/migrations.py +241 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/server.py +82 -3
- agentloop_profiler-0.5.0/agentloop/store.py +1256 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/version.py +1 -1
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0/agentloop_profiler.egg-info}/PKG-INFO +2 -2
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop_profiler.egg-info/SOURCES.txt +5 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/dashboard/app.py +59 -2
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/pyproject.toml +1 -1
- agentloop_profiler-0.5.0/tests/test_bump_version.py +173 -0
- agentloop_profiler-0.5.0/tests/test_cli_store_commands.py +95 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_client.py +31 -0
- agentloop_profiler-0.5.0/tests/test_migrations.py +151 -0
- agentloop_profiler-0.5.0/tests/test_release_workflows.py +91 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_server.py +104 -0
- agentloop_profiler-0.5.0/tests/test_store_contract.py +466 -0
- agentloop_profiler-0.4.0/agentloop/store.py +0 -840
- agentloop_profiler-0.4.0/tests/test_release_workflows.py +0 -34
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/LICENSE +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/THIRD_PARTY_LICENSES.md +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/__init__.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/audit.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/autoinstrument.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/ci.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/config.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/costs.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/decorators.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/demo.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/doctor.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/events.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/exporters.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/findings.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/graph.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/__init__.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/crewai.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/langgraph.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/openai.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/openai_agents.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/sdk.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/integrations/vercel_ai.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/issues.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/markdown.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/metrics.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/optimizer.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/otel.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/otel_ids.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/patches.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/plan_export.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/quality.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/replay.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/runtime.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/savings.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/timing.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/tracer.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop/value.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop_profiler.egg-info/dependency_links.txt +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop_profiler.egg-info/entry_points.txt +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop_profiler.egg-info/requires.txt +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/agentloop_profiler.egg-info/top_level.txt +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/dashboard/__init__.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/dashboard/value_view.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/setup.cfg +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_ci_command.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_cli_trace_inputs.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_crewai_integration.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_dashboard_value_view.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_decorators.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_demo_cli.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_distribution_metadata.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_doctor_autoinstrument.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_exporters.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_issues.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_langgraph_integration.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_markdown.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_markdown_exporters.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_metrics.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_openai_agents_integration.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_openai_integration.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_optimizer.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_otel_ids.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_otel_interop.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_patch_plan.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_quality.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_remote_cli_credentials.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_replay.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_runtime.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_savings_aggregation.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_store.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_timing.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_value.py +0 -0
- {agentloop_profiler-0.4.0 → agentloop_profiler-0.5.0}/tests/test_vercel_ai_integration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentloop-profiler
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Profiler and optimization layer for AI agent execution graphs
|
|
5
5
|
Author: Dipesh Babu
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -49,7 +49,7 @@ Dynamic: license-file
|
|
|
49
49
|
# AgentLoop
|
|
50
50
|
|
|
51
51
|
[](https://github.com/dipeshbabu/agentloop/actions/workflows/ci.yml)
|
|
52
|
-
[](https://pypi.org/project/agentloop-profiler/)
|
|
52
|
+
[](https://pypi.org/project/agentloop-profiler/)
|
|
53
53
|
[](https://www.python.org/downloads/)
|
|
54
54
|
[](LICENSE)
|
|
55
55
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# AgentLoop
|
|
2
2
|
|
|
3
3
|
[](https://github.com/dipeshbabu/agentloop/actions/workflows/ci.yml)
|
|
4
|
-
[](https://pypi.org/project/agentloop-profiler/)
|
|
4
|
+
[](https://pypi.org/project/agentloop-profiler/)
|
|
5
5
|
[](https://www.python.org/downloads/)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
@@ -27,7 +27,12 @@ from agentloop.quality import (
|
|
|
27
27
|
quality_report_to_markdown,
|
|
28
28
|
)
|
|
29
29
|
from agentloop.replay import ReplayGates, build_replay_report, replay_report_to_markdown
|
|
30
|
-
from agentloop.store import
|
|
30
|
+
from agentloop.store import (
|
|
31
|
+
DEFAULT_PAGE_SIZE,
|
|
32
|
+
FindingNotFoundError,
|
|
33
|
+
FindingTransitionError,
|
|
34
|
+
get_store,
|
|
35
|
+
)
|
|
31
36
|
from agentloop.tracer import AgentTrace
|
|
32
37
|
from agentloop.value import build_value_report
|
|
33
38
|
|
|
@@ -581,9 +586,23 @@ def store_trace(
|
|
|
581
586
|
|
|
582
587
|
|
|
583
588
|
@app.command("list-stored-traces")
|
|
584
|
-
def list_stored_traces(
|
|
589
|
+
def list_stored_traces(
|
|
590
|
+
project_id: str | None = None,
|
|
591
|
+
limit: int | None = typer.Option(None, help="Page size; omit to list every stored trace."),
|
|
592
|
+
cursor: str | None = typer.Option(
|
|
593
|
+
None, help="Continuation cursor from a previous --limit call's printed next-cursor."
|
|
594
|
+
),
|
|
595
|
+
) -> None:
|
|
585
596
|
db = get_store()
|
|
586
|
-
|
|
597
|
+
if limit is None and cursor is None:
|
|
598
|
+
traces = db.list_traces(project_id=project_id)
|
|
599
|
+
next_cursor = None
|
|
600
|
+
else:
|
|
601
|
+
page = db.list_traces_page(
|
|
602
|
+
project_id=project_id, limit=limit or DEFAULT_PAGE_SIZE, cursor=cursor
|
|
603
|
+
)
|
|
604
|
+
traces = page["items"]
|
|
605
|
+
next_cursor = page["next_cursor"]
|
|
587
606
|
table = Table(title="Stored AgentLoop Traces")
|
|
588
607
|
table.add_column("Project")
|
|
589
608
|
table.add_column("Run ID")
|
|
@@ -599,12 +618,29 @@ def list_stored_traces(project_id: str | None = None) -> None:
|
|
|
599
618
|
f"${float(item.get('estimated_cost_usd', 0)):.4f}",
|
|
600
619
|
)
|
|
601
620
|
console.print(table)
|
|
621
|
+
if next_cursor:
|
|
622
|
+
console.print(f"More results available. Next cursor: {next_cursor}")
|
|
602
623
|
|
|
603
624
|
|
|
604
625
|
@app.command("list-findings")
|
|
605
|
-
def list_findings(
|
|
626
|
+
def list_findings(
|
|
627
|
+
project_id: str | None = None,
|
|
628
|
+
status: str | None = None,
|
|
629
|
+
limit: int | None = typer.Option(None, help="Page size; omit to list every matching finding."),
|
|
630
|
+
cursor: str | None = typer.Option(
|
|
631
|
+
None, help="Continuation cursor from a previous --limit call's printed next-cursor."
|
|
632
|
+
),
|
|
633
|
+
) -> None:
|
|
606
634
|
db = get_store()
|
|
607
|
-
|
|
635
|
+
if limit is None and cursor is None:
|
|
636
|
+
findings = db.list_findings(project_id=project_id, status=status)
|
|
637
|
+
next_cursor = None
|
|
638
|
+
else:
|
|
639
|
+
page = db.list_findings_page(
|
|
640
|
+
project_id=project_id, status=status, limit=limit or DEFAULT_PAGE_SIZE, cursor=cursor
|
|
641
|
+
)
|
|
642
|
+
findings = page["items"]
|
|
643
|
+
next_cursor = page["next_cursor"]
|
|
608
644
|
table = Table(title="AgentLoop Findings")
|
|
609
645
|
table.add_column("Severity")
|
|
610
646
|
table.add_column("Type")
|
|
@@ -624,6 +660,41 @@ def list_findings(project_id: str | None = None, status: str | None = None) -> N
|
|
|
624
660
|
f"{finding['estimated_latency_savings_ms'] / 1000:.2f}s / ${finding['estimated_cost_savings_usd']:.4f}",
|
|
625
661
|
)
|
|
626
662
|
console.print(table)
|
|
663
|
+
if next_cursor:
|
|
664
|
+
console.print(f"More results available. Next cursor: {next_cursor}")
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
@app.command("update-finding-status")
|
|
668
|
+
def update_finding_status_command(
|
|
669
|
+
run_id: str,
|
|
670
|
+
finding_id: str,
|
|
671
|
+
status: str,
|
|
672
|
+
project_id: str = "default",
|
|
673
|
+
) -> None:
|
|
674
|
+
"""Transition a stored finding's lifecycle status (detected/accepted/resolved/dismissed)."""
|
|
675
|
+
db = get_store()
|
|
676
|
+
try:
|
|
677
|
+
updated = db.update_finding_status(project_id, run_id, finding_id, status)
|
|
678
|
+
except FindingNotFoundError as exc:
|
|
679
|
+
raise typer.BadParameter(str(exc), param_hint="finding_id") from exc
|
|
680
|
+
except (FindingTransitionError, ValueError) as exc:
|
|
681
|
+
raise typer.BadParameter(str(exc), param_hint="status") from exc
|
|
682
|
+
console.print(f"Finding {finding_id} is now [bold]{updated['status']}[/bold]")
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
@app.command("remote-update-finding-status")
|
|
686
|
+
def remote_update_finding_status(
|
|
687
|
+
run_id: str,
|
|
688
|
+
finding_id: str,
|
|
689
|
+
status: str,
|
|
690
|
+
api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
|
|
691
|
+
api_key: str | None = typer.Option(
|
|
692
|
+
None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
|
|
693
|
+
),
|
|
694
|
+
) -> None:
|
|
695
|
+
client = _remote_client(api_url, api_key)
|
|
696
|
+
updated = client.update_finding_status(run_id, finding_id, status)
|
|
697
|
+
console.print_json(data=updated)
|
|
627
698
|
|
|
628
699
|
|
|
629
700
|
@app.command("optimization-queue")
|
|
@@ -49,10 +49,23 @@ class AgentLoopClient:
|
|
|
49
49
|
payload = json.loads(Path(trace).read_text(encoding="utf-8"))
|
|
50
50
|
return self._request("POST", "/traces", payload)
|
|
51
51
|
|
|
52
|
-
def list_traces(
|
|
53
|
-
|
|
52
|
+
def list_traces(
|
|
53
|
+
self,
|
|
54
|
+
project_id: str | None = None,
|
|
55
|
+
*,
|
|
56
|
+
page_size: int | None = None,
|
|
57
|
+
cursor: str | None = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
params: dict[str, Any] = {}
|
|
54
60
|
if project_id:
|
|
55
|
-
|
|
61
|
+
params["project_id"] = project_id
|
|
62
|
+
if page_size is not None:
|
|
63
|
+
params["page_size"] = page_size
|
|
64
|
+
if cursor:
|
|
65
|
+
params["cursor"] = cursor
|
|
66
|
+
route = "/traces"
|
|
67
|
+
if params:
|
|
68
|
+
route += "?" + urllib.parse.urlencode(params)
|
|
56
69
|
return self._request("GET", route)
|
|
57
70
|
|
|
58
71
|
def usage_summary(self, project_id: str | None = None) -> dict[str, Any]:
|
|
@@ -74,18 +87,34 @@ class AgentLoopClient:
|
|
|
74
87
|
return self._request("GET", f"/traces/{encoded_run_id}/diagnose")
|
|
75
88
|
|
|
76
89
|
def list_findings(
|
|
77
|
-
self,
|
|
90
|
+
self,
|
|
91
|
+
project_id: str | None = None,
|
|
92
|
+
status: str | None = None,
|
|
93
|
+
*,
|
|
94
|
+
page_size: int | None = None,
|
|
95
|
+
cursor: str | None = None,
|
|
78
96
|
) -> dict[str, Any]:
|
|
79
97
|
params: dict[str, Any] = {}
|
|
80
98
|
if project_id:
|
|
81
99
|
params["project_id"] = project_id
|
|
82
100
|
if status:
|
|
83
101
|
params["status"] = status
|
|
102
|
+
if page_size is not None:
|
|
103
|
+
params["page_size"] = page_size
|
|
104
|
+
if cursor:
|
|
105
|
+
params["cursor"] = cursor
|
|
84
106
|
route = "/findings"
|
|
85
107
|
if params:
|
|
86
108
|
route += "?" + urllib.parse.urlencode(params)
|
|
87
109
|
return self._request("GET", route)
|
|
88
110
|
|
|
111
|
+
def update_finding_status(self, run_id: str, finding_id: str, status: str) -> dict[str, Any]:
|
|
112
|
+
encoded_run_id = urllib.parse.quote(run_id, safe="")
|
|
113
|
+
encoded_finding_id = urllib.parse.quote(finding_id, safe="")
|
|
114
|
+
return self._request(
|
|
115
|
+
"POST", f"/findings/{encoded_run_id}/{encoded_finding_id}/status", {"status": status}
|
|
116
|
+
)
|
|
117
|
+
|
|
89
118
|
def optimization_queue(self, project_id: str | None = None) -> dict[str, Any]:
|
|
90
119
|
route = "/optimization-queue"
|
|
91
120
|
if project_id:
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Versioned schema migrations shared by :mod:`agentloop.store`'s two backends.
|
|
2
|
+
|
|
3
|
+
Each :class:`Migration` carries dialect-specific, idempotent SQL statements.
|
|
4
|
+
Idempotency (``IF NOT EXISTS`` / ``ON CONFLICT ... DO NOTHING``) lets concurrent
|
|
5
|
+
``init()`` calls apply the same pending migration twice without corrupting state
|
|
6
|
+
or racing on `schema_migrations` bookkeeping, which stands in for a real
|
|
7
|
+
distributed lock that neither backend otherwise provides here.
|
|
8
|
+
|
|
9
|
+
A fresh install and an existing pre-migration database both start with no rows
|
|
10
|
+
in `schema_migrations`; migration 0001 reproduces the exact schema `init()`
|
|
11
|
+
used to create inline, so both converge on the same structure.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MigrationError(RuntimeError):
|
|
20
|
+
"""Raised when a migration statement fails to apply."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Migration:
|
|
25
|
+
version: int
|
|
26
|
+
name: str
|
|
27
|
+
sqlite_statements: tuple[str, ...]
|
|
28
|
+
postgres_statements: tuple[str, ...]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_BASELINE_SQLITE = (
|
|
32
|
+
"""
|
|
33
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
34
|
+
project_id TEXT PRIMARY KEY,
|
|
35
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
36
|
+
)
|
|
37
|
+
""",
|
|
38
|
+
"""
|
|
39
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
40
|
+
key_hash TEXT PRIMARY KEY,
|
|
41
|
+
project_id TEXT NOT NULL,
|
|
42
|
+
name TEXT NOT NULL,
|
|
43
|
+
prefix TEXT NOT NULL,
|
|
44
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
45
|
+
last_used_at TEXT,
|
|
46
|
+
FOREIGN KEY(project_id) REFERENCES projects(project_id)
|
|
47
|
+
)
|
|
48
|
+
""",
|
|
49
|
+
"""
|
|
50
|
+
CREATE TABLE IF NOT EXISTS traces (
|
|
51
|
+
run_id TEXT PRIMARY KEY,
|
|
52
|
+
project_id TEXT NOT NULL,
|
|
53
|
+
name TEXT NOT NULL,
|
|
54
|
+
started_at TEXT,
|
|
55
|
+
payload_json TEXT NOT NULL,
|
|
56
|
+
total_runtime_ms REAL DEFAULT 0,
|
|
57
|
+
estimated_cost_usd REAL DEFAULT 0,
|
|
58
|
+
model_call_count INTEGER DEFAULT 0,
|
|
59
|
+
tool_call_count INTEGER DEFAULT 0,
|
|
60
|
+
retry_count INTEGER DEFAULT 0,
|
|
61
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
62
|
+
FOREIGN KEY(project_id) REFERENCES projects(project_id)
|
|
63
|
+
)
|
|
64
|
+
""",
|
|
65
|
+
"""
|
|
66
|
+
CREATE TABLE IF NOT EXISTS usage_events (
|
|
67
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
project_id TEXT NOT NULL,
|
|
69
|
+
run_id TEXT NOT NULL,
|
|
70
|
+
total_runtime_ms REAL DEFAULT 0,
|
|
71
|
+
estimated_cost_usd REAL DEFAULT 0,
|
|
72
|
+
input_tokens INTEGER DEFAULT 0,
|
|
73
|
+
output_tokens INTEGER DEFAULT 0,
|
|
74
|
+
model_call_count INTEGER DEFAULT 0,
|
|
75
|
+
tool_call_count INTEGER DEFAULT 0,
|
|
76
|
+
retry_count INTEGER DEFAULT 0,
|
|
77
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
78
|
+
)
|
|
79
|
+
""",
|
|
80
|
+
"""
|
|
81
|
+
CREATE TABLE IF NOT EXISTS trace_findings (
|
|
82
|
+
project_id TEXT NOT NULL,
|
|
83
|
+
run_id TEXT NOT NULL,
|
|
84
|
+
finding_id TEXT NOT NULL,
|
|
85
|
+
type TEXT NOT NULL,
|
|
86
|
+
severity TEXT NOT NULL,
|
|
87
|
+
title TEXT NOT NULL,
|
|
88
|
+
confidence TEXT NOT NULL,
|
|
89
|
+
patchable INTEGER DEFAULT 0,
|
|
90
|
+
status TEXT DEFAULT 'detected',
|
|
91
|
+
estimated_latency_savings_ms REAL DEFAULT 0,
|
|
92
|
+
estimated_cost_savings_usd REAL DEFAULT 0,
|
|
93
|
+
payload_json TEXT NOT NULL,
|
|
94
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
95
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
96
|
+
PRIMARY KEY(project_id, run_id, finding_id),
|
|
97
|
+
FOREIGN KEY(project_id) REFERENCES projects(project_id),
|
|
98
|
+
FOREIGN KEY(run_id) REFERENCES traces(run_id)
|
|
99
|
+
)
|
|
100
|
+
""",
|
|
101
|
+
"INSERT OR IGNORE INTO projects(project_id) VALUES ('default')",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
_BASELINE_POSTGRES = (
|
|
105
|
+
"CREATE TABLE IF NOT EXISTS projects ("
|
|
106
|
+
"project_id TEXT PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT now())",
|
|
107
|
+
"CREATE TABLE IF NOT EXISTS api_keys ("
|
|
108
|
+
"key_hash TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(project_id), "
|
|
109
|
+
"name TEXT NOT NULL, prefix TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now(), "
|
|
110
|
+
"last_used_at TIMESTAMPTZ)",
|
|
111
|
+
"CREATE TABLE IF NOT EXISTS traces ("
|
|
112
|
+
"run_id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(project_id), "
|
|
113
|
+
"name TEXT NOT NULL, started_at TEXT, payload_json JSONB NOT NULL, "
|
|
114
|
+
"total_runtime_ms DOUBLE PRECISION DEFAULT 0, estimated_cost_usd DOUBLE PRECISION DEFAULT 0, "
|
|
115
|
+
"model_call_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, "
|
|
116
|
+
"retry_count INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now())",
|
|
117
|
+
"CREATE TABLE IF NOT EXISTS usage_events ("
|
|
118
|
+
"id BIGSERIAL PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, "
|
|
119
|
+
"total_runtime_ms DOUBLE PRECISION DEFAULT 0, estimated_cost_usd DOUBLE PRECISION DEFAULT 0, "
|
|
120
|
+
"input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, "
|
|
121
|
+
"model_call_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, "
|
|
122
|
+
"retry_count INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now())",
|
|
123
|
+
"CREATE TABLE IF NOT EXISTS trace_findings ("
|
|
124
|
+
"project_id TEXT NOT NULL REFERENCES projects(project_id), "
|
|
125
|
+
"run_id TEXT NOT NULL REFERENCES traces(run_id), finding_id TEXT NOT NULL, "
|
|
126
|
+
"type TEXT NOT NULL, severity TEXT NOT NULL, title TEXT NOT NULL, confidence TEXT NOT NULL, "
|
|
127
|
+
"patchable BOOLEAN DEFAULT false, status TEXT DEFAULT 'detected', "
|
|
128
|
+
"estimated_latency_savings_ms DOUBLE PRECISION DEFAULT 0, "
|
|
129
|
+
"estimated_cost_savings_usd DOUBLE PRECISION DEFAULT 0, payload_json JSONB NOT NULL, "
|
|
130
|
+
"created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(), "
|
|
131
|
+
"PRIMARY KEY(project_id, run_id, finding_id))",
|
|
132
|
+
"INSERT INTO projects(project_id) VALUES ('default') ON CONFLICT DO NOTHING",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# usage_events had no uniqueness constraint, so retried save_trace() calls appended
|
|
136
|
+
# a new row per retry instead of updating one. De-duplicate existing data (keep the
|
|
137
|
+
# most recent row per project/run) before the constraint can be added, or the
|
|
138
|
+
# index creation fails on any store that already has duplicates.
|
|
139
|
+
_DEDUPE_AND_CONSTRAIN_USAGE_EVENTS = (
|
|
140
|
+
"DELETE FROM usage_events WHERE id NOT IN "
|
|
141
|
+
"(SELECT MAX(id) FROM usage_events GROUP BY project_id, run_id)",
|
|
142
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS ux_usage_events_project_run "
|
|
143
|
+
"ON usage_events(project_id, run_id)",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
_PAGINATION_INDEXES = (
|
|
147
|
+
"CREATE INDEX IF NOT EXISTS ix_traces_project_created "
|
|
148
|
+
"ON traces(project_id, created_at, run_id)",
|
|
149
|
+
"CREATE INDEX IF NOT EXISTS ix_trace_findings_project_status_updated "
|
|
150
|
+
"ON trace_findings(project_id, status, updated_at, run_id, finding_id)",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
MIGRATIONS: tuple[Migration, ...] = (
|
|
154
|
+
Migration(
|
|
155
|
+
version=1,
|
|
156
|
+
name="baseline",
|
|
157
|
+
sqlite_statements=_BASELINE_SQLITE,
|
|
158
|
+
postgres_statements=_BASELINE_POSTGRES,
|
|
159
|
+
),
|
|
160
|
+
Migration(
|
|
161
|
+
version=2,
|
|
162
|
+
name="idempotent_usage_events",
|
|
163
|
+
sqlite_statements=_DEDUPE_AND_CONSTRAIN_USAGE_EVENTS,
|
|
164
|
+
postgres_statements=_DEDUPE_AND_CONSTRAIN_USAGE_EVENTS,
|
|
165
|
+
),
|
|
166
|
+
Migration(
|
|
167
|
+
version=3,
|
|
168
|
+
name="pagination_indexes",
|
|
169
|
+
sqlite_statements=_PAGINATION_INDEXES,
|
|
170
|
+
postgres_statements=_PAGINATION_INDEXES,
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _ensure_migrations_table_sqlite(conn) -> None:
|
|
176
|
+
conn.execute(
|
|
177
|
+
"""
|
|
178
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
179
|
+
version INTEGER PRIMARY KEY,
|
|
180
|
+
name TEXT NOT NULL,
|
|
181
|
+
applied_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
182
|
+
)
|
|
183
|
+
"""
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _ensure_migrations_table_postgres(conn) -> None:
|
|
188
|
+
conn.execute(
|
|
189
|
+
"CREATE TABLE IF NOT EXISTS schema_migrations ("
|
|
190
|
+
"version INTEGER PRIMARY KEY, name TEXT NOT NULL, "
|
|
191
|
+
"applied_at TIMESTAMPTZ DEFAULT now())"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def apply_sqlite_migrations(conn) -> None:
|
|
196
|
+
"""Apply pending migrations to a SQLite connection inside its open transaction."""
|
|
197
|
+
import sqlite3
|
|
198
|
+
|
|
199
|
+
_ensure_migrations_table_sqlite(conn)
|
|
200
|
+
applied = {row[0] for row in conn.execute("SELECT version FROM schema_migrations").fetchall()}
|
|
201
|
+
for migration in MIGRATIONS:
|
|
202
|
+
if migration.version in applied:
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
for statement in migration.sqlite_statements:
|
|
206
|
+
conn.execute(statement)
|
|
207
|
+
conn.execute(
|
|
208
|
+
"INSERT OR IGNORE INTO schema_migrations(version, name) VALUES (?, ?)",
|
|
209
|
+
(migration.version, migration.name),
|
|
210
|
+
)
|
|
211
|
+
except sqlite3.Error as exc:
|
|
212
|
+
raise MigrationError(
|
|
213
|
+
f"migration {migration.version} ({migration.name}) failed: {exc}. "
|
|
214
|
+
"The transaction was rolled back; the store is unchanged. Fix the "
|
|
215
|
+
"underlying issue (e.g. free disk space, resolve a lock) and retry."
|
|
216
|
+
) from exc
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def apply_postgres_migrations(conn) -> None:
|
|
220
|
+
"""Apply pending migrations to a psycopg connection inside its open transaction."""
|
|
221
|
+
import psycopg
|
|
222
|
+
|
|
223
|
+
_ensure_migrations_table_postgres(conn)
|
|
224
|
+
applied = {row[0] for row in conn.execute("SELECT version FROM schema_migrations").fetchall()}
|
|
225
|
+
for migration in MIGRATIONS:
|
|
226
|
+
if migration.version in applied:
|
|
227
|
+
continue
|
|
228
|
+
try:
|
|
229
|
+
for statement in migration.postgres_statements:
|
|
230
|
+
conn.execute(statement)
|
|
231
|
+
conn.execute(
|
|
232
|
+
"INSERT INTO schema_migrations(version, name) VALUES (%s, %s) "
|
|
233
|
+
"ON CONFLICT (version) DO NOTHING",
|
|
234
|
+
(migration.version, migration.name),
|
|
235
|
+
)
|
|
236
|
+
except psycopg.Error as exc:
|
|
237
|
+
raise MigrationError(
|
|
238
|
+
f"migration {migration.version} ({migration.name}) failed: {exc}. "
|
|
239
|
+
"The transaction was rolled back; the store is unchanged. Fix the "
|
|
240
|
+
"underlying issue (e.g. permissions, a blocking lock) and retry."
|
|
241
|
+
) from exc
|
|
@@ -12,7 +12,16 @@ from agentloop.findings import build_diagnosis
|
|
|
12
12
|
from agentloop.issues import build_issue_drafts
|
|
13
13
|
from agentloop.optimizer import build_optimization_plan
|
|
14
14
|
from agentloop.quality import build_quality_report
|
|
15
|
-
from agentloop.store import
|
|
15
|
+
from agentloop.store import (
|
|
16
|
+
DEFAULT_PAGE_SIZE,
|
|
17
|
+
MAX_PAGE_SIZE,
|
|
18
|
+
FindingNotFoundError,
|
|
19
|
+
FindingTransitionError,
|
|
20
|
+
InvalidCursorError,
|
|
21
|
+
TraceProjectConflictError,
|
|
22
|
+
TraceStore,
|
|
23
|
+
get_store,
|
|
24
|
+
)
|
|
16
25
|
from agentloop.tracer import AgentTrace
|
|
17
26
|
from agentloop.value import build_value_report
|
|
18
27
|
from agentloop.version import __version__
|
|
@@ -140,11 +149,36 @@ def _selected_project(project_id_filter: str | None, authenticated_project: str)
|
|
|
140
149
|
@app.get("/traces")
|
|
141
150
|
def list_traces(
|
|
142
151
|
project_id_filter: str | None = Query(default=None, alias="project_id"),
|
|
152
|
+
page_size: int | None = Query(default=None, ge=1, le=MAX_PAGE_SIZE),
|
|
153
|
+
cursor: str | None = Query(default=None),
|
|
143
154
|
project_id: str = Depends(resolve_project),
|
|
144
155
|
db: TraceStore = Depends(store),
|
|
145
156
|
) -> dict[str, Any]:
|
|
157
|
+
"""List traces for a project.
|
|
158
|
+
|
|
159
|
+
Without `page_size`/`cursor`, returns the full (bounded) list for
|
|
160
|
+
backward compatibility. Pass `page_size` to opt into keyset pagination:
|
|
161
|
+
the response then includes `next_cursor` — pass it back as `cursor` to
|
|
162
|
+
fetch the next page; `next_cursor: null` means there are no more rows.
|
|
163
|
+
"""
|
|
146
164
|
selected_project = _selected_project(project_id_filter, project_id)
|
|
147
|
-
|
|
165
|
+
if page_size is None and cursor is None:
|
|
166
|
+
return {
|
|
167
|
+
"project_id": selected_project,
|
|
168
|
+
"traces": db.list_traces(project_id=selected_project),
|
|
169
|
+
"next_cursor": None,
|
|
170
|
+
}
|
|
171
|
+
try:
|
|
172
|
+
page = db.list_traces_page(
|
|
173
|
+
project_id=selected_project, limit=page_size or DEFAULT_PAGE_SIZE, cursor=cursor
|
|
174
|
+
)
|
|
175
|
+
except InvalidCursorError as exc:
|
|
176
|
+
raise HTTPException(status_code=400, detail=str(exc)) from None
|
|
177
|
+
return {
|
|
178
|
+
"project_id": selected_project,
|
|
179
|
+
"traces": page["items"],
|
|
180
|
+
"next_cursor": page["next_cursor"],
|
|
181
|
+
}
|
|
148
182
|
|
|
149
183
|
|
|
150
184
|
def _load_trace_or_404(db: TraceStore, run_id: str, project_id: str) -> AgentTrace:
|
|
@@ -187,16 +221,61 @@ def diagnose_trace(
|
|
|
187
221
|
def list_findings(
|
|
188
222
|
status: str | None = Query(default=None),
|
|
189
223
|
project_id_filter: str | None = Query(default=None, alias="project_id"),
|
|
224
|
+
page_size: int | None = Query(default=None, ge=1, le=MAX_PAGE_SIZE),
|
|
225
|
+
cursor: str | None = Query(default=None),
|
|
190
226
|
project_id: str = Depends(resolve_project),
|
|
191
227
|
db: TraceStore = Depends(store),
|
|
192
228
|
) -> dict[str, Any]:
|
|
229
|
+
"""List findings for a project. See `list_traces` for the pagination contract.
|
|
230
|
+
|
|
231
|
+
Paginated results are ordered by recency (most recently updated first),
|
|
232
|
+
not by the priority order `optimization-queue` uses.
|
|
233
|
+
"""
|
|
193
234
|
selected_project = _selected_project(project_id_filter, project_id)
|
|
235
|
+
if page_size is None and cursor is None:
|
|
236
|
+
return {
|
|
237
|
+
"project_id": selected_project,
|
|
238
|
+
"findings": db.list_findings(project_id=selected_project, status=status),
|
|
239
|
+
"next_cursor": None,
|
|
240
|
+
}
|
|
241
|
+
try:
|
|
242
|
+
page = db.list_findings_page(
|
|
243
|
+
project_id=selected_project,
|
|
244
|
+
status=status,
|
|
245
|
+
limit=page_size or DEFAULT_PAGE_SIZE,
|
|
246
|
+
cursor=cursor,
|
|
247
|
+
)
|
|
248
|
+
except InvalidCursorError as exc:
|
|
249
|
+
raise HTTPException(status_code=400, detail=str(exc)) from None
|
|
194
250
|
return {
|
|
195
251
|
"project_id": selected_project,
|
|
196
|
-
"findings":
|
|
252
|
+
"findings": page["items"],
|
|
253
|
+
"next_cursor": page["next_cursor"],
|
|
197
254
|
}
|
|
198
255
|
|
|
199
256
|
|
|
257
|
+
class FindingStatusPayload(BaseModel):
|
|
258
|
+
status: str
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@app.post("/findings/{run_id}/{finding_id}/status")
|
|
262
|
+
def update_finding_status(
|
|
263
|
+
run_id: str,
|
|
264
|
+
finding_id: str,
|
|
265
|
+
payload: FindingStatusPayload,
|
|
266
|
+
project_id: str = Depends(resolve_project),
|
|
267
|
+
db: TraceStore = Depends(store),
|
|
268
|
+
) -> dict[str, Any]:
|
|
269
|
+
try:
|
|
270
|
+
return db.update_finding_status(project_id, run_id, finding_id, payload.status)
|
|
271
|
+
except FindingNotFoundError as exc:
|
|
272
|
+
raise HTTPException(status_code=404, detail=str(exc)) from None
|
|
273
|
+
except FindingTransitionError as exc:
|
|
274
|
+
raise HTTPException(status_code=409, detail=str(exc)) from None
|
|
275
|
+
except ValueError as exc:
|
|
276
|
+
raise HTTPException(status_code=400, detail=str(exc)) from None
|
|
277
|
+
|
|
278
|
+
|
|
200
279
|
@app.get("/optimization-queue")
|
|
201
280
|
def optimization_queue(
|
|
202
281
|
project_id_filter: str | None = Query(default=None, alias="project_id"),
|