agentloop-profiler 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of agentloop-profiler might be problematic. Click here for more details.
- agentloop/__init__.py +68 -0
- agentloop/audit.py +34 -0
- agentloop/autoinstrument.py +102 -0
- agentloop/ci.py +135 -0
- agentloop/cli.py +876 -0
- agentloop/client.py +170 -0
- agentloop/config.py +50 -0
- agentloop/costs.py +15 -0
- agentloop/decorators.py +160 -0
- agentloop/demo.py +283 -0
- agentloop/doctor.py +344 -0
- agentloop/events.py +49 -0
- agentloop/exporters.py +60 -0
- agentloop/findings.py +215 -0
- agentloop/graph.py +312 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/langgraph.py +140 -0
- agentloop/integrations/openai.py +369 -0
- agentloop/integrations/openai_agents.py +212 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/issues.py +88 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +123 -0
- agentloop/optimizer.py +323 -0
- agentloop/otel.py +302 -0
- agentloop/otel_ids.py +81 -0
- agentloop/patches.py +583 -0
- agentloop/plan_export.py +72 -0
- agentloop/quality.py +222 -0
- agentloop/replay.py +424 -0
- agentloop/runtime.py +244 -0
- agentloop/savings.py +135 -0
- agentloop/server.py +292 -0
- agentloop/store.py +840 -0
- agentloop/timing.py +124 -0
- agentloop/tracer.py +356 -0
- agentloop/value.py +243 -0
- agentloop/version.py +3 -0
- agentloop_profiler-0.4.0.dist-info/METADATA +598 -0
- agentloop_profiler-0.4.0.dist-info/RECORD +50 -0
- agentloop_profiler-0.4.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.4.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.4.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.4.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +54 -0
- agentloop_profiler-0.4.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +1 -0
- dashboard/app.py +698 -0
- dashboard/value_view.py +101 -0
agentloop/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()
|