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
@@ -0,0 +1,392 @@
1
+ """Bounded local candidate runs using native traces, budgets and interventions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from fractions import Fraction
7
+ from pathlib import Path
8
+ from time import monotonic, perf_counter
9
+
10
+ from agentloop.ablation_protocol import timestamp
11
+ from agentloop.budget_types import DispatchOptions
12
+ from agentloop.budgets import BudgetLimits, budget_policy
13
+ from agentloop.events import utc_now_iso
14
+ from agentloop.experiment_artifacts import journal_lock, local_path, read_json, write_once
15
+ from agentloop.experiment_types import (
16
+ EXPERIMENT_KEY,
17
+ ExperimentCase,
18
+ ExperimentPlan,
19
+ ExperimentRequest,
20
+ ExperimentResult,
21
+ ExperimentRunner,
22
+ canonical,
23
+ fingerprint,
24
+ )
25
+ from agentloop.harness import Harness, HarnessConfig, HarnessControlError
26
+ from agentloop.interventions import build_intervention
27
+ from agentloop.quality import build_quality_report
28
+ from agentloop.replay import ReplayGates
29
+ from agentloop.tracer import AgentTrace, bind_trace_context
30
+ from agentloop.workflows import WorkflowInfo, record_operation, workflow_metadata
31
+
32
+
33
+ def slot_id(experiment_id, case_id, candidate_id):
34
+ return fingerprint([experiment_id, case_id, candidate_id])
35
+
36
+
37
+ class ExperimentSession:
38
+ """Resume finished slots idempotently; never repeat an uncertain attempt.
39
+
40
+ The root is a local evidence journal, not a workflow engine. Callback I/O
41
+ deadlines, provider limits and any side effects remain host responsibilities.
42
+ """
43
+
44
+ def __init__(self, plan, *, cases, runners, root):
45
+ if (
46
+ type(plan) is not ExperimentPlan
47
+ or any(type(case) is not ExperimentCase for case in cases)
48
+ or any(type(runner) is not ExperimentRunner for runner in runners)
49
+ ):
50
+ raise ValueError("typed experiment plan, cases and runner bindings required")
51
+ self._plan, self._spec = plan, plan.to_dict()
52
+ self._cases = {case.case_id: case for case in cases}
53
+ self._runners = {runner.candidate_id: runner for runner in runners}
54
+ if [case.declaration() for case in cases] != self._spec["cases"] or [
55
+ runner.declaration() for runner in runners
56
+ ] != self._spec["runners"]:
57
+ raise ValueError("experiment bindings differ from the frozen plan")
58
+ self.root = Path(root).resolve()
59
+ with journal_lock(self.root):
60
+ write_once(
61
+ self.root,
62
+ "experiment.json",
63
+ {"experiment_id": plan.experiment_id, "specification": self._spec},
64
+ )
65
+ for case in cases:
66
+ data = case.payload()
67
+ write_once(
68
+ self.root, f"baselines/{fingerprint(case.case_id)}.json", data["baseline"]
69
+ )
70
+ write_once(
71
+ self.root, f"predictions/{fingerprint(case.case_id)}.json", data["diagnosis"]
72
+ )
73
+
74
+ @classmethod
75
+ def resume(cls, root, *, cases, runners):
76
+ saved = read_json(local_path(root, "experiment.json"))
77
+ spec = saved["specification"]
78
+ from agentloop.experiment_types import ExperimentBudget
79
+
80
+ plan = ExperimentPlan(
81
+ spec["name"],
82
+ cases=cases,
83
+ runners=runners,
84
+ intervention_type=spec["intervention"]["type"],
85
+ intervention_version=spec["intervention"]["version"],
86
+ scorer=spec["scorer"],
87
+ gate_version=spec["gate_version"],
88
+ gates=ReplayGates(**spec["gates"]),
89
+ budget=ExperimentBudget(**spec["budget"]),
90
+ permission_ref=spec["permission_ref"],
91
+ frozen_at=spec["frozen_at"],
92
+ synthetic=spec["synthetic"],
93
+ )
94
+ if plan.experiment_id != saved["experiment_id"] or plan.to_dict() != spec:
95
+ raise ValueError("saved experiment differs from supplied frozen bindings")
96
+ return cls(plan, cases=cases, runners=runners, root=root)
97
+
98
+ @property
99
+ def experiment_id(self):
100
+ return self._plan.experiment_id
101
+
102
+ def _slots(self):
103
+ for case in self._spec["cases"]:
104
+ for runner in self._spec["runners"]:
105
+ yield (
106
+ case,
107
+ runner,
108
+ slot_id(self.experiment_id, case["case_id"], runner["candidate_id"]),
109
+ )
110
+
111
+ def _ledger(self):
112
+ from agentloop.experiment_reports import read_experiment
113
+
114
+ if read_json(local_path(self.root, "experiment.json")) != {
115
+ "experiment_id": self.experiment_id,
116
+ "specification": self._spec,
117
+ }:
118
+ raise ValueError("frozen experiment declaration changed")
119
+ evidence = read_experiment(self.root)
120
+ if any(row["status"] == "unresolved" for row in evidence["rows"]):
121
+ raise ValueError("uncertain experiment attempt requires host reconciliation")
122
+ started = [row["admission"] for row in evidence["rows"] if row["admission"] is not None]
123
+ receipts = [row["receipt"] for row in evidence["rows"] if row["receipt"] is not None]
124
+ return started, receipts
125
+
126
+ def run(self, *, enabled=False):
127
+ if type(enabled) is not bool:
128
+ raise ValueError("experiment execution requires explicit boolean opt-in")
129
+ with journal_lock(self.root):
130
+ if not enabled:
131
+ return {"experiment_id": self.experiment_id, "enabled": False, "executed": 0}
132
+ if timestamp(utc_now_iso()) < timestamp(self._spec["frozen_at"]):
133
+ raise ValueError("experiment clock precedes its frozen declaration")
134
+ started, receipts = self._ledger()
135
+ finished = {receipt["slot_id"] for receipt in receipts}
136
+ executed, stop_reason = 0, None
137
+ budget = self._spec["budget"]
138
+ consumed_tokens = sum(item["runner"]["reservation"]["tokens"] or 0 for item in started)
139
+ consumed_cost = sum(
140
+ (Fraction(str(item["runner"]["reservation"]["cost_usd"] or 0)) for item in started),
141
+ Fraction(0),
142
+ )
143
+ halted = any(receipt["halted"] for receipt in receipts)
144
+ for declared_case, declared_runner, identity in self._slots():
145
+ if identity in finished:
146
+ continue
147
+ if self._runners[declared_runner["candidate_id"]].declaration() != declared_runner:
148
+ raise ValueError("runner declaration changed before admission")
149
+ if halted:
150
+ stop_reason = "prior_budget_control"
151
+ break
152
+ reservation = declared_runner["reservation"]
153
+ if (
154
+ len(started) >= budget["max_invocations"]
155
+ or (
156
+ budget["max_tokens"] is not None
157
+ and consumed_tokens + reservation["tokens"] > budget["max_tokens"]
158
+ )
159
+ or (
160
+ budget["max_cost_usd"] is not None
161
+ and consumed_cost + Fraction(str(reservation["cost_usd"]))
162
+ > Fraction(str(budget["max_cost_usd"]))
163
+ )
164
+ ):
165
+ stop_reason = "execution_budget_exhausted"
166
+ break
167
+ admission = {
168
+ "experiment_id": self.experiment_id,
169
+ "slot_id": identity,
170
+ "case_id": declared_case["case_id"],
171
+ "candidate_id": declared_runner["candidate_id"],
172
+ "runner": declared_runner,
173
+ "started_at": utc_now_iso(),
174
+ }
175
+ write_once(self.root, f"attempts/{identity}/started.json", admission)
176
+ remaining = BudgetLimits(
177
+ max_tool_calls=budget["max_invocations"] - len(started),
178
+ max_tokens=None
179
+ if budget["max_tokens"] is None
180
+ else budget["max_tokens"] - consumed_tokens,
181
+ max_cost_usd=None
182
+ if budget["max_cost_usd"] is None
183
+ else float(Fraction(str(budget["max_cost_usd"])) - consumed_cost),
184
+ )
185
+ receipt, interruption = self._execute(
186
+ declared_case, declared_runner, admission, remaining
187
+ )
188
+ started.append(admission)
189
+ receipts.append(receipt)
190
+ consumed_tokens += reservation["tokens"] or 0
191
+ consumed_cost += Fraction(str(reservation["cost_usd"] or 0))
192
+ halted = receipt["halted"]
193
+ executed += 1
194
+ if interruption is not None:
195
+ raise interruption
196
+ return {
197
+ "experiment_id": self.experiment_id,
198
+ "enabled": True,
199
+ "executed": executed,
200
+ "completed_slots": len(receipts),
201
+ "stop_reason": stop_reason,
202
+ }
203
+
204
+ def _execute(self, declaration, runner_declaration, admission, remaining):
205
+ case, runner = (
206
+ self._cases[declaration["case_id"]],
207
+ self._runners[runner_declaration["candidate_id"]],
208
+ )
209
+ data = case.payload()
210
+ identity = admission["slot_id"]
211
+ started = utc_now_iso()
212
+ trace = AgentTrace(
213
+ "Experiment candidate: " + runner.candidate_id,
214
+ run_id="experiment_run_" + identity,
215
+ started_at=started,
216
+ metadata=workflow_metadata(
217
+ WorkflowInfo(
218
+ self._spec["intervention"]["type"], self._spec["intervention"]["version"]
219
+ ),
220
+ task_id=case.example_id,
221
+ example_id=case.example_id,
222
+ status="running",
223
+ metadata={
224
+ "synthetic": self._spec["synthetic"],
225
+ "experiment_id": self.experiment_id,
226
+ "case_id": case.case_id,
227
+ "repetition": case.repetition,
228
+ "condition": runner.candidate_id,
229
+ },
230
+ ),
231
+ )
232
+ run = Harness(
233
+ HarnessConfig("enforce", (budget_policy(remaining, metered_boundaries=("tool",)),))
234
+ ).start_run(trace.run_id)
235
+ request = ExperimentRequest(
236
+ self.experiment_id,
237
+ case.case_id,
238
+ runner.candidate_id,
239
+ data["input_ref"],
240
+ monotonic() + self._spec["budget"]["timeout_s"],
241
+ canonical(data["inputs"]),
242
+ )
243
+ invoked, result, error, interruption = False, None, None, None
244
+ status = "completed"
245
+ root_event_id = "experiment_root_" + identity
246
+
247
+ def invoke():
248
+ nonlocal invoked, result
249
+ if request.remaining_s <= 0:
250
+ raise TimeoutError("candidate admission deadline expired")
251
+ invoked = True
252
+ with bind_trace_context(trace, root_event_id):
253
+ value = runner.invoke(request)
254
+ if type(value) is not ExperimentResult:
255
+ if inspect.iscoroutine(value) or inspect.isgenerator(value):
256
+ value.close()
257
+ raise ValueError("runner returned an invalid result")
258
+ result = value
259
+ return value
260
+
261
+ began = perf_counter()
262
+ with bind_trace_context(trace):
263
+ try:
264
+ run.wrap(
265
+ invoke,
266
+ boundary="tool",
267
+ branch_id="experiment",
268
+ dispatch=DispatchOptions(reservation=runner.reservation),
269
+ usage_reader=lambda value: value.usage,
270
+ )()
271
+ if runner.declaration() != runner_declaration:
272
+ raise ValueError("runner declaration changed")
273
+ except HarnessControlError:
274
+ status, error = "stopped", "budget_control"
275
+ except TimeoutError:
276
+ status, error = "timed_out", "runner_timeout"
277
+ except Exception:
278
+ status, error = "failed", "runner_error"
279
+ except BaseException as exc:
280
+ status, error, interruption = "cancelled", "runner_cancelled", exc
281
+ elapsed = (perf_counter() - began) * 1000
282
+ if status == "completed" and request.remaining_s <= 0:
283
+ status, error = "timed_out", "runner_timeout"
284
+ ended = utc_now_iso()
285
+ trace.ended_at, trace.elapsed_ms = ended, elapsed
286
+ workflow_status = (
287
+ "completed"
288
+ if status == "completed"
289
+ else "cancelled"
290
+ if status == "cancelled"
291
+ else "failed"
292
+ )
293
+ trace.metadata["agentloop.workflow"]["status"] = workflow_status
294
+ trace.metadata[EXPERIMENT_KEY] = {
295
+ "schema_version": "1.0",
296
+ "experiment_id": self.experiment_id,
297
+ "slot_id": identity,
298
+ "runner": runner_declaration,
299
+ "status": status,
300
+ "invoked": invoked,
301
+ }
302
+ if invoked:
303
+ record_operation(
304
+ "candidate runner",
305
+ kind="workflow",
306
+ trace=trace,
307
+ event_id=root_event_id,
308
+ started_at=started,
309
+ ended_at=ended,
310
+ duration_ms=elapsed,
311
+ status="ok" if status == "completed" else "error",
312
+ metadata={"error_type": error} if error else None,
313
+ )
314
+ try:
315
+ baseline = AgentTrace.from_dict(data["baseline"])
316
+ fixture = {
317
+ "schema_version": "2.0",
318
+ "id": case.case_id,
319
+ "input_ref": data["input_ref"],
320
+ "expected_ref": data["quality_ref"],
321
+ "expected": data["expected"],
322
+ "scorer": self._spec["scorer"],
323
+ "baseline_output": data["baseline_output"],
324
+ "candidate_status": "completed"
325
+ if status == "completed"
326
+ else "cancelled"
327
+ if status == "cancelled"
328
+ else "timed_out"
329
+ if status == "timed_out"
330
+ else "failed",
331
+ }
332
+ if status == "completed":
333
+ fixture["candidate_output"] = result.output
334
+ quality = build_quality_report(
335
+ [fixture],
336
+ baseline_trace=baseline,
337
+ candidate_trace=trace,
338
+ min_score=self._spec["gates"]["min_quality_score"],
339
+ )
340
+ trace.metadata.update(
341
+ success=status == "completed" and quality["passed"],
342
+ quality_score=quality["candidate_score"],
343
+ )
344
+ record = build_intervention(
345
+ baseline,
346
+ trace,
347
+ target_finding_ids=declaration["source_finding_ids"],
348
+ intervention_type=self._spec["intervention"]["type"],
349
+ configuration={
350
+ "experiment_id": self.experiment_id,
351
+ "slot_id": identity,
352
+ "intervention_version": self._spec["intervention"]["version"],
353
+ "runner": runner_declaration,
354
+ "gate_version": self._spec["gate_version"],
355
+ },
356
+ diagnosis=data["diagnosis"],
357
+ gates=ReplayGates(**self._spec["gates"]),
358
+ quality_report=quality,
359
+ )
360
+ documents = {
361
+ "trace.json": trace.to_dict(),
362
+ "quality.json": quality,
363
+ "intervention.json": record.to_dict(),
364
+ }
365
+ for filename, document in documents.items():
366
+ write_once(self.root, f"attempts/{identity}/{filename}", document)
367
+ receipt = {
368
+ "schema_version": "1.0",
369
+ "experiment_id": self.experiment_id,
370
+ "slot_id": identity,
371
+ "started_hash": fingerprint(admission),
372
+ "case_id": case.case_id,
373
+ "candidate_id": runner.candidate_id,
374
+ "status": status,
375
+ "error_category": error,
376
+ "invoked": invoked,
377
+ "halted": run.stopped,
378
+ "budget_call_id": run.results[0].call_id,
379
+ "budget_config_hash": run.harness.config.config_hash,
380
+ "budget_dispatched": run.results[-1].dispatched,
381
+ "usage": None if result is None else result.usage.to_dict(),
382
+ "output_hash": None if result is None else fingerprint(result.output),
383
+ "quality_passed": quality["passed"],
384
+ "gates_passed": record.to_dict()["gates_passed"],
385
+ "artifact_hashes": {name: fingerprint(value) for name, value in documents.items()},
386
+ }
387
+ write_once(self.root, f"attempts/{identity}/receipt.json", receipt)
388
+ return receipt, interruption
389
+ except BaseException:
390
+ if interruption is not None:
391
+ raise interruption
392
+ raise
agentloop/exporters.py ADDED
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from agentloop.costs import format_cost_usd
8
+ from agentloop.markdown import (
9
+ markdown_code_span,
10
+ markdown_heading,
11
+ markdown_table_cell,
12
+ markdown_text,
13
+ )
14
+ from agentloop.timing import format_duration_ms
15
+
16
+
17
+ def export_report_json(report: dict[str, Any], path: str | Path) -> Path:
18
+ out = Path(path)
19
+ out.parent.mkdir(parents=True, exist_ok=True)
20
+ out.write_text(json.dumps(report, indent=2), encoding="utf-8")
21
+ return out
22
+
23
+
24
+ def export_report_markdown(report: dict[str, Any], path: str | Path) -> Path:
25
+ out = Path(path)
26
+ out.parent.mkdir(parents=True, exist_ok=True)
27
+ lines = [
28
+ f"# AgentLoop Report: {markdown_heading(report['name'])}",
29
+ "",
30
+ f"- Run ID: {markdown_code_span(report['run_id'])}",
31
+ f"- Runtime: {format_duration_ms(report['total_runtime_ms'])}",
32
+ "- Estimated cost: "
33
+ + format_cost_usd(report.get("estimated_cost_usd"), report.get("cost_status", "complete")),
34
+ f"- Model calls: {report['model_call_count']}",
35
+ f"- Tool calls: {report['tool_call_count']}",
36
+ f"- Retries: {report['retry_count']}",
37
+ f"- Input tokens: {report['input_tokens']}",
38
+ f"- Output tokens: {report['output_tokens']}",
39
+ "- Repeated context: "
40
+ + (
41
+ "unavailable"
42
+ if report["repeated_context_ratio"] is None
43
+ else f"{report['repeated_context_ratio']:.1%}"
44
+ ),
45
+ "",
46
+ "## Recommendations",
47
+ ]
48
+ for rec in report["recommendations"]:
49
+ lines.append(f"- **{markdown_text(rec['title'])}** — {markdown_text(rec['description'])}")
50
+ if "execution" in report:
51
+ lines.extend(
52
+ [
53
+ "",
54
+ "## Workflow",
55
+ "",
56
+ markdown_code_span(json.dumps(report["execution"], sort_keys=True)),
57
+ "",
58
+ "Cost and token totals cover recorded model calls, not arbitrary service billing.",
59
+ ]
60
+ )
61
+ lines.extend(
62
+ [
63
+ "",
64
+ "## Events",
65
+ "",
66
+ "| Type | Name | Duration ms | Model | Input | Output | Status |",
67
+ "|---|---|---:|---|---:|---:|---|",
68
+ ]
69
+ )
70
+ for event in report["events"]:
71
+ lines.append(
72
+ f"| {markdown_table_cell(event['event_type'])} | "
73
+ f"{markdown_table_cell(event['name'])} | "
74
+ + ("unavailable" if event["duration_ms"] is None else f"{event['duration_ms']:.2f}")
75
+ + " | "
76
+ f"{markdown_table_cell(event.get('model') or '')} | "
77
+ f"{event.get('input_tokens', 0)} | {event.get('output_tokens', 0)} | "
78
+ f"{markdown_table_cell(event.get('status', 'ok'))} |"
79
+ )
80
+ if report.get("stages"):
81
+ lines.extend(
82
+ [
83
+ "",
84
+ "## Stages",
85
+ "",
86
+ "| Span | Stage / version | Kind | Outcome | Schema references | Data references | Dependencies |",
87
+ "| --- | --- | --- | --- | --- | --- | --- |",
88
+ ]
89
+ )
90
+ for identity, stage in report["stages"].items():
91
+ cells = [
92
+ identity,
93
+ f"{stage.get('stage_id')} / {stage.get('version')}",
94
+ stage.get("kind"),
95
+ stage.get("outcome"),
96
+ f"{stage.get('input_schema_ref')} -> {stage.get('output_schema_ref')}",
97
+ f"{stage.get('input_ref')} -> {stage.get('output_ref')}",
98
+ ", ".join(stage.get("depends_on", [])),
99
+ ]
100
+ lines.append(
101
+ "| "
102
+ + " | ".join(
103
+ markdown_table_cell("unavailable" if value is None else str(value))
104
+ for value in cells
105
+ )
106
+ + " |"
107
+ )
108
+ if "semantic_judgments" in report:
109
+ from agentloop.judgment_views import judgment_markdown
110
+
111
+ lines.extend(judgment_markdown(report["semantic_judgments"]))
112
+ if "semantic_waste" in report:
113
+ from agentloop.semantic_waste import semantic_waste_markdown
114
+
115
+ lines.extend(semantic_waste_markdown(report["semantic_waste"]))
116
+ out.write_text("\n".join(lines) + "\n", encoding="utf-8")
117
+ return out
@@ -0,0 +1,131 @@
1
+ """Frozen native fixtures and independent labels for finding evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+
8
+ from agentloop.judgment_types import canonical, fingerprint, text
9
+ from agentloop.tracer import AgentTrace
10
+
11
+ FINDING_BENCHMARK_VERSION = "1.0"
12
+ LABELS = {"opportunity", "no_opportunity", "unknown", "ambiguous"}
13
+
14
+
15
+ def _keys(value, expected, name):
16
+ if not isinstance(value, dict) or set(value) != set(expected.split()):
17
+ raise ValueError(f"invalid {name} fields")
18
+
19
+
20
+ @dataclass(frozen=True, init=False)
21
+ class FindingBenchmark:
22
+ """Own a validated JSON snapshot; returned dictionaries cannot mutate the plan.
23
+
24
+ Labels enumerate justified findings by exact family and affected-span set.
25
+ They are never passed to detectors. Group declarations prevent splitting one
26
+ workload family across development and evaluation, not all possible leakage.
27
+ """
28
+
29
+ _json: str
30
+
31
+ def __init__(self, value):
32
+ owned = json.loads(canonical(value))
33
+ _keys(owned, "schema_version name version provenance_ref synthetic policy cases", "corpus")
34
+ if owned["schema_version"] != FINDING_BENCHMARK_VERSION:
35
+ raise ValueError("unsupported finding benchmark version")
36
+ for key in ("name", "version", "provenance_ref"):
37
+ text(owned[key], key)
38
+ if type(owned["synthetic"]) is not bool:
39
+ raise ValueError("synthetic must be boolean")
40
+ policy = owned["policy"]
41
+ _keys(policy, "id version ref", "frozen policy")
42
+ for key, val in policy.items():
43
+ text(val, key)
44
+ cases = owned["cases"]
45
+ if not isinstance(cases, list) or not 1 <= len(cases) <= 10000:
46
+ raise ValueError("corpus requires 1..10000 cases")
47
+ seen, groups, traces = set(), {}, {}
48
+ span_count = 0
49
+ for case in cases:
50
+ _keys(
51
+ case,
52
+ "id workload_ref group split scenario rule_id label label_ref label_version "
53
+ "rationale opportunities required_evidence trace",
54
+ "case",
55
+ )
56
+ for key in (
57
+ "id",
58
+ "workload_ref",
59
+ "group",
60
+ "scenario",
61
+ "rule_id",
62
+ "label_ref",
63
+ "label_version",
64
+ "rationale",
65
+ ):
66
+ text(case[key], key)
67
+ if case["id"] in seen:
68
+ raise ValueError("case IDs must be unique")
69
+ seen.add(case["id"])
70
+ split = case["split"]
71
+ if split not in {"development", "evaluation"} or case["label"] not in LABELS:
72
+ raise ValueError("invalid split or label")
73
+ if groups.setdefault(case["group"], split) != split:
74
+ raise ValueError("workload groups must not cross splits")
75
+ trace = AgentTrace.from_dict(case["trace"])
76
+ span_count += len(trace.events)
77
+ if span_count > 100000:
78
+ raise ValueError("corpus exceeds 100000 total spans")
79
+ ids = {event.event_id for event in trace.events}
80
+ if len(ids) != len(trace.events) or len(ids) > 10000:
81
+ raise ValueError("trace requires unique span IDs and at most 10000 spans")
82
+ if traces.setdefault(fingerprint(case["trace"]), split) != split:
83
+ raise ValueError("duplicate traces must not cross splits")
84
+ opportunities = case["opportunities"]
85
+ if not isinstance(opportunities, list):
86
+ raise ValueError("opportunities must be a list")
87
+ signatures = set()
88
+ for opportunity in opportunities:
89
+ _keys(opportunity, "family spans", "opportunity")
90
+ text(opportunity["family"], "family")
91
+ spans = opportunity["spans"]
92
+ if (
93
+ not isinstance(spans, list)
94
+ or not spans
95
+ or any(not isinstance(span, str) or span not in ids for span in spans)
96
+ or len(set(spans)) != len(spans)
97
+ ):
98
+ raise ValueError("opportunities require unique existing spans")
99
+ signature = (opportunity["family"], tuple(sorted(spans)))
100
+ if signature in signatures:
101
+ raise ValueError("duplicate opportunity")
102
+ signatures.add(signature)
103
+ if bool(opportunities) != (case["label"] == "opportunity"):
104
+ raise ValueError("only opportunity labels enumerate justified findings")
105
+ evidence = case["required_evidence"]
106
+ if (
107
+ not isinstance(evidence, list)
108
+ or any(
109
+ not isinstance(item, str)
110
+ or item not in {"timing", "tokens", "cost", "semantic"}
111
+ for item in evidence
112
+ )
113
+ or len(set(evidence)) != len(evidence)
114
+ ):
115
+ raise ValueError("unsupported evidence requirement")
116
+ object.__setattr__(self, "_json", canonical(owned))
117
+
118
+ def to_dict(self):
119
+ return json.loads(self._json)
120
+
121
+ @property
122
+ def corpus_hash(self):
123
+ return fingerprint(self.to_dict())
124
+
125
+ def cases(self, split):
126
+ if split not in {"development", "evaluation"}:
127
+ raise ValueError("select development or evaluation explicitly")
128
+ cases = [item for item in self.to_dict()["cases"] if item["split"] == split]
129
+ if not cases:
130
+ raise ValueError("selected split is empty")
131
+ return cases