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/replay.py ADDED
@@ -0,0 +1,424 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from agentloop.markdown import markdown_code_span, markdown_table_cell
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ReplayGates:
11
+ max_cost_regression_pct: float = 0.0
12
+ max_latency_regression_pct: float = 0.0
13
+ min_latency_improvement_pct: float = 0.0
14
+ min_cost_improvement_pct: float = 0.0
15
+ require_retry_non_increase: bool = True
16
+ require_schema_valid: bool = False
17
+ min_quality_score: float | None = None
18
+
19
+
20
+ def build_replay_report(
21
+ baseline_trace: Any,
22
+ candidate_trace: Any,
23
+ *,
24
+ gates: ReplayGates | None = None,
25
+ quality_report: dict[str, Any] | None = None,
26
+ ) -> dict[str, Any]:
27
+ gates = gates or ReplayGates()
28
+ baseline_report = baseline_trace.report()
29
+ candidate_report = candidate_trace.report()
30
+ baseline = _trace_summary(baseline_trace, baseline_report)
31
+ candidate = _trace_summary(candidate_trace, candidate_report)
32
+ if quality_report is not None:
33
+ baseline["quality_score"] = float(quality_report.get("baseline_score", 0.0))
34
+ candidate["quality_score"] = float(quality_report.get("candidate_score", 0.0))
35
+ deltas = _deltas(baseline, candidate)
36
+ gate_results = _gate_results(deltas, baseline, candidate, gates, quality_report=quality_report)
37
+ passed = all(item["passed"] for item in gate_results)
38
+
39
+ return {
40
+ "baseline": baseline,
41
+ "candidate": candidate,
42
+ "deltas": deltas,
43
+ "gates": {
44
+ "passed": passed,
45
+ "config": {
46
+ "max_cost_regression_pct": gates.max_cost_regression_pct,
47
+ "max_latency_regression_pct": gates.max_latency_regression_pct,
48
+ "min_latency_improvement_pct": gates.min_latency_improvement_pct,
49
+ "min_cost_improvement_pct": gates.min_cost_improvement_pct,
50
+ "require_retry_non_increase": gates.require_retry_non_increase,
51
+ "require_schema_valid": gates.require_schema_valid,
52
+ "min_quality_score": gates.min_quality_score,
53
+ },
54
+ "results": gate_results,
55
+ },
56
+ "quality": quality_report,
57
+ "summary": _summary(deltas, passed),
58
+ }
59
+
60
+
61
+ def replay_report_to_markdown(report: dict[str, Any]) -> str:
62
+ baseline = report["baseline"]
63
+ candidate = report["candidate"]
64
+ deltas = report["deltas"]
65
+ gates = report["gates"]
66
+ status = "passed" if gates["passed"] else "failed"
67
+
68
+ lines = [
69
+ "# AgentLoop Replay Report",
70
+ "",
71
+ f"- Status: {status}",
72
+ f"- Baseline: {markdown_code_span(baseline['name'])} "
73
+ f"{markdown_code_span(baseline['run_id'])}",
74
+ f"- Candidate: {markdown_code_span(candidate['name'])} "
75
+ f"{markdown_code_span(candidate['run_id'])}",
76
+ "",
77
+ "## Metric Deltas",
78
+ "",
79
+ "| Metric | Baseline | Candidate | Delta | Improvement |",
80
+ "|---|---:|---:|---:|---:|",
81
+ _metric_row(
82
+ "Runtime",
83
+ baseline["runtime_ms"],
84
+ candidate["runtime_ms"],
85
+ deltas["runtime_ms_delta"],
86
+ deltas["latency_improvement_pct"],
87
+ "ms",
88
+ ),
89
+ _metric_row(
90
+ "Cost",
91
+ baseline["estimated_cost_usd"],
92
+ candidate["estimated_cost_usd"],
93
+ deltas["cost_usd_delta"],
94
+ deltas["cost_improvement_pct"],
95
+ "usd",
96
+ ),
97
+ _metric_row(
98
+ "Input tokens",
99
+ baseline["input_tokens"],
100
+ candidate["input_tokens"],
101
+ deltas["input_tokens_delta"],
102
+ deltas["input_token_improvement_pct"],
103
+ "count",
104
+ ),
105
+ _metric_row(
106
+ "Output tokens",
107
+ baseline["output_tokens"],
108
+ candidate["output_tokens"],
109
+ deltas["output_tokens_delta"],
110
+ deltas["output_token_improvement_pct"],
111
+ "count",
112
+ ),
113
+ _metric_row(
114
+ "Retries",
115
+ baseline["retry_count"],
116
+ candidate["retry_count"],
117
+ deltas["retry_count_delta"],
118
+ deltas["retry_improvement_pct"],
119
+ "count",
120
+ ),
121
+ _metric_row(
122
+ "Tool calls",
123
+ baseline["tool_call_count"],
124
+ candidate["tool_call_count"],
125
+ deltas["tool_call_count_delta"],
126
+ deltas["tool_call_improvement_pct"],
127
+ "count",
128
+ ),
129
+ _metric_row(
130
+ "Model calls",
131
+ baseline["model_call_count"],
132
+ candidate["model_call_count"],
133
+ deltas["model_call_count_delta"],
134
+ deltas["model_call_improvement_pct"],
135
+ "count",
136
+ ),
137
+ ]
138
+ if (
139
+ baseline.get("schema_validity_pct") is not None
140
+ or candidate.get("schema_validity_pct") is not None
141
+ ):
142
+ lines.append(
143
+ _metric_row(
144
+ "Schema validity",
145
+ baseline.get("schema_validity_pct") or 0.0,
146
+ candidate.get("schema_validity_pct") or 0.0,
147
+ deltas["schema_validity_delta_pct"],
148
+ deltas["schema_validity_improvement_pct"],
149
+ "pct",
150
+ )
151
+ )
152
+ if baseline.get("quality_score") is not None or candidate.get("quality_score") is not None:
153
+ lines.append(
154
+ _metric_row(
155
+ "Quality score",
156
+ baseline.get("quality_score") or 0.0,
157
+ candidate.get("quality_score") or 0.0,
158
+ deltas["quality_score_delta"],
159
+ deltas["quality_score_improvement_pct"],
160
+ "score",
161
+ )
162
+ )
163
+ if report.get("quality"):
164
+ quality = report["quality"]
165
+ lines.extend(
166
+ [
167
+ "",
168
+ "## Quality",
169
+ "",
170
+ f"- Cases: {quality['case_count']}",
171
+ f"- Candidate score: {quality['candidate_score']:.4f}",
172
+ f"- Quality delta: {quality['quality_delta']:.4f}",
173
+ f"- Failed cases: {quality['failed_case_count']}",
174
+ ]
175
+ )
176
+ lines.extend(
177
+ [
178
+ "",
179
+ "## Gates",
180
+ "",
181
+ "| Gate | Status | Detail |",
182
+ "|---|---|---|",
183
+ ]
184
+ )
185
+ for item in gates["results"]:
186
+ gate_status = "pass" if item["passed"] else "fail"
187
+ lines.append(
188
+ f"| {markdown_table_cell(item['name'])} | {gate_status} | "
189
+ f"{markdown_table_cell(item['detail'])} |"
190
+ )
191
+ return "\n".join(lines).rstrip() + "\n"
192
+
193
+
194
+ def _trace_summary(trace: Any, report: dict[str, Any]) -> dict[str, Any]:
195
+ metadata = getattr(trace, "metadata", {}) or {}
196
+ return {
197
+ "run_id": trace.run_id,
198
+ "name": trace.name,
199
+ "runtime_ms": float(report.get("total_runtime_ms", 0.0) or 0.0),
200
+ "estimated_cost_usd": float(report.get("estimated_cost_usd", 0.0) or 0.0),
201
+ "input_tokens": int(report.get("input_tokens", 0) or 0),
202
+ "output_tokens": int(report.get("output_tokens", 0) or 0),
203
+ "retry_count": int(report.get("retry_count", 0) or 0),
204
+ "tool_call_count": int(report.get("tool_call_count", 0) or 0),
205
+ "model_call_count": int(report.get("model_call_count", 0) or 0),
206
+ "schema_validity_pct": _optional_float(
207
+ report.get("schema_validity_pct")
208
+ if report.get("schema_validity_pct") is not None
209
+ else metadata.get("schema_validity_pct")
210
+ ),
211
+ "schema_valid": _optional_bool(
212
+ report.get("schema_valid")
213
+ if report.get("schema_valid") is not None
214
+ else metadata.get("schema_valid")
215
+ ),
216
+ "quality_score": _optional_float(
217
+ report.get("quality_score")
218
+ if report.get("quality_score") is not None
219
+ else metadata.get("quality_score")
220
+ ),
221
+ }
222
+
223
+
224
+ def _deltas(baseline: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
225
+ base_runtime = float(baseline.get("runtime_ms", baseline.get("total_runtime_ms", 0.0)) or 0.0)
226
+ cand_runtime = float(candidate.get("runtime_ms", candidate.get("total_runtime_ms", 0.0)) or 0.0)
227
+ base_cost = float(baseline.get("estimated_cost_usd", 0.0) or 0.0)
228
+ cand_cost = float(candidate.get("estimated_cost_usd", 0.0) or 0.0)
229
+ base_input = int(baseline.get("input_tokens", 0) or 0)
230
+ cand_input = int(candidate.get("input_tokens", 0) or 0)
231
+ base_output = int(baseline.get("output_tokens", 0) or 0)
232
+ cand_output = int(candidate.get("output_tokens", 0) or 0)
233
+ base_retries = int(baseline.get("retry_count", 0) or 0)
234
+ cand_retries = int(candidate.get("retry_count", 0) or 0)
235
+ base_tools = int(baseline.get("tool_call_count", 0) or 0)
236
+ cand_tools = int(candidate.get("tool_call_count", 0) or 0)
237
+ base_models = int(baseline.get("model_call_count", 0) or 0)
238
+ cand_models = int(candidate.get("model_call_count", 0) or 0)
239
+ base_schema = _optional_float(baseline.get("schema_validity_pct"))
240
+ if base_schema is None:
241
+ base_schema = _schema_validity_from_bool(baseline.get("schema_valid"))
242
+ cand_schema = _optional_float(candidate.get("schema_validity_pct"))
243
+ if cand_schema is None:
244
+ cand_schema = _schema_validity_from_bool(candidate.get("schema_valid"))
245
+ base_quality = _optional_float(baseline.get("quality_score"))
246
+ cand_quality = _optional_float(candidate.get("quality_score"))
247
+
248
+ return {
249
+ "runtime_ms_delta": round(cand_runtime - base_runtime, 3),
250
+ "latency_improvement_pct": _improvement_pct(base_runtime, cand_runtime),
251
+ "latency_regression_pct": _regression_pct(base_runtime, cand_runtime),
252
+ "cost_usd_delta": round(cand_cost - base_cost, 6),
253
+ "cost_improvement_pct": _improvement_pct(base_cost, cand_cost),
254
+ "cost_regression_pct": _regression_pct(base_cost, cand_cost),
255
+ "input_tokens_delta": cand_input - base_input,
256
+ "input_token_improvement_pct": _improvement_pct(base_input, cand_input),
257
+ "output_tokens_delta": cand_output - base_output,
258
+ "output_token_improvement_pct": _improvement_pct(base_output, cand_output),
259
+ "retry_count_delta": cand_retries - base_retries,
260
+ "retry_improvement_pct": _improvement_pct(base_retries, cand_retries),
261
+ "tool_call_count_delta": cand_tools - base_tools,
262
+ "tool_call_improvement_pct": _improvement_pct(base_tools, cand_tools),
263
+ "model_call_count_delta": cand_models - base_models,
264
+ "model_call_improvement_pct": _improvement_pct(base_models, cand_models),
265
+ "schema_validity_delta_pct": round((cand_schema or 0.0) - (base_schema or 0.0), 3),
266
+ "schema_validity_improvement_pct": _relative_gain_pct(base_schema, cand_schema),
267
+ "quality_score_delta": round((cand_quality or 0.0) - (base_quality or 0.0), 4),
268
+ "quality_score_improvement_pct": _relative_gain_pct(base_quality, cand_quality),
269
+ }
270
+
271
+
272
+ def _gate_results(
273
+ deltas: dict[str, Any],
274
+ baseline: dict[str, Any],
275
+ candidate: dict[str, Any],
276
+ gates: ReplayGates,
277
+ *,
278
+ quality_report: dict[str, Any] | None = None,
279
+ ) -> list[dict[str, Any]]:
280
+ results = [
281
+ _gate(
282
+ "latency_regression",
283
+ deltas["latency_regression_pct"] <= gates.max_latency_regression_pct,
284
+ f"{deltas['latency_regression_pct']:.2f}% regression <= {gates.max_latency_regression_pct:.2f}% allowed",
285
+ ),
286
+ _gate(
287
+ "cost_regression",
288
+ deltas["cost_regression_pct"] <= gates.max_cost_regression_pct,
289
+ f"{deltas['cost_regression_pct']:.2f}% regression <= {gates.max_cost_regression_pct:.2f}% allowed",
290
+ ),
291
+ _gate(
292
+ "latency_improvement",
293
+ deltas["latency_improvement_pct"] >= gates.min_latency_improvement_pct,
294
+ f"{deltas['latency_improvement_pct']:.2f}% improvement >= {gates.min_latency_improvement_pct:.2f}% required",
295
+ ),
296
+ _gate(
297
+ "cost_improvement",
298
+ deltas["cost_improvement_pct"] >= gates.min_cost_improvement_pct,
299
+ f"{deltas['cost_improvement_pct']:.2f}% improvement >= {gates.min_cost_improvement_pct:.2f}% required",
300
+ ),
301
+ ]
302
+ if gates.require_retry_non_increase:
303
+ base_retries = int(baseline.get("retry_count", 0) or 0)
304
+ cand_retries = int(candidate.get("retry_count", 0) or 0)
305
+ results.append(
306
+ _gate(
307
+ "retry_non_increase",
308
+ cand_retries <= base_retries,
309
+ f"{cand_retries} candidate retries <= {base_retries} baseline retries",
310
+ )
311
+ )
312
+ if gates.require_schema_valid:
313
+ schema_valid = _candidate_schema_valid(candidate)
314
+ results.append(
315
+ _gate(
316
+ "schema_valid",
317
+ schema_valid,
318
+ "candidate schema validity passed"
319
+ if schema_valid
320
+ else "candidate schema validity missing or failed",
321
+ )
322
+ )
323
+ if gates.min_quality_score is not None:
324
+ quality_score = _optional_float(candidate.get("quality_score"))
325
+ fixture_passed = quality_report is None or bool(quality_report.get("passed"))
326
+ passed = (
327
+ quality_score is not None
328
+ and quality_score >= gates.min_quality_score
329
+ and fixture_passed
330
+ )
331
+ detail = (
332
+ f"{quality_score:.4f} candidate quality >= {gates.min_quality_score:.4f} required"
333
+ if quality_score is not None
334
+ else f"candidate quality score missing; {gates.min_quality_score:.4f} required"
335
+ )
336
+ if quality_report is not None and not fixture_passed:
337
+ detail += f"; {quality_report.get('failed_case_count', 0)} fixture case(s) failed"
338
+ results.append(_gate("quality_score", passed, detail))
339
+ return results
340
+
341
+
342
+ def _gate(name: str, passed: bool, detail: str) -> dict[str, Any]:
343
+ return {"name": name, "passed": passed, "detail": detail}
344
+
345
+
346
+ def _summary(deltas: dict[str, Any], passed: bool) -> str:
347
+ status = "passed" if passed else "failed"
348
+ return (
349
+ f"Replay {status}: latency improvement {deltas['latency_improvement_pct']:.2f}%, "
350
+ f"cost improvement {deltas['cost_improvement_pct']:.2f}%, "
351
+ f"retry delta {deltas['retry_count_delta']}."
352
+ )
353
+
354
+
355
+ def _improvement_pct(baseline: float, candidate: float) -> float:
356
+ if baseline == 0:
357
+ return 0.0 if candidate == 0 else -100.0
358
+ return round(((baseline - candidate) / baseline) * 100, 2)
359
+
360
+
361
+ def _relative_gain_pct(baseline: float | None, candidate: float | None) -> float:
362
+ if baseline is None:
363
+ return 0.0 if candidate is None else 100.0
364
+ if baseline == 0:
365
+ return 0.0 if not candidate else 100.0
366
+ return round((((candidate or 0.0) - baseline) / baseline) * 100, 2)
367
+
368
+
369
+ def _regression_pct(baseline: float, candidate: float) -> float:
370
+ if baseline == 0:
371
+ return 0.0 if candidate == 0 else 100.0
372
+ return round(max(0.0, ((candidate - baseline) / baseline) * 100), 2)
373
+
374
+
375
+ def _metric_row(
376
+ metric: str, baseline: float, candidate: float, delta: float, improvement: float, kind: str
377
+ ) -> str:
378
+ return (
379
+ f"| {metric} | {_format_metric(baseline, kind)} | {_format_metric(candidate, kind)} | "
380
+ f"{_format_metric(delta, kind)} | {improvement:.2f}% |"
381
+ )
382
+
383
+
384
+ def _format_metric(value: float, kind: str) -> str:
385
+ if kind == "usd":
386
+ return f"${value:.6f}"
387
+ if kind == "ms":
388
+ return f"{value:.3f}ms"
389
+ if kind == "pct":
390
+ return f"{value:.3f}%"
391
+ if kind == "score":
392
+ return f"{value:.4f}"
393
+ return str(int(value))
394
+
395
+
396
+ def _optional_float(value: Any) -> float | None:
397
+ if value is None or value == "":
398
+ return None
399
+ return float(value)
400
+
401
+
402
+ def _optional_bool(value: Any) -> bool | None:
403
+ if value is None or value == "":
404
+ return None
405
+ if isinstance(value, bool):
406
+ return value
407
+ if isinstance(value, str):
408
+ return value.strip().lower() in {"1", "true", "yes", "pass", "passed", "valid"}
409
+ return bool(value)
410
+
411
+
412
+ def _schema_validity_from_bool(value: Any) -> float | None:
413
+ parsed = _optional_bool(value)
414
+ if parsed is None:
415
+ return None
416
+ return 100.0 if parsed else 0.0
417
+
418
+
419
+ def _candidate_schema_valid(candidate: dict[str, Any]) -> bool:
420
+ schema_valid = _optional_bool(candidate.get("schema_valid"))
421
+ if schema_valid is not None:
422
+ return schema_valid
423
+ validity_pct = _optional_float(candidate.get("schema_validity_pct"))
424
+ return validity_pct is not None and validity_pct >= 100.0
agentloop/runtime.py ADDED
@@ -0,0 +1,244 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ _TRUE_VALUES = {"1", "true", "yes", "on"}
9
+
10
+
11
+ class _ClearSentinel:
12
+ """Sentinel passed to :func:`init` to explicitly clear an optional value.
13
+
14
+ ``init(api_key=None)`` means "keep the current value" (so environment and
15
+ prior configuration survive a reconfigure). Passing ``CLEAR`` instead sets
16
+ the value back to ``None``.
17
+ """
18
+
19
+ def __repr__(self) -> str:
20
+ return "CLEAR"
21
+
22
+
23
+ CLEAR = _ClearSentinel()
24
+
25
+
26
+ class FinalizationError(RuntimeError):
27
+ """Raised by :func:`finalize_trace` when a destination fails and
28
+ ``fail_silently`` is ``False``.
29
+
30
+ ``result`` holds the partial finalization result for destinations that
31
+ completed before the failure, so already-completed work is not lost.
32
+ ``errors`` lists the destination-tagged failures.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ message: str,
38
+ *,
39
+ result: dict[str, Any],
40
+ errors: list[dict[str, str]],
41
+ ) -> None:
42
+ super().__init__(message)
43
+ self.result = result
44
+ self.errors = errors
45
+
46
+
47
+ @dataclass
48
+ class AgentLoopRuntimeConfig:
49
+ api_url: str = "http://127.0.0.1:8000"
50
+ api_key: str | None = None
51
+ project_id: str = "default"
52
+ auto_upload: bool = False
53
+ auto_store: bool = False
54
+ fail_silently: bool = True
55
+ export_dir: Path | None = None
56
+
57
+
58
+ _runtime = AgentLoopRuntimeConfig()
59
+ _last_error: str | None = None
60
+
61
+
62
+ def _env_bool(name: str, default: bool = False) -> bool:
63
+ value = os.getenv(name)
64
+ if value is None:
65
+ return default
66
+ return value.lower() in _TRUE_VALUES
67
+
68
+
69
+ def init(
70
+ *,
71
+ api_url: str | None = None,
72
+ api_key: str | _ClearSentinel | None = None,
73
+ project_id: str | None = None,
74
+ auto_upload: bool | None = None,
75
+ auto_store: bool | None = None,
76
+ fail_silently: bool | None = None,
77
+ export_dir: str | Path | _ClearSentinel | None = None,
78
+ ) -> AgentLoopRuntimeConfig:
79
+ """Configure AgentLoop once at process startup.
80
+
81
+ Examples:
82
+
83
+ ```python
84
+ import agentloop
85
+
86
+ agentloop.init(api_key="al_xxx", project_id="demo", auto_upload=True)
87
+ ```
88
+
89
+ Environment variables are also supported:
90
+ - `AGENTLOOP_API_URL`
91
+ - `AGENTLOOP_API_KEY`
92
+ - `AGENTLOOP_PROJECT_ID`
93
+ - `AGENTLOOP_AUTO_UPLOAD`
94
+ - `AGENTLOOP_AUTO_STORE`
95
+ - `AGENTLOOP_FAIL_SILENTLY`
96
+ - `AGENTLOOP_EXPORT_DIR`
97
+
98
+ Optional values (`api_key`, `export_dir`) treat ``None`` as "keep the current
99
+ value". Pass :data:`CLEAR` to reset one of them back to ``None``.
100
+ """
101
+
102
+ global _runtime
103
+
104
+ if api_key is CLEAR:
105
+ resolved_api_key: str | None = None
106
+ elif api_key is not None:
107
+ resolved_api_key = api_key # type: ignore[assignment]
108
+ else:
109
+ resolved_api_key = os.getenv("AGENTLOOP_API_KEY", _runtime.api_key)
110
+
111
+ if export_dir is CLEAR:
112
+ resolved_export_dir: Path | None = None
113
+ elif export_dir is not None:
114
+ resolved_export_dir = Path(export_dir) # type: ignore[arg-type]
115
+ else:
116
+ env_export_dir = os.getenv("AGENTLOOP_EXPORT_DIR")
117
+ resolved_export_dir = Path(env_export_dir) if env_export_dir else _runtime.export_dir
118
+
119
+ _runtime = AgentLoopRuntimeConfig(
120
+ api_url=api_url or os.getenv("AGENTLOOP_API_URL", _runtime.api_url),
121
+ api_key=resolved_api_key,
122
+ project_id=project_id or os.getenv("AGENTLOOP_PROJECT_ID", _runtime.project_id),
123
+ auto_upload=auto_upload
124
+ if auto_upload is not None
125
+ else _env_bool("AGENTLOOP_AUTO_UPLOAD", _runtime.auto_upload),
126
+ auto_store=auto_store
127
+ if auto_store is not None
128
+ else _env_bool("AGENTLOOP_AUTO_STORE", _runtime.auto_store),
129
+ fail_silently=fail_silently
130
+ if fail_silently is not None
131
+ else _env_bool("AGENTLOOP_FAIL_SILENTLY", _runtime.fail_silently),
132
+ export_dir=resolved_export_dir,
133
+ )
134
+ return _runtime
135
+
136
+
137
+ def configure_from_env() -> AgentLoopRuntimeConfig:
138
+ return init()
139
+
140
+
141
+ def get_runtime_config() -> AgentLoopRuntimeConfig:
142
+ return _runtime
143
+
144
+
145
+ def get_last_error() -> str | None:
146
+ return _last_error
147
+
148
+
149
+ def reset_runtime() -> None:
150
+ global _runtime, _last_error
151
+ _runtime = AgentLoopRuntimeConfig()
152
+ _last_error = None
153
+
154
+
155
+ def should_auto_export() -> bool:
156
+ return bool(_runtime.auto_upload or _runtime.auto_store or _runtime.export_dir)
157
+
158
+
159
+ def finalize_trace(trace: Any) -> dict[str, Any]:
160
+ """Apply configured end-of-run side effects.
161
+
162
+ Depending on runtime config, this exports a JSON file, saves to the local
163
+ persistent store, and/or uploads to the configured API. Each destination runs
164
+ in its own error boundary in a deterministic order (export, store, upload), so
165
+ a failure in one cannot prevent the others from running.
166
+
167
+ When ``fail_silently`` is ``True`` (the default), every configured destination
168
+ is attempted and failures are collected in ``result["errors"]``, each tagged
169
+ with its ``destination``. When ``fail_silently`` is ``False``, the first failing
170
+ destination raises :class:`FinalizationError` (fail-fast); its ``result``
171
+ attribute still carries the destinations that completed before the failure.
172
+
173
+ A finalization in which every attempted destination succeeds clears any stale
174
+ process-global error so :func:`get_last_error` reflects the latest outcome.
175
+ """
176
+
177
+ global _last_error
178
+ result: dict[str, Any] = {
179
+ "exported_path": None,
180
+ "stored": False,
181
+ "uploaded": False,
182
+ "errors": [],
183
+ }
184
+
185
+ def _do_export() -> None:
186
+ export_dir = _runtime.export_dir
187
+ if export_dir is None: # pragma: no cover - guarded before registration
188
+ return
189
+ export_dir.mkdir(parents=True, exist_ok=True)
190
+ out = export_dir / f"{trace.run_id}.json"
191
+ trace.export_json(out)
192
+ result["exported_path"] = str(out)
193
+
194
+ def _do_store() -> None:
195
+ from agentloop.store import get_store
196
+
197
+ db = get_store()
198
+ db.init()
199
+ db.save_trace(trace, project_id=_runtime.project_id)
200
+ result["stored"] = True
201
+
202
+ def _do_upload() -> None:
203
+ from agentloop.client import AgentLoopClient
204
+
205
+ client = AgentLoopClient(base_url=_runtime.api_url, api_key=_runtime.api_key)
206
+ result["upload_response"] = client.upload_trace(trace)
207
+ result["uploaded"] = True
208
+
209
+ destinations: list[tuple[str, Any]] = []
210
+ if _runtime.export_dir is not None:
211
+ destinations.append(("export", _do_export))
212
+ if _runtime.auto_store:
213
+ destinations.append(("store", _do_store))
214
+ if _runtime.auto_upload:
215
+ destinations.append(("upload", _do_upload))
216
+
217
+ errors: list[dict[str, str]] = []
218
+ for destination, action in destinations:
219
+ try:
220
+ action()
221
+ except Exception as exc:
222
+ errors.append({"destination": destination, "error": str(exc)})
223
+ if not _runtime.fail_silently:
224
+ result["errors"] = errors
225
+ _last_error = f"{destination}: {exc}"
226
+ raise FinalizationError(
227
+ f"{destination} finalization failed: {exc}",
228
+ result=result,
229
+ errors=errors,
230
+ ) from exc
231
+
232
+ result["errors"] = errors
233
+ if errors:
234
+ last = errors[-1]
235
+ _last_error = f"{last['destination']}: {last['error']}"
236
+ elif destinations:
237
+ # Every attempted destination succeeded; drop any stale error state.
238
+ _last_error = None
239
+
240
+ return result
241
+
242
+
243
+ # Load env-driven defaults on import without forcing users to call init().
244
+ configure_from_env()