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,267 @@
1
+ """Explicit offline candidate comparisons and native paired-study artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import json
7
+ import platform
8
+ import random
9
+ from time import perf_counter
10
+
11
+ from agentloop.events import utc_now_iso
12
+ from agentloop.judgment_types import JudgeUsage, canonical, fingerprint
13
+ from agentloop.quality import build_quality_report
14
+ from agentloop.substitution_evidence import attach_trial_evidence, read_trial_evidence
15
+ from agentloop.substitution_types import (
16
+ SUBSTITUTION_VERSION,
17
+ DecisionImplementation,
18
+ DecisionRequest,
19
+ DecisionResult,
20
+ SubstitutionProtocol,
21
+ )
22
+ from agentloop.tracer import AgentTrace, bind_trace_context
23
+ from agentloop.workflows import StageInfo, WorkflowInfo, record_operation, workflow_metadata
24
+
25
+
26
+ def _copy(value):
27
+ return json.loads(canonical(value))
28
+
29
+
30
+ def _trace(
31
+ protocol, example, repeat, declaration, plan_hash, outcome, started, ended, latency, output_hash
32
+ ):
33
+ failed = outcome in {"failed", "timed_out", "invalid_result"}
34
+ status = (
35
+ "failed" if failed else ("unknown" if outcome in {"disabled", "missing"} else "completed")
36
+ )
37
+ metadata = workflow_metadata(
38
+ WorkflowInfo("offline-model-substitution", protocol.version),
39
+ task_id=example.example_id,
40
+ status=status,
41
+ metadata={
42
+ "synthetic": protocol.synthetic,
43
+ "source": "agentloop_substitution",
44
+ "plan_hash": plan_hash,
45
+ "example_id": example.example_id,
46
+ "repetition": repeat,
47
+ "condition": declaration["name"],
48
+ },
49
+ )
50
+ trace = AgentTrace(
51
+ "Decision trial: " + declaration["name"],
52
+ metadata=metadata,
53
+ started_at=started,
54
+ ended_at=ended,
55
+ elapsed_ms=latency or 0,
56
+ )
57
+ if latency is not None:
58
+ record_operation(
59
+ protocol.step.step_id,
60
+ kind="classifier",
61
+ duration_ms=latency,
62
+ started_at=started,
63
+ ended_at=ended,
64
+ stage=StageInfo(
65
+ protocol.step.step_id,
66
+ declaration["identity"]["version"],
67
+ kind=declaration["kind"],
68
+ input_ref=example.input_ref,
69
+ ),
70
+ outcome=outcome,
71
+ output_ref="sha256:" + output_hash if output_hash is not None else None,
72
+ status="error" if failed else "ok",
73
+ metadata={"error_type": outcome} if failed else None,
74
+ trace=trace,
75
+ event_id="decision",
76
+ )
77
+ return trace
78
+
79
+
80
+ def _execute(protocol, example, repeat, implementation, declaration, plan_hash, enabled):
81
+ started = utc_now_iso()
82
+ status, usage, cost_ref, output, output_hash = (
83
+ "disabled",
84
+ JudgeUsage(0, 0, 0, "not_dispatched", "not_dispatched"),
85
+ None,
86
+ None,
87
+ None,
88
+ )
89
+ latency = None
90
+ if enabled:
91
+ usage = JudgeUsage()
92
+ request = DecisionRequest(
93
+ example.example_id, example.input_ref, protocol.step, example._input_json
94
+ )
95
+ request.inputs # Decode the isolated input before measuring the callback.
96
+ started = utc_now_iso()
97
+ began = perf_counter()
98
+ try:
99
+ # Experimental callbacks cannot append work to an ambient live run.
100
+ with bind_trace_context(None):
101
+ value = implementation.invoke(request, timeout_s=protocol.timeout_s)
102
+ latency = (perf_counter() - began) * 1000
103
+ ended = utc_now_iso()
104
+ if type(value) is not DecisionResult:
105
+ if inspect.iscoroutine(value) or inspect.isgenerator(value):
106
+ value.close()
107
+ status = "invalid_result"
108
+ else:
109
+ status, usage, cost_ref = value.status, value.usage, value.cost_ref
110
+ if status == "completed":
111
+ output = value.output
112
+ output_hash = fingerprint(output)
113
+ if implementation.declaration() != declaration:
114
+ status, output, output_hash = "invalid_result", None, None
115
+ except TimeoutError:
116
+ status = "timed_out"
117
+ except Exception:
118
+ status = "failed"
119
+ if latency is None:
120
+ latency = (perf_counter() - began) * 1000
121
+ ended = utc_now_iso()
122
+ if protocol.timeout_s is not None and latency > protocol.timeout_s * 1000:
123
+ status, output, output_hash = "timed_out", None, None
124
+ else:
125
+ ended = started
126
+ trace = _trace(
127
+ protocol,
128
+ example,
129
+ repeat,
130
+ declaration,
131
+ plan_hash,
132
+ status,
133
+ started,
134
+ ended,
135
+ latency,
136
+ output_hash,
137
+ )
138
+ attach_trial_evidence(
139
+ trace,
140
+ plan_hash=plan_hash,
141
+ example_id=example.example_id,
142
+ repetition=repeat,
143
+ implementation=declaration,
144
+ status=status,
145
+ latency_ms=latency,
146
+ usage=usage,
147
+ cost_ref=cost_ref,
148
+ input_hash=fingerprint(example.inputs),
149
+ output_hash=output_hash,
150
+ )
151
+ return trace, output
152
+
153
+
154
+ def run_substitution_experiment(
155
+ protocol, baseline, candidates, *, enabled=False, max_invocations=10000
156
+ ):
157
+ """Run declared implementations, then freeze quality assessments for export.
158
+
159
+ Both baseline and candidates execute when enabled. Reference outputs and
160
+ scorer configuration are never passed to an implementation. The host owns
161
+ callback/scorer side effects, budgets and cooperative cancellation.
162
+ """
163
+ if (
164
+ type(protocol) is not SubstitutionProtocol
165
+ or type(baseline) is not DecisionImplementation
166
+ or type(enabled) is not bool
167
+ ):
168
+ raise ValueError("experiment requires a typed protocol, baseline and boolean enabled")
169
+ if (
170
+ not isinstance(candidates, (tuple, list))
171
+ or not candidates
172
+ or any(type(item) is not DecisionImplementation for item in candidates)
173
+ ):
174
+ raise ValueError("at least one explicit candidate is required")
175
+ implementations = [baseline, *candidates]
176
+ by_name = {item.name: item for item in implementations}
177
+ if len(by_name) != len(implementations):
178
+ raise ValueError("baseline and candidate names must be unique")
179
+ count = len(implementations) * len(protocol.examples) * protocol.repetitions
180
+ if type(max_invocations) is not int or max_invocations < 1 or count > max_invocations:
181
+ raise ValueError("planned experiment exceeds max_invocations")
182
+ declarations = {item.name: item.declaration() for item in implementations}
183
+ plan = {
184
+ "protocol": protocol.declaration(),
185
+ "baseline": baseline.name,
186
+ "implementations": declarations,
187
+ }
188
+ plan_hash = fingerprint(plan)
189
+ jobs = [
190
+ (name, index, repeat)
191
+ for name in sorted(by_name)
192
+ for index in range(len(protocol.examples))
193
+ for repeat in range(protocol.repetitions)
194
+ ]
195
+ random.Random(protocol.seed).shuffle(jobs) # nosec B311
196
+ rows, completed = [], {}
197
+ for ordinal, (name, index, repeat) in enumerate(jobs):
198
+ example = protocol.examples[index]
199
+ trace, output = _execute(
200
+ protocol, example, repeat, by_name[name], declarations[name], plan_hash, enabled
201
+ )
202
+ rows.append(
203
+ {
204
+ "ordinal": ordinal,
205
+ "condition": name,
206
+ "example_id": example.example_id,
207
+ "repetition": repeat,
208
+ "trace": trace.to_dict(),
209
+ }
210
+ )
211
+ completed[name, example.example_id, repeat] = trace, output
212
+ pairs = []
213
+ for candidate in candidates:
214
+ for example in protocol.examples:
215
+ for repeat in range(protocol.repetitions):
216
+ before, old_output = completed[baseline.name, example.example_id, repeat]
217
+ after, new_output = completed[candidate.name, example.example_id, repeat]
218
+ before_receipt, after_receipt = (
219
+ read_trial_evidence(before),
220
+ read_trial_evidence(after),
221
+ )
222
+ fixture = example.fixture(protocol.scorer)
223
+ if before_receipt["outcome"] == "completed":
224
+ fixture["baseline_output"] = old_output
225
+ if after_receipt["outcome"] == "completed":
226
+ fixture["candidate_output"] = new_output
227
+ quality = None
228
+ quality_error = None
229
+ try:
230
+ quality = build_quality_report(
231
+ [fixture],
232
+ baseline_trace=before,
233
+ candidate_trace=after,
234
+ min_score=protocol.min_quality,
235
+ )
236
+ except Exception:
237
+ quality_error = "assessment_failed"
238
+ agreement = None
239
+ if before_receipt["outcome"] == after_receipt["outcome"] == "completed":
240
+ agreement = canonical(old_output) == canonical(new_output)
241
+ pairs.append(
242
+ {
243
+ "condition": candidate.name,
244
+ "example_id": example.example_id,
245
+ "repetition": repeat,
246
+ "baseline_run_id": before.run_id,
247
+ "candidate_run_id": after.run_id,
248
+ "quality": quality,
249
+ "quality_error": quality_error,
250
+ "baseline_agreement": agreement,
251
+ }
252
+ )
253
+ return _copy(
254
+ {
255
+ "schema_version": SUBSTITUTION_VERSION,
256
+ **plan,
257
+ "plan_hash": plan_hash,
258
+ "enabled": enabled,
259
+ "records": rows,
260
+ "pairs": pairs,
261
+ "environment": {
262
+ "python": platform.python_version(),
263
+ "system": platform.system(),
264
+ "architecture": platform.machine(),
265
+ },
266
+ }
267
+ )
agentloop/timing.py ADDED
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import time
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Iterable
7
+
8
+
9
+ def format_duration_ms(value: float | None, *, unit="s", precision=2) -> str:
10
+ """Render an optional timing estimate without turning unavailable into zero."""
11
+ if value is None:
12
+ return "unavailable"
13
+ if unit not in {"s", "ms"}:
14
+ raise ValueError("unit must be s or ms")
15
+ amount = value / 1000 if unit == "s" else value
16
+ suffix = "s" if unit == "s" else " ms"
17
+ return f"{amount:.{precision}f}{suffix}"
18
+
19
+
20
+ def timestamp_ms(value: Any) -> float | None:
21
+ """Parse an ISO-8601 timestamp into milliseconds since the Unix epoch."""
22
+
23
+ if not isinstance(value, str) or not value:
24
+ return None
25
+ try:
26
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
27
+ except ValueError:
28
+ return None
29
+ if parsed.tzinfo is None:
30
+ parsed = parsed.replace(tzinfo=timezone.utc)
31
+ result = parsed.timestamp() * 1000
32
+ return result if math.isfinite(result) else None
33
+
34
+
35
+ def duration_ms(value: Any) -> float:
36
+ """Return a finite, non-negative duration for an event-like object."""
37
+
38
+ try:
39
+ result = float(getattr(value, "duration_ms", 0.0) or 0.0)
40
+ except (TypeError, ValueError):
41
+ return 0.0
42
+ return result if math.isfinite(result) and result >= 0 else 0.0
43
+
44
+
45
+ def event_interval_ms(event: Any) -> tuple[float, float] | None:
46
+ """Return a trustworthy timestamp interval for an event, when available.
47
+
48
+ Older adapters sometimes filled both timestamps with the current time while
49
+ carrying the real duration in ``duration_ms``. Treat a large disagreement
50
+ between those values as missing timing data so legacy traces can use the
51
+ cumulative-duration fallback instead of reporting an almost-zero runtime.
52
+ """
53
+
54
+ metadata = getattr(event, "metadata", {})
55
+ if (
56
+ isinstance(metadata, dict)
57
+ and metadata.get("external_evidence_schema") == "1.0"
58
+ and metadata.get("timing_available") is False
59
+ ):
60
+ return None
61
+ started = timestamp_ms(getattr(event, "started_at", None))
62
+ ended = timestamp_ms(getattr(event, "ended_at", None))
63
+ if started is None or ended is None or ended < started:
64
+ return None
65
+
66
+ recorded_duration = duration_ms(event)
67
+ observed_duration = ended - started
68
+ if recorded_duration > 0:
69
+ tolerance = max(5.0, recorded_duration * 0.10)
70
+ if abs(observed_duration - recorded_duration) > tolerance:
71
+ return None
72
+ return started, ended
73
+
74
+
75
+ def event_time_bounds(events: Iterable[Any]) -> tuple[float, float] | None:
76
+ """Return the earliest reliable start and latest reliable end.
77
+
78
+ A positive-duration event without trustworthy timestamps makes the complete
79
+ trace envelope unknowable, so callers should fall back to cumulative work.
80
+ Zero-duration events without timestamps do not affect either result.
81
+ """
82
+
83
+ earliest: float | None = None
84
+ latest: float | None = None
85
+ for event in events:
86
+ interval = event_interval_ms(event)
87
+ if interval is None:
88
+ if duration_ms(event) > 0:
89
+ return None
90
+ continue
91
+ started, ended = interval
92
+ earliest = started if earliest is None else min(earliest, started)
93
+ latest = ended if latest is None else max(latest, ended)
94
+ if earliest is None or latest is None:
95
+ return None
96
+ return earliest, latest
97
+
98
+
99
+ def cumulative_span_time_ms(events: Iterable[Any]) -> float:
100
+ """Return total instrumented work, including overlap and nesting."""
101
+
102
+ return sum(duration_ms(event) for event in events)
103
+
104
+
105
+ def elapsed_runtime_ms(trace: Any) -> float:
106
+ """Resolve end-to-end trace duration with backward-compatible fallbacks."""
107
+
108
+ explicit = _non_negative_float(getattr(trace, "elapsed_ms", None))
109
+ if explicit is not None:
110
+ return explicit
111
+
112
+ if getattr(trace, "_timing_active", False):
113
+ started_perf = _non_negative_float(getattr(trace, "_start_perf", None))
114
+ if started_perf is not None:
115
+ return max(0.0, (time.perf_counter() - started_perf) * 1000)
116
+
117
+ trace_started = timestamp_ms(getattr(trace, "started_at", None))
118
+ trace_ended = timestamp_ms(getattr(trace, "ended_at", None))
119
+ if trace_started is not None and trace_ended is not None and trace_ended >= trace_started:
120
+ return trace_ended - trace_started
121
+
122
+ events = list(getattr(trace, "events", []))
123
+ bounds = event_time_bounds(events)
124
+ if bounds is not None:
125
+ earliest, latest = bounds
126
+ if trace_started is not None and trace_started <= latest:
127
+ earliest = min(earliest, trace_started)
128
+ return max(0.0, latest - earliest)
129
+
130
+ return cumulative_span_time_ms(events)
131
+
132
+
133
+ def _non_negative_float(value: Any) -> float | None:
134
+ if value is None or value == "":
135
+ return None
136
+ try:
137
+ result = float(value)
138
+ except (TypeError, ValueError):
139
+ return None
140
+ if not math.isfinite(result) or result < 0:
141
+ return None
142
+ return result
agentloop/tokens.py ADDED
@@ -0,0 +1,188 @@
1
+ """Token-count provenance: where a token number came from, and how exact it is.
2
+
3
+ The core rule of this module mirrors :mod:`agentloop.costs`: **a measurement is
4
+ only ever presented as exact when it can be justified.** AgentLoop stores a
5
+ whitespace word-count fallback in the same ``input_tokens`` / ``output_tokens``
6
+ fields that carry provider-reported usage, so without provenance a trace can show
7
+ an exact-looking token count — and an exact-looking dollar amount derived from it
8
+ — that actually came from ``len(text.split())``.
9
+
10
+ Seven provenance values are represented (`TokenProvenance`), recorded per event:
11
+
12
+ - ``provider`` — the provider reported usage (e.g. an OpenAI ``usage`` object).
13
+ - ``tokenizer`` — counted with a real tokenizer for the target model.
14
+ - ``user_supplied`` — explicit counts passed by the calling application.
15
+ - ``external_reported`` — imported counts without verified provider accounting.
16
+ - ``estimated_words`` — the whitespace word-count fallback; an approximation.
17
+ - ``unavailable`` — no counts were available and none could be estimated.
18
+ - ``unspecified`` — the trace predates this field, so provenance is unknown.
19
+
20
+ The first three are *exact-grade*: they are counts of tokens rather than a proxy
21
+ for them. ``estimated_words`` is explicitly an approximation. Aggregating those
22
+ grades over a trace's model calls yields a `TokenStatus`, which is what reports,
23
+ cost breakdowns, and replay gates consult. See ``docs/TRACE_SCHEMA.md`` for the
24
+ serialized contract and ``docs/RESEARCH.md`` for which measurements are suitable
25
+ to report as experimental results.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Any, Literal
31
+
32
+ TokenProvenance = Literal[
33
+ "provider",
34
+ "tokenizer",
35
+ "user_supplied",
36
+ "external_reported",
37
+ "estimated_words",
38
+ "unavailable",
39
+ "unspecified",
40
+ ]
41
+ TokenStatus = Literal[
42
+ "exact", "estimated", "partial", "unavailable", "unspecified", "empty", "external_reported"
43
+ ]
44
+
45
+ PROVIDER = "provider"
46
+ TOKENIZER = "tokenizer"
47
+ USER_SUPPLIED = "user_supplied"
48
+ EXTERNAL_REPORTED = "external_reported"
49
+ ESTIMATED_WORDS = "estimated_words"
50
+ UNAVAILABLE = "unavailable"
51
+ UNSPECIFIED = "unspecified"
52
+
53
+ #: Provenance values a producer may record. ``unspecified`` is a *read* result
54
+ #: for traces written before the field existed, never something to write.
55
+ WRITABLE_PROVENANCE = frozenset(
56
+ {PROVIDER, TOKENIZER, USER_SUPPLIED, EXTERNAL_REPORTED, ESTIMATED_WORDS, UNAVAILABLE}
57
+ )
58
+
59
+ #: Provenance values that count tokens rather than approximate them.
60
+ EXACT_PROVENANCE = frozenset({PROVIDER, TOKENIZER, USER_SUPPLIED})
61
+
62
+ # Exactness grades, in the order the aggregate status resolves them.
63
+ _EXACT = "exact"
64
+ _ESTIMATED = "estimated"
65
+ _UNAVAILABLE = "unavailable"
66
+ _UNSPECIFIED = "unspecified"
67
+
68
+
69
+ class TokenProvenanceError(ValueError):
70
+ """A producer tried to record a token provenance AgentLoop does not define."""
71
+
72
+
73
+ def validate_provenance(value: Any) -> str:
74
+ """Return ``value`` if a producer may record it, else raise.
75
+
76
+ Producers (the tracer and the integrations) validate strictly so a typo fails
77
+ fast at the point it is introduced. The *deserialization* path deliberately
78
+ does not: see :func:`provenance_grade` for why unrecognized values read back
79
+ as ``unspecified`` instead of rejecting a trace from a newer minor version.
80
+ """
81
+
82
+ if value not in WRITABLE_PROVENANCE:
83
+ raise TokenProvenanceError(
84
+ f"unknown token provenance {value!r}; expected one of "
85
+ f"{', '.join(sorted(WRITABLE_PROVENANCE))}"
86
+ )
87
+ return str(value)
88
+
89
+
90
+ def provenance_grade(value: Any) -> str:
91
+ """Return the exactness grade for one event's provenance value.
92
+
93
+ A missing value (an event from a trace written before this field existed) and
94
+ any value this build does not recognize both grade as ``unspecified``. Being
95
+ lenient here is what keeps the schema's MINOR forward-compatibility promise:
96
+ a trace written by a newer AgentLoop that adds a provenance value stays
97
+ readable, and its unknown value is simply not claimed to be exact.
98
+ """
99
+
100
+ if value is None:
101
+ return _UNSPECIFIED
102
+ if value in EXACT_PROVENANCE:
103
+ return _EXACT
104
+ if value == ESTIMATED_WORDS:
105
+ return _ESTIMATED
106
+ if value == UNAVAILABLE:
107
+ return _UNAVAILABLE
108
+ if value == EXTERNAL_REPORTED:
109
+ return EXTERNAL_REPORTED
110
+ return _UNSPECIFIED
111
+
112
+
113
+ def token_status(model_events: list[Any]) -> str:
114
+ """Summarize how exact a trace's token totals are.
115
+
116
+ - ``empty`` — no model calls, so there is no token count to qualify.
117
+ - ``exact`` — every model call counted tokens (provider/tokenizer/user).
118
+ - ``partial`` — some model calls counted tokens and some did not.
119
+ - ``estimated`` — no exact counts, and at least one is a word approximation.
120
+ - ``unavailable`` — every model call recorded that it had no counts at all.
121
+ - ``unspecified`` — provenance is unknown for every model call (legacy trace).
122
+
123
+ Consumers should treat anything other than ``exact``/``empty`` as "do not
124
+ present these token totals, or a cost derived from them, as an exact
125
+ measurement."
126
+ """
127
+
128
+ grades = {provenance_grade(getattr(event, "token_provenance", None)) for event in model_events}
129
+ if not grades:
130
+ return "empty"
131
+ if grades == {_EXACT}:
132
+ return "exact"
133
+ if _EXACT in grades:
134
+ return "partial"
135
+ if _ESTIMATED in grades:
136
+ return "estimated"
137
+ if grades == {_UNAVAILABLE}:
138
+ return "unavailable"
139
+ if grades == {EXTERNAL_REPORTED}:
140
+ return EXTERNAL_REPORTED
141
+ if EXTERNAL_REPORTED in grades:
142
+ return "partial"
143
+ return "unspecified"
144
+
145
+
146
+ def provenance_counts(model_events: list[Any]) -> dict[str, int]:
147
+ """Count model calls per recorded provenance value, for report transparency."""
148
+
149
+ counts: dict[str, int] = {}
150
+ for event in model_events:
151
+ value = getattr(event, "token_provenance", None)
152
+ key = str(value) if value is not None else UNSPECIFIED
153
+ counts[key] = counts.get(key, 0) + 1
154
+ return dict(sorted(counts.items()))
155
+
156
+
157
+ def is_token_basis_exact(status: str | None) -> bool:
158
+ """Return whether token totals are exact enough to report as measurements."""
159
+
160
+ return status in {"exact", "empty"}
161
+
162
+
163
+ def is_token_basis_evaluable(status: str | None) -> bool:
164
+ """Return whether token totals may back a cost comparison or gate.
165
+
166
+ Stricter than nothing, looser than :func:`is_token_basis_exact`: a trace with
167
+ *unknown* provenance (``unspecified``) still gates, because every trace
168
+ written before this field existed would otherwise turn every configured cost
169
+ gate indeterminate on upgrade. A trace that positively reports a word
170
+ estimate does not gate — there the producer told us the number is a proxy.
171
+ See ``docs/TRACE_SCHEMA.md`` for the compatibility path.
172
+ """
173
+
174
+ return status in {"exact", "empty", "unspecified", None}
175
+
176
+
177
+ def describe_token_status(status: str | None) -> str:
178
+ """Return a short human explanation of a token status, for reports and CLI."""
179
+
180
+ return {
181
+ "external_reported": "external counts; provider accounting is not independently verified",
182
+ "exact": "counted tokens (provider, tokenizer, or caller-supplied)",
183
+ "partial": "mixed: some model calls counted tokens, some did not",
184
+ "estimated": "approximated from whitespace word counts, not counted tokens",
185
+ "unavailable": "no token counts were available",
186
+ "unspecified": "unknown: trace predates token provenance",
187
+ "empty": "no model calls",
188
+ }.get(str(status), "unknown token basis")