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/cli.py ADDED
@@ -0,0 +1,876 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from agentloop.audit import estimate_improvement
12
+ from agentloop.autoinstrument import detect_integrations
13
+ from agentloop.ci import build_ci_report, ci_report_to_markdown
14
+ from agentloop.client import AgentLoopClient
15
+ from agentloop.demo import run_baseline, run_langgraph_style, run_optimized, run_proof_pair
16
+ from agentloop.doctor import run_doctor, run_production_check
17
+ from agentloop.exporters import export_report_markdown
18
+ from agentloop.findings import build_diagnosis, diagnosis_to_markdown
19
+ from agentloop.issues import build_issue_drafts, issue_drafts_to_markdown
20
+ from agentloop.optimizer import build_optimization_plan
21
+ from agentloop.otel import trace_from_otel, trace_to_otel
22
+ from agentloop.patches import build_patch_plan, patch_plan_to_markdown
23
+ from agentloop.plan_export import export_optimization_json, export_optimization_markdown
24
+ from agentloop.quality import (
25
+ build_quality_report,
26
+ load_quality_fixtures,
27
+ quality_report_to_markdown,
28
+ )
29
+ from agentloop.replay import ReplayGates, build_replay_report, replay_report_to_markdown
30
+ from agentloop.store import get_store
31
+ from agentloop.tracer import AgentTrace
32
+ from agentloop.value import build_value_report
33
+
34
+ app = typer.Typer(help="AgentLoop profiler CLI")
35
+ console = Console()
36
+
37
+
38
+ def _remote_client(api_url: str, api_key: str | None) -> AgentLoopClient:
39
+ resolved_api_key = api_key if api_key is not None else os.getenv("AGENTLOOP_API_KEY")
40
+ return AgentLoopClient(base_url=api_url, api_key=resolved_api_key)
41
+
42
+
43
+ def _remote_admin_client(api_url: str, admin_api_key: str | None) -> AgentLoopClient:
44
+ resolved_admin_key = (
45
+ admin_api_key if admin_api_key is not None else os.getenv("AGENTLOOP_ADMIN_API_KEY")
46
+ )
47
+ return AgentLoopClient(base_url=api_url, admin_api_key=resolved_admin_key)
48
+
49
+
50
+ def _read_json_input(path: Path, *, param_hint: str) -> object:
51
+ try:
52
+ exists = path.exists()
53
+ is_file = path.is_file() if exists else False
54
+ except OSError as exc:
55
+ raise typer.BadParameter(
56
+ f"Input file is not readable: {path} ({exc})", param_hint=param_hint
57
+ ) from exc
58
+
59
+ if not exists:
60
+ raise typer.BadParameter(
61
+ f"Input file does not exist: {path}. "
62
+ "Generate synthetic traces explicitly with `agentloop demo` or `agentloop demo-all`.",
63
+ param_hint=param_hint,
64
+ )
65
+ if not is_file:
66
+ raise typer.BadParameter(f"Input path is not a regular file: {path}", param_hint=param_hint)
67
+
68
+ try:
69
+ raw = path.read_text(encoding="utf-8")
70
+ except (OSError, UnicodeError) as exc:
71
+ raise typer.BadParameter(
72
+ f"Input file is not readable as UTF-8: {path} ({exc})", param_hint=param_hint
73
+ ) from exc
74
+ try:
75
+ return json.loads(raw)
76
+ except json.JSONDecodeError as exc:
77
+ raise typer.BadParameter(
78
+ f"Input file is not valid JSON: {path} ({exc.msg} at line {exc.lineno}, "
79
+ f"column {exc.colno})",
80
+ param_hint=param_hint,
81
+ ) from exc
82
+
83
+
84
+ def _write_json(out: Path, payload: dict) -> None:
85
+ out.parent.mkdir(parents=True, exist_ok=True)
86
+ out.write_text(json.dumps(payload, indent=2), encoding="utf-8")
87
+
88
+
89
+ def _load_trace(
90
+ path: Path,
91
+ *,
92
+ otel: bool = False,
93
+ name: str | None = None,
94
+ param_hint: str = "path",
95
+ ) -> AgentTrace:
96
+ payload = _read_json_input(path, param_hint=param_hint)
97
+ format_name = "OpenTelemetry" if otel else "AgentLoop"
98
+ try:
99
+ if otel:
100
+ if not isinstance(payload, (dict, list)):
101
+ raise ValueError("expected a JSON object or array")
102
+ return trace_from_otel(payload, name=name)
103
+ if not isinstance(payload, dict):
104
+ raise ValueError("expected a JSON object")
105
+ return AgentTrace.from_dict(payload)
106
+ except (AttributeError, KeyError, TypeError, ValueError) as exc:
107
+ raise typer.BadParameter(
108
+ f"Input file is not a valid {format_name} trace: {path} ({exc})",
109
+ param_hint=param_hint,
110
+ ) from exc
111
+
112
+
113
+ def _print_value_summary(value: dict) -> None:
114
+ monthly = value["monthly_value"]
115
+ per_run = value["per_run"]
116
+ reliability = value["reliability"]
117
+ pricing = value.get("pricing", {})
118
+ table = Table(title="AgentLoop Value Report")
119
+ table.add_column("Metric")
120
+ table.add_column("Value")
121
+ table.add_row("Total monthly value", f"${monthly['total_value_usd']:,.2f}")
122
+ table.add_row("Model cost savings / month", f"${monthly['direct_model_cost_savings_usd']:,.2f}")
123
+ table.add_row("Engineering hours saved / month", str(monthly["engineering_hours_saved"]))
124
+ table.add_row("Latency saved / run", f"{per_run['latency_savings_ms'] / 1000:.3f}s")
125
+ table.add_row("Cost saved / run", f"${per_run['cost_savings_usd']:.6f}")
126
+ table.add_row("Reliability risk score", f"{reliability['risk_score']}/100")
127
+ if pricing:
128
+ table.add_row("Suggested plan", str(pricing.get("suggested_plan", "")))
129
+ table.add_row(
130
+ "Suggested monthly price",
131
+ f"${float(pricing.get('suggested_monthly_price_usd', 0)):,.0f}",
132
+ )
133
+ ratio = pricing.get("value_to_price_ratio")
134
+ table.add_row("Value / price ratio", "n/a" if ratio is None else f"{ratio}x")
135
+ console.print(table)
136
+ console.print(value["value_summary"])
137
+
138
+
139
+ def _print_doctor(result: dict) -> None:
140
+ table = Table(title="AgentLoop Doctor")
141
+ table.add_column("Status")
142
+ table.add_column("Check")
143
+ table.add_column("Detail")
144
+ table.add_column("Fix")
145
+ for check in result["checks"]:
146
+ status = check["status"]
147
+ style = "green" if status == "ok" else "yellow" if status == "warn" else "red"
148
+ table.add_row(
149
+ f"[{style}]{status}[/{style}]", check["name"], check["detail"], check.get("fix", "")
150
+ )
151
+ console.print(table)
152
+ if result["failed"]:
153
+ raise typer.Exit(1)
154
+
155
+
156
+ @app.command()
157
+ def report(path: Path) -> None:
158
+ trace = _load_trace(path)
159
+ trace.print_report()
160
+
161
+
162
+ @app.command()
163
+ def compare(
164
+ baseline: Path = Path("runs/research_agent_baseline.json"),
165
+ optimized: Path = Path("runs/research_agent_optimized.json"),
166
+ ) -> None:
167
+ base = _load_trace(baseline, param_hint="--baseline").report()
168
+ opt = _load_trace(optimized, param_hint="--optimized").report()
169
+ table = Table(title="AgentLoop Comparison")
170
+ table.add_column("Metric")
171
+ table.add_column("Baseline")
172
+ table.add_column("Optimized")
173
+ table.add_column("Delta")
174
+
175
+ pairs = [
176
+ ("Runtime sec", base["total_runtime_ms"] / 1000, opt["total_runtime_ms"] / 1000),
177
+ ("Cost USD", base["estimated_cost_usd"], opt["estimated_cost_usd"]),
178
+ ("Input tokens", base["input_tokens"], opt["input_tokens"]),
179
+ ("Retry count", base["retry_count"], opt["retry_count"]),
180
+ ]
181
+ for name, b, o in pairs:
182
+ table.add_row(
183
+ name,
184
+ f"{b:.4f}" if isinstance(b, float) else str(b),
185
+ f"{o:.4f}" if isinstance(o, float) else str(o),
186
+ f"{(o - b):.4f}" if isinstance(o, float) else str(o - b),
187
+ )
188
+ console.print(table)
189
+
190
+
191
+ @app.command("replay")
192
+ def replay_command(
193
+ baseline: Path = Path("runs/research_agent_baseline.json"),
194
+ candidate: Path = Path("runs/research_agent_optimized.json"),
195
+ out: Path = Path("runs/replay_report.md"),
196
+ json_out: Path | None = typer.Option(
197
+ None, help="Optional machine-readable replay report output."
198
+ ),
199
+ max_cost_regression_pct: float = typer.Option(0.0, min=0.0),
200
+ max_latency_regression_pct: float = typer.Option(0.0, min=0.0),
201
+ min_latency_improvement_pct: float = typer.Option(0.0, min=0.0),
202
+ min_cost_improvement_pct: float = typer.Option(0.0, min=0.0),
203
+ require_retry_non_increase: bool = typer.Option(True),
204
+ require_schema_valid: bool = typer.Option(
205
+ False, help="Require candidate trace metadata/report to mark schema output as valid."
206
+ ),
207
+ min_quality_score: float | None = typer.Option(
208
+ None, min=0.0, help="Optional minimum candidate quality score."
209
+ ),
210
+ quality_fixtures: Path | None = typer.Option(None, help="Optional quality fixture JSON file."),
211
+ fail_on_gate: bool = typer.Option(True, help="Exit non-zero when replay gates fail."),
212
+ ) -> None:
213
+ baseline_trace = _load_trace(baseline, param_hint="--baseline")
214
+ candidate_trace = _load_trace(candidate, param_hint="--candidate")
215
+ quality = (
216
+ build_quality_report(
217
+ load_quality_fixtures(quality_fixtures),
218
+ baseline_trace=baseline_trace,
219
+ candidate_trace=candidate_trace,
220
+ min_score=min_quality_score,
221
+ )
222
+ if quality_fixtures is not None
223
+ else None
224
+ )
225
+ report_data = build_replay_report(
226
+ baseline_trace,
227
+ candidate_trace,
228
+ gates=ReplayGates(
229
+ max_cost_regression_pct=max_cost_regression_pct,
230
+ max_latency_regression_pct=max_latency_regression_pct,
231
+ min_latency_improvement_pct=min_latency_improvement_pct,
232
+ min_cost_improvement_pct=min_cost_improvement_pct,
233
+ require_retry_non_increase=require_retry_non_increase,
234
+ require_schema_valid=require_schema_valid,
235
+ min_quality_score=min_quality_score,
236
+ ),
237
+ quality_report=quality,
238
+ )
239
+ out.parent.mkdir(parents=True, exist_ok=True)
240
+ out.write_text(replay_report_to_markdown(report_data), encoding="utf-8")
241
+ if json_out is not None:
242
+ _write_json(json_out, report_data)
243
+ console.print(f"Wrote replay report to {out}")
244
+ if json_out is not None:
245
+ console.print(f"Wrote replay JSON to {json_out}")
246
+ console.print(report_data["summary"])
247
+ if fail_on_gate and not report_data["gates"]["passed"]:
248
+ raise typer.Exit(1)
249
+
250
+
251
+ @app.command("quality-report")
252
+ def quality_report_command(
253
+ fixtures: Path,
254
+ baseline: Path | None = typer.Option(None, help="Optional baseline trace JSON."),
255
+ candidate: Path | None = typer.Option(None, help="Optional candidate trace JSON."),
256
+ out: Path = Path("runs/quality_report.md"),
257
+ json_out: Path | None = typer.Option(
258
+ None, help="Optional machine-readable quality report output."
259
+ ),
260
+ min_score: float | None = typer.Option(None, min=0.0),
261
+ ) -> None:
262
+ baseline_trace = (
263
+ _load_trace(baseline, param_hint="--baseline") if baseline is not None else None
264
+ )
265
+ candidate_trace = (
266
+ _load_trace(candidate, param_hint="--candidate") if candidate is not None else None
267
+ )
268
+ report_data = build_quality_report(
269
+ load_quality_fixtures(fixtures),
270
+ baseline_trace=baseline_trace,
271
+ candidate_trace=candidate_trace,
272
+ min_score=min_score,
273
+ )
274
+ out.parent.mkdir(parents=True, exist_ok=True)
275
+ out.write_text(quality_report_to_markdown(report_data), encoding="utf-8")
276
+ if json_out is not None:
277
+ _write_json(json_out, report_data)
278
+ console.print(f"Wrote quality JSON to {json_out}")
279
+ console.print(f"Wrote quality report to {out}")
280
+ if not report_data["passed"]:
281
+ raise typer.Exit(1)
282
+
283
+
284
+ @app.command("ci")
285
+ def ci_command(
286
+ baseline: Path = Path("runs/research_agent_baseline.json"),
287
+ candidate: Path = Path("runs/research_agent_optimized.json"),
288
+ out: Path = Path("runs/agentloop_ci.md"),
289
+ json_out: Path | None = typer.Option(None, help="Optional machine-readable CI report output."),
290
+ max_cost_regression_pct: float = typer.Option(0.0, min=0.0),
291
+ max_latency_regression_pct: float = typer.Option(0.0, min=0.0),
292
+ min_latency_improvement_pct: float = typer.Option(0.0, min=0.0),
293
+ min_cost_improvement_pct: float = typer.Option(0.0, min=0.0),
294
+ require_retry_non_increase: bool = typer.Option(True),
295
+ require_schema_valid: bool = typer.Option(
296
+ False, help="Require candidate trace metadata/report to mark schema output as valid."
297
+ ),
298
+ min_quality_score: float | None = typer.Option(
299
+ None, min=0.0, help="Optional minimum candidate quality score."
300
+ ),
301
+ quality_fixtures: Path | None = typer.Option(None, help="Optional quality fixture JSON file."),
302
+ github_step_summary: bool = typer.Option(
303
+ False, help="Append report Markdown to GITHUB_STEP_SUMMARY."
304
+ ),
305
+ fail_on_gate: bool = typer.Option(True, help="Exit non-zero when CI gates fail."),
306
+ ) -> None:
307
+ baseline_trace = _load_trace(baseline, param_hint="--baseline")
308
+ candidate_trace = _load_trace(candidate, param_hint="--candidate")
309
+ quality = (
310
+ build_quality_report(
311
+ load_quality_fixtures(quality_fixtures),
312
+ baseline_trace=baseline_trace,
313
+ candidate_trace=candidate_trace,
314
+ min_score=min_quality_score,
315
+ )
316
+ if quality_fixtures is not None
317
+ else None
318
+ )
319
+ report_data = build_ci_report(
320
+ baseline_trace,
321
+ candidate_trace,
322
+ gates=ReplayGates(
323
+ max_cost_regression_pct=max_cost_regression_pct,
324
+ max_latency_regression_pct=max_latency_regression_pct,
325
+ min_latency_improvement_pct=min_latency_improvement_pct,
326
+ min_cost_improvement_pct=min_cost_improvement_pct,
327
+ require_retry_non_increase=require_retry_non_increase,
328
+ require_schema_valid=require_schema_valid,
329
+ min_quality_score=min_quality_score,
330
+ ),
331
+ quality_report=quality,
332
+ )
333
+ markdown = ci_report_to_markdown(report_data)
334
+ out.parent.mkdir(parents=True, exist_ok=True)
335
+ out.write_text(markdown, encoding="utf-8")
336
+ if json_out is not None:
337
+ _write_json(json_out, report_data)
338
+ if github_step_summary:
339
+ summary_path = os.getenv("GITHUB_STEP_SUMMARY")
340
+ if summary_path:
341
+ with open(summary_path, "a", encoding="utf-8") as handle:
342
+ handle.write(markdown)
343
+ console.print(f"Wrote CI report to {out}")
344
+ if json_out is not None:
345
+ console.print(f"Wrote CI JSON to {json_out}")
346
+ console.print(report_data["replay"]["summary"])
347
+ if fail_on_gate and not report_data["passed"]:
348
+ raise typer.Exit(1)
349
+
350
+
351
+ @app.command("dump-report")
352
+ def dump_report(path: Path, out: Path) -> None:
353
+ trace = _load_trace(path)
354
+ out.parent.mkdir(parents=True, exist_ok=True)
355
+ out.write_text(json.dumps(trace.report(), indent=2), encoding="utf-8")
356
+ console.print(f"Wrote {out}")
357
+
358
+
359
+ @app.command()
360
+ def audit(
361
+ path: Path = Path("runs/research_agent_baseline.json"),
362
+ out: Path = Path("runs/agentloop_audit.md"),
363
+ ) -> None:
364
+ trace = _load_trace(path, param_hint="--path")
365
+ report_data = trace.report()
366
+ improvement = estimate_improvement(report_data)
367
+ report_data["estimated_latency_savings_ms"] = improvement["estimated_latency_savings_ms"]
368
+ report_data["estimated_cost_savings_usd"] = improvement["estimated_cost_savings_usd"]
369
+ export_report_markdown(report_data, out)
370
+ console.print(f"Wrote audit to {out}")
371
+ console.print(
372
+ f"Estimated savings: {improvement['estimated_latency_savings_ms'] / 1000:.2f}s latency, "
373
+ f"${improvement['estimated_cost_savings_usd']:.4f} cost per run"
374
+ )
375
+
376
+
377
+ @app.command()
378
+ def optimize(
379
+ path: Path = Path("runs/research_agent_baseline.json"),
380
+ out: Path = Path("runs/optimization_plan.md"),
381
+ json_out: Path | None = typer.Option(None, help="Optional JSON output path."),
382
+ ) -> None:
383
+ trace = _load_trace(path, param_hint="--path")
384
+ plan = build_optimization_plan(trace)
385
+ export_optimization_markdown(plan, out)
386
+ if json_out is not None:
387
+ export_optimization_json(plan, json_out)
388
+ console.print(f"Wrote optimization plan to {out}")
389
+ if json_out is not None:
390
+ console.print(f"Wrote optimization JSON to {json_out}")
391
+ after = plan["estimated_after"]
392
+ console.print(
393
+ f"Estimated improvement: {after['latency_reduction_pct']:.1f}% latency, "
394
+ f"{after['cost_reduction_pct']:.1f}% cost"
395
+ )
396
+
397
+
398
+ @app.command("diagnose")
399
+ def diagnose(
400
+ path: Path = Path("runs/research_agent_baseline.json"),
401
+ out: Path = Path("runs/diagnosis.md"),
402
+ json_out: Path | None = typer.Option(None, help="Optional machine-readable diagnosis output."),
403
+ otel: bool = typer.Option(False, help="Read the input path as OTLP/GenAI-style JSON."),
404
+ name: str | None = typer.Option(None, help="Trace name to use when importing OTLP JSON."),
405
+ ) -> None:
406
+ trace = _load_trace(path, otel=otel, name=name, param_hint="--path")
407
+ diagnosis = build_diagnosis(trace)
408
+ out.parent.mkdir(parents=True, exist_ok=True)
409
+ out.write_text(diagnosis_to_markdown(diagnosis), encoding="utf-8")
410
+ if json_out is not None:
411
+ _write_json(json_out, diagnosis)
412
+ summary = diagnosis["summary"]
413
+ console.print(f"Wrote diagnosis to {out}")
414
+ if json_out is not None:
415
+ console.print(f"Wrote diagnosis JSON to {json_out}")
416
+ console.print(
417
+ f"Findings: {summary['finding_count']} "
418
+ f"({summary['patchable_count']} patchable), "
419
+ f"estimated improvement: {summary['estimated_latency_reduction_pct']:.1f}% latency, "
420
+ f"{summary['estimated_cost_reduction_pct']:.1f}% cost"
421
+ )
422
+
423
+
424
+ @app.command("import-otel")
425
+ def import_otel(
426
+ path: Path,
427
+ out: Path,
428
+ name: str | None = typer.Option(None, help="Optional imported trace name."),
429
+ ) -> None:
430
+ trace = _load_trace(path, otel=True, name=name)
431
+ trace.export_json(out)
432
+ console.print(f"Imported OTLP trace {trace.run_id} to {out}")
433
+
434
+
435
+ @app.command("export-otel")
436
+ def export_otel(path: Path, out: Path) -> None:
437
+ trace = _load_trace(path)
438
+ _write_json(out, trace_to_otel(trace))
439
+ console.print(f"Exported OTLP-style trace to {out}")
440
+
441
+
442
+ @app.command("patch")
443
+ def patch_command(
444
+ path: Path = Path("runs/research_agent_baseline.json"),
445
+ repo: Path = Path("."),
446
+ out: Path = Path("runs/patch_plan.md"),
447
+ json_out: Path | None = typer.Option(None, help="Optional machine-readable patch plan output."),
448
+ otel: bool = typer.Option(False, help="Read the input path as OTLP/GenAI-style JSON."),
449
+ name: str | None = typer.Option(None, help="Trace name to use when importing OTLP JSON."),
450
+ dry_run: bool = typer.Option(
451
+ True, help="Only generate a patch plan. File edits are not supported yet."
452
+ ),
453
+ ) -> None:
454
+ if not dry_run:
455
+ raise typer.BadParameter(
456
+ "Only --dry-run is supported. Generate a patch plan first, then apply manually."
457
+ )
458
+ trace = _load_trace(path, otel=otel, name=name, param_hint="--path")
459
+ try:
460
+ plan = build_patch_plan(trace, repo_path=repo)
461
+ except ValueError as exc:
462
+ raise typer.BadParameter(str(exc), param_hint="--repo") from exc
463
+ out.parent.mkdir(parents=True, exist_ok=True)
464
+ out.write_text(patch_plan_to_markdown(plan), encoding="utf-8")
465
+ if json_out is not None:
466
+ _write_json(json_out, plan)
467
+ console.print(f"Wrote patch plan to {out}")
468
+ if json_out is not None:
469
+ console.print(f"Wrote patch plan JSON to {json_out}")
470
+ summary = plan["summary"]
471
+ console.print(
472
+ f"Patch plans: {summary['patch_count']}, "
473
+ f"unsupported findings: {summary['unsupported_finding_count']}, "
474
+ f"frameworks: {', '.join(summary['frameworks_detected']) or 'none'}"
475
+ )
476
+
477
+
478
+ @app.command("value-report")
479
+ def value_report(
480
+ path: Path = Path("runs/research_agent_baseline.json"),
481
+ out: Path = Path("runs/value_report.json"),
482
+ runs_per_month: int = typer.Option(1000, min=0),
483
+ engineer_hourly_rate_usd: float = typer.Option(150.0, min=0),
484
+ incident_cost_usd: float = typer.Option(500.0, min=0),
485
+ ) -> None:
486
+ trace = _load_trace(path, param_hint="--path")
487
+ value = build_value_report(
488
+ trace,
489
+ runs_per_month=runs_per_month,
490
+ engineer_hourly_rate_usd=engineer_hourly_rate_usd,
491
+ incident_cost_usd=incident_cost_usd,
492
+ )
493
+ _write_json(out, value)
494
+ _print_value_summary(value)
495
+ console.print(f"Wrote value report to {out}")
496
+
497
+
498
+ @app.command("doctor")
499
+ def doctor_command(
500
+ check_api: bool = typer.Option(True, help="Also call the configured /health endpoint."),
501
+ json_out: Path | None = typer.Option(
502
+ None, help="Optional path for machine-readable doctor output."
503
+ ),
504
+ ) -> None:
505
+ result = run_doctor(check_api=check_api)
506
+ if json_out:
507
+ _write_json(json_out, result)
508
+ console.print(f"Wrote doctor output to {json_out}")
509
+ _print_doctor(result)
510
+
511
+
512
+ @app.command("production-check")
513
+ def production_check_command(
514
+ check_api: bool = typer.Option(True, help="Call the configured /health and /readyz endpoints."),
515
+ check_store: bool = typer.Option(True, help="Initialize and query the configured store."),
516
+ allow_http: bool = typer.Option(
517
+ False, help="Allow non-HTTPS AGENTLOOP_API_URL for local staging."
518
+ ),
519
+ json_out: Path | None = typer.Option(
520
+ None, help="Optional path for machine-readable check output."
521
+ ),
522
+ ) -> None:
523
+ result = run_production_check(
524
+ check_api=check_api, check_store=check_store, allow_http=allow_http
525
+ )
526
+ if json_out:
527
+ _write_json(json_out, result)
528
+ console.print(f"Wrote production check output to {json_out}")
529
+ _print_doctor(result)
530
+
531
+
532
+ @app.command("detect-integrations")
533
+ def detect_integrations_command(json_out: Path | None = typer.Option(None)) -> None:
534
+ """Report which framework integrations are available in this environment.
535
+
536
+ Detection only: it checks whether each integration's SDK is importable and
537
+ does not instrument anything. Apply agentloop.integrations helpers from your
538
+ application startup to record traces.
539
+ """
540
+ result = detect_integrations().to_dict()
541
+ if json_out:
542
+ _write_json(json_out, result)
543
+ console.print(f"Wrote integration detection output to {json_out}")
544
+ console.print_json(data=result)
545
+
546
+
547
+ @app.command("auto-instrument", hidden=True, deprecated=True)
548
+ def auto_instrument_command(json_out: Path | None = typer.Option(None)) -> None:
549
+ """Deprecated alias for `detect-integrations`; it only detects, never instruments."""
550
+ console.print(
551
+ "[yellow]`auto-instrument` is deprecated and never enabled instrumentation; "
552
+ "use `detect-integrations`.[/yellow]"
553
+ )
554
+ detect_integrations_command(json_out=json_out)
555
+
556
+
557
+ @app.command("init-store")
558
+ def init_store() -> None:
559
+ db = get_store()
560
+ db.init()
561
+ console.print("Initialized AgentLoop store")
562
+
563
+
564
+ @app.command("create-api-key")
565
+ def create_api_key(project_id: str = "default", name: str = "default") -> None:
566
+ db = get_store()
567
+ key = db.create_api_key(project_id=project_id, name=name)
568
+ console.print("Created API key. Save it now; it will not be shown again.")
569
+ console.print(key["api_key"])
570
+
571
+
572
+ @app.command("store-trace")
573
+ def store_trace(
574
+ path: Path = Path("runs/research_agent_baseline.json"),
575
+ project_id: str = "default",
576
+ ) -> None:
577
+ trace = _load_trace(path, param_hint="--path")
578
+ db = get_store()
579
+ db.save_trace(trace, project_id=project_id)
580
+ console.print(f"Stored trace {trace.run_id} under project {project_id}")
581
+
582
+
583
+ @app.command("list-stored-traces")
584
+ def list_stored_traces(project_id: str | None = None) -> None:
585
+ db = get_store()
586
+ traces = db.list_traces(project_id=project_id)
587
+ table = Table(title="Stored AgentLoop Traces")
588
+ table.add_column("Project")
589
+ table.add_column("Run ID")
590
+ table.add_column("Name")
591
+ table.add_column("Runtime ms")
592
+ table.add_column("Cost")
593
+ for item in traces:
594
+ table.add_row(
595
+ str(item.get("project_id", "")),
596
+ str(item.get("run_id", "")),
597
+ str(item.get("name", "")),
598
+ f"{float(item.get('total_runtime_ms', 0)):.2f}",
599
+ f"${float(item.get('estimated_cost_usd', 0)):.4f}",
600
+ )
601
+ console.print(table)
602
+
603
+
604
+ @app.command("list-findings")
605
+ def list_findings(project_id: str | None = None, status: str | None = None) -> None:
606
+ db = get_store()
607
+ findings = db.list_findings(project_id=project_id, status=status)
608
+ table = Table(title="AgentLoop Findings")
609
+ table.add_column("Severity")
610
+ table.add_column("Type")
611
+ table.add_column("Title")
612
+ table.add_column("Run")
613
+ table.add_column("Status")
614
+ table.add_column("Patchable")
615
+ table.add_column("Savings")
616
+ for finding in findings:
617
+ table.add_row(
618
+ str(finding["severity"]),
619
+ str(finding["type"]),
620
+ str(finding["title"]),
621
+ str(finding["run_id"]),
622
+ str(finding["status"]),
623
+ "yes" if finding["patchable"] else "no",
624
+ f"{finding['estimated_latency_savings_ms'] / 1000:.2f}s / ${finding['estimated_cost_savings_usd']:.4f}",
625
+ )
626
+ console.print(table)
627
+
628
+
629
+ @app.command("optimization-queue")
630
+ def optimization_queue(
631
+ project_id: str | None = None, json_out: Path | None = typer.Option(None)
632
+ ) -> None:
633
+ db = get_store()
634
+ queue = db.optimization_queue(project_id=project_id)
635
+ payload = {"project_id": project_id, "queue": queue}
636
+ if json_out is not None:
637
+ _write_json(json_out, payload)
638
+ console.print(f"Wrote optimization queue JSON to {json_out}")
639
+ table = Table(title="AgentLoop Optimization Queue")
640
+ table.add_column("Priority")
641
+ table.add_column("Severity")
642
+ table.add_column("Type")
643
+ table.add_column("Title")
644
+ table.add_column("Runs")
645
+ table.add_column("Patchable")
646
+ table.add_column("Savings")
647
+ for item in queue:
648
+ table.add_row(
649
+ f"{item['priority_score']:.1f}",
650
+ str(item["severity"]),
651
+ str(item["type"]),
652
+ str(item["title"]),
653
+ str(item["run_count"]),
654
+ str(item["patchable_count"]),
655
+ f"{item['estimated_latency_savings_ms'] / 1000:.2f}s / ${item['estimated_cost_savings_usd']:.4f}",
656
+ )
657
+ console.print(table)
658
+
659
+
660
+ @app.command("github-issue-drafts")
661
+ def github_issue_drafts(
662
+ project_id: str | None = None,
663
+ out: Path = Path("runs/agentloop_issue_drafts.md"),
664
+ json_out: Path | None = typer.Option(None),
665
+ limit: int = typer.Option(5, min=1, max=20),
666
+ ) -> None:
667
+ db = get_store()
668
+ drafts = build_issue_drafts(db.optimization_queue(project_id=project_id), limit=limit)
669
+ out.parent.mkdir(parents=True, exist_ok=True)
670
+ out.write_text(issue_drafts_to_markdown(drafts), encoding="utf-8")
671
+ if json_out is not None:
672
+ _write_json(json_out, {"project_id": project_id, "issue_drafts": drafts})
673
+ console.print(f"Wrote GitHub issue draft JSON to {json_out}")
674
+ console.print(f"Wrote GitHub issue drafts to {out}")
675
+
676
+
677
+ @app.command("usage-summary")
678
+ def usage_summary(project_id: str | None = None) -> None:
679
+ db = get_store()
680
+ summary = db.usage_summary(project_id=project_id)
681
+ table = Table(title="AgentLoop Usage Summary")
682
+ table.add_column("Metric")
683
+ table.add_column("Value")
684
+ for key, value in summary.items():
685
+ table.add_row(key, str(value))
686
+ console.print(table)
687
+
688
+
689
+ @app.command()
690
+ def upload(
691
+ path: Path = Path("runs/research_agent_baseline.json"),
692
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
693
+ api_key: str | None = typer.Option(
694
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
695
+ ),
696
+ ) -> None:
697
+ trace = _load_trace(path, param_hint="--path")
698
+ client = _remote_client(api_url, api_key)
699
+ response = client.upload_trace(trace)
700
+ console.print(f"Uploaded trace {response['run_id']} to {api_url}")
701
+ console.print(f"Project: {response.get('project_id', 'default')}")
702
+
703
+
704
+ @app.command("remote-optimize")
705
+ def remote_optimize(
706
+ run_id: str,
707
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
708
+ api_key: str | None = typer.Option(
709
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
710
+ ),
711
+ out: Path = Path("runs/remote_optimization_plan.json"),
712
+ ) -> None:
713
+ client = _remote_client(api_url, api_key)
714
+ plan = client.get_optimization_plan(run_id)
715
+ export_optimization_json(plan, out)
716
+ console.print(f"Wrote remote optimization plan to {out}")
717
+
718
+
719
+ @app.command("remote-diagnose")
720
+ def remote_diagnose(
721
+ run_id: str,
722
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
723
+ api_key: str | None = typer.Option(
724
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
725
+ ),
726
+ out: Path = Path("runs/remote_diagnosis.json"),
727
+ ) -> None:
728
+ client = _remote_client(api_url, api_key)
729
+ diagnosis = client.get_diagnosis(run_id)
730
+ _write_json(out, diagnosis)
731
+ console.print(f"Wrote remote diagnosis to {out}")
732
+
733
+
734
+ @app.command("remote-findings")
735
+ def remote_findings(
736
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
737
+ api_key: str | None = typer.Option(
738
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
739
+ ),
740
+ project_id: str | None = typer.Option(None),
741
+ status: str | None = typer.Option(None),
742
+ out: Path = Path("runs/remote_findings.json"),
743
+ ) -> None:
744
+ client = _remote_client(api_url, api_key)
745
+ findings = client.list_findings(project_id=project_id, status=status)
746
+ _write_json(out, findings)
747
+ console.print(f"Wrote remote findings to {out}")
748
+
749
+
750
+ @app.command("remote-optimization-queue")
751
+ def remote_optimization_queue(
752
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
753
+ api_key: str | None = typer.Option(
754
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
755
+ ),
756
+ project_id: str | None = typer.Option(None),
757
+ out: Path = Path("runs/remote_optimization_queue.json"),
758
+ ) -> None:
759
+ client = _remote_client(api_url, api_key)
760
+ queue = client.optimization_queue(project_id=project_id)
761
+ _write_json(out, queue)
762
+ console.print(f"Wrote remote optimization queue to {out}")
763
+
764
+
765
+ @app.command("remote-github-issue-drafts")
766
+ def remote_github_issue_drafts(
767
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
768
+ api_key: str | None = typer.Option(
769
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
770
+ ),
771
+ project_id: str | None = typer.Option(None),
772
+ limit: int = typer.Option(5, min=1, max=20),
773
+ out: Path = Path("runs/remote_agentloop_issue_drafts.json"),
774
+ ) -> None:
775
+ client = _remote_client(api_url, api_key)
776
+ drafts = client.github_issue_drafts(project_id=project_id, limit=limit)
777
+ _write_json(out, drafts)
778
+ console.print(f"Wrote remote GitHub issue drafts to {out}")
779
+
780
+
781
+ @app.command("remote-value-report")
782
+ def remote_value_report(
783
+ run_id: str,
784
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
785
+ api_key: str | None = typer.Option(
786
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
787
+ ),
788
+ out: Path = Path("runs/remote_value_report.json"),
789
+ runs_per_month: int = typer.Option(1000, min=0),
790
+ engineer_hourly_rate_usd: float = typer.Option(150.0, min=0),
791
+ incident_cost_usd: float = typer.Option(500.0, min=0),
792
+ ) -> None:
793
+ client = _remote_client(api_url, api_key)
794
+ value = client.get_value_report(
795
+ run_id,
796
+ runs_per_month=runs_per_month,
797
+ engineer_hourly_rate_usd=engineer_hourly_rate_usd,
798
+ incident_cost_usd=incident_cost_usd,
799
+ )
800
+ _write_json(out, value)
801
+ _print_value_summary(value)
802
+ console.print(f"Wrote remote value report to {out}")
803
+
804
+
805
+ @app.command("remote-usage")
806
+ def remote_usage(
807
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
808
+ api_key: str | None = typer.Option(
809
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
810
+ ),
811
+ ) -> None:
812
+ client = _remote_client(api_url, api_key)
813
+ console.print_json(data=client.usage_summary())
814
+
815
+
816
+ @app.command("remote-create-api-key")
817
+ def remote_create_api_key(
818
+ project_id: str = "default",
819
+ name: str = "default",
820
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
821
+ admin_api_key: str | None = typer.Option(
822
+ None, help="Admin API key. Defaults to AGENTLOOP_ADMIN_API_KEY."
823
+ ),
824
+ ) -> None:
825
+ client = _remote_admin_client(api_url, admin_api_key)
826
+ key = client.create_api_key(project_id=project_id, name=name)
827
+ console.print("Created remote API key. Save it now; it will not be shown again.")
828
+ console.print(key["api_key"])
829
+
830
+
831
+ @app.command()
832
+ def demo(
833
+ kind: str = typer.Option("baseline", help="baseline, optimized, langgraph, or proof"),
834
+ ) -> None:
835
+ if kind == "baseline":
836
+ path = run_baseline()
837
+ elif kind == "optimized":
838
+ path = run_optimized()
839
+ elif kind == "langgraph":
840
+ path = run_langgraph_style()
841
+ elif kind == "proof":
842
+ baseline, candidate = run_proof_pair()
843
+ console.print(f"Wrote synthetic demo trace to {baseline}")
844
+ console.print(f"Wrote synthetic demo trace to {candidate}")
845
+ return
846
+ else:
847
+ raise typer.BadParameter("kind must be 'baseline', 'optimized', 'langgraph', or 'proof'")
848
+ console.print(f"Wrote synthetic demo trace to {path}")
849
+
850
+
851
+ @app.command("demo-all")
852
+ def demo_all() -> None:
853
+ base = run_baseline()
854
+ opt = run_optimized()
855
+ lg = run_langgraph_style()
856
+ proof_base, proof_candidate = run_proof_pair()
857
+ console.print(f"Wrote synthetic demo trace to {base}")
858
+ console.print(f"Wrote synthetic demo trace to {opt}")
859
+ console.print(f"Wrote synthetic demo trace to {lg}")
860
+ console.print(f"Wrote synthetic demo trace to {proof_base}")
861
+ console.print(f"Wrote synthetic demo trace to {proof_candidate}")
862
+
863
+
864
+ @app.command()
865
+ def server(host: str = "127.0.0.1", port: int = 8000, reload: bool = False) -> None:
866
+ try:
867
+ import uvicorn
868
+ except ImportError as exc:
869
+ raise typer.BadParameter(
870
+ "Install server dependencies with: uv sync --locked --extra server"
871
+ ) from exc
872
+ uvicorn.run("agentloop.server:app", host=host, port=port, reload=reload)
873
+
874
+
875
+ if __name__ == "__main__":
876
+ app()