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/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")
|