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.
- agentloop/__init__.py +136 -0
- agentloop/ablation_protocol.py +229 -0
- agentloop/ablations.py +564 -0
- agentloop/aggregate_cli.py +88 -0
- agentloop/aggregate_stats.py +175 -0
- agentloop/aggregates.py +532 -0
- agentloop/audit.py +39 -0
- agentloop/autoinstrument.py +145 -0
- agentloop/batch_experiments.py +295 -0
- agentloop/batching.py +95 -0
- agentloop/budget_types.py +335 -0
- agentloop/budgets.py +539 -0
- agentloop/calibration.py +872 -0
- agentloop/checkpoint_state.py +306 -0
- agentloop/checkpoint_store.py +86 -0
- agentloop/ci.py +202 -0
- agentloop/cli.py +1314 -0
- agentloop/client.py +228 -0
- agentloop/completion.py +345 -0
- agentloop/completion_types.py +199 -0
- agentloop/config.py +50 -0
- agentloop/context_controls.py +443 -0
- agentloop/context_experiments.py +337 -0
- agentloop/context_types.py +216 -0
- agentloop/costs.py +667 -0
- agentloop/cursor_validation.py +48 -0
- agentloop/decorators.py +445 -0
- agentloop/demo.py +300 -0
- agentloop/doctor.py +341 -0
- agentloop/drift.py +361 -0
- agentloop/drift_cli.py +147 -0
- agentloop/drift_types.py +110 -0
- agentloop/drift_windows.py +340 -0
- agentloop/entrypoint.py +201 -0
- agentloop/estimates.py +133 -0
- agentloop/events.py +75 -0
- agentloop/experiment_artifacts.py +80 -0
- agentloop/experiment_observations.py +169 -0
- agentloop/experiment_reports.py +545 -0
- agentloop/experiment_types.py +355 -0
- agentloop/experiments.py +392 -0
- agentloop/exporters.py +117 -0
- agentloop/finding_benchmark_types.py +131 -0
- agentloop/finding_benchmarks.py +424 -0
- agentloop/findings.py +419 -0
- agentloop/graph.py +464 -0
- agentloop/harness.py +825 -0
- agentloop/harness_bench/__init__.py +1 -0
- agentloop/harness_bench/cli.py +77 -0
- agentloop/harness_bench/drivers.py +96 -0
- agentloop/harness_bench/offline.py +507 -0
- agentloop/harness_evidence.py +585 -0
- agentloop/harness_usage.py +91 -0
- agentloop/html_report.py +860 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/harbor/__init__.py +1 -0
- agentloop/integrations/harbor/atif.py +982 -0
- agentloop/integrations/harbor/atif_validation.py +329 -0
- agentloop/integrations/harbor/cli.py +207 -0
- agentloop/integrations/harbor/manifest.py +86 -0
- agentloop/integrations/harbor/trial_evidence.py +196 -0
- agentloop/integrations/harbor/trials.py +1109 -0
- agentloop/integrations/harbor/verifier.py +118 -0
- agentloop/integrations/langgraph.py +150 -0
- agentloop/integrations/langgraph_harness.py +466 -0
- agentloop/integrations/langgraph_recovery.py +366 -0
- agentloop/integrations/omnigent/__init__.py +1 -0
- agentloop/integrations/omnigent/cli.py +78 -0
- agentloop/integrations/omnigent/evidence.py +93 -0
- agentloop/integrations/omnigent/telemetry.py +422 -0
- agentloop/integrations/openai.py +415 -0
- agentloop/integrations/openai_agents.py +291 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/interoperability/__init__.py +5 -0
- agentloop/interoperability/artifacts.py +112 -0
- agentloop/interoperability/cli.py +59 -0
- agentloop/interoperability/cohort_evidence.py +175 -0
- agentloop/interoperability/cohorts.py +681 -0
- agentloop/interoperability/contracts.py +427 -0
- agentloop/interoperability/coordination.py +554 -0
- agentloop/interoperability/evidence.py +215 -0
- agentloop/interoperability/otlp_jsonl.py +775 -0
- agentloop/interoperability/otlp_validation.py +496 -0
- agentloop/interoperability/registry.py +561 -0
- agentloop/interoperability/validation.py +333 -0
- agentloop/intervention_service.py +110 -0
- agentloop/intervention_store.py +118 -0
- agentloop/interventions.py +190 -0
- agentloop/issues.py +90 -0
- agentloop/judgment_adapters.py +125 -0
- agentloop/judgment_benchmark_types.py +98 -0
- agentloop/judgment_benchmarks.py +606 -0
- agentloop/judgment_types.py +241 -0
- agentloop/judgment_views.py +38 -0
- agentloop/judgments.py +342 -0
- agentloop/loop_guards.py +274 -0
- agentloop/loop_types.py +66 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +342 -0
- agentloop/migrations.py +303 -0
- agentloop/model_routing.py +470 -0
- agentloop/onboarding.py +315 -0
- agentloop/operations.py +44 -0
- agentloop/optimizer.py +199 -0
- agentloop/otel.py +715 -0
- agentloop/otel_ids.py +81 -0
- agentloop/otel_semantics.py +134 -0
- agentloop/parallelism.py +141 -0
- agentloop/patches.py +603 -0
- agentloop/plan_export.py +126 -0
- agentloop/policy_promotion.py +325 -0
- agentloop/promotion_evidence.py +304 -0
- agentloop/promotion_types.py +209 -0
- agentloop/quality.py +446 -0
- agentloop/ranking.py +529 -0
- agentloop/recovery_evidence.py +96 -0
- agentloop/replay.py +753 -0
- agentloop/retention.py +375 -0
- agentloop/retention_types.py +113 -0
- agentloop/reuse_experiments.py +247 -0
- agentloop/routing_checks.py +116 -0
- agentloop/routing_types.py +241 -0
- agentloop/rules.py +396 -0
- agentloop/runtime.py +286 -0
- agentloop/savings.py +158 -0
- agentloop/scheduling_plan.py +99 -0
- agentloop/scheduling_types.py +169 -0
- agentloop/schema.py +257 -0
- agentloop/semantic_rules.py +200 -0
- agentloop/semantic_waste.py +345 -0
- agentloop/semantic_waste_types.py +88 -0
- agentloop/server.py +494 -0
- agentloop/store.py +1429 -0
- agentloop/structural_experiments.py +431 -0
- agentloop/structural_types.py +113 -0
- agentloop/structured_quality.py +1020 -0
- agentloop/studies.py +629 -0
- agentloop/study_statistics.py +58 -0
- agentloop/substitution_evidence.py +199 -0
- agentloop/substitution_export.py +190 -0
- agentloop/substitution_reports.py +407 -0
- agentloop/substitution_types.py +284 -0
- agentloop/substitutions.py +267 -0
- agentloop/timing.py +142 -0
- agentloop/tokens.py +188 -0
- agentloop/tool_scheduling.py +410 -0
- agentloop/trace_sources.py +31 -0
- agentloop/tracer.py +494 -0
- agentloop/value.py +306 -0
- agentloop/version.py +3 -0
- agentloop/workflow_types.py +264 -0
- agentloop/workflows.py +284 -0
- agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
- agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
- agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
- agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +5 -0
- dashboard/app.py +922 -0
- dashboard/streamlit_compat.py +63 -0
- dashboard/trace_upload.py +57 -0
- dashboard/value_view.py +105 -0
agentloop/experiments.py
ADDED
|
@@ -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
|