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,410 @@
1
+ """Bounded host-declared tool scheduling with cooperative stopping and no retries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
6
+ from contextvars import ContextVar, copy_context
7
+ from copy import deepcopy
8
+ from dataclasses import dataclass
9
+ from threading import Event, RLock
10
+ from time import monotonic, perf_counter
11
+ from types import MappingProxyType
12
+ from uuid import uuid4
13
+
14
+ from agentloop.events import utc_now_iso
15
+ from agentloop.harness import Decision, HarnessControlError, HarnessRun, Hook, Policy
16
+ from agentloop.scheduling_plan import plan_tools
17
+ from agentloop.scheduling_types import (
18
+ SCHEDULE_KEY,
19
+ SCHEDULE_VERSION,
20
+ ScheduleConfig,
21
+ ScheduledResult,
22
+ SchedulingError,
23
+ ToolContext,
24
+ ToolOutcome,
25
+ )
26
+ from agentloop.tracer import current_trace
27
+
28
+ _ACTIVE = ContextVar("agentloop_scheduled_tool", default=None)
29
+ _EVIDENCE_LOCK = RLock()
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class _ScheduleAdmission:
34
+ config: ScheduleConfig
35
+
36
+ def __call__(self, context):
37
+ if not context.branch_id.startswith(self.config.branch_id + ":"):
38
+ return Decision("continue", "schedule_not_selected")
39
+ active = _ACTIVE.get()
40
+ if (
41
+ active is None
42
+ or active["used"]
43
+ or active["run_id"] != context.run_id
44
+ or active["branch"] != context.branch_id
45
+ or active["config"] != self.config
46
+ ):
47
+ return Decision("deny", "schedule_adapter_required")
48
+ active["used"] = True
49
+ active["record"]["harness_call_id"] = context.call_id
50
+ if active["unknown"] and self.config.on_unknown == "error":
51
+ return Decision("deny", "unknown_scheduling_safety")
52
+ return Decision("continue", "declared_schedule_admitted")
53
+
54
+
55
+ def scheduling_policy(config):
56
+ if type(config) is not ScheduleConfig:
57
+ raise ValueError("scheduling policy requires ScheduleConfig")
58
+ return Policy(
59
+ config.policy_id,
60
+ config.version,
61
+ _ScheduleAdmission(config),
62
+ hooks=frozenset({Hook("tool")}),
63
+ actions=frozenset({"continue", "deny"}),
64
+ priority=50,
65
+ configuration=config.to_dict(),
66
+ )
67
+
68
+
69
+ class ToolScheduler:
70
+ """Execute once, drain in-flight work, and return results in submission order.
71
+
72
+ Deadlines/cancellation stop new admission; Python cannot forcibly terminate a
73
+ running thread. Already-started callbacks must return or cooperate. The method
74
+ drains them before returning or re-raising an interruption. Never retries.
75
+ """
76
+
77
+ def __init__(self, run, *, config, max_records=64, max_claimed_calls=4096):
78
+ if not isinstance(run, HarnessRun) or type(config) is not ScheduleConfig:
79
+ raise ValueError("typed run and scheduling configuration required")
80
+ expected = scheduling_policy(config)
81
+ if run.harness.config.mode != "disabled" and not any(
82
+ policy.config_hash == expected.config_hash
83
+ and type(policy.evaluate) is _ScheduleAdmission
84
+ for policy in run.harness.config.policies
85
+ ):
86
+ raise ValueError("register matching scheduling_policy before binding")
87
+ if (
88
+ "sync" not in run.harness.capabilities.execution_kinds
89
+ or not {Hook("tool"), Hook("tool", "after")} <= run.harness.capabilities.hooks
90
+ ):
91
+ raise SchedulingError("unsupported_lifecycle")
92
+ if (
93
+ type(max_records) is not int
94
+ or not 1 <= max_records <= 1000
95
+ or type(max_claimed_calls) is not int
96
+ or not 1 <= max_claimed_calls <= 100000
97
+ ):
98
+ raise ValueError("scheduler evidence and call registries must be bounded")
99
+ self._run, self._config = run, config
100
+ self._max_records, self._max_claimed = max_records, max_claimed_calls
101
+ self._lock, self._records, self._claimed = RLock(), {}, set()
102
+ self._running = False
103
+ self._capture_failed = False
104
+ self._last_result = None
105
+
106
+ @property
107
+ def run(self):
108
+ return self._run
109
+
110
+ @property
111
+ def config(self):
112
+ return self._config
113
+
114
+ @property
115
+ def last_result(self):
116
+ """Retain partial/completed outputs even when caller interruption propagates."""
117
+ with self._lock:
118
+ return self._last_result
119
+
120
+ def export_evidence(self):
121
+ with self._lock:
122
+ return {"schema_version": SCHEDULE_VERSION, "records": deepcopy(self._records)}
123
+
124
+ def execute(self, calls, *, timeout_s=None, cancel_event=None):
125
+ if timeout_s is not None and (
126
+ type(timeout_s) not in {int, float} or not 0 < timeout_s < float("inf")
127
+ ):
128
+ raise ValueError("timeout_s must be finite and positive")
129
+ if cancel_event is not None and not isinstance(cancel_event, Event):
130
+ raise ValueError("cancel_event must be threading.Event")
131
+ mode = self.run.harness.config.mode
132
+ plan = plan_tools(calls, self.config, enforce=mode == "enforce")
133
+ by_id = {call.call_id: call for call in calls}
134
+ identity = "schedule_" + uuid4().hex
135
+ trace = current_trace() if mode != "disabled" else None
136
+ records = {
137
+ call.call_id: {
138
+ "status": "pending",
139
+ "harness_call_id": None,
140
+ "callback_invoked": False,
141
+ "callback_completed": False,
142
+ "started_at": None,
143
+ "ended_at": None,
144
+ "duration_ms": None,
145
+ "reason": None,
146
+ }
147
+ for call in calls
148
+ }
149
+ concurrency = plan["proposed_concurrency"] if mode == "enforce" else 1
150
+ receipt = {
151
+ "schedule_id": identity,
152
+ "schema_version": SCHEDULE_VERSION,
153
+ "policy_config_hash": scheduling_policy(self.config).config_hash,
154
+ "mode": mode,
155
+ "plan": plan,
156
+ "actual_concurrency_limit": concurrency,
157
+ "peak_running": 0,
158
+ "dispatch_order": [],
159
+ "completion_order": [],
160
+ "calls": records,
161
+ "status": "running",
162
+ "stop_reason": None,
163
+ "timeout_semantics": "stop new admission and drain already-started callbacks; no thread termination",
164
+ }
165
+ with self._lock, _EVIDENCE_LOCK:
166
+ if self._running or self._claimed.intersection(by_id):
167
+ raise SchedulingError("already_started")
168
+ if self._capture_failed:
169
+ raise SchedulingError("invalid_trace_evidence")
170
+ if (
171
+ len(self._records) >= self._max_records
172
+ or len(self._claimed) + len(calls) > self._max_claimed
173
+ ):
174
+ raise SchedulingError("evidence_limit")
175
+ if trace is not None:
176
+ envelope = trace.metadata.setdefault(
177
+ SCHEDULE_KEY, {"schema_version": SCHEDULE_VERSION, "records": {}}
178
+ )
179
+ if (
180
+ not isinstance(envelope, dict)
181
+ or envelope.get("schema_version") != SCHEDULE_VERSION
182
+ or not isinstance(envelope.get("records"), dict)
183
+ or len(envelope["records"]) >= 10000
184
+ ):
185
+ raise SchedulingError("invalid_trace_evidence")
186
+ envelope["records"][identity] = deepcopy(receipt)
187
+ self._running = True
188
+ self._claimed.update(by_id)
189
+ if mode != "disabled":
190
+ self._records[identity] = deepcopy(receipt)
191
+ stop = Event()
192
+ deadline = None if timeout_s is None else monotonic() + timeout_s
193
+ outcomes, finished, active, pending = {}, {}, {}, set(by_id)
194
+ running = 0
195
+ failure = None
196
+ executor = None
197
+
198
+ def call_one(call, prerequisites):
199
+ nonlocal running
200
+ record = records[call.call_id]
201
+ returned = []
202
+ context = ToolContext(
203
+ call.call_id, MappingProxyType(prerequisites), stop, cancel_event, deadline
204
+ )
205
+ if context.cancellation_requested:
206
+ record.update(status="not_started", reason="admission_stopped")
207
+ return ToolOutcome(call.call_id, "not_started")
208
+ branch = self.config.branch_id + ":" + call.call_id
209
+
210
+ def invoke():
211
+ nonlocal running
212
+ if context.cancellation_requested:
213
+ # No side effect begins after the cooperative stop is observed.
214
+ return _NOT_STARTED
215
+ with self._lock:
216
+ running += 1
217
+ receipt["peak_running"] = max(receipt["peak_running"], running)
218
+ receipt["dispatch_order"].append(call.call_id)
219
+ record.update(callback_invoked=True, status="running", started_at=utc_now_iso())
220
+ started = perf_counter()
221
+ try:
222
+ value = call.invoke(context)
223
+ returned.append(value)
224
+ record["callback_completed"] = True
225
+ return value
226
+ finally:
227
+ with self._lock:
228
+ running -= 1
229
+ record["ended_at"] = utc_now_iso()
230
+ record["duration_ms"] = (perf_counter() - started) * 1000
231
+
232
+ protected = self.run.wrap(
233
+ invoke,
234
+ boundary="tool",
235
+ branch_id=branch,
236
+ dispatch=call.dispatch,
237
+ usage_reader=(
238
+ lambda value: None if value is _NOT_STARTED else call.usage_reader(value)
239
+ )
240
+ if call.usage_reader is not None
241
+ else None,
242
+ error_usage_reader=call.error_usage_reader,
243
+ )
244
+ binding = {
245
+ "used": False,
246
+ "config": self.config,
247
+ "branch": branch,
248
+ "run_id": self.run.run_id,
249
+ "record": record,
250
+ "unknown": call.call_id in plan["unknown_safety"],
251
+ }
252
+ token = _ACTIVE.set(binding)
253
+ try:
254
+ value = protected()
255
+ status = "not_started" if value is _NOT_STARTED else "completed"
256
+ record["status"] = status
257
+ return ToolOutcome(call.call_id, status, None if value is _NOT_STARTED else value)
258
+ except BaseException as exc:
259
+ status = (
260
+ "cancelled"
261
+ if not isinstance(exc, Exception)
262
+ else "blocked"
263
+ if isinstance(exc, HarnessControlError)
264
+ else "failed"
265
+ )
266
+ record.update(
267
+ status=status,
268
+ reason="harness_control"
269
+ if isinstance(exc, HarnessControlError)
270
+ else "cancelled"
271
+ if status == "cancelled"
272
+ else "tool_error",
273
+ )
274
+ if returned:
275
+ record["status"] = "completed"
276
+ return ToolOutcome(call.call_id, "completed", returned[0], exc)
277
+ return ToolOutcome(call.call_id, status, error=exc)
278
+ finally:
279
+ _ACTIVE.reset(token)
280
+
281
+ def worker(call, prerequisites):
282
+ try:
283
+ result = call_one(call, prerequisites)
284
+ except BaseException as exc:
285
+ records[call.call_id].update(status="failed", reason="dispatch_setup_failed")
286
+ result = ToolOutcome(call.call_id, "failed", error=exc)
287
+ with self._lock:
288
+ finished[call.call_id] = result
289
+ return result
290
+
291
+ def accept(result):
292
+ nonlocal failure
293
+ if result.call_id in outcomes:
294
+ return
295
+ outcomes[result.call_id] = result
296
+ receipt["completion_order"].append(result.call_id)
297
+ if result.status != "completed" and self.config.on_error == "stop":
298
+ stop.set()
299
+ receipt["stop_reason"] = receipt["stop_reason"] or "tool_failure"
300
+ if isinstance(result.error, HarnessControlError):
301
+ stop.set()
302
+ receipt["stop_reason"] = receipt["stop_reason"] or "harness_control"
303
+ if result.error is not None and not isinstance(result.error, Exception):
304
+ failure = failure or result.error
305
+ stop.set()
306
+ receipt["stop_reason"] = "cancelled"
307
+
308
+ try:
309
+ if concurrency > 1:
310
+ executor = ThreadPoolExecutor(max_workers=concurrency)
311
+ while pending or active:
312
+ if cancel_event is not None and cancel_event.is_set():
313
+ stop.set()
314
+ receipt["stop_reason"] = "cancelled"
315
+ if deadline is not None and monotonic() >= deadline:
316
+ stop.set()
317
+ receipt["stop_reason"] = receipt["stop_reason"] or "deadline"
318
+ if self.run.stopped:
319
+ stop.set()
320
+ receipt["stop_reason"] = receipt["stop_reason"] or "harness_stopped"
321
+ for call_id in plan["serial_order"]:
322
+ if call_id not in pending or stop.is_set() or len(active) >= concurrency:
323
+ continue
324
+ dependencies = plan["effective_dependencies"][call_id]
325
+ if not all(dependency in outcomes for dependency in dependencies):
326
+ continue
327
+ pending.remove(call_id)
328
+ if any(
329
+ outcomes[dependency].status != "completed" for dependency in dependencies
330
+ ):
331
+ records[call_id].update(
332
+ status="dependency_failed", reason="prerequisite_failed"
333
+ )
334
+ accept(ToolOutcome(call_id, "dependency_failed"))
335
+ continue
336
+ call = by_id[call_id]
337
+ selected = call.depends_on if call.depends_on is not None else tuple(outcomes)
338
+ prerequisites = {key: outcomes[key].value for key in selected}
339
+ if executor is None:
340
+ accept(worker(call, prerequisites))
341
+ break
342
+ future = executor.submit(copy_context().run, worker, call, prerequisites)
343
+ active[future] = call_id
344
+ if stop.is_set():
345
+ for call_id in pending:
346
+ records[call_id].update(status="not_started", reason=receipt["stop_reason"])
347
+ outcomes[call_id] = ToolOutcome(call_id, "not_started")
348
+ pending.clear()
349
+ if active:
350
+ completed, _ = wait(active, timeout=0.02, return_when=FIRST_COMPLETED)
351
+ for future in completed:
352
+ active.pop(future)
353
+ accept(future.result())
354
+ receipt["status"] = (
355
+ "completed"
356
+ if all(result.status == "completed" for result in outcomes.values())
357
+ and receipt["stop_reason"] is None
358
+ else "partial"
359
+ )
360
+ except BaseException as exc:
361
+ failure = exc
362
+ stop.set()
363
+ receipt.update(
364
+ status="interrupted",
365
+ stop_reason="cancelled" if not isinstance(exc, Exception) else "scheduler_error",
366
+ )
367
+ finally:
368
+ if executor is not None:
369
+ executor.shutdown(wait=True)
370
+ for future, call_id in active.items():
371
+ if future.done():
372
+ accept(future.result())
373
+ for result in finished.values():
374
+ accept(result)
375
+ for call_id in by_id.keys() - outcomes.keys():
376
+ records[call_id].update(
377
+ status="not_started", reason=receipt["stop_reason"] or "interrupted"
378
+ )
379
+ outcomes[call_id] = ToolOutcome(call_id, "not_started")
380
+ with self._lock, _EVIDENCE_LOCK:
381
+ self._running = False
382
+ self._last_result = ScheduledResult(
383
+ identity,
384
+ tuple(outcomes[call.call_id] for call in calls),
385
+ receipt["stop_reason"],
386
+ )
387
+ capture_error = False
388
+ if trace is not None:
389
+ try:
390
+ current = trace.metadata[SCHEDULE_KEY]
391
+ if (
392
+ current is not envelope
393
+ or current.get("schema_version") != SCHEDULE_VERSION
394
+ or not isinstance(current["records"], dict)
395
+ ):
396
+ raise ValueError()
397
+ current["records"][identity] = deepcopy(receipt)
398
+ except (KeyError, TypeError, ValueError):
399
+ capture_error = self._capture_failed = True
400
+ receipt["capture_error"] = "invalid_trace_evidence"
401
+ if mode != "disabled":
402
+ self._records[identity] = deepcopy(receipt)
403
+ if capture_error and failure is None:
404
+ raise SchedulingError("invalid_trace_evidence")
405
+ if failure is not None:
406
+ raise failure
407
+ return self._last_result
408
+
409
+
410
+ _NOT_STARTED = object()
@@ -0,0 +1,31 @@
1
+ """Native source-trace verification across compatible numeric normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from hashlib import sha256
7
+
8
+ from agentloop.interventions import canonical_json, trace_fingerprint
9
+ from agentloop.tracer import AgentTrace
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class TraceSource:
14
+ trace: AgentTrace
15
+ fingerprints: frozenset[str]
16
+
17
+ @classmethod
18
+ def from_dict(cls, payload):
19
+ """Validate before accepting either canonical source or normalized identity.
20
+
21
+ Ledger creation can precede export (integer duration fields) or follow a
22
+ store read (normalized floats/defaults). Preserve both existing identities;
23
+ do not alter historical ledger hashes or recompute reports/pricing.
24
+ """
25
+ trace = AgentTrace.from_dict(payload)
26
+ return cls(
27
+ trace,
28
+ frozenset(
29
+ {sha256(canonical_json(payload).encode()).hexdigest(), trace_fingerprint(trace)}
30
+ ),
31
+ )