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,585 @@
1
+ """Versioned, payload-free harness evidence carried by existing trace artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from hashlib import sha256
11
+ from typing import Any
12
+
13
+ from agentloop.budget_types import BudgetSnapshot, BudgetValidationError
14
+
15
+ EVIDENCE_VERSION = "1.1"
16
+ _IDENTITY_VERSION = "1.0"
17
+ _COMPARISON_VERSION = "1.0"
18
+ METADATA_KEY = "agentloop.harness"
19
+ _ACTIONS = {"continue", "deny", "stop", "escalate"}
20
+ _OUTCOMES = {"proposed", "applied", "rejected", "failed", "no_op"}
21
+ _HASH = re.compile(r"[a-f0-9]{64}\Z")
22
+ _DECISION_ID = re.compile(r"hdec_[a-f0-9]{64}\Z")
23
+ _LABEL = re.compile(r"[A-Za-z0-9_.:-]{1,96}\Z")
24
+ _FIELDS = {
25
+ "schema_version",
26
+ "decision_id",
27
+ "hook_id",
28
+ "run_id",
29
+ "call_id",
30
+ "branch_id",
31
+ "trace_id",
32
+ "span_id",
33
+ "policy_id",
34
+ "policy_version",
35
+ "policy_config_hash",
36
+ "harness_config_hash",
37
+ "boundary",
38
+ "phase",
39
+ "mode",
40
+ "requested_action",
41
+ "resolved_action",
42
+ "outcome",
43
+ "reason_code",
44
+ "dispatched",
45
+ "execution_status",
46
+ "evidence_refs",
47
+ "conflicting_decision_ids",
48
+ "retry_of",
49
+ "timing",
50
+ "budget_snapshot",
51
+ "evaluation_status",
52
+ "origin",
53
+ "hook_sequence",
54
+ "policy_order",
55
+ }
56
+ _CURRENT_FIELDS = _FIELDS | {"feedback"}
57
+
58
+
59
+ class HarnessEvidenceError(ValueError):
60
+ """Harness evidence is malformed, unsupported, or conflicts with an earlier record."""
61
+
62
+
63
+ def canonical_json(value: Any) -> str:
64
+ """Own a finite JSON snapshot without retaining caller-owned containers."""
65
+ try:
66
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
67
+ except (TypeError, ValueError, RecursionError):
68
+ raise HarnessEvidenceError("harness evidence must contain finite JSON values") from None
69
+
70
+
71
+ def hook_id(run_id: str, call_id: str, boundary: str, phase: str) -> str:
72
+ identity = [_IDENTITY_VERSION, run_id, call_id, boundary, phase]
73
+ return "hook_" + sha256(canonical_json(identity).encode()).hexdigest()
74
+
75
+
76
+ def decision_id(run_id: str, call_id: str, boundary: str, phase: str, policy_id: str) -> str:
77
+ """Return one stable identity for a policy at a particular call boundary."""
78
+ identity = [_IDENTITY_VERSION, run_id, call_id, boundary, phase, policy_id]
79
+ return "hdec_" + sha256(canonical_json(identity).encode()).hexdigest()
80
+
81
+
82
+ def valid_decision_id(value: Any) -> bool:
83
+ return isinstance(value, str) and _DECISION_ID.fullmatch(value) is not None
84
+
85
+
86
+ def empty_evidence() -> dict[str, Any]:
87
+ return {
88
+ "schema_version": EVIDENCE_VERSION,
89
+ "policies": {},
90
+ "decisions": {},
91
+ "capture_errors": [],
92
+ }
93
+
94
+
95
+ def _envelope(value: Any) -> dict[str, Any]:
96
+ if (
97
+ not isinstance(value, dict)
98
+ or set(value) != {"schema_version", "policies", "decisions", "capture_errors"}
99
+ or not isinstance(value.get("schema_version"), str)
100
+ or value.get("schema_version") not in {"1.0", EVIDENCE_VERSION}
101
+ or not isinstance(value.get("policies"), dict)
102
+ or not isinstance(value.get("decisions"), dict)
103
+ or not isinstance(value.get("capture_errors"), list)
104
+ ):
105
+ raise HarnessEvidenceError("invalid or unsupported harness evidence envelope")
106
+ return value
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class HarnessDecisionRecord:
111
+ """Immutable decision evidence; exports are independent JSON copies."""
112
+
113
+ _json: str
114
+
115
+ def to_dict(self) -> dict[str, Any]:
116
+ return json.loads(self._json)
117
+
118
+ @classmethod
119
+ def from_dict(cls, value: dict[str, Any]) -> HarnessDecisionRecord:
120
+ if not isinstance(value, dict):
121
+ raise HarnessEvidenceError("invalid harness decision fields")
122
+ version = value.get("schema_version")
123
+ if not isinstance(version, str) or version not in {"1.0", EVIDENCE_VERSION}:
124
+ raise HarnessEvidenceError("unsupported harness decision version")
125
+ if set(value) != (_FIELDS if version == "1.0" else _CURRENT_FIELDS):
126
+ raise HarnessEvidenceError("invalid harness decision fields")
127
+ feedback = value.get("feedback")
128
+ if feedback is not None and (
129
+ not isinstance(feedback, str) or not feedback or len(feedback) > 256
130
+ ):
131
+ raise HarnessEvidenceError("invalid bounded decision feedback")
132
+ for key in ("run_id", "call_id", "branch_id", "policy_id", "policy_version", "reason_code"):
133
+ if not isinstance(value[key], str) or not value[key]:
134
+ raise HarnessEvidenceError(
135
+ "decision identity and reason fields must be nonempty strings"
136
+ )
137
+ for key in ("policy_id", "policy_version", "reason_code"):
138
+ if not _LABEL.fullmatch(value[key]):
139
+ raise HarnessEvidenceError(
140
+ "policy identity and reasons must be bounded identifiers"
141
+ )
142
+ for key in ("trace_id", "span_id"):
143
+ if value[key] is not None and (not isinstance(value[key], str) or not value[key]):
144
+ raise HarnessEvidenceError("trace/span references must be strings or null")
145
+ for key in ("policy_config_hash", "harness_config_hash"):
146
+ if not isinstance(value[key], str) or not _HASH.fullmatch(value[key]):
147
+ raise HarnessEvidenceError("configuration hashes must be SHA-256 hex digests")
148
+ for key in (
149
+ "boundary",
150
+ "phase",
151
+ "mode",
152
+ "origin",
153
+ "requested_action",
154
+ "resolved_action",
155
+ "outcome",
156
+ "execution_status",
157
+ "evaluation_status",
158
+ ):
159
+ if not isinstance(value[key], str):
160
+ raise HarnessEvidenceError("decision enum fields must be strings")
161
+ if value["boundary"] not in {"model", "tool", "iteration", "completion"} or value[
162
+ "phase"
163
+ ] not in {"before", "after"}:
164
+ raise HarnessEvidenceError("unsupported decision boundary")
165
+ if value["mode"] not in {"shadow", "enforce"} or value["origin"] not in {
166
+ "policy",
167
+ "harness",
168
+ }:
169
+ raise HarnessEvidenceError("unsupported decision mode or origin")
170
+ if value["requested_action"] not in _ACTIONS or value["resolved_action"] not in _ACTIONS:
171
+ raise HarnessEvidenceError("unsupported decision action")
172
+ if value["outcome"] not in _OUTCOMES:
173
+ raise HarnessEvidenceError("unsupported decision outcome")
174
+ if value["mode"] == "shadow" and value["outcome"] not in {"proposed", "failed", "no_op"}:
175
+ raise HarnessEvidenceError("shadow decisions cannot be applied or rejected controls")
176
+ if value["outcome"] == "applied" and (
177
+ value["requested_action"] == "continue"
178
+ or value["requested_action"] != value["resolved_action"]
179
+ ):
180
+ raise HarnessEvidenceError("applied decisions must match the resolved control action")
181
+ if type(value["dispatched"]) is not bool or (
182
+ value["phase"] == "before" and value["dispatched"]
183
+ ):
184
+ raise HarnessEvidenceError("invalid dispatch evidence")
185
+ if value["execution_status"] not in {
186
+ "pending",
187
+ "ok",
188
+ "error",
189
+ "cancelled",
190
+ "closed",
191
+ "denied",
192
+ }:
193
+ raise HarnessEvidenceError("unsupported execution status")
194
+ if value["evaluation_status"] != "unverified":
195
+ raise HarnessEvidenceError("a decision alone is not a measured evaluation")
196
+ if value["hook_sequence"] is not None and (
197
+ type(value["hook_sequence"]) is not int or value["hook_sequence"] < 0
198
+ ):
199
+ raise HarnessEvidenceError("hook sequence must be nonnegative or unavailable")
200
+ if type(value["policy_order"]) is not int or value["policy_order"] < 0:
201
+ raise HarnessEvidenceError("policy order must be nonnegative")
202
+ if value["budget_snapshot"] is not None:
203
+ try:
204
+ budget = BudgetSnapshot.from_dict(value["budget_snapshot"]).to_dict()
205
+ if value["mode"] == "shadow" and any(
206
+ budget[key] == "best_effort"
207
+ for key in ("spend_enforcement", "token_enforcement")
208
+ ):
209
+ raise BudgetValidationError("shadow budgets cannot claim enforcement")
210
+ except (BudgetValidationError, TypeError):
211
+ raise HarnessEvidenceError("invalid or unsupported budget snapshot") from None
212
+ for key in ("evidence_refs", "conflicting_decision_ids"):
213
+ items = value[key]
214
+ if (
215
+ not isinstance(items, list)
216
+ or any(not isinstance(item, str) or not item for item in items)
217
+ or items != sorted(set(items))
218
+ ):
219
+ raise HarnessEvidenceError("decision references must be sorted unique IDs")
220
+ refs = value["conflicting_decision_ids"] + (
221
+ [value["retry_of"]] if value["retry_of"] is not None else []
222
+ )
223
+ if any(not valid_decision_id(item) or item == value["decision_id"] for item in refs):
224
+ raise HarnessEvidenceError("invalid related decision identity")
225
+ identity_args = (value["run_id"], value["call_id"], value["boundary"], value["phase"])
226
+ if value["decision_id"] != decision_id(*identity_args, value["policy_id"]) or value[
227
+ "hook_id"
228
+ ] != hook_id(*identity_args):
229
+ raise HarnessEvidenceError("decision identity does not match its source")
230
+ timing = value["timing"]
231
+ if not isinstance(timing, dict) or set(timing) != {
232
+ "started_at",
233
+ "duration_ms",
234
+ "hook_duration_ms",
235
+ }:
236
+ raise HarnessEvidenceError("invalid decision timing")
237
+ if timing["started_at"] is not None:
238
+ try:
239
+ parsed = datetime.fromisoformat(timing["started_at"].replace("Z", "+00:00"))
240
+ if parsed.tzinfo is None:
241
+ raise ValueError
242
+ except (AttributeError, TypeError, ValueError):
243
+ raise HarnessEvidenceError("decision timestamp must include a timezone") from None
244
+ for key in ("duration_ms", "hook_duration_ms"):
245
+ item = timing[key]
246
+ if item is not None:
247
+ try:
248
+ valid = type(item) in {int, float} and math.isfinite(float(item)) and item >= 0
249
+ except (OverflowError, ValueError):
250
+ valid = False
251
+ if not valid:
252
+ raise HarnessEvidenceError("decision durations must be finite and nonnegative")
253
+ return cls(canonical_json(value))
254
+
255
+
256
+ def policy_snapshot(policy: Any, *, include_configuration: bool) -> dict[str, Any]:
257
+ """Capture declarations; raw configuration requires explicit caller opt-in."""
258
+
259
+ # Policy's configuration is already deeply frozen and JSON-validated. Convert
260
+ # its mapping/tuple containers only when the caller requests raw capture.
261
+ def native(value):
262
+ from collections.abc import Mapping
263
+
264
+ if isinstance(value, Mapping):
265
+ return {key: native(item) for key, item in value.items()}
266
+ if isinstance(value, tuple):
267
+ return [native(item) for item in value]
268
+ return value
269
+
270
+ return {
271
+ "policy_id": policy.policy_id,
272
+ "version": policy.version,
273
+ "config_hash": policy.config_hash,
274
+ "priority": policy.priority,
275
+ "hooks": [
276
+ {"boundary": hook.boundary, "phase": hook.phase} for hook in sorted(policy.hooks)
277
+ ],
278
+ "actions": sorted(policy.actions),
279
+ "configuration_capture": "explicit" if include_configuration else "redacted",
280
+ "configuration": native(policy.configuration) if include_configuration else None,
281
+ }
282
+
283
+
284
+ def records_for_hook(
285
+ result: Any, config_hash: str, system_snapshot: dict
286
+ ) -> tuple[HarnessDecisionRecord, ...]:
287
+ """Resolve each proposal separately while sharing one hook/effect identity."""
288
+ identities = {
289
+ item.policy_id: decision_id(
290
+ result.run_id, result.call_id, result.hook.boundary, result.hook.phase, item.policy_id
291
+ )
292
+ for item in result.proposals
293
+ }
294
+ rows = result.proposals or (None,)
295
+ records = []
296
+ for policy_order, proposal in enumerate(rows):
297
+ action = proposal.decision.action if proposal is not None else result.action
298
+ if proposal is not None and proposal.failed:
299
+ outcome = "failed"
300
+ elif action == "continue":
301
+ outcome = "no_op"
302
+ elif result.mode == "shadow":
303
+ outcome = "proposed"
304
+ elif result.applied and action == result.action:
305
+ outcome = "applied"
306
+ else:
307
+ outcome = "rejected"
308
+ policy_id = proposal.policy_id if proposal is not None else system_snapshot["policy_id"]
309
+ record_id = decision_id(
310
+ result.run_id, result.call_id, result.hook.boundary, result.hook.phase, policy_id
311
+ )
312
+ records.append(
313
+ HarnessDecisionRecord.from_dict(
314
+ {
315
+ "schema_version": EVIDENCE_VERSION,
316
+ "decision_id": record_id,
317
+ "hook_id": hook_id(
318
+ result.run_id, result.call_id, result.hook.boundary, result.hook.phase
319
+ ),
320
+ "run_id": result.run_id,
321
+ "call_id": result.call_id,
322
+ "branch_id": result.branch_id,
323
+ "trace_id": result.trace_id,
324
+ "span_id": result.parent_span_id,
325
+ "policy_id": policy_id,
326
+ "policy_version": proposal.policy_version
327
+ if proposal is not None
328
+ else system_snapshot["version"],
329
+ "policy_config_hash": proposal.config_hash
330
+ if proposal is not None
331
+ else system_snapshot["config_hash"],
332
+ "harness_config_hash": config_hash,
333
+ "boundary": result.hook.boundary,
334
+ "phase": result.hook.phase,
335
+ "mode": result.mode,
336
+ "requested_action": action,
337
+ "resolved_action": result.action,
338
+ "outcome": outcome,
339
+ "reason_code": proposal.decision.reason_code
340
+ if proposal is not None
341
+ else "run_stopped"
342
+ if result.action == "stop"
343
+ else "no_policy_change",
344
+ "dispatched": result.dispatched,
345
+ "execution_status": result.status,
346
+ "evidence_refs": sorted(set(proposal.decision.evidence_refs))
347
+ if proposal is not None
348
+ else [],
349
+ "conflicting_decision_ids": sorted(
350
+ identities[item.policy_id]
351
+ for item in result.proposals
352
+ if item.policy_id != policy_id
353
+ and item.decision.action != action
354
+ and item.decision.action != "continue"
355
+ and action != "continue"
356
+ ),
357
+ "retry_of": proposal.decision.retry_of if proposal is not None else None,
358
+ "timing": {
359
+ "started_at": proposal.started_at
360
+ if proposal is not None
361
+ else result.started_at,
362
+ "duration_ms": proposal.duration_ms if proposal is not None else 0.0,
363
+ "hook_duration_ms": result.duration_ms,
364
+ },
365
+ "budget_snapshot": (
366
+ proposal.decision.budget_snapshot.to_dict()
367
+ if proposal is not None and proposal.decision.budget_snapshot is not None
368
+ else None
369
+ ),
370
+ "evaluation_status": "unverified",
371
+ "origin": "policy" if proposal is not None else "harness",
372
+ "feedback": proposal.decision.feedback if proposal is not None else None,
373
+ "hook_sequence": result.sequence,
374
+ "policy_order": policy_order,
375
+ }
376
+ )
377
+ )
378
+ return tuple(records)
379
+
380
+
381
+ def append_records(
382
+ envelope: dict, records: tuple[HarnessDecisionRecord, ...], snapshots: dict[str, dict]
383
+ ) -> None:
384
+ """Append only new identities in O(new records); exact retries are idempotent."""
385
+ target = _envelope(envelope)
386
+ updates = []
387
+ for record in records:
388
+ payload = record.to_dict()
389
+ identity, config_hash = payload["decision_id"], payload["policy_config_hash"]
390
+ snapshot = snapshots.get(config_hash)
391
+ if (
392
+ not isinstance(snapshot, dict)
393
+ or snapshot.get("config_hash") != config_hash
394
+ or snapshot.get("policy_id") != payload["policy_id"]
395
+ or snapshot.get("version") != payload["policy_version"]
396
+ ):
397
+ raise HarnessEvidenceError("decision policy snapshot is missing or incompatible")
398
+ snapshot_json = canonical_json(snapshot)
399
+ existing = target["decisions"].get(identity)
400
+ if existing is not None and canonical_json(existing) != record._json:
401
+ raise HarnessEvidenceError("decision identity already has different evidence")
402
+ saved_policy = target["policies"].get(config_hash)
403
+ if saved_policy is not None and canonical_json(saved_policy) != snapshot_json:
404
+ raise HarnessEvidenceError("policy snapshot already has different evidence")
405
+ updates.append((identity, config_hash, payload, snapshot_json))
406
+ for identity, config_hash, payload, snapshot_json in updates:
407
+ if config_hash not in target["policies"]:
408
+ target["policies"].setdefault(config_hash, json.loads(snapshot_json))
409
+ target["decisions"].setdefault(identity, payload)
410
+ if payload["schema_version"] == EVIDENCE_VERSION:
411
+ target["schema_version"] = EVIDENCE_VERSION
412
+
413
+
414
+ def validate_evidence(value: Any, *, trace_id: str | None = None) -> dict[str, Any]:
415
+ """Validate a standalone artifact or evidence belonging to one native trace."""
416
+ envelope = _envelope(value)
417
+ snapshot_fields = {
418
+ "policy_id",
419
+ "version",
420
+ "config_hash",
421
+ "priority",
422
+ "hooks",
423
+ "actions",
424
+ "configuration_capture",
425
+ "configuration",
426
+ }
427
+ for config_hash, snapshot in envelope["policies"].items():
428
+ if (
429
+ not isinstance(config_hash, str)
430
+ or not _HASH.fullmatch(config_hash)
431
+ or not isinstance(snapshot, dict)
432
+ or set(snapshot) != snapshot_fields
433
+ or snapshot["config_hash"] != config_hash
434
+ ):
435
+ raise HarnessEvidenceError("invalid policy snapshot")
436
+ if (
437
+ any(
438
+ not isinstance(snapshot[key], str) or not snapshot[key]
439
+ for key in ("policy_id", "version", "configuration_capture")
440
+ )
441
+ or type(snapshot["priority"]) is not int
442
+ ):
443
+ raise HarnessEvidenceError("invalid policy declaration")
444
+ if any(not _LABEL.fullmatch(snapshot[key]) for key in ("policy_id", "version")):
445
+ raise HarnessEvidenceError("invalid policy identity")
446
+ if snapshot["configuration_capture"] == "redacted":
447
+ if snapshot["configuration"] is not None:
448
+ raise HarnessEvidenceError("redacted policy configuration must be omitted")
449
+ elif snapshot["configuration_capture"] != "explicit" or not isinstance(
450
+ snapshot["configuration"], dict
451
+ ):
452
+ raise HarnessEvidenceError("invalid policy configuration capture")
453
+ actions = snapshot["actions"]
454
+ if (
455
+ not isinstance(actions, list)
456
+ or any(not isinstance(action, str) or action not in _ACTIONS for action in actions)
457
+ or actions != sorted(set(actions))
458
+ ):
459
+ raise HarnessEvidenceError("invalid policy action declaration")
460
+ if not isinstance(snapshot["hooks"], list):
461
+ raise HarnessEvidenceError("invalid policy hook declaration")
462
+ for hook in snapshot["hooks"]:
463
+ if (
464
+ not isinstance(hook, dict)
465
+ or set(hook) != {"boundary", "phase"}
466
+ or not isinstance(hook["boundary"], str)
467
+ or not isinstance(hook["phase"], str)
468
+ or hook["boundary"] not in {"model", "tool", "iteration", "completion"}
469
+ or hook["phase"] not in {"before", "after"}
470
+ ):
471
+ raise HarnessEvidenceError("invalid policy hook declaration")
472
+ hooks = {}
473
+ orders: dict[str, set[int]] = {}
474
+ for identity, payload in envelope["decisions"].items():
475
+ record = HarnessDecisionRecord.from_dict(payload).to_dict()
476
+ if envelope["schema_version"] == "1.0" and record["schema_version"] != "1.0":
477
+ raise HarnessEvidenceError("decision version exceeds its envelope version")
478
+ if identity != record["decision_id"] or (
479
+ trace_id is not None and record["trace_id"] != trace_id
480
+ ):
481
+ raise HarnessEvidenceError("decision does not belong to this trace")
482
+ snapshot = envelope["policies"].get(record["policy_config_hash"])
483
+ if (
484
+ not isinstance(snapshot, dict)
485
+ or snapshot.get("config_hash") != record["policy_config_hash"]
486
+ or snapshot.get("policy_id") != record["policy_id"]
487
+ or snapshot.get("version") != record["policy_version"]
488
+ ):
489
+ raise HarnessEvidenceError("decision policy snapshot is missing or incompatible")
490
+ if record["outcome"] != "failed" and (
491
+ record["requested_action"] not in snapshot["actions"]
492
+ or {"boundary": record["boundary"], "phase": record["phase"]} not in snapshot["hooks"]
493
+ ):
494
+ raise HarnessEvidenceError("decision contradicts its policy declaration")
495
+ hook_fields = {
496
+ key: record[key]
497
+ for key in (
498
+ "run_id",
499
+ "call_id",
500
+ "branch_id",
501
+ "trace_id",
502
+ "span_id",
503
+ "harness_config_hash",
504
+ "boundary",
505
+ "phase",
506
+ "mode",
507
+ "resolved_action",
508
+ "dispatched",
509
+ "execution_status",
510
+ "hook_sequence",
511
+ )
512
+ }
513
+ hook_fields["duration_ms"] = record["timing"]["hook_duration_ms"]
514
+ if hooks.setdefault(record["hook_id"], hook_fields) != hook_fields:
515
+ raise HarnessEvidenceError("decisions disagree about their shared hook")
516
+ seen_orders = orders.setdefault(record["hook_id"], set())
517
+ if record["policy_order"] in seen_orders:
518
+ raise HarnessEvidenceError("duplicate policy order within a hook")
519
+ seen_orders.add(record["policy_order"])
520
+ for related in record["conflicting_decision_ids"]:
521
+ other = envelope["decisions"].get(related)
522
+ if isinstance(other, dict) and other.get("hook_id") != record["hook_id"]:
523
+ raise HarnessEvidenceError("conflict references must belong to the same hook")
524
+ for error in envelope["capture_errors"]:
525
+ if (
526
+ not isinstance(error, dict)
527
+ or set(error) != {"call_id", "phase", "category"}
528
+ or not isinstance(error["call_id"], str)
529
+ or not isinstance(error["phase"], str)
530
+ or error["phase"] not in {"before", "after"}
531
+ or error["category"] != "trace_evidence_error"
532
+ ):
533
+ raise HarnessEvidenceError("invalid capture diagnostic")
534
+ return json.loads(canonical_json(envelope))
535
+
536
+
537
+ def read_evidence(trace: Any) -> dict[str, Any] | None:
538
+ """Validate/copy the known envelope without rewriting unknown native metadata."""
539
+ if METADATA_KEY not in trace.metadata:
540
+ return None
541
+ return validate_evidence(trace.metadata[METADATA_KEY], trace_id=trace.run_id)
542
+
543
+
544
+ def comparison_evidence(baseline: Any, candidate: Any) -> dict[str, Any] | None:
545
+ """Snapshot decision evidence only inside a real baseline/candidate comparison."""
546
+ before, after = read_evidence(baseline), read_evidence(candidate)
547
+ if before is None and after is None:
548
+ return None
549
+ applied = (
550
+ []
551
+ if after is None
552
+ else sorted(
553
+ identity
554
+ for identity, record in after["decisions"].items()
555
+ if record["mode"] == "enforce"
556
+ and record["outcome"] == "applied"
557
+ and record["origin"] == "policy"
558
+ )
559
+ )
560
+ unresolved = sorted(
561
+ {
562
+ reference
563
+ for envelope in (before, after)
564
+ if envelope is not None
565
+ for record in envelope["decisions"].values()
566
+ for reference in record["conflicting_decision_ids"]
567
+ + ([record["retry_of"]] if record["retry_of"] else [])
568
+ if reference not in envelope["decisions"]
569
+ }
570
+ )
571
+ return {
572
+ "schema_version": _COMPARISON_VERSION,
573
+ "baseline_run_id": baseline.run_id,
574
+ "candidate_run_id": candidate.run_id,
575
+ "comparison_status": "observed_pair",
576
+ "capture_status": "known_incomplete"
577
+ if unresolved
578
+ or any(envelope["capture_errors"] for envelope in (before, after) if envelope is not None)
579
+ else "no_reported_gaps",
580
+ "individual_policy_effect": "unverified",
581
+ "unresolved_decision_ids": unresolved,
582
+ "applied_candidate_decision_ids": applied,
583
+ "baseline": before,
584
+ "candidate": after,
585
+ }
@@ -0,0 +1,91 @@
1
+ """Per-invocation usage observation that never retains a raw result or chunk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from agentloop.budget_types import ResourceUsage
10
+
11
+
12
+ class UsageTracker:
13
+ def __init__(
14
+ self,
15
+ reader: Callable[[Any], ResourceUsage | None] | None,
16
+ invoke: Callable,
17
+ error_reader: Callable[[BaseException], ResourceUsage | None] | None = None,
18
+ ) -> None:
19
+ self.reader = reader
20
+ self.invoke = invoke
21
+ self.error_reader = error_reader
22
+ self.latest: ResourceUsage | None = None
23
+ self.error: str | None = None
24
+
25
+ def observe(self, value: Any) -> None:
26
+ self._observe(value, self.reader)
27
+
28
+ def observe_error(self, error: BaseException) -> None:
29
+ """Retain available failure usage without replacing the original failure."""
30
+ try:
31
+ self._observe(error, self.error_reader)
32
+ except BaseException:
33
+ self.error = "collector_cancelled"
34
+
35
+ def _observe(self, value: Any, reader: Callable | None) -> None:
36
+ if reader is None:
37
+ return
38
+ try:
39
+ report = self.invoke(reader, value)
40
+ if report is None:
41
+ return
42
+ if type(report) is not ResourceUsage:
43
+ if inspect.iscoroutine(report):
44
+ report.close()
45
+ self.error = "invalid_usage"
46
+ return
47
+ except Exception:
48
+ self.error = "collector_error"
49
+ return
50
+ except BaseException:
51
+ self.error = "collector_cancelled"
52
+ raise
53
+ # Usage updates are snapshots, not increments. In particular, successive
54
+ # cumulative stream usage chunks must never be added to one another.
55
+ self.latest = report
56
+ self.error = None
57
+
58
+
59
+ class ObservedGenerator:
60
+ """Delegate the complete generator protocol while observing returned chunks."""
61
+
62
+ def __init__(self, source: Any, tracker: UsageTracker) -> None:
63
+ self.source = source
64
+ self.tracker = tracker
65
+
66
+ def __iter__(self):
67
+ return self
68
+
69
+ def _advance(self, operation: Callable, *args: Any):
70
+ try:
71
+ value = operation(*args)
72
+ except StopIteration as stopped:
73
+ if stopped.value is not None:
74
+ self.tracker.observe(stopped.value)
75
+ raise
76
+ self.tracker.observe(value)
77
+ return value
78
+
79
+ def __next__(self):
80
+ return self._advance(self.source.__next__)
81
+
82
+ def send(self, value):
83
+ return self._advance(self.source.send, value)
84
+
85
+ def throw(self, *args):
86
+ if len(args) == 3 and isinstance(args[1], BaseException):
87
+ return self._advance(self.source.throw, args[1].with_traceback(args[2]))
88
+ return self._advance(self.source.throw, *args)
89
+
90
+ def close(self):
91
+ return self.source.close()