agentloop-profiler 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (50) hide show
  1. agentloop/__init__.py +68 -0
  2. agentloop/audit.py +34 -0
  3. agentloop/autoinstrument.py +102 -0
  4. agentloop/ci.py +135 -0
  5. agentloop/cli.py +876 -0
  6. agentloop/client.py +170 -0
  7. agentloop/config.py +50 -0
  8. agentloop/costs.py +15 -0
  9. agentloop/decorators.py +160 -0
  10. agentloop/demo.py +283 -0
  11. agentloop/doctor.py +344 -0
  12. agentloop/events.py +49 -0
  13. agentloop/exporters.py +60 -0
  14. agentloop/findings.py +215 -0
  15. agentloop/graph.py +312 -0
  16. agentloop/integrations/__init__.py +1 -0
  17. agentloop/integrations/crewai.py +144 -0
  18. agentloop/integrations/langgraph.py +140 -0
  19. agentloop/integrations/openai.py +369 -0
  20. agentloop/integrations/openai_agents.py +212 -0
  21. agentloop/integrations/sdk.py +82 -0
  22. agentloop/integrations/vercel_ai.py +83 -0
  23. agentloop/issues.py +88 -0
  24. agentloop/markdown.py +102 -0
  25. agentloop/metrics.py +123 -0
  26. agentloop/optimizer.py +323 -0
  27. agentloop/otel.py +302 -0
  28. agentloop/otel_ids.py +81 -0
  29. agentloop/patches.py +583 -0
  30. agentloop/plan_export.py +72 -0
  31. agentloop/quality.py +222 -0
  32. agentloop/replay.py +424 -0
  33. agentloop/runtime.py +244 -0
  34. agentloop/savings.py +135 -0
  35. agentloop/server.py +292 -0
  36. agentloop/store.py +840 -0
  37. agentloop/timing.py +124 -0
  38. agentloop/tracer.py +356 -0
  39. agentloop/value.py +243 -0
  40. agentloop/version.py +3 -0
  41. agentloop_profiler-0.4.0.dist-info/METADATA +598 -0
  42. agentloop_profiler-0.4.0.dist-info/RECORD +50 -0
  43. agentloop_profiler-0.4.0.dist-info/WHEEL +5 -0
  44. agentloop_profiler-0.4.0.dist-info/entry_points.txt +2 -0
  45. agentloop_profiler-0.4.0.dist-info/licenses/LICENSE +201 -0
  46. agentloop_profiler-0.4.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +54 -0
  47. agentloop_profiler-0.4.0.dist-info/top_level.txt +2 -0
  48. dashboard/__init__.py +1 -0
  49. dashboard/app.py +698 -0
  50. dashboard/value_view.py +101 -0
agentloop/savings.py ADDED
@@ -0,0 +1,135 @@
1
+ """Compatible-selection model for combining per-recommendation savings.
2
+
3
+ Optimization cards (and the findings derived from them) that touch the same
4
+ spans compete for the same work: applying one changes or removes the spans the
5
+ other targeted, so their estimates are alternatives, not additive. A realizable
6
+ plan therefore applies a pairwise span-disjoint subset of the recommendations.
7
+
8
+ ``select_compatible`` picks that subset: latency savings is the primary
9
+ objective and cost savings breaks ties, so both reported totals always come
10
+ from the same subset — the (latency, cost) pair is achievable by one concrete
11
+ plan rather than a mix of incompatible alternatives. Items without affected
12
+ spans do not compete with anything and always count.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Iterator, Sequence
19
+
20
+ # Beyond this many mutually overlapping items the exact search (exponential in
21
+ # the size of the overlap-connected component) falls back to a greedy pick.
22
+ _EXACT_SEARCH_LIMIT = 16
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SavingsItem:
27
+ spans: frozenset[str]
28
+ latency_ms: float = 0.0
29
+ cost_usd: float = 0.0
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Selection:
34
+ indices: tuple[int, ...]
35
+ latency_ms: float
36
+ cost_usd: float
37
+
38
+
39
+ def select_compatible(items: Sequence[SavingsItem]) -> Selection:
40
+ """Return the compatible (span-disjoint) subset with the best savings.
41
+
42
+ Maximizes total latency savings, breaking ties by total cost savings.
43
+ Span-free items are always selected.
44
+ """
45
+ chosen = [index for index, item in enumerate(items) if not item.spans]
46
+ spanned = [index for index, item in enumerate(items) if item.spans]
47
+ neighbor_sets: dict[int, set[int]] = {index: set() for index in spanned}
48
+ for position, left in enumerate(spanned):
49
+ for right in spanned[position + 1 :]:
50
+ if items[left].spans & items[right].spans:
51
+ neighbor_sets[left].add(right)
52
+ neighbor_sets[right].add(left)
53
+ adjacency = {index: frozenset(neighbors) for index, neighbors in neighbor_sets.items()}
54
+ for component in _components(spanned, adjacency):
55
+ if len(component) > _EXACT_SEARCH_LIMIT:
56
+ chosen.extend(_greedy_pick(items, component))
57
+ else:
58
+ chosen.extend(_exact_pick(items, component, adjacency))
59
+ chosen.sort()
60
+ return Selection(
61
+ indices=tuple(chosen),
62
+ latency_ms=sum(items[index].latency_ms for index in chosen),
63
+ cost_usd=sum(items[index].cost_usd for index in chosen),
64
+ )
65
+
66
+
67
+ def _components(nodes: list[int], adjacency: dict[int, frozenset[int]]) -> Iterator[list[int]]:
68
+ seen: set[int] = set()
69
+ for start in nodes:
70
+ if start in seen:
71
+ continue
72
+ seen.add(start)
73
+ stack = [start]
74
+ component: list[int] = []
75
+ while stack:
76
+ node = stack.pop()
77
+ component.append(node)
78
+ for neighbor in adjacency[node]:
79
+ if neighbor not in seen:
80
+ seen.add(neighbor)
81
+ stack.append(neighbor)
82
+ yield component
83
+
84
+
85
+ def _savings_key(items: Sequence[SavingsItem], index: int) -> tuple[float, float]:
86
+ return (items[index].latency_ms, items[index].cost_usd)
87
+
88
+
89
+ def _exact_pick(
90
+ items: Sequence[SavingsItem], component: list[int], adjacency: dict[int, frozenset[int]]
91
+ ) -> list[int]:
92
+ order = sorted(component, key=lambda index: _savings_key(items, index), reverse=True)
93
+ suffix_latency = [0.0] * (len(order) + 1)
94
+ for position in range(len(order) - 1, -1, -1):
95
+ suffix_latency[position] = suffix_latency[position + 1] + items[order[position]].latency_ms
96
+ best_key = (float("-inf"), float("-inf"))
97
+ best: list[int] = []
98
+
99
+ def search(
100
+ position: int, blocked: frozenset[int], latency: float, cost: float, picked: list[int]
101
+ ) -> None:
102
+ nonlocal best_key, best
103
+ if latency + suffix_latency[position] < best_key[0]:
104
+ return
105
+ if position == len(order):
106
+ if (latency, cost) > best_key:
107
+ best_key = (latency, cost)
108
+ best = list(picked)
109
+ return
110
+ index = order[position]
111
+ if index not in blocked:
112
+ picked.append(index)
113
+ search(
114
+ position + 1,
115
+ blocked | adjacency[index],
116
+ latency + items[index].latency_ms,
117
+ cost + items[index].cost_usd,
118
+ picked,
119
+ )
120
+ picked.pop()
121
+ search(position + 1, blocked, latency, cost, picked)
122
+
123
+ search(0, frozenset(), 0.0, 0.0, [])
124
+ return best
125
+
126
+
127
+ def _greedy_pick(items: Sequence[SavingsItem], component: list[int]) -> list[int]:
128
+ picked: list[int] = []
129
+ used_spans: set[str] = set()
130
+ for index in sorted(component, key=lambda index: _savings_key(items, index), reverse=True):
131
+ if items[index].spans & used_spans:
132
+ continue
133
+ picked.append(index)
134
+ used_spans |= items[index].spans
135
+ return picked
agentloop/server.py ADDED
@@ -0,0 +1,292 @@
1
+ from __future__ import annotations
2
+
3
+ import hmac
4
+ from typing import Any
5
+
6
+ from fastapi import Depends, FastAPI, Header, HTTPException, Query
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from pydantic import BaseModel, Field
9
+
10
+ from agentloop.config import get_admin_api_key, get_api_key, get_cors_origins, require_api_key
11
+ from agentloop.findings import build_diagnosis
12
+ from agentloop.issues import build_issue_drafts
13
+ from agentloop.optimizer import build_optimization_plan
14
+ from agentloop.quality import build_quality_report
15
+ from agentloop.store import TraceProjectConflictError, TraceStore, get_store
16
+ from agentloop.tracer import AgentTrace
17
+ from agentloop.value import build_value_report
18
+ from agentloop.version import __version__
19
+
20
+ app = FastAPI(title="AgentLoop API", version=__version__)
21
+
22
+ _cors_origins = get_cors_origins()
23
+ if _cors_origins:
24
+ app.add_middleware(
25
+ CORSMiddleware,
26
+ allow_origins=_cors_origins,
27
+ allow_credentials=True,
28
+ allow_methods=["*"],
29
+ allow_headers=["*"],
30
+ )
31
+
32
+
33
+ class TracePayload(BaseModel):
34
+ name: str
35
+ run_id: str
36
+ started_at: str | None = None
37
+ ended_at: str | None = None
38
+ elapsed_ms: float | None = Field(default=None, ge=0)
39
+ metadata: dict[str, Any] = Field(default_factory=dict)
40
+ events: list[dict[str, Any]] = Field(default_factory=list)
41
+
42
+
43
+ class CreateApiKeyPayload(BaseModel):
44
+ project_id: str = "default"
45
+ name: str = "default"
46
+
47
+
48
+ class QualityReportPayload(BaseModel):
49
+ fixtures: list[dict[str, Any]] = Field(default_factory=list)
50
+ baseline_run_id: str | None = None
51
+ candidate_run_id: str | None = None
52
+ min_score: float | None = Field(default=None, ge=0)
53
+
54
+
55
+ def store() -> TraceStore:
56
+ db = get_store()
57
+ db.init()
58
+ return db
59
+
60
+
61
+ def resolve_project(
62
+ x_agentloop_key: str | None = Header(default=None),
63
+ db: TraceStore = Depends(store),
64
+ ) -> str:
65
+ if x_agentloop_key:
66
+ key_record = db.verify_api_key(x_agentloop_key)
67
+ if key_record is not None:
68
+ return str(key_record["project_id"])
69
+
70
+ expected = get_api_key()
71
+ if require_api_key() and expected and hmac.compare_digest(x_agentloop_key, expected):
72
+ return "default"
73
+
74
+ raise HTTPException(status_code=401, detail="invalid AgentLoop API key")
75
+
76
+ if require_api_key():
77
+ raise HTTPException(status_code=401, detail="missing AgentLoop API key")
78
+
79
+ return "default"
80
+
81
+
82
+ def resolve_admin(x_agentloop_admin_key: str | None = Header(default=None)) -> None:
83
+ admin_key = get_admin_api_key()
84
+ if admin_key:
85
+ if x_agentloop_admin_key and hmac.compare_digest(x_agentloop_admin_key, admin_key):
86
+ return
87
+ raise HTTPException(status_code=401, detail="invalid AgentLoop admin API key")
88
+
89
+ if require_api_key():
90
+ raise HTTPException(
91
+ status_code=503,
92
+ detail="AGENTLOOP_ADMIN_API_KEY is required to create API keys when API auth is enabled",
93
+ )
94
+
95
+
96
+ @app.get("/health")
97
+ def health() -> dict[str, str | bool]:
98
+ return {"status": "ok", "version": __version__, "auth_required": require_api_key()}
99
+
100
+
101
+ @app.get("/readyz")
102
+ def readyz(db: TraceStore = Depends(store)) -> dict[str, str]:
103
+ db.usage_summary(project_id="default")
104
+ return {"status": "ready"}
105
+
106
+
107
+ @app.post("/api-keys")
108
+ def create_api_key(
109
+ payload: CreateApiKeyPayload,
110
+ _: None = Depends(resolve_admin),
111
+ db: TraceStore = Depends(store),
112
+ ) -> dict[str, Any]:
113
+ return db.create_api_key(project_id=payload.project_id, name=payload.name)
114
+
115
+
116
+ @app.post("/traces")
117
+ def ingest_trace(
118
+ payload: TracePayload,
119
+ project_id: str = Depends(resolve_project),
120
+ db: TraceStore = Depends(store),
121
+ ) -> dict[str, Any]:
122
+ trace = AgentTrace.from_dict(payload.model_dump())
123
+ try:
124
+ db.save_trace(trace, project_id=project_id)
125
+ except TraceProjectConflictError as exc:
126
+ raise HTTPException(status_code=409, detail=str(exc)) from None
127
+ return {"ok": True, "run_id": trace.run_id, "project_id": project_id, "report": trace.report()}
128
+
129
+
130
+ def _selected_project(project_id_filter: str | None, authenticated_project: str) -> str:
131
+ if (
132
+ require_api_key()
133
+ and project_id_filter is not None
134
+ and project_id_filter != authenticated_project
135
+ ):
136
+ raise HTTPException(status_code=403, detail="project access denied")
137
+ return project_id_filter or authenticated_project
138
+
139
+
140
+ @app.get("/traces")
141
+ def list_traces(
142
+ project_id_filter: str | None = Query(default=None, alias="project_id"),
143
+ project_id: str = Depends(resolve_project),
144
+ db: TraceStore = Depends(store),
145
+ ) -> dict[str, Any]:
146
+ selected_project = _selected_project(project_id_filter, project_id)
147
+ return {"project_id": selected_project, "traces": db.list_traces(project_id=selected_project)}
148
+
149
+
150
+ def _load_trace_or_404(db: TraceStore, run_id: str, project_id: str) -> AgentTrace:
151
+ trace = db.get_trace(run_id=run_id, project_id=project_id)
152
+ if trace is None:
153
+ raise HTTPException(status_code=404, detail="trace not found")
154
+ return trace
155
+
156
+
157
+ @app.get("/traces/{run_id}/report")
158
+ def get_report(
159
+ run_id: str,
160
+ project_id: str = Depends(resolve_project),
161
+ db: TraceStore = Depends(store),
162
+ ) -> dict[str, Any]:
163
+ return _load_trace_or_404(db, run_id, project_id).report()
164
+
165
+
166
+ @app.get("/traces/{run_id}/optimize")
167
+ def optimize_trace(
168
+ run_id: str,
169
+ project_id: str = Depends(resolve_project),
170
+ db: TraceStore = Depends(store),
171
+ ) -> dict[str, Any]:
172
+ return build_optimization_plan(_load_trace_or_404(db, run_id, project_id))
173
+
174
+
175
+ @app.get("/traces/{run_id}/diagnose")
176
+ def diagnose_trace(
177
+ run_id: str,
178
+ project_id: str = Depends(resolve_project),
179
+ db: TraceStore = Depends(store),
180
+ ) -> dict[str, Any]:
181
+ diagnosis = build_diagnosis(_load_trace_or_404(db, run_id, project_id))
182
+ db.save_diagnosis(diagnosis, project_id=project_id)
183
+ return diagnosis
184
+
185
+
186
+ @app.get("/findings")
187
+ def list_findings(
188
+ status: str | None = Query(default=None),
189
+ project_id_filter: str | None = Query(default=None, alias="project_id"),
190
+ project_id: str = Depends(resolve_project),
191
+ db: TraceStore = Depends(store),
192
+ ) -> dict[str, Any]:
193
+ selected_project = _selected_project(project_id_filter, project_id)
194
+ return {
195
+ "project_id": selected_project,
196
+ "findings": db.list_findings(project_id=selected_project, status=status),
197
+ }
198
+
199
+
200
+ @app.get("/optimization-queue")
201
+ def optimization_queue(
202
+ project_id_filter: str | None = Query(default=None, alias="project_id"),
203
+ project_id: str = Depends(resolve_project),
204
+ db: TraceStore = Depends(store),
205
+ ) -> dict[str, Any]:
206
+ selected_project = _selected_project(project_id_filter, project_id)
207
+ return {
208
+ "project_id": selected_project,
209
+ "queue": db.optimization_queue(project_id=selected_project),
210
+ }
211
+
212
+
213
+ @app.get("/optimization-queue/github-issues")
214
+ def github_issue_drafts(
215
+ limit: int = Query(default=5, ge=1, le=20),
216
+ project_id_filter: str | None = Query(default=None, alias="project_id"),
217
+ project_id: str = Depends(resolve_project),
218
+ db: TraceStore = Depends(store),
219
+ ) -> dict[str, Any]:
220
+ selected_project = _selected_project(project_id_filter, project_id)
221
+ queue = db.optimization_queue(project_id=selected_project)
222
+ return {
223
+ "project_id": selected_project,
224
+ "issue_drafts": build_issue_drafts(queue, limit=limit),
225
+ }
226
+
227
+
228
+ @app.post("/quality-report")
229
+ def quality_report_endpoint(
230
+ payload: QualityReportPayload,
231
+ project_id: str = Depends(resolve_project),
232
+ db: TraceStore = Depends(store),
233
+ ) -> dict[str, Any]:
234
+ scorer_types = {
235
+ str(fixture["scorer"].get("type", "")).lower()
236
+ for fixture in payload.fixtures
237
+ if isinstance(fixture.get("scorer"), dict)
238
+ }
239
+ if "custom" in scorer_types:
240
+ raise HTTPException(
241
+ status_code=400,
242
+ detail="custom Python scorers are not accepted by the HTTP API",
243
+ )
244
+ if "regex" in scorer_types:
245
+ raise HTTPException(
246
+ status_code=400,
247
+ detail="raw regex scorers are not accepted; use glob, contains, or exact_match",
248
+ )
249
+ baseline_trace = (
250
+ _load_trace_or_404(db, payload.baseline_run_id, project_id)
251
+ if payload.baseline_run_id
252
+ else None
253
+ )
254
+ candidate_trace = (
255
+ _load_trace_or_404(db, payload.candidate_run_id, project_id)
256
+ if payload.candidate_run_id
257
+ else None
258
+ )
259
+ return build_quality_report(
260
+ payload.fixtures,
261
+ baseline_trace=baseline_trace,
262
+ candidate_trace=candidate_trace,
263
+ min_score=payload.min_score,
264
+ )
265
+
266
+
267
+ @app.get("/traces/{run_id}/value")
268
+ def value_report(
269
+ run_id: str,
270
+ runs_per_month: int = Query(default=1000, ge=0),
271
+ engineer_hourly_rate_usd: float = Query(default=150.0, ge=0),
272
+ incident_cost_usd: float = Query(default=500.0, ge=0),
273
+ project_id: str = Depends(resolve_project),
274
+ db: TraceStore = Depends(store),
275
+ ) -> dict[str, Any]:
276
+ trace = _load_trace_or_404(db, run_id, project_id)
277
+ return build_value_report(
278
+ trace,
279
+ runs_per_month=runs_per_month,
280
+ engineer_hourly_rate_usd=engineer_hourly_rate_usd,
281
+ incident_cost_usd=incident_cost_usd,
282
+ )
283
+
284
+
285
+ @app.get("/usage")
286
+ def usage_summary(
287
+ project_id_filter: str | None = Query(default=None, alias="project_id"),
288
+ project_id: str = Depends(resolve_project),
289
+ db: TraceStore = Depends(store),
290
+ ) -> dict[str, Any]:
291
+ selected_project = _selected_project(project_id_filter, project_id)
292
+ return db.usage_summary(project_id=selected_project)