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
agentloop/retention.py ADDED
@@ -0,0 +1,375 @@
1
+ """Loss-aware native trace retention. No callbacks run except an explicit redactor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import hashlib
7
+ import inspect
8
+ import json
9
+ import random
10
+ from collections import Counter
11
+ from threading import Lock
12
+ from typing import Any, Callable
13
+
14
+ from agentloop.events import AgentEvent
15
+ from agentloop.retention_types import (
16
+ RETENTION_KEY,
17
+ RETENTION_VERSION,
18
+ RetentionContext,
19
+ RetentionPolicy,
20
+ )
21
+ from agentloop.workflow_types import stage_summary, workflow_summary
22
+
23
+
24
+ def _digest(value: Any) -> str:
25
+ # Streaming encoding avoids a second full serialized trace in memory.
26
+ digest = hashlib.sha256()
27
+ for chunk in json.JSONEncoder(
28
+ sort_keys=True, separators=(",", ":"), allow_nan=False
29
+ ).iterencode(value):
30
+ digest.update(chunk.encode("utf-8"))
31
+ return digest.hexdigest()
32
+
33
+
34
+ def _summary(value: Any) -> dict:
35
+ return {
36
+ "sha256": _digest(value),
37
+ "type": type(value).__name__,
38
+ "length": len(value) if isinstance(value, (str, list, dict)) else None,
39
+ }
40
+
41
+
42
+ def _alias(value: str) -> str:
43
+ return "sha256:" + _digest(value)
44
+
45
+
46
+ def _binding(trace) -> dict:
47
+ value = trace.to_dict()
48
+ value["metadata"] = dict(value["metadata"])
49
+ value["metadata"].pop(RETENTION_KEY, None)
50
+ return value
51
+
52
+
53
+ def read_retention(trace) -> dict | None:
54
+ """Validate content binding; hashes detect mutation, not hostile forgery."""
55
+ metadata = getattr(trace, "metadata", {})
56
+ if not isinstance(metadata, dict) or RETENTION_KEY not in metadata:
57
+ return None
58
+ envelope = metadata[RETENTION_KEY]
59
+ if not isinstance(envelope, dict) or envelope.get("schema_version") != RETENTION_VERSION:
60
+ raise ValueError("unsupported retention evidence")
61
+ content = dict(envelope)
62
+ checksum = content.pop("sha256", None)
63
+ if checksum != _digest(content) or content.get("artifact_sha256") != _digest(_binding(trace)):
64
+ raise ValueError("retention evidence no longer matches trace")
65
+ if (
66
+ not isinstance(content.get("metrics"), dict)
67
+ or type(content.get("complete_evidence")) is not bool
68
+ ):
69
+ raise ValueError("invalid retention evidence")
70
+ return copy.deepcopy(envelope)
71
+
72
+
73
+ def require_complete_evidence(trace, analysis: str) -> None:
74
+ from agentloop.interoperability.evidence import require_external_comparison
75
+
76
+ require_external_comparison(trace, analysis)
77
+ evidence = read_retention(trace)
78
+ if evidence is not None and not evidence["complete_evidence"]:
79
+ raise ValueError(
80
+ f"{analysis} requires complete trace evidence; retention omitted required evidence"
81
+ )
82
+
83
+
84
+ def retained_report(trace) -> dict | None:
85
+ evidence = read_retention(trace)
86
+ if evidence is None or evidence["complete_evidence"]:
87
+ return None
88
+ report = copy.deepcopy(evidence["metrics"])
89
+ report.update(
90
+ run_id=trace.run_id,
91
+ name=trace.name,
92
+ events=[event.to_dict() for event in trace.events],
93
+ retention=evidence,
94
+ retained_event_count=len(trace.events),
95
+ finding_candidates=[],
96
+ parallelism_opportunities=[],
97
+ analysis_complete=False,
98
+ rule_errors=[
99
+ {
100
+ "rule_id": "retention.incomplete_evidence",
101
+ "message": "Only recorded aggregate metrics are available; missing evidence prevents finding analysis.",
102
+ }
103
+ ],
104
+ recommendations=[
105
+ {
106
+ "title": "Analysis unavailable after retention",
107
+ "description": "Use the original trace for findings, replay, quality or study comparisons.",
108
+ }
109
+ ],
110
+ )
111
+ return report
112
+
113
+
114
+ def _metrics(trace) -> dict:
115
+ from agentloop.metrics import build_report
116
+
117
+ report = build_report(trace, _aggregate_only=True)
118
+ for key in ("events", "recommendations", "parallelism_opportunities", "name", "run_id"):
119
+ report.pop(key, None)
120
+ cost = report["cost_breakdown"]
121
+ cost.pop("model_calls", None)
122
+ cost["unknown_models"] = [_alias(model) for model in cost["unknown_models"]]
123
+ cost["detail_availability"] = "aggregate_only"
124
+ report["error_span_count"] = sum(event.status == "error" for event in trace.events)
125
+ return report
126
+
127
+
128
+ class RetentionSession:
129
+ """Bounded stream state; calls are serialized and counts describe calls, not unique runs.
130
+
131
+ The session does not persist raw traces or deduplicate run IDs. A host must
132
+ supply each completed execution once. Repeated keys form a cluster sample.
133
+ """
134
+
135
+ def __init__(self, policy: RetentionPolicy, *, redactor: Callable[[str], str] | None = None):
136
+ if not isinstance(policy, RetentionPolicy):
137
+ raise TypeError("policy must be RetentionPolicy")
138
+ if (policy.payloads == "redact" and not callable(redactor)) or (
139
+ policy.payloads != "redact" and redactor is not None
140
+ ):
141
+ raise ValueError(
142
+ "redact mode requires an explicit trusted redactor, and only that mode accepts one"
143
+ )
144
+ self._policy = policy
145
+ self._redactor = redactor
146
+ self._rng = random.Random(policy.seed) # nosec B311: reproducible telemetry sampling
147
+ self._lock = Lock()
148
+ self._seen = self._retained = 0
149
+ self._buckets: Counter[str] = Counter()
150
+ self._reasons: Counter[str] = Counter()
151
+
152
+ @property
153
+ def policy(self) -> RetentionPolicy:
154
+ return self._policy
155
+
156
+ def summary(self) -> dict:
157
+ with self._lock:
158
+ return self._summary()
159
+
160
+ def _summary(self) -> dict:
161
+ return {
162
+ "schema_version": RETENTION_VERSION,
163
+ "policy": self.policy.to_dict(),
164
+ "observed_input_count": self._seen,
165
+ "observed_sample_count": self._retained,
166
+ "estimated_population_count": None,
167
+ "population_estimate_status": "unavailable_without_sampling_frame_and_uncertainty_model",
168
+ "effective_rate": self._retained / self._seen if self._seen else None,
169
+ "rate_scope": "session_prefix_calls",
170
+ "retention_reason_counts": dict(self._reasons),
171
+ "tracked_bucket_count": len(self._buckets),
172
+ }
173
+
174
+ def retain(self, trace, *, context: RetentionContext | None = None):
175
+ """Return an owned native trace or None. Failures never return raw data."""
176
+ from agentloop.schema import coerce_event_dict, validate_trace_dict
177
+ from agentloop.tracer import AgentTrace
178
+
179
+ if not isinstance(trace, AgentTrace):
180
+ raise TypeError("trace must be AgentTrace")
181
+ if RETENTION_KEY in trace.metadata:
182
+ raise ValueError("cannot retain an already retained trace")
183
+ if trace.ended_at is None or trace.elapsed_ms is None:
184
+ raise ValueError("retention requires a finished trace")
185
+ context = RetentionContext() if context is None else context
186
+ if not isinstance(context, RetentionContext):
187
+ raise TypeError("context must be RetentionContext")
188
+ with self._lock:
189
+ validate_trace_dict(trace.to_dict())
190
+ for index, event in enumerate(trace.events):
191
+ coerce_event_dict(event.to_dict(), index=index)
192
+ return self._retain(trace, context)
193
+
194
+ def _retain(self, trace, context):
195
+ policy = self.policy
196
+ metrics = _metrics(trace)
197
+ reasons: list[str] = []
198
+ execution = workflow_summary(trace.metadata)
199
+ status = execution.get("status") if execution else None
200
+ failure = (
201
+ context.outcome in {"failure", "timeout"}
202
+ or trace.metadata.get("success") is False
203
+ or metrics["error_span_count"] > 0
204
+ or status in {"failed", "cancelled", "interrupted"}
205
+ )
206
+ unknown = (
207
+ (context.outcome == "unknown" and status != "completed")
208
+ or status not in {None, "completed", "failed", "cancelled", "interrupted"}
209
+ or execution is not None
210
+ and execution.get("schema_status") != "supported"
211
+ or metrics["cost_status"] not in {"empty", "complete"}
212
+ or metrics["token_status"] not in {"empty", "exact"}
213
+ or any(event.status not in {"ok", "error"} for event in trace.events)
214
+ )
215
+ for reason, protected in (
216
+ ("failure_or_timeout", policy.retain_failures and failure),
217
+ ("unknown", policy.retain_unknowns and unknown),
218
+ ("disagreement", policy.retain_disagreements and context.disagreement is True),
219
+ ("anomaly", policy.retain_anomalies and context.anomaly is True),
220
+ ("protected_cohort", bool(set(context.cohorts).intersection(policy.protected_cohorts))),
221
+ (
222
+ "high_cost",
223
+ policy.high_cost_usd is not None
224
+ and metrics["estimated_cost_usd"] >= policy.high_cost_usd,
225
+ ),
226
+ (
227
+ "high_latency",
228
+ policy.high_latency_ms is not None
229
+ and metrics["total_runtime_ms"] >= policy.high_latency_ms,
230
+ ),
231
+ ):
232
+ if protected:
233
+ reasons.append(reason)
234
+ buckets: set[str] = set()
235
+ if policy.representatives_per_bucket:
236
+ new_buckets = 0
237
+ for event in trace.events:
238
+ stage = stage_summary(event) or {}
239
+ bucket = _digest(
240
+ [
241
+ stage.get("stage_id") or event.name,
242
+ stage.get("outcome") or context.outcome,
243
+ event.status,
244
+ ]
245
+ )
246
+ if bucket in buckets:
247
+ continue
248
+ if bucket in self._buckets or len(self._buckets) + new_buckets < policy.max_buckets:
249
+ buckets.add(bucket)
250
+ new_buckets += bucket not in self._buckets
251
+ if any(self._buckets[bucket] < policy.representatives_per_bucket for bucket in buckets):
252
+ reasons.append("representative_bucket")
253
+ key = context.sample_key or trace.run_id
254
+ draw = (
255
+ int(_digest([policy.seed, key]), 16) / 2**256
256
+ if policy.sampling == "deterministic"
257
+ else self._rng.random()
258
+ )
259
+ if draw < policy.sample_rate:
260
+ reasons.append("sample")
261
+ if not reasons:
262
+ self._seen += 1
263
+ return None
264
+ source_hash = _digest(trace.to_dict())
265
+ derived, indices = self._compact(trace)
266
+ if self._redactor is not None and _digest(trace.to_dict()) != source_hash:
267
+ raise ValueError("source trace changed during redaction")
268
+ envelope = {
269
+ "schema_version": RETENTION_VERSION,
270
+ "policy": policy.to_dict(),
271
+ "policy_sha256": _digest(policy.to_dict()),
272
+ "source_sha256": source_hash,
273
+ "source_metadata": _summary(trace.metadata),
274
+ "original_event_count": len(trace.events),
275
+ "retained_event_count": len(derived.events),
276
+ "original_event_order_sha256": _digest([event.event_id for event in trace.events]),
277
+ "retained_original_indices": indices,
278
+ "complete_evidence": policy.mode == "full" and policy.payloads == "capture",
279
+ "execution_outcome": "failure"
280
+ if failure
281
+ else "success"
282
+ if status == "completed"
283
+ or execution is None
284
+ and (context.outcome == "success" or trace.metadata.get("success") is True)
285
+ else "unknown",
286
+ "metrics": metrics,
287
+ "reasons": reasons,
288
+ "base_sample_rate": policy.sample_rate,
289
+ "conditional_inclusion_rate": 1.0
290
+ if any(reason != "sample" for reason in reasons)
291
+ else policy.sample_rate,
292
+ "sample_key_sha256": _digest(key),
293
+ "context": {
294
+ "outcome": context.outcome,
295
+ "cohort_hashes": [_digest(value) for value in context.cohorts],
296
+ "disagreement": context.disagreement,
297
+ "anomaly": context.anomaly,
298
+ },
299
+ "bucket_hashes": sorted(buckets),
300
+ "artifact_sha256": _digest(_binding(derived)),
301
+ }
302
+ self._seen += 1
303
+ self._retained += 1
304
+ self._reasons.update(reasons)
305
+ for bucket in buckets:
306
+ self._buckets[bucket] += 1
307
+ envelope["sampling"] = self._summary()
308
+ envelope["sha256"] = _digest(envelope)
309
+ derived.metadata[RETENTION_KEY] = envelope
310
+ return derived
311
+
312
+ def _compact(self, trace):
313
+ from agentloop.tracer import AgentTrace
314
+
315
+ policy = self.policy
316
+ capture = policy.payloads == "capture"
317
+ result = AgentTrace(
318
+ name=trace.name if capture else _alias(trace.name),
319
+ run_id=trace.run_id,
320
+ metadata=copy.deepcopy(trace.metadata) if capture else {},
321
+ started_at=trace.started_at,
322
+ ended_at=trace.ended_at,
323
+ elapsed_ms=trace.elapsed_ms,
324
+ )
325
+ if policy.mode == "metrics_only":
326
+ return result, []
327
+ indices = []
328
+ for index, event in enumerate(trace.events):
329
+ # max_events is soft: errors and unknown statuses survive span compaction.
330
+ protected = (policy.retain_failures and event.status == "error") or (
331
+ policy.retain_unknowns and event.status not in {"ok", "error"}
332
+ )
333
+ if policy.mode == "compact" and index >= policy.max_events and not protected:
334
+ continue
335
+ value = copy.deepcopy(event.to_dict()) if capture else event.to_dict()
336
+ if not capture:
337
+ value["name"] = _alias(event.name)
338
+ value["model"] = _alias(event.model) if event.model is not None else None
339
+ value["metadata"] = {
340
+ "retention_payloads": {
341
+ field: _summary(value[field])
342
+ for field in ("input_text", "output_text", "error", "metadata")
343
+ }
344
+ }
345
+ stage = stage_summary(event)
346
+ if stage is not None:
347
+ value["metadata"]["retention_stage"] = {
348
+ "schema_status": stage["schema_status"],
349
+ "stage_id_sha256": _digest(stage.get("stage_id")),
350
+ "version_sha256": _digest(stage.get("version")),
351
+ "input_schema_ref_sha256": _digest(stage.get("input_schema_ref")),
352
+ "output_schema_ref_sha256": _digest(stage.get("output_schema_ref")),
353
+ "outcome_sha256": _digest(stage.get("outcome")),
354
+ }
355
+ for field in ("input_text", "output_text", "error"):
356
+ original = value[field]
357
+ redacted = (
358
+ self._redactor(original)
359
+ if self._redactor and original is not None
360
+ else None
361
+ )
362
+ if (
363
+ self._redactor
364
+ and original is not None
365
+ and (not isinstance(redacted, str) or len(redacted) > 100_000)
366
+ ):
367
+ if inspect.iscoroutine(redacted):
368
+ redacted.close()
369
+ raise ValueError(
370
+ "redactor must return a string of at most 100000 characters"
371
+ )
372
+ value[field] = redacted
373
+ result.add_event(AgentEvent.from_dict(value, index=index))
374
+ indices.append(index)
375
+ return result, indices
@@ -0,0 +1,113 @@
1
+ """Versioned, bounded configuration for offline and finalization retention."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import asdict, dataclass
7
+ from typing import Literal
8
+
9
+ RETENTION_KEY = "agentloop.retention"
10
+ RETENTION_VERSION = "1.0"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class RetentionPolicy:
15
+ """Labels and references must be opaque, permission-cleared identifiers."""
16
+
17
+ policy_id: str
18
+ version: str
19
+ mode: Literal["full", "compact", "metrics_only"] = "compact"
20
+ sampling: Literal["deterministic", "probabilistic"] = "deterministic"
21
+ sample_rate: float = 1.0
22
+ seed: str = "agentloop"
23
+ max_events: int = 100
24
+ payloads: Literal["omit", "redact", "capture"] = "omit"
25
+ redactor_ref: str | None = None
26
+ retain_failures: bool = True
27
+ retain_unknowns: bool = True
28
+ retain_disagreements: bool = True
29
+ retain_anomalies: bool = True
30
+ protected_cohorts: tuple[str, ...] = ()
31
+ high_cost_usd: float | None = None
32
+ high_latency_ms: float | None = None
33
+ representatives_per_bucket: int = 0
34
+ max_buckets: int = 256
35
+
36
+ def __post_init__(self) -> None:
37
+ for key in ("policy_id", "version", "seed"):
38
+ _label(getattr(self, key), key)
39
+ for key, choices in (
40
+ ("mode", {"full", "compact", "metrics_only"}),
41
+ ("sampling", {"deterministic", "probabilistic"}),
42
+ ("payloads", {"omit", "redact", "capture"}),
43
+ ):
44
+ if getattr(self, key) not in choices:
45
+ raise ValueError(f"invalid {key}")
46
+ for key in ("sample_rate", "high_cost_usd", "high_latency_ms"):
47
+ value = getattr(self, key)
48
+ if value is None and key != "sample_rate":
49
+ continue
50
+ if type(value) not in {int, float} or not math.isfinite(value) or value < 0:
51
+ raise ValueError(f"{key} must be finite and nonnegative")
52
+ if self.sample_rate > 1:
53
+ raise ValueError("sample_rate must be at most 1")
54
+ for key, minimum, maximum in (
55
+ ("max_events", 0, 100_000),
56
+ ("representatives_per_bucket", 0, 100),
57
+ ("max_buckets", 1, 10_000),
58
+ ):
59
+ value = getattr(self, key)
60
+ if type(value) is not int or not minimum <= value <= maximum:
61
+ raise ValueError(f"invalid {key}")
62
+ for key in (
63
+ "retain_failures",
64
+ "retain_unknowns",
65
+ "retain_disagreements",
66
+ "retain_anomalies",
67
+ ):
68
+ if type(getattr(self, key)) is not bool:
69
+ raise ValueError(f"{key} must be boolean")
70
+ if not isinstance(self.protected_cohorts, tuple) or len(self.protected_cohorts) > 256:
71
+ raise ValueError("protected_cohorts must be a tuple of at most 256 labels")
72
+ for label in self.protected_cohorts:
73
+ _label(label, "cohort")
74
+ if self.payloads == "redact":
75
+ _label(self.redactor_ref, "redactor_ref")
76
+ elif self.redactor_ref is not None:
77
+ raise ValueError("redactor_ref requires payloads='redact'")
78
+
79
+ def to_dict(self) -> dict:
80
+ return {
81
+ "schema_version": RETENTION_VERSION,
82
+ **asdict(self),
83
+ "protected_cohorts": list(self.protected_cohorts),
84
+ }
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class RetentionContext:
89
+ """Host annotations; None is unknown, not a negative observation."""
90
+
91
+ sample_key: str | None = None
92
+ outcome: Literal["success", "failure", "timeout", "unknown"] = "unknown"
93
+ cohorts: tuple[str, ...] = ()
94
+ disagreement: bool | None = None
95
+ anomaly: bool | None = None
96
+
97
+ def __post_init__(self) -> None:
98
+ if self.sample_key is not None:
99
+ _label(self.sample_key, "sample_key")
100
+ if self.outcome not in {"success", "failure", "timeout", "unknown"}:
101
+ raise ValueError("invalid outcome")
102
+ if not isinstance(self.cohorts, tuple) or len(self.cohorts) > 256:
103
+ raise ValueError("cohorts must be a tuple of at most 256 labels")
104
+ for value in self.cohorts:
105
+ _label(value, "cohort")
106
+ for value in (self.disagreement, self.anomaly):
107
+ if value is not None and type(value) is not bool:
108
+ raise ValueError("disagreement/anomaly must be boolean or None")
109
+
110
+
111
+ def _label(value, key):
112
+ if not isinstance(value, str) or not value.strip() or len(value) > 512:
113
+ raise ValueError(f"{key} must be a nonempty bounded string")