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