agentloop-profiler 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. agentloop/__init__.py +136 -0
  2. agentloop/ablation_protocol.py +229 -0
  3. agentloop/ablations.py +564 -0
  4. agentloop/aggregate_cli.py +88 -0
  5. agentloop/aggregate_stats.py +175 -0
  6. agentloop/aggregates.py +532 -0
  7. agentloop/audit.py +39 -0
  8. agentloop/autoinstrument.py +145 -0
  9. agentloop/batch_experiments.py +295 -0
  10. agentloop/batching.py +95 -0
  11. agentloop/budget_types.py +335 -0
  12. agentloop/budgets.py +539 -0
  13. agentloop/calibration.py +872 -0
  14. agentloop/checkpoint_state.py +306 -0
  15. agentloop/checkpoint_store.py +86 -0
  16. agentloop/ci.py +202 -0
  17. agentloop/cli.py +1314 -0
  18. agentloop/client.py +228 -0
  19. agentloop/completion.py +345 -0
  20. agentloop/completion_types.py +199 -0
  21. agentloop/config.py +50 -0
  22. agentloop/context_controls.py +443 -0
  23. agentloop/context_experiments.py +337 -0
  24. agentloop/context_types.py +216 -0
  25. agentloop/costs.py +667 -0
  26. agentloop/cursor_validation.py +48 -0
  27. agentloop/decorators.py +445 -0
  28. agentloop/demo.py +300 -0
  29. agentloop/doctor.py +341 -0
  30. agentloop/drift.py +361 -0
  31. agentloop/drift_cli.py +147 -0
  32. agentloop/drift_types.py +110 -0
  33. agentloop/drift_windows.py +340 -0
  34. agentloop/entrypoint.py +201 -0
  35. agentloop/estimates.py +133 -0
  36. agentloop/events.py +75 -0
  37. agentloop/experiment_artifacts.py +80 -0
  38. agentloop/experiment_observations.py +169 -0
  39. agentloop/experiment_reports.py +545 -0
  40. agentloop/experiment_types.py +355 -0
  41. agentloop/experiments.py +392 -0
  42. agentloop/exporters.py +117 -0
  43. agentloop/finding_benchmark_types.py +131 -0
  44. agentloop/finding_benchmarks.py +424 -0
  45. agentloop/findings.py +419 -0
  46. agentloop/graph.py +464 -0
  47. agentloop/harness.py +825 -0
  48. agentloop/harness_bench/__init__.py +1 -0
  49. agentloop/harness_bench/cli.py +77 -0
  50. agentloop/harness_bench/drivers.py +96 -0
  51. agentloop/harness_bench/offline.py +507 -0
  52. agentloop/harness_evidence.py +585 -0
  53. agentloop/harness_usage.py +91 -0
  54. agentloop/html_report.py +860 -0
  55. agentloop/integrations/__init__.py +1 -0
  56. agentloop/integrations/crewai.py +144 -0
  57. agentloop/integrations/harbor/__init__.py +1 -0
  58. agentloop/integrations/harbor/atif.py +982 -0
  59. agentloop/integrations/harbor/atif_validation.py +329 -0
  60. agentloop/integrations/harbor/cli.py +207 -0
  61. agentloop/integrations/harbor/manifest.py +86 -0
  62. agentloop/integrations/harbor/trial_evidence.py +196 -0
  63. agentloop/integrations/harbor/trials.py +1109 -0
  64. agentloop/integrations/harbor/verifier.py +118 -0
  65. agentloop/integrations/langgraph.py +150 -0
  66. agentloop/integrations/langgraph_harness.py +466 -0
  67. agentloop/integrations/langgraph_recovery.py +366 -0
  68. agentloop/integrations/omnigent/__init__.py +1 -0
  69. agentloop/integrations/omnigent/cli.py +78 -0
  70. agentloop/integrations/omnigent/evidence.py +93 -0
  71. agentloop/integrations/omnigent/telemetry.py +422 -0
  72. agentloop/integrations/openai.py +415 -0
  73. agentloop/integrations/openai_agents.py +291 -0
  74. agentloop/integrations/sdk.py +82 -0
  75. agentloop/integrations/vercel_ai.py +83 -0
  76. agentloop/interoperability/__init__.py +5 -0
  77. agentloop/interoperability/artifacts.py +112 -0
  78. agentloop/interoperability/cli.py +59 -0
  79. agentloop/interoperability/cohort_evidence.py +175 -0
  80. agentloop/interoperability/cohorts.py +681 -0
  81. agentloop/interoperability/contracts.py +427 -0
  82. agentloop/interoperability/coordination.py +554 -0
  83. agentloop/interoperability/evidence.py +215 -0
  84. agentloop/interoperability/otlp_jsonl.py +775 -0
  85. agentloop/interoperability/otlp_validation.py +496 -0
  86. agentloop/interoperability/registry.py +561 -0
  87. agentloop/interoperability/validation.py +333 -0
  88. agentloop/intervention_service.py +110 -0
  89. agentloop/intervention_store.py +118 -0
  90. agentloop/interventions.py +190 -0
  91. agentloop/issues.py +90 -0
  92. agentloop/judgment_adapters.py +125 -0
  93. agentloop/judgment_benchmark_types.py +98 -0
  94. agentloop/judgment_benchmarks.py +606 -0
  95. agentloop/judgment_types.py +241 -0
  96. agentloop/judgment_views.py +38 -0
  97. agentloop/judgments.py +342 -0
  98. agentloop/loop_guards.py +274 -0
  99. agentloop/loop_types.py +66 -0
  100. agentloop/markdown.py +102 -0
  101. agentloop/metrics.py +342 -0
  102. agentloop/migrations.py +303 -0
  103. agentloop/model_routing.py +470 -0
  104. agentloop/onboarding.py +315 -0
  105. agentloop/operations.py +44 -0
  106. agentloop/optimizer.py +199 -0
  107. agentloop/otel.py +715 -0
  108. agentloop/otel_ids.py +81 -0
  109. agentloop/otel_semantics.py +134 -0
  110. agentloop/parallelism.py +141 -0
  111. agentloop/patches.py +603 -0
  112. agentloop/plan_export.py +126 -0
  113. agentloop/policy_promotion.py +325 -0
  114. agentloop/promotion_evidence.py +304 -0
  115. agentloop/promotion_types.py +209 -0
  116. agentloop/quality.py +446 -0
  117. agentloop/ranking.py +529 -0
  118. agentloop/recovery_evidence.py +96 -0
  119. agentloop/replay.py +753 -0
  120. agentloop/retention.py +375 -0
  121. agentloop/retention_types.py +113 -0
  122. agentloop/reuse_experiments.py +247 -0
  123. agentloop/routing_checks.py +116 -0
  124. agentloop/routing_types.py +241 -0
  125. agentloop/rules.py +396 -0
  126. agentloop/runtime.py +286 -0
  127. agentloop/savings.py +158 -0
  128. agentloop/scheduling_plan.py +99 -0
  129. agentloop/scheduling_types.py +169 -0
  130. agentloop/schema.py +257 -0
  131. agentloop/semantic_rules.py +200 -0
  132. agentloop/semantic_waste.py +345 -0
  133. agentloop/semantic_waste_types.py +88 -0
  134. agentloop/server.py +494 -0
  135. agentloop/store.py +1429 -0
  136. agentloop/structural_experiments.py +431 -0
  137. agentloop/structural_types.py +113 -0
  138. agentloop/structured_quality.py +1020 -0
  139. agentloop/studies.py +629 -0
  140. agentloop/study_statistics.py +58 -0
  141. agentloop/substitution_evidence.py +199 -0
  142. agentloop/substitution_export.py +190 -0
  143. agentloop/substitution_reports.py +407 -0
  144. agentloop/substitution_types.py +284 -0
  145. agentloop/substitutions.py +267 -0
  146. agentloop/timing.py +142 -0
  147. agentloop/tokens.py +188 -0
  148. agentloop/tool_scheduling.py +410 -0
  149. agentloop/trace_sources.py +31 -0
  150. agentloop/tracer.py +494 -0
  151. agentloop/value.py +306 -0
  152. agentloop/version.py +3 -0
  153. agentloop/workflow_types.py +264 -0
  154. agentloop/workflows.py +284 -0
  155. agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
  156. agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
  157. agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
  158. agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
  159. agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
  160. agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
  161. agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
  162. dashboard/__init__.py +5 -0
  163. dashboard/app.py +922 -0
  164. dashboard/streamlit_compat.py +63 -0
  165. dashboard/trace_upload.py +57 -0
  166. dashboard/value_view.py +105 -0
agentloop/cli.py ADDED
@@ -0,0 +1,1314 @@
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.ablation_protocol import AblationValidationError
12
+ from agentloop.ablations import ablation_to_markdown, summarize_ablation
13
+ from agentloop.aggregate_cli import aggregate_app
14
+ from agentloop.audit import estimate_improvement
15
+ from agentloop.autoinstrument import detect_integrations
16
+ from agentloop.calibration import (
17
+ CalibrationValidationError,
18
+ calibration_to_markdown,
19
+ summarize_calibration,
20
+ )
21
+ from agentloop.ci import build_ci_report, ci_report_to_markdown
22
+ from agentloop.client import AgentLoopClient, AgentLoopClientError
23
+ from agentloop.costs import format_cost_usd, is_cost_evaluable
24
+ from agentloop.demo import run_baseline, run_langgraph_style, run_optimized, run_proof_pair
25
+ from agentloop.doctor import run_doctor, run_production_check
26
+ from agentloop.drift_cli import drift_app
27
+ from agentloop.exporters import export_report_markdown
28
+ from agentloop.findings import build_diagnosis, diagnosis_to_markdown
29
+ from agentloop.harness_bench.cli import harness_app
30
+ from agentloop.integrations.harbor.cli import harbor_app
31
+ from agentloop.integrations.omnigent.cli import omnigent_app
32
+ from agentloop.interoperability.cli import telemetry_app
33
+ from agentloop.intervention_service import create_stored_intervention
34
+ from agentloop.interventions import (
35
+ InterventionConflictError,
36
+ InterventionReferenceError,
37
+ InterventionValidationError,
38
+ build_intervention,
39
+ )
40
+ from agentloop.issues import build_issue_drafts, issue_drafts_to_markdown
41
+ from agentloop.optimizer import build_optimization_plan
42
+ from agentloop.otel import trace_from_otel, trace_to_otel
43
+ from agentloop.patches import build_patch_plan, patch_plan_to_markdown
44
+ from agentloop.plan_export import export_optimization_json, export_optimization_markdown
45
+ from agentloop.quality import (
46
+ QualityValidationError,
47
+ build_quality_report,
48
+ load_quality_fixtures,
49
+ quality_report_to_markdown,
50
+ )
51
+ from agentloop.ranking import RankingSort, sort_ranked
52
+ from agentloop.replay import ReplayGates, build_replay_report, replay_report_to_markdown
53
+ from agentloop.store import (
54
+ DEFAULT_PAGE_SIZE,
55
+ FindingNotFoundError,
56
+ FindingTransitionError,
57
+ get_store,
58
+ )
59
+ from agentloop.studies import StudyValidationError, study_to_markdown, summarize_study
60
+ from agentloop.timing import format_duration_ms
61
+ from agentloop.tracer import AgentTrace
62
+ from agentloop.value import build_value_report
63
+
64
+ app = typer.Typer(help="AgentLoop profiler CLI")
65
+ console = Console()
66
+ study_app = typer.Typer(help="Summarize offline studies and preserved intervention evidence.")
67
+ app.add_typer(study_app, name="study")
68
+ app.add_typer(aggregate_app, name="aggregate")
69
+ app.add_typer(drift_app, name="drift")
70
+ app.add_typer(harbor_app, name="harbor")
71
+ app.add_typer(telemetry_app, name="telemetry")
72
+ app.add_typer(omnigent_app, name="omnigent")
73
+ app.add_typer(harness_app, name="harness")
74
+
75
+
76
+ @app.command("onboard")
77
+ def onboard_command(
78
+ path: Path,
79
+ format: str = typer.Option(
80
+ "native", help="native or otlp; OTLP includes supported OpenInference attributes."
81
+ ),
82
+ out: Path = typer.Option(
83
+ Path("runs/onboarding.json"), help="Validation and payload-free analysis summary."
84
+ ),
85
+ expected_operation: list[str] | None = typer.Option(
86
+ None, help="Repeat for expected model/tool/classifier/etc. kinds."
87
+ ),
88
+ analyze: bool = typer.Option(
89
+ True, help="Analyze after validation; limited to 2000 spans per trace."
90
+ ),
91
+ ) -> None:
92
+ """Validate captured evidence and analyze existing telemetry without application changes."""
93
+ from agentloop.aggregates import read_json
94
+ from agentloop.onboarding import onboard
95
+
96
+ if path.resolve() == out.resolve():
97
+ raise typer.BadParameter("output must differ from the source telemetry", param_hint="out")
98
+ try:
99
+ result = onboard(
100
+ read_json(path),
101
+ format=format,
102
+ expected_operations=tuple(expected_operation or ()),
103
+ analyze=analyze,
104
+ )
105
+ except (OSError, ValueError, TypeError, OverflowError) as exc:
106
+ raise typer.BadParameter(str(exc), param_hint="path/format") from None
107
+ _write_json(out, result)
108
+ console.print(
109
+ f"Capture status: {result['status']}; traces: {result['trace_count']}", markup=False
110
+ )
111
+ for item in result["traces"]:
112
+ console.print(
113
+ f"Trace {item['run_id']}: {item['validation']['status']}; analysis {item['analysis_status']}",
114
+ markup=False,
115
+ )
116
+ for check in item["validation"]["checks"]:
117
+ console.print(f"- {check['code']} ({check['count']}): {check['detail']}", markup=False)
118
+ if item["analysis"] is not None:
119
+ report = item["analysis"]
120
+ console.print(
121
+ f" Runtime {report['total_runtime_ms']} ms; model calls {report['model_call_count']}; token basis {report['token_status']}; findings {len(item['findings'])}",
122
+ markup=False,
123
+ )
124
+ console.print(f"Wrote onboarding evidence to {out}")
125
+ if result["status"] in {"invalid", "no_execution_data"}:
126
+ raise typer.Exit(code=1)
127
+
128
+
129
+ @study_app.command("ablation")
130
+ def study_ablation_command(
131
+ bundle: Path,
132
+ out: Path = typer.Option(Path("runs/ablation.md"), help="Readable ablation report."),
133
+ json_out: Path = typer.Option(
134
+ Path("runs/ablation.json"), help="Full observations and denominators."
135
+ ),
136
+ ) -> None:
137
+ try:
138
+ report = summarize_ablation(bundle)
139
+ except AblationValidationError as exc:
140
+ raise typer.BadParameter(str(exc), param_hint="bundle") from None
141
+ outputs = {out.resolve(), json_out.resolve()}
142
+ sources = {Path(source).resolve() for source in report["source_paths"]}
143
+ if len(outputs) != 2 or outputs & sources:
144
+ raise typer.BadParameter(
145
+ "report paths must differ from each other and all source evidence",
146
+ param_hint="out/json-out",
147
+ )
148
+ out.parent.mkdir(parents=True, exist_ok=True)
149
+ out.write_text(ablation_to_markdown(report), encoding="utf-8")
150
+ _write_json(json_out, report)
151
+ console.print(f"Wrote ablation reports to {out} and {json_out}")
152
+
153
+
154
+ @study_app.command("calibrate")
155
+ def study_calibrate_command(
156
+ manifest: Path,
157
+ out: Path = typer.Option(Path("runs/calibration.md"), help="Readable historical calibration."),
158
+ json_out: Path = typer.Option(
159
+ Path("runs/calibration.json"), help="Versioned calibration artifact."
160
+ ),
161
+ ) -> None:
162
+ try:
163
+ report = summarize_calibration(manifest)
164
+ except CalibrationValidationError as exc:
165
+ raise typer.BadParameter(str(exc), param_hint="manifest") from None
166
+ outputs = {out.resolve(), json_out.resolve()}
167
+ sources = {Path(source).resolve() for source in report["source_paths"]}
168
+ if len(outputs) != 2 or outputs & sources:
169
+ raise typer.BadParameter(
170
+ "report paths must differ from each other and all source evidence",
171
+ param_hint="out/json-out",
172
+ )
173
+ out.parent.mkdir(parents=True, exist_ok=True)
174
+ out.write_text(calibration_to_markdown(report), encoding="utf-8")
175
+ _write_json(json_out, report)
176
+ console.print(f"Wrote calibration reports to {out} and {json_out}")
177
+
178
+
179
+ @study_app.command("summarize")
180
+ def study_summarize_command(
181
+ manifest: Path,
182
+ out: Path = typer.Option(Path("runs/study.md"), help="Human-readable study report."),
183
+ json_out: Path = typer.Option(
184
+ Path("runs/study.json"), help="Full study results and unmatched runs."
185
+ ),
186
+ ) -> None:
187
+ try:
188
+ report = summarize_study(manifest)
189
+ except StudyValidationError as exc:
190
+ raise typer.BadParameter(str(exc), param_hint="manifest") from None
191
+ out.parent.mkdir(parents=True, exist_ok=True)
192
+ out.write_text(study_to_markdown(report), encoding="utf-8")
193
+ _write_json(json_out, report)
194
+ console.print(f"Wrote study reports to {out} and {json_out}")
195
+ for name, comparison in report["comparisons"].items():
196
+ console.print(
197
+ f"{name}: {comparison['pair_count']} paired runs, {len(comparison['unmatched'])} unmatched runs"
198
+ )
199
+
200
+
201
+ def _remote_client(api_url: str, api_key: str | None) -> AgentLoopClient:
202
+ resolved_api_key = api_key if api_key is not None else os.getenv("AGENTLOOP_API_KEY")
203
+ return AgentLoopClient(base_url=api_url, api_key=resolved_api_key)
204
+
205
+
206
+ def _remote_admin_client(api_url: str, admin_api_key: str | None) -> AgentLoopClient:
207
+ resolved_admin_key = (
208
+ admin_api_key if admin_api_key is not None else os.getenv("AGENTLOOP_ADMIN_API_KEY")
209
+ )
210
+ return AgentLoopClient(base_url=api_url, admin_api_key=resolved_admin_key)
211
+
212
+
213
+ def _read_json_input(path: Path, *, param_hint: str) -> object:
214
+ try:
215
+ exists = path.exists()
216
+ is_file = path.is_file() if exists else False
217
+ except OSError as exc:
218
+ raise typer.BadParameter(
219
+ f"Input file is not readable: {path} ({exc})", param_hint=param_hint
220
+ ) from exc
221
+
222
+ if not exists:
223
+ raise typer.BadParameter(
224
+ f"Input file does not exist: {path}. "
225
+ "Generate synthetic traces explicitly with `agentloop demo` or `agentloop demo-all`.",
226
+ param_hint=param_hint,
227
+ )
228
+ if not is_file:
229
+ raise typer.BadParameter(f"Input path is not a regular file: {path}", param_hint=param_hint)
230
+
231
+ try:
232
+ raw = path.read_text(encoding="utf-8")
233
+ except (OSError, UnicodeError) as exc:
234
+ raise typer.BadParameter(
235
+ f"Input file is not readable as UTF-8: {path} ({exc})", param_hint=param_hint
236
+ ) from exc
237
+ try:
238
+ return json.loads(raw)
239
+ except json.JSONDecodeError as exc:
240
+ raise typer.BadParameter(
241
+ f"Input file is not valid JSON: {path} ({exc.msg} at line {exc.lineno}, "
242
+ f"column {exc.colno})",
243
+ param_hint=param_hint,
244
+ ) from exc
245
+
246
+
247
+ def _write_json(out: Path, payload: dict) -> None:
248
+ out.parent.mkdir(parents=True, exist_ok=True)
249
+ out.write_text(json.dumps(payload, indent=2), encoding="utf-8")
250
+
251
+
252
+ def _load_trace(
253
+ path: Path,
254
+ *,
255
+ otel: bool = False,
256
+ name: str | None = None,
257
+ param_hint: str = "path",
258
+ ) -> AgentTrace:
259
+ payload = _read_json_input(path, param_hint=param_hint)
260
+ format_name = "OpenTelemetry" if otel else "AgentLoop"
261
+ try:
262
+ if otel:
263
+ if not isinstance(payload, (dict, list)):
264
+ raise ValueError("expected a JSON object or array")
265
+ return trace_from_otel(payload, name=name)
266
+ if not isinstance(payload, dict):
267
+ raise ValueError("expected a JSON object")
268
+ return AgentTrace.from_dict(payload)
269
+ except (AttributeError, KeyError, TypeError, ValueError) as exc:
270
+ raise typer.BadParameter(
271
+ f"Input file is not a valid {format_name} trace: {path} ({exc})",
272
+ param_hint=param_hint,
273
+ ) from exc
274
+
275
+
276
+ def _build_quality_report_from_file(
277
+ path: Path,
278
+ *,
279
+ param_hint: str,
280
+ baseline_trace: AgentTrace | None = None,
281
+ candidate_trace: AgentTrace | None = None,
282
+ min_score: float | None = None,
283
+ ) -> dict:
284
+ try:
285
+ fixtures = load_quality_fixtures(path)
286
+ return build_quality_report(
287
+ fixtures,
288
+ baseline_trace=baseline_trace,
289
+ candidate_trace=candidate_trace,
290
+ min_score=min_score,
291
+ )
292
+ except (OSError, UnicodeError, QualityValidationError) as exc:
293
+ raise typer.BadParameter(
294
+ f"Invalid quality fixture file: {path} ({exc})",
295
+ param_hint=param_hint,
296
+ ) from exc
297
+
298
+
299
+ def _print_value_summary(value: dict) -> None:
300
+ monthly = value["monthly_value"]
301
+ per_run = value["per_run"]
302
+ reliability = value["reliability"]
303
+ pricing = value.get("pricing", {})
304
+ table = Table(title="AgentLoop Value Report")
305
+ table.add_column("Metric")
306
+ table.add_column("Value")
307
+ table.add_row(
308
+ "Total monthly value", format_cost_usd(monthly.get("total_value_usd"), decimals=2)
309
+ )
310
+ table.add_row(
311
+ "Model cost savings / month",
312
+ format_cost_usd(monthly.get("direct_model_cost_savings_usd"), decimals=2),
313
+ )
314
+ table.add_row("Engineering hours saved / month", str(monthly["engineering_hours_saved"]))
315
+ table.add_row("Latency saved / run", f"{per_run['latency_savings_ms'] / 1000:.3f}s")
316
+ table.add_row("Cost saved / run", format_cost_usd(per_run.get("cost_savings_usd"), decimals=6))
317
+ table.add_row("Reliability risk score", f"{reliability['risk_score']}/100")
318
+ if pricing:
319
+ table.add_row("Suggested plan", str(pricing.get("suggested_plan") or "unavailable"))
320
+ table.add_row(
321
+ "Suggested monthly price",
322
+ format_cost_usd(pricing.get("suggested_monthly_price_usd"), decimals=0),
323
+ )
324
+ ratio = pricing.get("value_to_price_ratio")
325
+ table.add_row("Value / price ratio", "n/a" if ratio is None else f"{ratio}x")
326
+ console.print(table)
327
+ console.print(value["value_summary"])
328
+
329
+
330
+ def _print_doctor(result: dict) -> None:
331
+ table = Table(title="AgentLoop Doctor")
332
+ table.add_column("Status")
333
+ table.add_column("Check")
334
+ table.add_column("Detail")
335
+ table.add_column("Fix")
336
+ for check in result["checks"]:
337
+ status = check["status"]
338
+ style = "green" if status == "ok" else "yellow" if status == "warn" else "red"
339
+ table.add_row(
340
+ f"[{style}]{status}[/{style}]", check["name"], check["detail"], check.get("fix", "")
341
+ )
342
+ console.print(table)
343
+ if result["failed"]:
344
+ raise typer.Exit(1)
345
+
346
+
347
+ @app.command()
348
+ def report(path: Path) -> None:
349
+ trace = _load_trace(path)
350
+ trace.print_report()
351
+
352
+
353
+ @app.command()
354
+ def compare(
355
+ baseline: Path = Path("runs/research_agent_baseline.json"),
356
+ optimized: Path = Path("runs/research_agent_optimized.json"),
357
+ ) -> None:
358
+ base_trace = _load_trace(baseline, param_hint="--baseline")
359
+ opt_trace = _load_trace(optimized, param_hint="--optimized")
360
+ from agentloop.interoperability.evidence import require_external_comparison
361
+
362
+ try:
363
+ require_external_comparison(base_trace, "compare")
364
+ require_external_comparison(opt_trace, "compare")
365
+ except ValueError as exc:
366
+ raise typer.BadParameter(str(exc), param_hint="--baseline/--optimized") from None
367
+ base = base_trace.report()
368
+ opt = opt_trace.report()
369
+ table = Table(title="AgentLoop Comparison")
370
+ table.add_column("Metric")
371
+ table.add_column("Baseline")
372
+ table.add_column("Optimized")
373
+ table.add_column("Delta")
374
+
375
+ pairs = [
376
+ ("Runtime sec", base["total_runtime_ms"] / 1000, opt["total_runtime_ms"] / 1000),
377
+ ("Cost USD", base["estimated_cost_usd"], opt["estimated_cost_usd"]),
378
+ ("Input tokens", base["input_tokens"], opt["input_tokens"]),
379
+ ("Retry count", base["retry_count"], opt["retry_count"]),
380
+ ]
381
+ for name, b, o in pairs:
382
+ if name == "Cost USD" and not (
383
+ is_cost_evaluable(base.get("cost_status", "complete"))
384
+ and is_cost_evaluable(opt.get("cost_status", "complete"))
385
+ ):
386
+ table.add_row(
387
+ name,
388
+ format_cost_usd(b, base.get("cost_status")),
389
+ format_cost_usd(o, opt.get("cost_status")),
390
+ "unavailable",
391
+ )
392
+ continue
393
+ table.add_row(
394
+ name,
395
+ f"{b:.4f}" if isinstance(b, float) else str(b),
396
+ f"{o:.4f}" if isinstance(o, float) else str(o),
397
+ f"{(o - b):.4f}" if isinstance(o, float) else str(o - b),
398
+ )
399
+ console.print(table)
400
+
401
+
402
+ @app.command("replay")
403
+ def replay_command(
404
+ baseline: Path = Path("runs/research_agent_baseline.json"),
405
+ candidate: Path = Path("runs/research_agent_optimized.json"),
406
+ out: Path = Path("runs/replay_report.md"),
407
+ json_out: Path | None = typer.Option(
408
+ None, help="Optional machine-readable replay report output."
409
+ ),
410
+ max_cost_regression_pct: float = typer.Option(0.0, min=0.0),
411
+ max_latency_regression_pct: float = typer.Option(0.0, min=0.0),
412
+ min_latency_improvement_pct: float = typer.Option(0.0, min=0.0),
413
+ min_cost_improvement_pct: float = typer.Option(0.0, min=0.0),
414
+ require_retry_non_increase: bool = typer.Option(True),
415
+ require_schema_valid: bool = typer.Option(
416
+ False, help="Require candidate trace metadata/report to mark schema output as valid."
417
+ ),
418
+ min_quality_score: float | None = typer.Option(
419
+ None, min=0.0, max=1.0, help="Optional minimum candidate quality score."
420
+ ),
421
+ quality_fixtures: Path | None = typer.Option(None, help="Optional quality fixture JSON file."),
422
+ fail_on_gate: bool = typer.Option(True, help="Exit non-zero when replay gates fail."),
423
+ intervention_out: Path | None = typer.Option(
424
+ None, help="Export linked intervention evidence, including failed gates."
425
+ ),
426
+ target_finding: list[str] | None = typer.Option(
427
+ None, help="Baseline finding ID; repeat for multiple targets."
428
+ ),
429
+ intervention_type: str | None = typer.Option(
430
+ None, help="Applied change, such as context_compression."
431
+ ),
432
+ intervention_config: Path | None = typer.Option(
433
+ None, help="JSON object describing the applied change."
434
+ ),
435
+ intervention_metadata: Path | None = typer.Option(
436
+ None, help="JSON object with task, seed, or experiment metadata."
437
+ ),
438
+ baseline_diagnosis: Path | None = typer.Option(
439
+ None, help="Optional saved baseline diagnosis for historical prediction snapshots."
440
+ ),
441
+ ) -> None:
442
+ baseline_trace = _load_trace(baseline, param_hint="--baseline")
443
+ candidate_trace = _load_trace(candidate, param_hint="--candidate")
444
+ quality = (
445
+ _build_quality_report_from_file(
446
+ quality_fixtures,
447
+ param_hint="--quality-fixtures",
448
+ baseline_trace=baseline_trace,
449
+ candidate_trace=candidate_trace,
450
+ min_score=min_quality_score,
451
+ )
452
+ if quality_fixtures is not None
453
+ else None
454
+ )
455
+ report_data = build_replay_report(
456
+ baseline_trace,
457
+ candidate_trace,
458
+ gates=ReplayGates(
459
+ max_cost_regression_pct=max_cost_regression_pct,
460
+ max_latency_regression_pct=max_latency_regression_pct,
461
+ min_latency_improvement_pct=min_latency_improvement_pct,
462
+ min_cost_improvement_pct=min_cost_improvement_pct,
463
+ require_retry_non_increase=require_retry_non_increase,
464
+ require_schema_valid=require_schema_valid,
465
+ min_quality_score=min_quality_score,
466
+ ),
467
+ quality_report=quality,
468
+ )
469
+ intervention = None
470
+ if intervention_out is not None:
471
+ try:
472
+ intervention = build_intervention(
473
+ baseline_trace,
474
+ candidate_trace,
475
+ target_finding_ids=target_finding or [],
476
+ intervention_type=intervention_type,
477
+ configuration=_json_object_input(intervention_config, "--intervention-config"),
478
+ metadata=_json_object_input(intervention_metadata, "--intervention-metadata"),
479
+ diagnosis=_json_object_input(baseline_diagnosis, "--baseline-diagnosis")
480
+ if baseline_diagnosis
481
+ else None,
482
+ replay_report=report_data,
483
+ ).to_dict()
484
+ except (InterventionValidationError, InterventionReferenceError) as exc:
485
+ raise typer.BadParameter(str(exc), param_hint="--intervention-out") from None
486
+ elif any(
487
+ (
488
+ target_finding,
489
+ intervention_type,
490
+ intervention_config,
491
+ intervention_metadata,
492
+ baseline_diagnosis,
493
+ )
494
+ ):
495
+ raise typer.BadParameter("intervention options require --intervention-out")
496
+ out.parent.mkdir(parents=True, exist_ok=True)
497
+ out.write_text(replay_report_to_markdown(report_data), encoding="utf-8")
498
+ if intervention is not None:
499
+ _write_json(intervention_out, intervention)
500
+ if json_out is not None:
501
+ _write_json(json_out, report_data)
502
+ console.print(f"Wrote replay report to {out}")
503
+ if json_out is not None:
504
+ console.print(f"Wrote replay JSON to {json_out}")
505
+ console.print(report_data["summary"])
506
+ if fail_on_gate and not report_data["gates"]["passed"]:
507
+ raise typer.Exit(1)
508
+
509
+
510
+ def _json_object_input(path: Path | None, param_hint: str) -> dict:
511
+ if path is None:
512
+ return {}
513
+ payload = _read_json_input(path, param_hint=param_hint)
514
+ if not isinstance(payload, dict):
515
+ raise typer.BadParameter("expected a JSON object", param_hint=param_hint)
516
+ return payload
517
+
518
+
519
+ @app.command("intervention-create")
520
+ def intervention_create_command(
521
+ request: Path,
522
+ out: Path = typer.Option(
523
+ Path("runs/intervention.json"), help="Export the stored evidence as JSON."
524
+ ),
525
+ project_id: str = typer.Option(
526
+ "default", help="Project for a local store; remote API keys determine their project."
527
+ ),
528
+ api_url: str | None = typer.Option(
529
+ None, help="Use a remote API instead of the configured local store."
530
+ ),
531
+ api_key: str | None = typer.Option(
532
+ None, help="Remote project API key; defaults to AGENTLOOP_API_KEY."
533
+ ),
534
+ ) -> None:
535
+ payload = _json_object_input(request, "request")
536
+ try:
537
+ record = (
538
+ _remote_client(api_url, api_key).create_intervention(payload)
539
+ if api_url
540
+ else create_stored_intervention(get_store(), payload, project_id)
541
+ )
542
+ except (
543
+ InterventionValidationError,
544
+ InterventionReferenceError,
545
+ InterventionConflictError,
546
+ QualityValidationError,
547
+ AgentLoopClientError,
548
+ ) as exc:
549
+ raise typer.BadParameter(str(exc), param_hint="request") from None
550
+ _write_json(out, record)
551
+ console.print(f"Wrote intervention {record['intervention_id']} to {out}")
552
+
553
+
554
+ @app.command("intervention-get")
555
+ def intervention_get_command(
556
+ intervention_id: str,
557
+ out: Path = typer.Option(Path("runs/intervention.json")),
558
+ project_id: str = typer.Option(
559
+ "default", help="Local project; remote API keys determine their project."
560
+ ),
561
+ api_url: str | None = typer.Option(None),
562
+ api_key: str | None = typer.Option(None),
563
+ ) -> None:
564
+ try:
565
+ record = (
566
+ _remote_client(api_url, api_key).get_intervention(intervention_id)
567
+ if api_url
568
+ else get_store().get_intervention(intervention_id, project_id=project_id)
569
+ )
570
+ except AgentLoopClientError as exc:
571
+ raise typer.BadParameter(str(exc), param_hint="intervention_id") from None
572
+ if record is None:
573
+ raise typer.BadParameter("intervention not found", param_hint="intervention_id")
574
+ _write_json(out, record)
575
+ console.print(f"Wrote intervention to {out}")
576
+
577
+
578
+ @app.command("quality-report")
579
+ def quality_report_command(
580
+ fixtures: Path,
581
+ baseline: Path | None = typer.Option(None, help="Optional baseline trace JSON."),
582
+ candidate: Path | None = typer.Option(None, help="Optional candidate trace JSON."),
583
+ out: Path = Path("runs/quality_report.md"),
584
+ json_out: Path | None = typer.Option(
585
+ None, help="Optional machine-readable quality report output."
586
+ ),
587
+ min_score: float | None = typer.Option(None, min=0.0, max=1.0),
588
+ ) -> None:
589
+ baseline_trace = (
590
+ _load_trace(baseline, param_hint="--baseline") if baseline is not None else None
591
+ )
592
+ candidate_trace = (
593
+ _load_trace(candidate, param_hint="--candidate") if candidate is not None else None
594
+ )
595
+ report_data = _build_quality_report_from_file(
596
+ fixtures,
597
+ param_hint="fixtures",
598
+ baseline_trace=baseline_trace,
599
+ candidate_trace=candidate_trace,
600
+ min_score=min_score,
601
+ )
602
+ out.parent.mkdir(parents=True, exist_ok=True)
603
+ out.write_text(quality_report_to_markdown(report_data), encoding="utf-8")
604
+ if json_out is not None:
605
+ _write_json(json_out, report_data)
606
+ console.print(f"Wrote quality JSON to {json_out}")
607
+ console.print(f"Wrote quality report to {out}")
608
+ if not report_data["passed"]:
609
+ raise typer.Exit(1)
610
+
611
+
612
+ @app.command("ci")
613
+ def ci_command(
614
+ baseline: Path = Path("runs/research_agent_baseline.json"),
615
+ candidate: Path = Path("runs/research_agent_optimized.json"),
616
+ out: Path = Path("runs/agentloop_ci.md"),
617
+ json_out: Path | None = typer.Option(None, help="Optional machine-readable CI report output."),
618
+ max_cost_regression_pct: float = typer.Option(0.0, min=0.0),
619
+ max_latency_regression_pct: float = typer.Option(0.0, min=0.0),
620
+ min_latency_improvement_pct: float = typer.Option(0.0, min=0.0),
621
+ min_cost_improvement_pct: float = typer.Option(0.0, min=0.0),
622
+ require_retry_non_increase: bool = typer.Option(True),
623
+ require_schema_valid: bool = typer.Option(
624
+ False, help="Require candidate trace metadata/report to mark schema output as valid."
625
+ ),
626
+ min_quality_score: float | None = typer.Option(
627
+ None, min=0.0, max=1.0, help="Optional minimum candidate quality score."
628
+ ),
629
+ quality_fixtures: Path | None = typer.Option(None, help="Optional quality fixture JSON file."),
630
+ github_step_summary: bool = typer.Option(
631
+ False, help="Append report Markdown to GITHUB_STEP_SUMMARY."
632
+ ),
633
+ fail_on_gate: bool = typer.Option(True, help="Exit non-zero when CI gates fail."),
634
+ ) -> None:
635
+ baseline_trace = _load_trace(baseline, param_hint="--baseline")
636
+ candidate_trace = _load_trace(candidate, param_hint="--candidate")
637
+ quality = (
638
+ _build_quality_report_from_file(
639
+ quality_fixtures,
640
+ param_hint="--quality-fixtures",
641
+ baseline_trace=baseline_trace,
642
+ candidate_trace=candidate_trace,
643
+ min_score=min_quality_score,
644
+ )
645
+ if quality_fixtures is not None
646
+ else None
647
+ )
648
+ report_data = build_ci_report(
649
+ baseline_trace,
650
+ candidate_trace,
651
+ gates=ReplayGates(
652
+ max_cost_regression_pct=max_cost_regression_pct,
653
+ max_latency_regression_pct=max_latency_regression_pct,
654
+ min_latency_improvement_pct=min_latency_improvement_pct,
655
+ min_cost_improvement_pct=min_cost_improvement_pct,
656
+ require_retry_non_increase=require_retry_non_increase,
657
+ require_schema_valid=require_schema_valid,
658
+ min_quality_score=min_quality_score,
659
+ ),
660
+ quality_report=quality,
661
+ )
662
+ report_data["trace_inputs"]["baseline"]["path"] = str(baseline)
663
+ report_data["trace_inputs"]["candidate"]["path"] = str(candidate)
664
+ markdown = ci_report_to_markdown(report_data)
665
+ out.parent.mkdir(parents=True, exist_ok=True)
666
+ out.write_text(markdown, encoding="utf-8")
667
+ if json_out is not None:
668
+ _write_json(json_out, report_data)
669
+ if github_step_summary:
670
+ summary_path = os.getenv("GITHUB_STEP_SUMMARY")
671
+ if summary_path:
672
+ with open(summary_path, "a", encoding="utf-8") as handle:
673
+ handle.write(markdown)
674
+ console.print(f"Wrote CI report to {out}")
675
+ if json_out is not None:
676
+ console.print(f"Wrote CI JSON to {json_out}")
677
+ console.print(report_data["replay"]["summary"])
678
+ if fail_on_gate and not report_data["passed"]:
679
+ raise typer.Exit(1)
680
+
681
+
682
+ @app.command("dump-report")
683
+ def dump_report(path: Path, out: Path) -> None:
684
+ trace = _load_trace(path)
685
+ out.parent.mkdir(parents=True, exist_ok=True)
686
+ out.write_text(json.dumps(trace.report(), indent=2), encoding="utf-8")
687
+ console.print(f"Wrote {out}")
688
+
689
+
690
+ @app.command()
691
+ def audit(
692
+ path: Path = Path("runs/research_agent_baseline.json"),
693
+ out: Path = Path("runs/agentloop_audit.md"),
694
+ ) -> None:
695
+ trace = _load_trace(path, param_hint="--path")
696
+ report_data = trace.report()
697
+ improvement = estimate_improvement(report_data)
698
+ report_data["estimated_latency_savings_ms"] = improvement["estimated_latency_savings_ms"]
699
+ report_data["estimated_cost_savings_usd"] = improvement["estimated_cost_savings_usd"]
700
+ export_report_markdown(report_data, out)
701
+ console.print(f"Wrote audit to {out}")
702
+ console.print(
703
+ f"Estimated savings: {improvement['estimated_latency_savings_ms'] / 1000:.2f}s latency, "
704
+ f"{format_cost_usd(improvement['estimated_cost_savings_usd'])} cost per run"
705
+ )
706
+
707
+
708
+ @app.command()
709
+ def optimize(
710
+ path: Path = Path("runs/research_agent_baseline.json"),
711
+ out: Path = Path("runs/optimization_plan.md"),
712
+ json_out: Path | None = typer.Option(None, help="Optional JSON output path."),
713
+ ) -> None:
714
+ trace = _load_trace(path, param_hint="--path")
715
+ plan = build_optimization_plan(trace)
716
+ export_optimization_markdown(plan, out)
717
+ if json_out is not None:
718
+ export_optimization_json(plan, json_out)
719
+ console.print(f"Wrote optimization plan to {out}")
720
+ if json_out is not None:
721
+ console.print(f"Wrote optimization JSON to {json_out}")
722
+ after = plan["estimated_after"]
723
+ console.print(
724
+ "Estimated improvement: "
725
+ + (
726
+ "unavailable latency, "
727
+ if after["latency_reduction_pct"] is None
728
+ else f"{after['latency_reduction_pct']:.1f}% latency, "
729
+ )
730
+ + (
731
+ "unavailable cost"
732
+ if after["cost_reduction_pct"] is None
733
+ else f"{after['cost_reduction_pct']:.1f}% cost"
734
+ )
735
+ )
736
+
737
+
738
+ @app.command("diagnose")
739
+ def diagnose(
740
+ path: Path = Path("runs/research_agent_baseline.json"),
741
+ out: Path = Path("runs/diagnosis.md"),
742
+ json_out: Path | None = typer.Option(None, help="Optional machine-readable diagnosis output."),
743
+ otel: bool = typer.Option(False, help="Read the input path as OTLP/GenAI-style JSON."),
744
+ name: str | None = typer.Option(None, help="Trace name to use when importing OTLP JSON."),
745
+ sort_by: RankingSort = typer.Option(
746
+ RankingSort.PRIORITY, help="Finding ranking dimension; unknown values stay explicit."
747
+ ),
748
+ ) -> None:
749
+ trace = _load_trace(path, otel=otel, name=name, param_hint="--path")
750
+ diagnosis = build_diagnosis(trace, sort_by=sort_by.value)
751
+ out.parent.mkdir(parents=True, exist_ok=True)
752
+ out.write_text(diagnosis_to_markdown(diagnosis), encoding="utf-8")
753
+ if json_out is not None:
754
+ _write_json(json_out, diagnosis)
755
+ summary = diagnosis["summary"]
756
+ console.print(f"Wrote diagnosis to {out}")
757
+ if json_out is not None:
758
+ console.print(f"Wrote diagnosis JSON to {json_out}")
759
+ console.print(
760
+ f"Findings: {summary['finding_count']} "
761
+ f"({summary['patchable_count']} patchable), "
762
+ "estimated improvement: "
763
+ + (
764
+ "unavailable latency, "
765
+ if summary["estimated_latency_reduction_pct"] is None
766
+ else f"{summary['estimated_latency_reduction_pct']:.1f}% latency, "
767
+ )
768
+ + (
769
+ "unavailable cost"
770
+ if summary["estimated_cost_reduction_pct"] is None
771
+ else f"{summary['estimated_cost_reduction_pct']:.1f}% cost"
772
+ )
773
+ )
774
+
775
+
776
+ @app.command("import-otel")
777
+ def import_otel(
778
+ path: Path,
779
+ out: Path,
780
+ name: str | None = typer.Option(None, help="Optional imported trace name."),
781
+ ) -> None:
782
+ trace = _load_trace(path, otel=True, name=name)
783
+ trace.export_json(out)
784
+ console.print(f"Imported OTLP trace {trace.run_id} to {out}")
785
+
786
+
787
+ @app.command("export-otel")
788
+ def export_otel(path: Path, out: Path) -> None:
789
+ trace = _load_trace(path)
790
+ _write_json(out, trace_to_otel(trace))
791
+ console.print(f"Exported OTLP-style trace to {out}")
792
+
793
+
794
+ @app.command("patch")
795
+ def patch_command(
796
+ path: Path = Path("runs/research_agent_baseline.json"),
797
+ repo: Path = Path("."),
798
+ out: Path = Path("runs/patch_plan.md"),
799
+ json_out: Path | None = typer.Option(None, help="Optional machine-readable patch plan output."),
800
+ otel: bool = typer.Option(False, help="Read the input path as OTLP/GenAI-style JSON."),
801
+ name: str | None = typer.Option(None, help="Trace name to use when importing OTLP JSON."),
802
+ dry_run: bool = typer.Option(
803
+ True, help="Only generate a patch plan. File edits are not supported yet."
804
+ ),
805
+ ) -> None:
806
+ if not dry_run:
807
+ raise typer.BadParameter(
808
+ "Only --dry-run is supported. Generate a patch plan first, then apply manually."
809
+ )
810
+ trace = _load_trace(path, otel=otel, name=name, param_hint="--path")
811
+ try:
812
+ plan = build_patch_plan(trace, repo_path=repo)
813
+ except ValueError as exc:
814
+ raise typer.BadParameter(str(exc), param_hint="--repo") from exc
815
+ out.parent.mkdir(parents=True, exist_ok=True)
816
+ out.write_text(patch_plan_to_markdown(plan), encoding="utf-8")
817
+ if json_out is not None:
818
+ _write_json(json_out, plan)
819
+ console.print(f"Wrote patch plan to {out}")
820
+ if json_out is not None:
821
+ console.print(f"Wrote patch plan JSON to {json_out}")
822
+ summary = plan["summary"]
823
+ console.print(
824
+ f"Patch plans: {summary['patch_count']}, "
825
+ f"unsupported findings: {summary['unsupported_finding_count']}, "
826
+ f"frameworks: {', '.join(summary['frameworks_detected']) or 'none'}"
827
+ )
828
+
829
+
830
+ @app.command("value-report")
831
+ def value_report(
832
+ path: Path = Path("runs/research_agent_baseline.json"),
833
+ out: Path = Path("runs/value_report.json"),
834
+ runs_per_month: int = typer.Option(1000, min=0),
835
+ engineer_hourly_rate_usd: float = typer.Option(150.0, min=0),
836
+ incident_cost_usd: float = typer.Option(500.0, min=0),
837
+ ) -> None:
838
+ trace = _load_trace(path, param_hint="--path")
839
+ value = build_value_report(
840
+ trace,
841
+ runs_per_month=runs_per_month,
842
+ engineer_hourly_rate_usd=engineer_hourly_rate_usd,
843
+ incident_cost_usd=incident_cost_usd,
844
+ )
845
+ _write_json(out, value)
846
+ _print_value_summary(value)
847
+ console.print(f"Wrote value report to {out}")
848
+
849
+
850
+ @app.command("doctor")
851
+ def doctor_command(
852
+ check_api: bool = typer.Option(True, help="Also call the configured /health endpoint."),
853
+ json_out: Path | None = typer.Option(
854
+ None, help="Optional path for machine-readable doctor output."
855
+ ),
856
+ ) -> None:
857
+ result = run_doctor(check_api=check_api)
858
+ if json_out:
859
+ _write_json(json_out, result)
860
+ console.print(f"Wrote doctor output to {json_out}")
861
+ _print_doctor(result)
862
+
863
+
864
+ @app.command("production-check")
865
+ def production_check_command(
866
+ check_api: bool = typer.Option(True, help="Call the configured /health and /readyz endpoints."),
867
+ check_store: bool = typer.Option(True, help="Initialize and query the configured store."),
868
+ allow_http: bool = typer.Option(
869
+ False, help="Allow non-HTTPS AGENTLOOP_API_URL for local staging."
870
+ ),
871
+ json_out: Path | None = typer.Option(
872
+ None, help="Optional path for machine-readable check output."
873
+ ),
874
+ ) -> None:
875
+ result = run_production_check(
876
+ check_api=check_api, check_store=check_store, allow_http=allow_http
877
+ )
878
+ if json_out:
879
+ _write_json(json_out, result)
880
+ console.print(f"Wrote production check output to {json_out}")
881
+ _print_doctor(result)
882
+
883
+
884
+ @app.command("detect-integrations")
885
+ def detect_integrations_command(json_out: Path | None = typer.Option(None)) -> None:
886
+ """Report which framework integrations are available in this environment.
887
+
888
+ Detection only: it checks whether each integration's SDK is importable and
889
+ does not instrument anything. Apply agentloop.integrations helpers from your
890
+ application startup to record traces.
891
+ """
892
+ result = detect_integrations().to_dict()
893
+ if json_out:
894
+ _write_json(json_out, result)
895
+ console.print(f"Wrote integration detection output to {json_out}")
896
+ console.print_json(data=result)
897
+
898
+
899
+ @app.command("auto-instrument", hidden=True, deprecated=True)
900
+ def auto_instrument_command(json_out: Path | None = typer.Option(None)) -> None:
901
+ """Deprecated alias for `detect-integrations`; it only detects, never instruments."""
902
+ console.print(
903
+ "[yellow]`auto-instrument` is deprecated and never enabled instrumentation; "
904
+ "use `detect-integrations`.[/yellow]"
905
+ )
906
+ detect_integrations_command(json_out=json_out)
907
+
908
+
909
+ @app.command("init-store")
910
+ def init_store() -> None:
911
+ db = get_store()
912
+ db.init()
913
+ console.print("Initialized AgentLoop store")
914
+
915
+
916
+ @app.command("create-api-key")
917
+ def create_api_key(project_id: str = "default", name: str = "default") -> None:
918
+ db = get_store()
919
+ key = db.create_api_key(project_id=project_id, name=name)
920
+ console.print("Created API key. Save it now; it will not be shown again.")
921
+ console.print(key["api_key"])
922
+
923
+
924
+ @app.command("store-trace")
925
+ def store_trace(
926
+ path: Path = Path("runs/research_agent_baseline.json"),
927
+ project_id: str = "default",
928
+ ) -> None:
929
+ trace = _load_trace(path, param_hint="--path")
930
+ db = get_store()
931
+ db.save_trace(trace, project_id=project_id)
932
+ console.print(f"Stored trace {trace.run_id} under project {project_id}")
933
+
934
+
935
+ @app.command("list-stored-traces")
936
+ def list_stored_traces(
937
+ project_id: str | None = None,
938
+ limit: int | None = typer.Option(None, help="Page size; omit to list every stored trace."),
939
+ cursor: str | None = typer.Option(
940
+ None, help="Continuation cursor from a previous --limit call's printed next-cursor."
941
+ ),
942
+ ) -> None:
943
+ db = get_store()
944
+ if limit is None and cursor is None:
945
+ traces = db.list_traces(project_id=project_id)
946
+ next_cursor = None
947
+ else:
948
+ page = db.list_traces_page(
949
+ project_id=project_id, limit=limit or DEFAULT_PAGE_SIZE, cursor=cursor
950
+ )
951
+ traces = page["items"]
952
+ next_cursor = page["next_cursor"]
953
+ table = Table(title="Stored AgentLoop Traces")
954
+ table.add_column("Project")
955
+ table.add_column("Run ID")
956
+ table.add_column("Name")
957
+ table.add_column("Runtime ms")
958
+ table.add_column("Cost")
959
+ for item in traces:
960
+ table.add_row(
961
+ str(item.get("project_id", "")),
962
+ str(item.get("run_id", "")),
963
+ str(item.get("name", "")),
964
+ "unavailable"
965
+ if item.get("total_runtime_ms") is None
966
+ else f"{float(item.get('total_runtime_ms', 0)):.2f}",
967
+ format_cost_usd(item.get("estimated_cost_usd"), item.get("cost_status", "complete")),
968
+ )
969
+ console.print(table)
970
+ if next_cursor:
971
+ console.print(f"More results available. Next cursor: {next_cursor}")
972
+
973
+
974
+ @app.command("list-findings")
975
+ def list_findings(
976
+ project_id: str | None = None,
977
+ status: str | None = None,
978
+ limit: int | None = typer.Option(None, help="Page size; omit to list every matching finding."),
979
+ cursor: str | None = typer.Option(
980
+ None, help="Continuation cursor from a previous --limit call's printed next-cursor."
981
+ ),
982
+ ) -> None:
983
+ db = get_store()
984
+ if limit is None and cursor is None:
985
+ findings = db.list_findings(project_id=project_id, status=status)
986
+ next_cursor = None
987
+ else:
988
+ page = db.list_findings_page(
989
+ project_id=project_id, status=status, limit=limit or DEFAULT_PAGE_SIZE, cursor=cursor
990
+ )
991
+ findings = page["items"]
992
+ next_cursor = page["next_cursor"]
993
+ table = Table(title="AgentLoop Findings")
994
+ table.add_column("Severity")
995
+ table.add_column("Type")
996
+ table.add_column("Title")
997
+ table.add_column("Run")
998
+ table.add_column("Status")
999
+ table.add_column("Patchable")
1000
+ table.add_column("Savings")
1001
+ for finding in findings:
1002
+ table.add_row(
1003
+ str(finding["severity"]),
1004
+ str(finding["type"]),
1005
+ str(finding["title"]),
1006
+ str(finding["run_id"]),
1007
+ str(finding["status"]),
1008
+ "yes" if finding["patchable"] else "no",
1009
+ f"{format_duration_ms(finding['estimated_latency_savings_ms'])} / "
1010
+ f"{format_cost_usd(finding.get('estimated_cost_savings_usd'))}",
1011
+ )
1012
+ console.print(table)
1013
+ if next_cursor:
1014
+ console.print(f"More results available. Next cursor: {next_cursor}")
1015
+
1016
+
1017
+ @app.command("update-finding-status")
1018
+ def update_finding_status_command(
1019
+ run_id: str,
1020
+ finding_id: str,
1021
+ status: str,
1022
+ project_id: str = "default",
1023
+ ) -> None:
1024
+ """Transition a stored finding's lifecycle status (detected/accepted/resolved/dismissed)."""
1025
+ db = get_store()
1026
+ try:
1027
+ updated = db.update_finding_status(project_id, run_id, finding_id, status)
1028
+ except FindingNotFoundError as exc:
1029
+ raise typer.BadParameter(str(exc), param_hint="finding_id") from exc
1030
+ except (FindingTransitionError, ValueError) as exc:
1031
+ raise typer.BadParameter(str(exc), param_hint="status") from exc
1032
+ console.print(f"Finding {finding_id} is now [bold]{updated['status']}[/bold]")
1033
+
1034
+
1035
+ @app.command("remote-update-finding-status")
1036
+ def remote_update_finding_status(
1037
+ run_id: str,
1038
+ finding_id: str,
1039
+ status: str,
1040
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
1041
+ api_key: str | None = typer.Option(
1042
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
1043
+ ),
1044
+ ) -> None:
1045
+ client = _remote_client(api_url, api_key)
1046
+ updated = client.update_finding_status(run_id, finding_id, status)
1047
+ console.print_json(data=updated)
1048
+
1049
+
1050
+ @app.command("optimization-queue")
1051
+ def optimization_queue(
1052
+ project_id: str | None = None,
1053
+ json_out: Path | None = typer.Option(None),
1054
+ sort_by: RankingSort = typer.Option(
1055
+ RankingSort.PRIORITY, help="Investigation ranking dimension."
1056
+ ),
1057
+ ) -> None:
1058
+ db = get_store()
1059
+ queue = sort_ranked(db.optimization_queue(project_id=project_id), sort_by.value)
1060
+ payload = {"project_id": project_id, "sort_by": sort_by.value, "queue": queue}
1061
+ if json_out is not None:
1062
+ _write_json(json_out, payload)
1063
+ console.print(f"Wrote optimization queue JSON to {json_out}")
1064
+ table = Table(title="AgentLoop Optimization Queue")
1065
+ table.add_column("Priority / readiness")
1066
+ table.add_column("Severity")
1067
+ table.add_column("Type")
1068
+ table.add_column("Title")
1069
+ table.add_column("Runs")
1070
+ table.add_column("Patchable")
1071
+ table.add_column("Savings")
1072
+ for item in queue:
1073
+ table.add_row(
1074
+ f"{item['ranking']['priority_rank']} / {item['ranking']['status']}",
1075
+ str(item["severity"]),
1076
+ str(item["type"]),
1077
+ str(item["title"]),
1078
+ str(item["run_count"]),
1079
+ str(item["patchable_count"]),
1080
+ f"{format_duration_ms(item['estimated_latency_savings_ms'])} / "
1081
+ f"{format_cost_usd(item.get('estimated_cost_savings_usd'))}",
1082
+ )
1083
+ console.print(table)
1084
+
1085
+ for item in queue:
1086
+ console.print(
1087
+ f"{item['title']}: "
1088
+ + (", ".join(item["ranking"]["reasons"]) or "Required ranking inputs are complete."),
1089
+ markup=False,
1090
+ )
1091
+
1092
+
1093
+ @app.command("github-issue-drafts")
1094
+ def github_issue_drafts(
1095
+ project_id: str | None = None,
1096
+ out: Path = Path("runs/agentloop_issue_drafts.md"),
1097
+ json_out: Path | None = typer.Option(None),
1098
+ limit: int = typer.Option(5, min=1, max=20),
1099
+ ) -> None:
1100
+ db = get_store()
1101
+ drafts = build_issue_drafts(db.optimization_queue(project_id=project_id), limit=limit)
1102
+ out.parent.mkdir(parents=True, exist_ok=True)
1103
+ out.write_text(issue_drafts_to_markdown(drafts), encoding="utf-8")
1104
+ if json_out is not None:
1105
+ _write_json(json_out, {"project_id": project_id, "issue_drafts": drafts})
1106
+ console.print(f"Wrote GitHub issue draft JSON to {json_out}")
1107
+ console.print(f"Wrote GitHub issue drafts to {out}")
1108
+
1109
+
1110
+ @app.command("usage-summary")
1111
+ def usage_summary(project_id: str | None = None) -> None:
1112
+ db = get_store()
1113
+ summary = db.usage_summary(project_id=project_id)
1114
+ table = Table(title="AgentLoop Usage Summary")
1115
+ table.add_column("Metric")
1116
+ table.add_column("Value")
1117
+ for key, value in summary.items():
1118
+ rendered = (
1119
+ format_cost_usd(value, summary.get("cost_status", "complete"))
1120
+ if key == "estimated_cost_usd"
1121
+ else str(value)
1122
+ )
1123
+ table.add_row(key, rendered)
1124
+ console.print(table)
1125
+
1126
+
1127
+ @app.command()
1128
+ def upload(
1129
+ path: Path = Path("runs/research_agent_baseline.json"),
1130
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
1131
+ api_key: str | None = typer.Option(
1132
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
1133
+ ),
1134
+ ) -> None:
1135
+ trace = _load_trace(path, param_hint="--path")
1136
+ client = _remote_client(api_url, api_key)
1137
+ response = client.upload_trace(trace)
1138
+ console.print(f"Uploaded trace {response['run_id']} to {api_url}")
1139
+ console.print(f"Project: {response.get('project_id', 'default')}")
1140
+
1141
+
1142
+ @app.command("remote-optimize")
1143
+ def remote_optimize(
1144
+ run_id: str,
1145
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
1146
+ api_key: str | None = typer.Option(
1147
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
1148
+ ),
1149
+ out: Path = Path("runs/remote_optimization_plan.json"),
1150
+ ) -> None:
1151
+ client = _remote_client(api_url, api_key)
1152
+ plan = client.get_optimization_plan(run_id)
1153
+ export_optimization_json(plan, out)
1154
+ console.print(f"Wrote remote optimization plan to {out}")
1155
+
1156
+
1157
+ @app.command("remote-diagnose", help="Recompute and persist findings for a stored remote trace.")
1158
+ def remote_diagnose(
1159
+ run_id: str,
1160
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
1161
+ api_key: str | None = typer.Option(
1162
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
1163
+ ),
1164
+ out: Path = Path("runs/remote_diagnosis.json"),
1165
+ ) -> None:
1166
+ client = _remote_client(api_url, api_key)
1167
+ diagnosis = client.save_diagnosis(run_id)
1168
+ _write_json(out, diagnosis)
1169
+ console.print(f"Wrote remote diagnosis to {out}")
1170
+
1171
+
1172
+ @app.command("remote-findings")
1173
+ def remote_findings(
1174
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
1175
+ api_key: str | None = typer.Option(
1176
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
1177
+ ),
1178
+ project_id: str | None = typer.Option(None),
1179
+ status: str | None = typer.Option(None),
1180
+ out: Path = Path("runs/remote_findings.json"),
1181
+ ) -> None:
1182
+ client = _remote_client(api_url, api_key)
1183
+ findings = client.list_findings(project_id=project_id, status=status)
1184
+ _write_json(out, findings)
1185
+ console.print(f"Wrote remote findings to {out}")
1186
+
1187
+
1188
+ @app.command("remote-optimization-queue")
1189
+ def remote_optimization_queue(
1190
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
1191
+ api_key: str | None = typer.Option(
1192
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
1193
+ ),
1194
+ project_id: str | None = typer.Option(None),
1195
+ out: Path = Path("runs/remote_optimization_queue.json"),
1196
+ ) -> None:
1197
+ client = _remote_client(api_url, api_key)
1198
+ queue = client.optimization_queue(project_id=project_id)
1199
+ _write_json(out, queue)
1200
+ console.print(f"Wrote remote optimization queue to {out}")
1201
+
1202
+
1203
+ @app.command("remote-github-issue-drafts")
1204
+ def remote_github_issue_drafts(
1205
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
1206
+ api_key: str | None = typer.Option(
1207
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
1208
+ ),
1209
+ project_id: str | None = typer.Option(None),
1210
+ limit: int = typer.Option(5, min=1, max=20),
1211
+ out: Path = Path("runs/remote_agentloop_issue_drafts.json"),
1212
+ ) -> None:
1213
+ client = _remote_client(api_url, api_key)
1214
+ drafts = client.github_issue_drafts(project_id=project_id, limit=limit)
1215
+ _write_json(out, drafts)
1216
+ console.print(f"Wrote remote GitHub issue drafts to {out}")
1217
+
1218
+
1219
+ @app.command("remote-value-report")
1220
+ def remote_value_report(
1221
+ run_id: str,
1222
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
1223
+ api_key: str | None = typer.Option(
1224
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
1225
+ ),
1226
+ out: Path = Path("runs/remote_value_report.json"),
1227
+ runs_per_month: int = typer.Option(1000, min=0),
1228
+ engineer_hourly_rate_usd: float = typer.Option(150.0, min=0),
1229
+ incident_cost_usd: float = typer.Option(500.0, min=0),
1230
+ ) -> None:
1231
+ client = _remote_client(api_url, api_key)
1232
+ value = client.get_value_report(
1233
+ run_id,
1234
+ runs_per_month=runs_per_month,
1235
+ engineer_hourly_rate_usd=engineer_hourly_rate_usd,
1236
+ incident_cost_usd=incident_cost_usd,
1237
+ )
1238
+ _write_json(out, value)
1239
+ _print_value_summary(value)
1240
+ console.print(f"Wrote remote value report to {out}")
1241
+
1242
+
1243
+ @app.command("remote-usage")
1244
+ def remote_usage(
1245
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
1246
+ api_key: str | None = typer.Option(
1247
+ None, help="Optional API key. Defaults to AGENTLOOP_API_KEY."
1248
+ ),
1249
+ ) -> None:
1250
+ client = _remote_client(api_url, api_key)
1251
+ console.print_json(data=client.usage_summary())
1252
+
1253
+
1254
+ @app.command("remote-create-api-key")
1255
+ def remote_create_api_key(
1256
+ project_id: str = "default",
1257
+ name: str = "default",
1258
+ api_url: str = typer.Option("http://127.0.0.1:8000", help="AgentLoop API base URL."),
1259
+ admin_api_key: str | None = typer.Option(
1260
+ None, help="Admin API key. Defaults to AGENTLOOP_ADMIN_API_KEY."
1261
+ ),
1262
+ ) -> None:
1263
+ client = _remote_admin_client(api_url, admin_api_key)
1264
+ key = client.create_api_key(project_id=project_id, name=name)
1265
+ console.print("Created remote API key. Save it now; it will not be shown again.")
1266
+ console.print(key["api_key"])
1267
+
1268
+
1269
+ @app.command()
1270
+ def demo(
1271
+ kind: str = typer.Option("baseline", help="baseline, optimized, langgraph, or proof"),
1272
+ ) -> None:
1273
+ if kind == "baseline":
1274
+ path = run_baseline()
1275
+ elif kind == "optimized":
1276
+ path = run_optimized()
1277
+ elif kind == "langgraph":
1278
+ path = run_langgraph_style()
1279
+ elif kind == "proof":
1280
+ baseline, candidate = run_proof_pair()
1281
+ console.print(f"Wrote synthetic demo trace to {baseline}")
1282
+ console.print(f"Wrote synthetic demo trace to {candidate}")
1283
+ return
1284
+ else:
1285
+ raise typer.BadParameter("kind must be 'baseline', 'optimized', 'langgraph', or 'proof'")
1286
+ console.print(f"Wrote synthetic demo trace to {path}")
1287
+
1288
+
1289
+ @app.command("demo-all")
1290
+ def demo_all() -> None:
1291
+ base = run_baseline()
1292
+ opt = run_optimized()
1293
+ lg = run_langgraph_style()
1294
+ proof_base, proof_candidate = run_proof_pair()
1295
+ console.print(f"Wrote synthetic demo trace to {base}")
1296
+ console.print(f"Wrote synthetic demo trace to {opt}")
1297
+ console.print(f"Wrote synthetic demo trace to {lg}")
1298
+ console.print(f"Wrote synthetic demo trace to {proof_base}")
1299
+ console.print(f"Wrote synthetic demo trace to {proof_candidate}")
1300
+
1301
+
1302
+ @app.command()
1303
+ def server(host: str = "127.0.0.1", port: int = 8000, reload: bool = False) -> None:
1304
+ try:
1305
+ import uvicorn
1306
+ except ImportError as exc:
1307
+ raise typer.BadParameter(
1308
+ "Install server dependencies with: uv sync --locked --extra server"
1309
+ ) from exc
1310
+ uvicorn.run("agentloop.server:app", host=host, port=port, reload=reload)
1311
+
1312
+
1313
+ if __name__ == "__main__":
1314
+ app()