agentloop-profiler 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of agentloop-profiler might be problematic. Click here for more details.
- agentloop/__init__.py +68 -0
- agentloop/audit.py +34 -0
- agentloop/autoinstrument.py +102 -0
- agentloop/ci.py +135 -0
- agentloop/cli.py +876 -0
- agentloop/client.py +170 -0
- agentloop/config.py +50 -0
- agentloop/costs.py +15 -0
- agentloop/decorators.py +160 -0
- agentloop/demo.py +283 -0
- agentloop/doctor.py +344 -0
- agentloop/events.py +49 -0
- agentloop/exporters.py +60 -0
- agentloop/findings.py +215 -0
- agentloop/graph.py +312 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/langgraph.py +140 -0
- agentloop/integrations/openai.py +369 -0
- agentloop/integrations/openai_agents.py +212 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/issues.py +88 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +123 -0
- agentloop/optimizer.py +323 -0
- agentloop/otel.py +302 -0
- agentloop/otel_ids.py +81 -0
- agentloop/patches.py +583 -0
- agentloop/plan_export.py +72 -0
- agentloop/quality.py +222 -0
- agentloop/replay.py +424 -0
- agentloop/runtime.py +244 -0
- agentloop/savings.py +135 -0
- agentloop/server.py +292 -0
- agentloop/store.py +840 -0
- agentloop/timing.py +124 -0
- agentloop/tracer.py +356 -0
- agentloop/value.py +243 -0
- agentloop/version.py +3 -0
- agentloop_profiler-0.4.0.dist-info/METADATA +598 -0
- agentloop_profiler-0.4.0.dist-info/RECORD +50 -0
- agentloop_profiler-0.4.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.4.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.4.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.4.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +54 -0
- agentloop_profiler-0.4.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +1 -0
- dashboard/app.py +698 -0
- dashboard/value_view.py +101 -0
agentloop/doctor.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import socket
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.error import URLError
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
from urllib.request import Request, urlopen
|
|
14
|
+
|
|
15
|
+
from agentloop.config import postgres_connection_source
|
|
16
|
+
from agentloop.runtime import get_runtime_config
|
|
17
|
+
from agentloop.store import get_store
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class DoctorCheck:
|
|
22
|
+
name: str
|
|
23
|
+
status: str
|
|
24
|
+
detail: str
|
|
25
|
+
fix: str | None = None
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
payload = {"name": self.name, "status": self.status, "detail": self.detail}
|
|
29
|
+
if self.fix:
|
|
30
|
+
payload["fix"] = self.fix
|
|
31
|
+
return payload
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ok(name: str, detail: str) -> DoctorCheck:
|
|
35
|
+
return DoctorCheck(name=name, status="ok", detail=detail)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _warn(name: str, detail: str, fix: str | None = None) -> DoctorCheck:
|
|
39
|
+
return DoctorCheck(name=name, status="warn", detail=detail, fix=fix)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fail(name: str, detail: str, fix: str | None = None) -> DoctorCheck:
|
|
43
|
+
return DoctorCheck(name=name, status="fail", detail=detail, fix=fix)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _can_connect(host: str, port: int, timeout: float = 1.0) -> bool:
|
|
47
|
+
try:
|
|
48
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
49
|
+
return True
|
|
50
|
+
except OSError:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _health_check(api_url: str, timeout: float = 2.0) -> DoctorCheck:
|
|
55
|
+
parsed = urlsplit(api_url)
|
|
56
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
57
|
+
return _warn(
|
|
58
|
+
"api_health",
|
|
59
|
+
"AGENTLOOP_API_URL must use http:// or https://",
|
|
60
|
+
"Set AGENTLOOP_API_URL to the AgentLoop HTTP API origin.",
|
|
61
|
+
)
|
|
62
|
+
url = api_url.rstrip("/") + "/health"
|
|
63
|
+
try:
|
|
64
|
+
request = Request(url, headers={"User-Agent": "agentloop-doctor"})
|
|
65
|
+
with urlopen( # nosec B310 - API URL scheme is validated above.
|
|
66
|
+
request, timeout=timeout
|
|
67
|
+
) as response:
|
|
68
|
+
if response.status == 200:
|
|
69
|
+
return _ok("api_health", f"{url} returned HTTP 200")
|
|
70
|
+
return _warn("api_health", f"{url} returned HTTP {response.status}")
|
|
71
|
+
except URLError as exc:
|
|
72
|
+
return _warn(
|
|
73
|
+
"api_health",
|
|
74
|
+
f"Could not reach {url}: {exc.reason}",
|
|
75
|
+
"Start the API with: agentloop server --host 127.0.0.1 --port 8000",
|
|
76
|
+
)
|
|
77
|
+
except Exception as exc: # pragma: no cover - defensive for platform SSL/socket edge cases
|
|
78
|
+
return _warn("api_health", f"Could not reach {url}: {exc}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _ready_check(api_url: str, timeout: float = 2.0) -> DoctorCheck:
|
|
82
|
+
parsed = urlsplit(api_url)
|
|
83
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
84
|
+
return _fail(
|
|
85
|
+
"api_ready",
|
|
86
|
+
"AGENTLOOP_API_URL must use http:// or https://",
|
|
87
|
+
"Set AGENTLOOP_API_URL to the deployed AgentLoop HTTP API origin.",
|
|
88
|
+
)
|
|
89
|
+
url = api_url.rstrip("/") + "/readyz"
|
|
90
|
+
try:
|
|
91
|
+
request = Request(url, headers={"User-Agent": "agentloop-production-check"})
|
|
92
|
+
with urlopen( # nosec B310 - API URL scheme is validated above.
|
|
93
|
+
request, timeout=timeout
|
|
94
|
+
) as response:
|
|
95
|
+
if response.status == 200:
|
|
96
|
+
return _ok("api_ready", f"{url} returned HTTP 200")
|
|
97
|
+
return _fail("api_ready", f"{url} returned HTTP {response.status}")
|
|
98
|
+
except URLError as exc:
|
|
99
|
+
return _fail(
|
|
100
|
+
"api_ready",
|
|
101
|
+
f"Could not reach {url}: {exc.reason}",
|
|
102
|
+
"Deploy the API and confirm /readyz is reachable.",
|
|
103
|
+
)
|
|
104
|
+
except Exception as exc: # pragma: no cover - defensive for platform SSL/socket edge cases
|
|
105
|
+
return _fail("api_ready", f"Could not reach {url}: {exc}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _env_enabled(name: str) -> bool:
|
|
109
|
+
return os.getenv(name, "").lower() in {"1", "true", "yes", "on"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run_doctor(*, check_api: bool = True, runs_dir: Path | str = "runs") -> dict[str, Any]:
|
|
113
|
+
"""Run local setup checks for SDK, CLI, store, and hosted API wiring."""
|
|
114
|
+
|
|
115
|
+
checks: list[DoctorCheck] = []
|
|
116
|
+
|
|
117
|
+
py_version = sys.version_info
|
|
118
|
+
if py_version >= (3, 10):
|
|
119
|
+
checks.append(_ok("python", f"Python {platform.python_version()}"))
|
|
120
|
+
else:
|
|
121
|
+
checks.append(
|
|
122
|
+
_fail(
|
|
123
|
+
"python",
|
|
124
|
+
f"Python {platform.python_version()} is too old",
|
|
125
|
+
"Use Python 3.10 or newer.",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
for module_name in ["typer", "rich", "pydantic"]:
|
|
130
|
+
try:
|
|
131
|
+
importlib.import_module(module_name)
|
|
132
|
+
checks.append(_ok(f"dependency:{module_name}", "importable"))
|
|
133
|
+
except ImportError:
|
|
134
|
+
checks.append(
|
|
135
|
+
_fail(
|
|
136
|
+
f"dependency:{module_name}",
|
|
137
|
+
"not installed",
|
|
138
|
+
"Run: uv sync --locked --all-extras --dev",
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
out_dir = Path(runs_dir)
|
|
143
|
+
try:
|
|
144
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
probe = out_dir / ".agentloop_write_probe"
|
|
146
|
+
probe.write_text("ok", encoding="utf-8")
|
|
147
|
+
probe.unlink(missing_ok=True)
|
|
148
|
+
checks.append(_ok("runs_dir", f"{out_dir} is writable"))
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
checks.append(
|
|
151
|
+
_fail(
|
|
152
|
+
"runs_dir",
|
|
153
|
+
f"{out_dir} is not writable: {exc}",
|
|
154
|
+
"Choose a writable path or set AGENTLOOP_EXPORT_DIR.",
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
db = get_store()
|
|
160
|
+
db.init()
|
|
161
|
+
checks.append(_ok("store", f"{db.__class__.__name__} initialized"))
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
checks.append(
|
|
164
|
+
_fail(
|
|
165
|
+
"store",
|
|
166
|
+
f"store initialization failed: {exc}",
|
|
167
|
+
"Check AGENTLOOP_STORE_BACKEND and database credentials.",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
cfg = get_runtime_config()
|
|
172
|
+
if (
|
|
173
|
+
cfg.auto_upload
|
|
174
|
+
and not cfg.api_key
|
|
175
|
+
and os.getenv("AGENTLOOP_REQUIRE_API_KEY", "").lower() in {"1", "true", "yes", "on"}
|
|
176
|
+
):
|
|
177
|
+
checks.append(
|
|
178
|
+
_warn(
|
|
179
|
+
"api_key",
|
|
180
|
+
"auto upload is enabled but AGENTLOOP_API_KEY is empty",
|
|
181
|
+
"Create a key with: agentloop create-api-key --project-id demo",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
elif cfg.api_key:
|
|
185
|
+
checks.append(_ok("api_key", "AGENTLOOP_API_KEY is configured"))
|
|
186
|
+
else:
|
|
187
|
+
checks.append(
|
|
188
|
+
_warn("api_key", "no API key configured; OK for local API when auth is disabled")
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if cfg.auto_upload:
|
|
192
|
+
checks.append(_ok("auto_upload", f"enabled -> {cfg.api_url}"))
|
|
193
|
+
else:
|
|
194
|
+
checks.append(
|
|
195
|
+
_warn(
|
|
196
|
+
"auto_upload",
|
|
197
|
+
"disabled",
|
|
198
|
+
"Set AGENTLOOP_AUTO_UPLOAD=true to upload traces automatically.",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if check_api:
|
|
203
|
+
checks.append(_health_check(cfg.api_url))
|
|
204
|
+
|
|
205
|
+
failed = sum(1 for check in checks if check.status == "fail")
|
|
206
|
+
warned = sum(1 for check in checks if check.status == "warn")
|
|
207
|
+
return {
|
|
208
|
+
"ok": failed == 0,
|
|
209
|
+
"failed": failed,
|
|
210
|
+
"warnings": warned,
|
|
211
|
+
"checks": [check.to_dict() for check in checks],
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def run_production_check(
|
|
216
|
+
*,
|
|
217
|
+
check_api: bool = True,
|
|
218
|
+
check_store: bool = True,
|
|
219
|
+
allow_http: bool = False,
|
|
220
|
+
) -> dict[str, Any]:
|
|
221
|
+
"""Run strict checks for an internet-facing AgentLoop API/dashboard deployment."""
|
|
222
|
+
|
|
223
|
+
checks: list[DoctorCheck] = []
|
|
224
|
+
|
|
225
|
+
py_version = sys.version_info
|
|
226
|
+
if py_version >= (3, 10):
|
|
227
|
+
checks.append(_ok("python", f"Python {platform.python_version()}"))
|
|
228
|
+
else:
|
|
229
|
+
checks.append(
|
|
230
|
+
_fail(
|
|
231
|
+
"python",
|
|
232
|
+
f"Python {platform.python_version()} is too old",
|
|
233
|
+
"Use Python 3.10 or newer.",
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
backend = os.getenv("AGENTLOOP_STORE_BACKEND", "sqlite").lower()
|
|
238
|
+
if backend == "postgres":
|
|
239
|
+
checks.append(_ok("store_backend", "postgres"))
|
|
240
|
+
else:
|
|
241
|
+
checks.append(
|
|
242
|
+
_fail(
|
|
243
|
+
"store_backend",
|
|
244
|
+
f"{backend} is not production-ready",
|
|
245
|
+
"Set AGENTLOOP_STORE_BACKEND=postgres.",
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
database_source = postgres_connection_source()
|
|
250
|
+
if database_source:
|
|
251
|
+
checks.append(_ok("database_url", f"configured via {database_source}"))
|
|
252
|
+
else:
|
|
253
|
+
checks.append(
|
|
254
|
+
_fail(
|
|
255
|
+
"database_url",
|
|
256
|
+
"missing",
|
|
257
|
+
"Set AGENTLOOP_DATABASE_URL/DATABASE_URL or libpq PGHOST/PGSERVICE variables.",
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if check_store:
|
|
262
|
+
try:
|
|
263
|
+
db = get_store()
|
|
264
|
+
db.init()
|
|
265
|
+
checks.append(_ok("store", f"{db.__class__.__name__} initialized"))
|
|
266
|
+
except Exception as exc:
|
|
267
|
+
checks.append(
|
|
268
|
+
_fail(
|
|
269
|
+
"store",
|
|
270
|
+
f"store initialization failed: {exc}",
|
|
271
|
+
"Check database credentials and network access.",
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if _env_enabled("AGENTLOOP_REQUIRE_API_KEY"):
|
|
276
|
+
checks.append(_ok("api_auth", "AGENTLOOP_REQUIRE_API_KEY=true"))
|
|
277
|
+
else:
|
|
278
|
+
checks.append(
|
|
279
|
+
_fail("api_auth", "API key auth is disabled", "Set AGENTLOOP_REQUIRE_API_KEY=true.")
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
admin_key = os.getenv("AGENTLOOP_ADMIN_API_KEY", "")
|
|
283
|
+
weak_admin_values = {
|
|
284
|
+
"",
|
|
285
|
+
"change-me-admin-key",
|
|
286
|
+
"replace-with-a-long-random-admin-secret",
|
|
287
|
+
"dev-secret",
|
|
288
|
+
"admin-secret",
|
|
289
|
+
}
|
|
290
|
+
if admin_key in weak_admin_values or len(admin_key) < 32:
|
|
291
|
+
checks.append(
|
|
292
|
+
_fail(
|
|
293
|
+
"admin_api_key",
|
|
294
|
+
"missing or too weak",
|
|
295
|
+
"Set AGENTLOOP_ADMIN_API_KEY to a generated secret with at least 32 characters.",
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
else:
|
|
299
|
+
checks.append(_ok("admin_api_key", "configured"))
|
|
300
|
+
|
|
301
|
+
cors_origins = [
|
|
302
|
+
origin.strip()
|
|
303
|
+
for origin in os.getenv("AGENTLOOP_CORS_ORIGINS", "").split(",")
|
|
304
|
+
if origin.strip()
|
|
305
|
+
]
|
|
306
|
+
if not cors_origins:
|
|
307
|
+
checks.append(
|
|
308
|
+
_fail("cors_origins", "missing", "Set AGENTLOOP_CORS_ORIGINS to the dashboard origin.")
|
|
309
|
+
)
|
|
310
|
+
elif "*" in cors_origins:
|
|
311
|
+
checks.append(
|
|
312
|
+
_fail(
|
|
313
|
+
"cors_origins",
|
|
314
|
+
"wildcard origin is not allowed for production",
|
|
315
|
+
"Use exact HTTPS origins.",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
checks.append(_ok("cors_origins", ", ".join(cors_origins)))
|
|
320
|
+
|
|
321
|
+
api_url = os.getenv("AGENTLOOP_API_URL", "")
|
|
322
|
+
if not api_url:
|
|
323
|
+
checks.append(_fail("api_url", "missing", "Set AGENTLOOP_API_URL to the public API URL."))
|
|
324
|
+
elif api_url.startswith("https://"):
|
|
325
|
+
checks.append(_ok("api_url", api_url))
|
|
326
|
+
elif allow_http:
|
|
327
|
+
checks.append(
|
|
328
|
+
_warn("api_url", f"{api_url} is not HTTPS", "Use HTTPS for production traffic.")
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
checks.append(_fail("api_url", f"{api_url} is not HTTPS", "Use an HTTPS public API URL."))
|
|
332
|
+
|
|
333
|
+
if check_api and api_url:
|
|
334
|
+
checks.append(_health_check(api_url))
|
|
335
|
+
checks.append(_ready_check(api_url))
|
|
336
|
+
|
|
337
|
+
failed = sum(1 for check in checks if check.status == "fail")
|
|
338
|
+
warned = sum(1 for check in checks if check.status == "warn")
|
|
339
|
+
return {
|
|
340
|
+
"ok": failed == 0,
|
|
341
|
+
"failed": failed,
|
|
342
|
+
"warnings": warned,
|
|
343
|
+
"checks": [check.to_dict() for check in checks],
|
|
344
|
+
}
|
agentloop/events.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def utc_now_iso() -> str:
|
|
10
|
+
return datetime.now(timezone.utc).isoformat()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def new_event_id() -> str:
|
|
14
|
+
return "evt_" + uuid4().hex[:16]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def new_run_id() -> str:
|
|
18
|
+
return "run_" + uuid4().hex[:16]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AgentEvent:
|
|
23
|
+
event_id: str
|
|
24
|
+
run_id: str
|
|
25
|
+
event_type: str
|
|
26
|
+
name: str
|
|
27
|
+
started_at: str
|
|
28
|
+
ended_at: str
|
|
29
|
+
duration_ms: float
|
|
30
|
+
parent_id: str | None = None
|
|
31
|
+
model: str | None = None
|
|
32
|
+
input_tokens: int = 0
|
|
33
|
+
output_tokens: int = 0
|
|
34
|
+
input_text: str | None = None
|
|
35
|
+
output_text: str | None = None
|
|
36
|
+
status: str = "ok"
|
|
37
|
+
error: str | None = None
|
|
38
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def total_tokens(self) -> int:
|
|
42
|
+
return self.input_tokens + self.output_tokens
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
return self.__dict__.copy()
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dict(cls, data: dict[str, Any]) -> "AgentEvent":
|
|
49
|
+
return cls(**data)
|
agentloop/exporters.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agentloop.markdown import (
|
|
8
|
+
markdown_code_span,
|
|
9
|
+
markdown_heading,
|
|
10
|
+
markdown_table_cell,
|
|
11
|
+
markdown_text,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def export_report_json(report: dict[str, Any], path: str | Path) -> Path:
|
|
16
|
+
out = Path(path)
|
|
17
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
out.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
|
19
|
+
return out
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def export_report_markdown(report: dict[str, Any], path: str | Path) -> Path:
|
|
23
|
+
out = Path(path)
|
|
24
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
lines = [
|
|
26
|
+
f"# AgentLoop Report: {markdown_heading(report['name'])}",
|
|
27
|
+
"",
|
|
28
|
+
f"- Run ID: {markdown_code_span(report['run_id'])}",
|
|
29
|
+
f"- Runtime: {report['total_runtime_ms'] / 1000:.2f}s",
|
|
30
|
+
f"- Estimated cost: ${report['estimated_cost_usd']:.4f}",
|
|
31
|
+
f"- Model calls: {report['model_call_count']}",
|
|
32
|
+
f"- Tool calls: {report['tool_call_count']}",
|
|
33
|
+
f"- Retries: {report['retry_count']}",
|
|
34
|
+
f"- Input tokens: {report['input_tokens']}",
|
|
35
|
+
f"- Output tokens: {report['output_tokens']}",
|
|
36
|
+
f"- Repeated context: {report['repeated_context_ratio']:.1%}",
|
|
37
|
+
"",
|
|
38
|
+
"## Recommendations",
|
|
39
|
+
]
|
|
40
|
+
for rec in report["recommendations"]:
|
|
41
|
+
lines.append(f"- **{markdown_text(rec['title'])}** — {markdown_text(rec['description'])}")
|
|
42
|
+
lines.extend(
|
|
43
|
+
[
|
|
44
|
+
"",
|
|
45
|
+
"## Events",
|
|
46
|
+
"",
|
|
47
|
+
"| Type | Name | Duration ms | Model | Input | Output | Status |",
|
|
48
|
+
"|---|---|---:|---|---:|---:|---|",
|
|
49
|
+
]
|
|
50
|
+
)
|
|
51
|
+
for event in report["events"]:
|
|
52
|
+
lines.append(
|
|
53
|
+
f"| {markdown_table_cell(event['event_type'])} | "
|
|
54
|
+
f"{markdown_table_cell(event['name'])} | {event['duration_ms']:.2f} | "
|
|
55
|
+
f"{markdown_table_cell(event.get('model') or '')} | "
|
|
56
|
+
f"{event.get('input_tokens', 0)} | {event.get('output_tokens', 0)} | "
|
|
57
|
+
f"{markdown_table_cell(event.get('status', 'ok'))} |"
|
|
58
|
+
)
|
|
59
|
+
out.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
60
|
+
return out
|
agentloop/findings.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from agentloop.markdown import markdown_code_span, markdown_heading, markdown_text
|
|
7
|
+
from agentloop.optimizer import build_optimization_plan
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class OptimizationFinding:
|
|
12
|
+
finding_id: str
|
|
13
|
+
severity: str
|
|
14
|
+
type: str
|
|
15
|
+
title: str
|
|
16
|
+
confidence: str
|
|
17
|
+
affected_spans: list[str]
|
|
18
|
+
evidence: list[dict[str, Any]]
|
|
19
|
+
savings: dict[str, Any]
|
|
20
|
+
rewrite: dict[str, Any]
|
|
21
|
+
validation: dict[str, Any]
|
|
22
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict[str, Any]:
|
|
25
|
+
return {
|
|
26
|
+
"finding_id": self.finding_id,
|
|
27
|
+
"severity": self.severity,
|
|
28
|
+
"type": self.type,
|
|
29
|
+
"title": self.title,
|
|
30
|
+
"confidence": self.confidence,
|
|
31
|
+
"affected_spans": self.affected_spans,
|
|
32
|
+
"evidence": self.evidence,
|
|
33
|
+
"savings": self.savings,
|
|
34
|
+
"rewrite": self.rewrite,
|
|
35
|
+
"validation": self.validation,
|
|
36
|
+
"metadata": self.metadata,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_diagnosis(trace: Any) -> dict[str, Any]:
|
|
41
|
+
plan = build_optimization_plan(trace)
|
|
42
|
+
findings = [
|
|
43
|
+
_finding_from_card(index, card, plan)
|
|
44
|
+
for index, card in enumerate(plan.get("optimization_cards", []), start=1)
|
|
45
|
+
]
|
|
46
|
+
findings = [finding for finding in findings if finding is not None]
|
|
47
|
+
return {
|
|
48
|
+
"run_id": plan["run_id"],
|
|
49
|
+
"name": plan["name"],
|
|
50
|
+
"current": plan["current"],
|
|
51
|
+
"estimated_after": plan["estimated_after"],
|
|
52
|
+
"summary": _summary(plan, findings),
|
|
53
|
+
"findings": [finding.to_dict() for finding in findings],
|
|
54
|
+
"graph": plan["graph"],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def diagnosis_to_markdown(diagnosis: dict[str, Any]) -> str:
|
|
59
|
+
current = diagnosis["current"]
|
|
60
|
+
after = diagnosis["estimated_after"]
|
|
61
|
+
lines = [
|
|
62
|
+
f"# AgentLoop Diagnosis: {markdown_heading(diagnosis['name'])}",
|
|
63
|
+
"",
|
|
64
|
+
f"- Run ID: {markdown_code_span(diagnosis['run_id'])}",
|
|
65
|
+
f"- Current runtime: {current['runtime_ms'] / 1000:.2f}s",
|
|
66
|
+
f"- Estimated runtime after fixes: {after['runtime_ms'] / 1000:.2f}s",
|
|
67
|
+
f"- Estimated latency reduction: {after['latency_reduction_pct']:.2f}%",
|
|
68
|
+
f"- Current cost: ${current['estimated_cost_usd']:.4f}",
|
|
69
|
+
f"- Estimated cost after fixes: ${after['estimated_cost_usd']:.4f}",
|
|
70
|
+
f"- Estimated cost reduction: {after['cost_reduction_pct']:.2f}%",
|
|
71
|
+
"",
|
|
72
|
+
"## Findings",
|
|
73
|
+
"",
|
|
74
|
+
]
|
|
75
|
+
findings = diagnosis.get("findings", [])
|
|
76
|
+
if not findings:
|
|
77
|
+
lines.append("No machine-actionable optimization findings detected.")
|
|
78
|
+
for finding in findings:
|
|
79
|
+
savings = finding["savings"]
|
|
80
|
+
rewrite = finding["rewrite"]
|
|
81
|
+
validation = finding["validation"]
|
|
82
|
+
lines.extend(
|
|
83
|
+
[
|
|
84
|
+
f"### {markdown_heading(finding['finding_id'])}: "
|
|
85
|
+
f"{markdown_heading(finding['title'])}",
|
|
86
|
+
"",
|
|
87
|
+
f"- Severity: {markdown_text(finding['severity'])}",
|
|
88
|
+
f"- Type: {markdown_code_span(finding['type'])}",
|
|
89
|
+
f"- Confidence: {markdown_text(finding['confidence'])}",
|
|
90
|
+
"- Affected spans: "
|
|
91
|
+
f"{markdown_text(', '.join(map(str, finding['affected_spans'])) or 'none')}",
|
|
92
|
+
f"- Estimated latency savings: {savings['estimated_latency_savings_ms'] / 1000:.2f}s",
|
|
93
|
+
f"- Estimated cost savings: ${savings['estimated_cost_savings_usd']:.4f}",
|
|
94
|
+
f"- Rewrite: {markdown_text(rewrite['hint'])}",
|
|
95
|
+
f"- Validation: {markdown_text(validation['acceptance_criteria'])}",
|
|
96
|
+
"",
|
|
97
|
+
]
|
|
98
|
+
)
|
|
99
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _finding_from_card(
|
|
103
|
+
index: int, card: dict[str, Any], plan: dict[str, Any]
|
|
104
|
+
) -> OptimizationFinding | None:
|
|
105
|
+
affected_spans = list(card.get("affected_nodes", []))
|
|
106
|
+
finding_type = str(card.get("type", "optimization"))
|
|
107
|
+
latency_savings = float(card.get("estimated_latency_savings_ms", 0.0) or 0.0)
|
|
108
|
+
cost_savings = float(card.get("estimated_cost_savings_usd", 0.0) or 0.0)
|
|
109
|
+
severity = _severity(latency_savings, cost_savings, str(card.get("confidence", "low")), plan)
|
|
110
|
+
node_lookup = {node["node_id"]: node for node in plan.get("graph", {}).get("nodes", [])}
|
|
111
|
+
evidence = [
|
|
112
|
+
_evidence_row(node_lookup[node_id]) for node_id in affected_spans if node_id in node_lookup
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
return OptimizationFinding(
|
|
116
|
+
finding_id=f"al_{finding_type}_{index:03d}",
|
|
117
|
+
severity=severity,
|
|
118
|
+
type=finding_type,
|
|
119
|
+
title=str(card.get("title", "Optimization opportunity")),
|
|
120
|
+
confidence=str(card.get("confidence", "unknown")),
|
|
121
|
+
affected_spans=affected_spans,
|
|
122
|
+
evidence=evidence,
|
|
123
|
+
savings={
|
|
124
|
+
"estimated_latency_savings_ms": round(latency_savings, 3),
|
|
125
|
+
"estimated_cost_savings_usd": round(cost_savings, 6),
|
|
126
|
+
"formula": _savings_formula(finding_type),
|
|
127
|
+
},
|
|
128
|
+
rewrite={
|
|
129
|
+
"kind": finding_type,
|
|
130
|
+
"hint": str(card.get("rewrite_hint", "")),
|
|
131
|
+
"patchable": finding_type
|
|
132
|
+
in {
|
|
133
|
+
"parallelize_tools",
|
|
134
|
+
"cache_context",
|
|
135
|
+
"add_schema_validation",
|
|
136
|
+
"batch_model_calls",
|
|
137
|
+
"route_to_smaller_model",
|
|
138
|
+
"split_large_step",
|
|
139
|
+
"runaway_loop",
|
|
140
|
+
"tool_oscillation",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
validation={
|
|
144
|
+
"command": "agentloop replay",
|
|
145
|
+
"acceptance_criteria": _acceptance_criteria(finding_type),
|
|
146
|
+
},
|
|
147
|
+
metadata={"why": card.get("why", "")},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _summary(plan: dict[str, Any], findings: list[OptimizationFinding]) -> dict[str, Any]:
|
|
152
|
+
return {
|
|
153
|
+
"finding_count": len(findings),
|
|
154
|
+
"high_severity_count": sum(1 for finding in findings if finding.severity == "high"),
|
|
155
|
+
"patchable_count": sum(1 for finding in findings if finding.rewrite.get("patchable")),
|
|
156
|
+
"estimated_latency_reduction_pct": plan["estimated_after"]["latency_reduction_pct"],
|
|
157
|
+
"estimated_cost_reduction_pct": plan["estimated_after"]["cost_reduction_pct"],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _severity(
|
|
162
|
+
latency_savings: float, cost_savings: float, confidence: str, plan: dict[str, Any]
|
|
163
|
+
) -> str:
|
|
164
|
+
runtime = float(plan.get("current", {}).get("runtime_ms", 0.0) or 0.0)
|
|
165
|
+
cost = float(plan.get("current", {}).get("estimated_cost_usd", 0.0) or 0.0)
|
|
166
|
+
latency_share = latency_savings / runtime if runtime else 0.0
|
|
167
|
+
cost_share = cost_savings / cost if cost else 0.0
|
|
168
|
+
if confidence == "high" or latency_share >= 0.25 or cost_share >= 0.20:
|
|
169
|
+
return "high"
|
|
170
|
+
if confidence == "medium" or latency_share >= 0.10 or cost_share >= 0.10:
|
|
171
|
+
return "medium"
|
|
172
|
+
return "low"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _evidence_row(node: dict[str, Any]) -> dict[str, Any]:
|
|
176
|
+
return {
|
|
177
|
+
"span_id": node["node_id"],
|
|
178
|
+
"name": node["name"],
|
|
179
|
+
"event_type": node["event_type"],
|
|
180
|
+
"duration_ms": node["duration_ms"],
|
|
181
|
+
"input_tokens": node.get("input_tokens", 0),
|
|
182
|
+
"output_tokens": node.get("output_tokens", 0),
|
|
183
|
+
"model": node.get("model"),
|
|
184
|
+
"status": node.get("status", "ok"),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _savings_formula(finding_type: str) -> str:
|
|
189
|
+
formulas = {
|
|
190
|
+
"parallelize_tools": "sum(serial tool durations) - max(tool duration)",
|
|
191
|
+
"cache_context": "current cost * repeated context ratio, capped at 50%",
|
|
192
|
+
"add_schema_validation": "retry duration * 80%",
|
|
193
|
+
"batch_model_calls": "sum(repeated model durations) * 35%",
|
|
194
|
+
"route_to_smaller_model": "model step duration * 25%",
|
|
195
|
+
"split_large_step": "large step duration * 20%",
|
|
196
|
+
"runaway_loop": "repeated loop duration * 30%",
|
|
197
|
+
"tool_oscillation": "alternating tool duration * 40%",
|
|
198
|
+
}
|
|
199
|
+
return formulas.get(finding_type, "optimizer estimate")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _acceptance_criteria(finding_type: str) -> str:
|
|
203
|
+
criteria = {
|
|
204
|
+
"parallelize_tools": "runtime decreases while tool outputs remain equivalent",
|
|
205
|
+
"cache_context": "input tokens and cost decrease without output quality regression",
|
|
206
|
+
"add_schema_validation": "retry count decreases and output schema validity stays at 100%",
|
|
207
|
+
"batch_model_calls": "runtime decreases and item-level outputs remain equivalent",
|
|
208
|
+
"route_to_smaller_model": "cost decreases while configured quality scorer passes",
|
|
209
|
+
"split_large_step": "context size decreases while final answer quality scorer passes",
|
|
210
|
+
"runaway_loop": "iteration count is bounded and replay passes quality and budget gates",
|
|
211
|
+
"tool_oscillation": "tool-call count decreases without losing required state transitions",
|
|
212
|
+
}
|
|
213
|
+
return criteria.get(
|
|
214
|
+
finding_type, "before/after replay passes configured quality and budget gates"
|
|
215
|
+
)
|