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/aggregates.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""Streaming native-trace aggregates with explicit completeness and merge semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from agentloop.aggregate_stats import AggregateConfig, Histogram, WeightedCandidates, number, total
|
|
14
|
+
from agentloop.costs import load_pricing_table
|
|
15
|
+
from agentloop.metrics import _event_cost_estimate
|
|
16
|
+
from agentloop.operations import OPERATION_KINDS, operation_kind
|
|
17
|
+
from agentloop.retention import RetentionPolicy, read_retention
|
|
18
|
+
from agentloop.schema import coerce_event_dict, validate_trace_dict
|
|
19
|
+
from agentloop.timing import elapsed_runtime_ms
|
|
20
|
+
from agentloop.tokens import EXACT_PROVENANCE, WRITABLE_PROVENANCE, provenance_grade
|
|
21
|
+
from agentloop.tracer import AgentTrace
|
|
22
|
+
from agentloop.workflow_types import stage_summary, workflow_summary
|
|
23
|
+
|
|
24
|
+
AGGREGATE_VERSION = "1.0"
|
|
25
|
+
MAX_ARTIFACT_BYTES = 16 * 1024 * 1024
|
|
26
|
+
_COUNTERS = (
|
|
27
|
+
"runs",
|
|
28
|
+
"events",
|
|
29
|
+
"model_calls",
|
|
30
|
+
"tool_calls",
|
|
31
|
+
"retries",
|
|
32
|
+
"input_tokens",
|
|
33
|
+
"output_tokens",
|
|
34
|
+
"error_spans",
|
|
35
|
+
"stage_spans",
|
|
36
|
+
"decision_spans",
|
|
37
|
+
"stage_evidence_missing_runs",
|
|
38
|
+
"inexact_token_runs",
|
|
39
|
+
"unknown_cost_runs",
|
|
40
|
+
"invalid_inputs",
|
|
41
|
+
"undeclared_stage_spans",
|
|
42
|
+
"cost_detail_missing_runs",
|
|
43
|
+
)
|
|
44
|
+
_CATEGORIES = {
|
|
45
|
+
"outcomes": {"success", "failure", "unknown"},
|
|
46
|
+
"operations": OPERATION_KINDS | {"unknown"},
|
|
47
|
+
"token_provenance": WRITABLE_PROVENANCE | {"unspecified"},
|
|
48
|
+
"cost_calls": {"calculated", "provider_reported", "unknown", "priced_unsplit"},
|
|
49
|
+
"runtime_basis": {"explicit_elapsed", "legacy_fallback", "retained_snapshot"},
|
|
50
|
+
}
|
|
51
|
+
_SKETCHES = (
|
|
52
|
+
"stage_counts",
|
|
53
|
+
"stage_latency_ms",
|
|
54
|
+
"stage_known_cost_usd",
|
|
55
|
+
"decision_outcomes",
|
|
56
|
+
"cost_provenance",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _hash(value):
|
|
61
|
+
return hashlib.sha256(
|
|
62
|
+
json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
|
63
|
+
).hexdigest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _increment(counter, key, value=1):
|
|
67
|
+
if type(value) is not int or value < 0:
|
|
68
|
+
raise ValueError("aggregate counts must be nonnegative integers")
|
|
69
|
+
counter[key] += value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TraceAggregate:
|
|
73
|
+
"""Bounded summary state. Input iteration loads one trace at a time.
|
|
74
|
+
|
|
75
|
+
Counts refer to supplied records, not unique run IDs. Inputs and partitions
|
|
76
|
+
must be disjoint; no unbounded deduplication registry is maintained.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, config: AggregateConfig | None = None, *, pricing=None):
|
|
80
|
+
self.config = AggregateConfig() if config is None else config
|
|
81
|
+
if not isinstance(self.config, AggregateConfig):
|
|
82
|
+
raise TypeError("config must be AggregateConfig")
|
|
83
|
+
self._pricing = copy.deepcopy(pricing if pricing is not None else load_pricing_table())
|
|
84
|
+
self._pricing_hash = _hash(asdict(self._pricing))
|
|
85
|
+
self._reset()
|
|
86
|
+
|
|
87
|
+
def _reset(self):
|
|
88
|
+
self._semantics = None
|
|
89
|
+
self._counts = Counter({key: 0 for key in _COUNTERS})
|
|
90
|
+
self._categories = {
|
|
91
|
+
key: Counter({value: 0 for value in sorted(values)})
|
|
92
|
+
for key, values in _CATEGORIES.items()
|
|
93
|
+
}
|
|
94
|
+
self._latency = Histogram(self.config.latency_bounds_ms)
|
|
95
|
+
self._cost = {"calculated_usd": 0.0, "provider_reported_usd": 0.0}
|
|
96
|
+
self._sketches = {
|
|
97
|
+
key: WeightedCandidates(self.config.heavy_hitter_capacity) for key in _SKETCHES
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
def add(self, trace: AgentTrace) -> None:
|
|
101
|
+
if not isinstance(trace, AgentTrace):
|
|
102
|
+
raise TypeError("aggregate input must be AgentTrace")
|
|
103
|
+
# Build an isolated bounded delta, so any malformed metric leaves state intact.
|
|
104
|
+
delta = copy.copy(self)
|
|
105
|
+
delta._reset()
|
|
106
|
+
delta._consume(trace)
|
|
107
|
+
self.merge(delta)
|
|
108
|
+
|
|
109
|
+
def consume(self, traces, *, on_error="raise"):
|
|
110
|
+
if on_error not in {"raise", "skip"}:
|
|
111
|
+
raise ValueError("on_error must be raise or skip")
|
|
112
|
+
for trace in traces:
|
|
113
|
+
try:
|
|
114
|
+
self.add(trace)
|
|
115
|
+
except (ValueError, TypeError, OverflowError):
|
|
116
|
+
if on_error == "raise":
|
|
117
|
+
raise
|
|
118
|
+
self._counts["invalid_inputs"] += 1
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def _consume(self, trace):
|
|
122
|
+
validate_trace_dict(trace.to_dict())
|
|
123
|
+
if getattr(trace, "_timing_active", False):
|
|
124
|
+
raise ValueError("aggregate input trace is still running")
|
|
125
|
+
for index, event in enumerate(trace.events):
|
|
126
|
+
coerce_event_dict(event.to_dict(), index=index)
|
|
127
|
+
retained = read_retention(trace)
|
|
128
|
+
self._semantics = {
|
|
129
|
+
"retention_policy": retained["policy"] if retained else None,
|
|
130
|
+
"cost_basis": "retained_metric_snapshots" if retained else "offline_pricing_table",
|
|
131
|
+
"pricing_sha256": None if retained else self._pricing_hash,
|
|
132
|
+
}
|
|
133
|
+
self._counts["runs"] = 1
|
|
134
|
+
if retained:
|
|
135
|
+
metrics = retained["metrics"]
|
|
136
|
+
for target, source in (
|
|
137
|
+
("events", "event_count"),
|
|
138
|
+
("model_calls", "model_call_count"),
|
|
139
|
+
("tool_calls", "tool_call_count"),
|
|
140
|
+
("retries", "retry_count"),
|
|
141
|
+
("input_tokens", "input_tokens"),
|
|
142
|
+
("output_tokens", "output_tokens"),
|
|
143
|
+
("error_spans", "error_span_count"),
|
|
144
|
+
):
|
|
145
|
+
_increment(self._counts, target, metrics[source])
|
|
146
|
+
self._latency.add(metrics["total_runtime_ms"])
|
|
147
|
+
self._categories["runtime_basis"]["retained_snapshot"] = 1
|
|
148
|
+
for kind, count in metrics["operation_counts"].items():
|
|
149
|
+
_increment(
|
|
150
|
+
self._categories["operations"],
|
|
151
|
+
kind if kind in OPERATION_KINDS else "unknown",
|
|
152
|
+
count,
|
|
153
|
+
)
|
|
154
|
+
for kind, count in metrics["token_provenance_counts"].items():
|
|
155
|
+
_increment(
|
|
156
|
+
self._categories["token_provenance"],
|
|
157
|
+
kind if kind in WRITABLE_PROVENANCE else "unspecified",
|
|
158
|
+
count,
|
|
159
|
+
)
|
|
160
|
+
self._counts["inexact_token_runs"] = int(
|
|
161
|
+
metrics["token_status"] not in {"exact", "empty"}
|
|
162
|
+
)
|
|
163
|
+
self._counts["unknown_cost_runs"] = int(
|
|
164
|
+
metrics["cost_status"] not in {"complete", "empty"}
|
|
165
|
+
)
|
|
166
|
+
costs = metrics["cost_breakdown"]
|
|
167
|
+
for key in self._cost:
|
|
168
|
+
self._cost[key] = number(costs[key])
|
|
169
|
+
# Older retention snapshots do not split priced call counts by source.
|
|
170
|
+
self._categories["cost_calls"]["unknown"] = costs["unavailable_model_call_count"]
|
|
171
|
+
self._categories["cost_calls"]["priced_unsplit"] = costs["priced_model_call_count"]
|
|
172
|
+
self._counts["cost_detail_missing_runs"] = int(metrics["model_call_count"] > 0)
|
|
173
|
+
self._sketches["cost_provenance"].add(
|
|
174
|
+
_hash([costs["pricing_sources"], costs["pricing_as_of"]]), 1
|
|
175
|
+
)
|
|
176
|
+
outcome = retained.get("execution_outcome", retained["context"]["outcome"])
|
|
177
|
+
failure = (
|
|
178
|
+
metrics["error_span_count"] > 0
|
|
179
|
+
or "failure_or_timeout" in retained["reasons"]
|
|
180
|
+
or outcome in {"failure", "timeout"}
|
|
181
|
+
)
|
|
182
|
+
self._categories["outcomes"][
|
|
183
|
+
"failure" if failure else "success" if outcome == "success" else "unknown"
|
|
184
|
+
] = 1
|
|
185
|
+
if not retained["complete_evidence"]:
|
|
186
|
+
self._counts["stage_evidence_missing_runs"] = 1
|
|
187
|
+
return
|
|
188
|
+
# Original per-event prices may differ; never reprice retained snapshots.
|
|
189
|
+
for event in trace.events:
|
|
190
|
+
self._stage(event, cost=None)
|
|
191
|
+
return
|
|
192
|
+
self._latency.add(round(elapsed_runtime_ms(trace), 3))
|
|
193
|
+
self._categories["runtime_basis"][
|
|
194
|
+
"explicit_elapsed" if trace.elapsed_ms is not None else "legacy_fallback"
|
|
195
|
+
] = 1
|
|
196
|
+
failed = False
|
|
197
|
+
for event in trace.events:
|
|
198
|
+
self._counts["events"] += 1
|
|
199
|
+
kind = operation_kind(event)
|
|
200
|
+
self._categories["operations"][kind] += 1
|
|
201
|
+
self._counts["tool_calls"] += event.event_type == "tool_call"
|
|
202
|
+
self._counts["retries"] += event.event_type == "retry"
|
|
203
|
+
failed |= event.status == "error"
|
|
204
|
+
self._counts["error_spans"] += event.status == "error"
|
|
205
|
+
cost = None
|
|
206
|
+
if event.event_type == "model_call":
|
|
207
|
+
self._counts["model_calls"] += 1
|
|
208
|
+
self._counts["input_tokens"] += event.input_tokens
|
|
209
|
+
self._counts["output_tokens"] += event.output_tokens
|
|
210
|
+
provenance = (
|
|
211
|
+
event.token_provenance
|
|
212
|
+
if event.token_provenance in WRITABLE_PROVENANCE
|
|
213
|
+
else "unspecified"
|
|
214
|
+
)
|
|
215
|
+
self._categories["token_provenance"][provenance] += 1
|
|
216
|
+
self._counts["inexact_token_runs"] |= provenance not in EXACT_PROVENANCE
|
|
217
|
+
estimate = _event_cost_estimate(event, self._pricing)
|
|
218
|
+
self._categories["cost_calls"][estimate.state] += 1
|
|
219
|
+
if estimate.amount_usd is None:
|
|
220
|
+
self._counts["unknown_cost_runs"] = 1
|
|
221
|
+
else:
|
|
222
|
+
cost = estimate.amount_usd
|
|
223
|
+
self._cost[estimate.state + "_usd"] = total(
|
|
224
|
+
self._cost[estimate.state + "_usd"], cost
|
|
225
|
+
)
|
|
226
|
+
self._sketches["cost_provenance"].add(
|
|
227
|
+
_hash(
|
|
228
|
+
[
|
|
229
|
+
estimate.pricing_source,
|
|
230
|
+
estimate.pricing_as_of,
|
|
231
|
+
provenance_grade(event.token_provenance),
|
|
232
|
+
]
|
|
233
|
+
),
|
|
234
|
+
1,
|
|
235
|
+
)
|
|
236
|
+
self._stage(event, cost=cost)
|
|
237
|
+
execution = workflow_summary(trace.metadata)
|
|
238
|
+
status = (
|
|
239
|
+
execution.get("status")
|
|
240
|
+
if execution and execution.get("schema_status") == "supported"
|
|
241
|
+
else None
|
|
242
|
+
)
|
|
243
|
+
failed |= (
|
|
244
|
+
status in {"failed", "cancelled", "interrupted"}
|
|
245
|
+
or trace.metadata.get("success") is False
|
|
246
|
+
)
|
|
247
|
+
succeeded = (
|
|
248
|
+
status == "completed"
|
|
249
|
+
if execution is not None
|
|
250
|
+
else trace.metadata.get("success") is True
|
|
251
|
+
)
|
|
252
|
+
self._categories["outcomes"][
|
|
253
|
+
"failure" if failed else "success" if succeeded else "unknown"
|
|
254
|
+
] = 1
|
|
255
|
+
|
|
256
|
+
def _stage(self, event, *, cost):
|
|
257
|
+
stage = stage_summary(event)
|
|
258
|
+
if stage is None or stage.get("schema_status") != "supported" or not stage.get("stage_id"):
|
|
259
|
+
self._counts["undeclared_stage_spans"] += 1
|
|
260
|
+
return
|
|
261
|
+
key = _hash([stage["stage_id"], stage.get("version"), stage["kind"]])
|
|
262
|
+
self._counts["stage_spans"] += 1
|
|
263
|
+
self._sketches["stage_counts"].add(key, 1)
|
|
264
|
+
self._sketches["stage_latency_ms"].add(key, event.duration_ms)
|
|
265
|
+
if cost is not None:
|
|
266
|
+
self._sketches["stage_known_cost_usd"].add(key, cost)
|
|
267
|
+
if stage["kind"] in {"classifier", "rule"}:
|
|
268
|
+
self._counts["decision_spans"] += 1
|
|
269
|
+
self._sketches["decision_outcomes"].add(
|
|
270
|
+
_hash(
|
|
271
|
+
[stage["stage_id"], stage.get("version"), stage.get("outcome"), event.status]
|
|
272
|
+
),
|
|
273
|
+
1,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def merge(self, other):
|
|
277
|
+
if not isinstance(other, TraceAggregate) or self.config != other.config:
|
|
278
|
+
raise ValueError("incompatible aggregate configuration")
|
|
279
|
+
if (
|
|
280
|
+
self._semantics is not None
|
|
281
|
+
and other._semantics is not None
|
|
282
|
+
and self._semantics != other._semantics
|
|
283
|
+
):
|
|
284
|
+
raise ValueError("incompatible retention, sampling or pricing semantics")
|
|
285
|
+
owned = copy.copy(self)
|
|
286
|
+
owned._counts = self._counts.copy()
|
|
287
|
+
owned._categories = {key: value.copy() for key, value in self._categories.items()}
|
|
288
|
+
owned._latency = copy.deepcopy(self._latency)
|
|
289
|
+
owned._cost = dict(self._cost)
|
|
290
|
+
owned._sketches = copy.deepcopy(self._sketches)
|
|
291
|
+
owned._semantics = copy.deepcopy(
|
|
292
|
+
self._semantics if self._semantics is not None else other._semantics
|
|
293
|
+
)
|
|
294
|
+
owned._counts.update(other._counts)
|
|
295
|
+
for key in owned._categories:
|
|
296
|
+
owned._categories[key].update(other._categories[key])
|
|
297
|
+
owned._latency.merge(other._latency)
|
|
298
|
+
for key in owned._cost:
|
|
299
|
+
owned._cost[key] = total(owned._cost[key], other._cost[key])
|
|
300
|
+
for key in owned._sketches:
|
|
301
|
+
owned._sketches[key].merge(other._sketches[key])
|
|
302
|
+
self.__dict__.update(owned.__dict__)
|
|
303
|
+
return self
|
|
304
|
+
|
|
305
|
+
def to_dict(self):
|
|
306
|
+
counts = dict(self._counts)
|
|
307
|
+
snapshot_costs = counts["cost_detail_missing_runs"]
|
|
308
|
+
value = {
|
|
309
|
+
"schema_version": AGGREGATE_VERSION,
|
|
310
|
+
"kind": "agentloop.trace_aggregate",
|
|
311
|
+
"config": self.config.to_dict(),
|
|
312
|
+
"semantics": copy.deepcopy(self._semantics),
|
|
313
|
+
"counts": counts,
|
|
314
|
+
"categories": {key: dict(value) for key, value in self._categories.items()},
|
|
315
|
+
"latency": self._latency.to_dict(),
|
|
316
|
+
"cost": dict(self._cost),
|
|
317
|
+
"heavy_hitters": {key: value.to_dict() for key, value in self._sketches.items()},
|
|
318
|
+
"completeness": {
|
|
319
|
+
"input_tokens": None if counts["inexact_token_runs"] else counts["input_tokens"],
|
|
320
|
+
"output_tokens": None if counts["inexact_token_runs"] else counts["output_tokens"],
|
|
321
|
+
"known_model_cost_usd": total(*self._cost.values()),
|
|
322
|
+
"pricing_complete_model_cost_usd": None
|
|
323
|
+
if counts["unknown_cost_runs"]
|
|
324
|
+
else total(*self._cost.values()),
|
|
325
|
+
"priced_call_source_split_missing_runs": snapshot_costs,
|
|
326
|
+
"stage_cost_missing_runs": snapshot_costs,
|
|
327
|
+
"stage_metrics_scope": "complete-evidence runs with declared stages only",
|
|
328
|
+
"cost_scope": "recorded model calls; calculated and provider-reported kept separate",
|
|
329
|
+
"token_scope": "recorded model calls; reported totals can include estimates", # nosec B105: metric scope, not a credential
|
|
330
|
+
},
|
|
331
|
+
"sampling": {
|
|
332
|
+
"observed_record_count": counts["runs"],
|
|
333
|
+
"input_population_count": None,
|
|
334
|
+
"estimated_population_count": None,
|
|
335
|
+
"effective_population_rate": None,
|
|
336
|
+
"basis": "supplied records only; session prefix denominators are not additive",
|
|
337
|
+
},
|
|
338
|
+
"analysis": {
|
|
339
|
+
"findings_available": False,
|
|
340
|
+
"reason": "Individual evidence spans are required for findings, replay and quality analysis.",
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
value["sha256"] = _hash(value)
|
|
344
|
+
return value
|
|
345
|
+
|
|
346
|
+
@classmethod
|
|
347
|
+
def from_dict(cls, value):
|
|
348
|
+
"""Read our versioned aggregate, checking its bound state and derived fields."""
|
|
349
|
+
try:
|
|
350
|
+
if (
|
|
351
|
+
not isinstance(value, dict)
|
|
352
|
+
or value.get("schema_version") != AGGREGATE_VERSION
|
|
353
|
+
or value.get("kind") != "agentloop.trace_aggregate"
|
|
354
|
+
):
|
|
355
|
+
raise ValueError("unsupported aggregate schema")
|
|
356
|
+
content = dict(value)
|
|
357
|
+
if content.pop("sha256", None) != _hash(content):
|
|
358
|
+
raise ValueError("aggregate checksum mismatch")
|
|
359
|
+
config = value["config"]
|
|
360
|
+
result = cls(
|
|
361
|
+
AggregateConfig(tuple(config["latency_bounds_ms"]), config["heavy_hitter_capacity"])
|
|
362
|
+
)
|
|
363
|
+
semantics = value["semantics"]
|
|
364
|
+
if semantics is not None:
|
|
365
|
+
if not isinstance(semantics, dict) or set(semantics) != {
|
|
366
|
+
"retention_policy",
|
|
367
|
+
"cost_basis",
|
|
368
|
+
"pricing_sha256",
|
|
369
|
+
}:
|
|
370
|
+
raise ValueError("invalid aggregate semantics")
|
|
371
|
+
retention = semantics["retention_policy"]
|
|
372
|
+
if retention is not None:
|
|
373
|
+
options = dict(retention)
|
|
374
|
+
if options.pop("schema_version") != "1.0":
|
|
375
|
+
raise ValueError("unsupported retention policy")
|
|
376
|
+
options["protected_cohorts"] = tuple(options["protected_cohorts"])
|
|
377
|
+
if (
|
|
378
|
+
RetentionPolicy(**options).to_dict() != retention
|
|
379
|
+
or semantics["cost_basis"] != "retained_metric_snapshots"
|
|
380
|
+
or semantics["pricing_sha256"] is not None
|
|
381
|
+
):
|
|
382
|
+
raise ValueError("invalid retained aggregate semantics")
|
|
383
|
+
elif semantics["cost_basis"] != "offline_pricing_table" or not _sha(
|
|
384
|
+
semantics["pricing_sha256"]
|
|
385
|
+
):
|
|
386
|
+
raise ValueError("invalid pricing semantics")
|
|
387
|
+
result._semantics = copy.deepcopy(semantics)
|
|
388
|
+
if set(value["counts"]) != set(_COUNTERS):
|
|
389
|
+
raise ValueError("invalid aggregate counters")
|
|
390
|
+
for key, count in value["counts"].items():
|
|
391
|
+
_increment(result._counts, key, count)
|
|
392
|
+
if set(value["categories"]) != set(_CATEGORIES):
|
|
393
|
+
raise ValueError("invalid aggregate categories")
|
|
394
|
+
for category, choices in _CATEGORIES.items():
|
|
395
|
+
source = value["categories"][category]
|
|
396
|
+
if set(source) != choices:
|
|
397
|
+
raise ValueError("invalid aggregate category keys")
|
|
398
|
+
for key, count in source.items():
|
|
399
|
+
_increment(result._categories[category], key, count)
|
|
400
|
+
histogram = value["latency"]
|
|
401
|
+
counts = histogram["counts"]
|
|
402
|
+
if len(counts) != len(result._latency.counts) or any(
|
|
403
|
+
type(count) is not int or count < 0 for count in counts
|
|
404
|
+
):
|
|
405
|
+
raise ValueError("invalid histogram counts")
|
|
406
|
+
result._latency.counts = list(counts)
|
|
407
|
+
result._latency.count = sum(counts)
|
|
408
|
+
result._latency.sum = number(histogram["sum_ms"])
|
|
409
|
+
for attr, key in (("minimum", "min_ms"), ("maximum", "max_ms")):
|
|
410
|
+
setattr(result._latency, attr, number(histogram[key]) if sum(counts) else None)
|
|
411
|
+
for key in result._cost:
|
|
412
|
+
result._cost[key] = number(value["cost"][key])
|
|
413
|
+
for key, sketch in result._sketches.items():
|
|
414
|
+
source = value["heavy_hitters"][key]
|
|
415
|
+
if len(source["candidates"]) > sketch.capacity:
|
|
416
|
+
raise ValueError("aggregate candidate capacity exceeded")
|
|
417
|
+
sketch.total = number(source["total_weight"])
|
|
418
|
+
sketch.error = number(source["unlisted_key_upper_bound"])
|
|
419
|
+
for item in source["candidates"]:
|
|
420
|
+
label = item["key_sha256"]
|
|
421
|
+
weight = number(item["lower_bound"])
|
|
422
|
+
if (
|
|
423
|
+
not _sha(label)
|
|
424
|
+
or label in sketch.weights
|
|
425
|
+
or weight <= 0
|
|
426
|
+
or weight > sketch.total
|
|
427
|
+
):
|
|
428
|
+
raise ValueError("invalid aggregate candidate")
|
|
429
|
+
sketch.weights[label] = weight
|
|
430
|
+
runs = result._counts["runs"]
|
|
431
|
+
if (
|
|
432
|
+
runs != sum(result._categories["outcomes"].values())
|
|
433
|
+
or runs != result._latency.count
|
|
434
|
+
or runs != sum(result._categories["runtime_basis"].values())
|
|
435
|
+
):
|
|
436
|
+
raise ValueError("inconsistent aggregate run counts")
|
|
437
|
+
if bool(runs) != (semantics is not None):
|
|
438
|
+
raise ValueError("aggregate semantics missing or unexpected")
|
|
439
|
+
counters = result._counts
|
|
440
|
+
if (
|
|
441
|
+
counters["model_calls"] + counters["tool_calls"] + counters["retries"]
|
|
442
|
+
> counters["events"]
|
|
443
|
+
or counters["error_spans"] > counters["events"]
|
|
444
|
+
or counters["stage_spans"] + counters["undeclared_stage_spans"] > counters["events"]
|
|
445
|
+
or counters["decision_spans"] > counters["stage_spans"]
|
|
446
|
+
or not runs
|
|
447
|
+
and any(count for key, count in counters.items() if key != "invalid_inputs")
|
|
448
|
+
or not counters["model_calls"]
|
|
449
|
+
and (
|
|
450
|
+
counters["input_tokens"]
|
|
451
|
+
or counters["output_tokens"]
|
|
452
|
+
or any(result._cost.values())
|
|
453
|
+
)
|
|
454
|
+
or result._sketches["stage_counts"].total != counters["stage_spans"]
|
|
455
|
+
or result._sketches["decision_outcomes"].total != counters["decision_spans"]
|
|
456
|
+
):
|
|
457
|
+
raise ValueError("inconsistent aggregate metric counts")
|
|
458
|
+
if (
|
|
459
|
+
sum(result._categories["operations"].values()) != result._counts["events"]
|
|
460
|
+
or sum(result._categories["cost_calls"].values()) != result._counts["model_calls"]
|
|
461
|
+
or sum(result._categories["token_provenance"].values())
|
|
462
|
+
!= result._counts["model_calls"]
|
|
463
|
+
):
|
|
464
|
+
raise ValueError("inconsistent aggregate event counts")
|
|
465
|
+
for key in (
|
|
466
|
+
"inexact_token_runs",
|
|
467
|
+
"unknown_cost_runs",
|
|
468
|
+
"stage_evidence_missing_runs",
|
|
469
|
+
"cost_detail_missing_runs",
|
|
470
|
+
):
|
|
471
|
+
if result._counts[key] > runs:
|
|
472
|
+
raise ValueError("aggregate completeness count exceeds runs")
|
|
473
|
+
if runs:
|
|
474
|
+
minimum, maximum = result._latency.minimum, result._latency.maximum
|
|
475
|
+
mean = result._latency.sum / runs
|
|
476
|
+
if (
|
|
477
|
+
minimum > maximum
|
|
478
|
+
or mean < minimum
|
|
479
|
+
and not math.isclose(mean, minimum, rel_tol=1e-12)
|
|
480
|
+
or mean > maximum
|
|
481
|
+
and not math.isclose(mean, maximum, rel_tol=1e-12)
|
|
482
|
+
):
|
|
483
|
+
raise ValueError("inconsistent aggregate latency bounds")
|
|
484
|
+
elif result._latency.sum:
|
|
485
|
+
raise ValueError("nonzero latency in empty aggregate")
|
|
486
|
+
for sketch in result._sketches.values():
|
|
487
|
+
if (
|
|
488
|
+
sketch.error > sketch.total
|
|
489
|
+
or sum(sketch.weights.values()) > sketch.total
|
|
490
|
+
and not math.isclose(sum(sketch.weights.values()), sketch.total, rel_tol=1e-12)
|
|
491
|
+
):
|
|
492
|
+
raise ValueError("inconsistent aggregate candidate bounds")
|
|
493
|
+
if result.to_dict() != value:
|
|
494
|
+
raise ValueError("inconsistent aggregate derived fields")
|
|
495
|
+
return result
|
|
496
|
+
except (KeyError, TypeError, OverflowError) as exc:
|
|
497
|
+
raise ValueError("invalid aggregate artifact") from exc
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _sha(value):
|
|
501
|
+
return (
|
|
502
|
+
isinstance(value, str)
|
|
503
|
+
and len(value) == 64
|
|
504
|
+
and all(char in "0123456789abcdef" for char in value)
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def read_json(path, *, max_bytes=MAX_ARTIFACT_BYTES):
|
|
509
|
+
"""Bounded per-file read, including files whose size changes while reading."""
|
|
510
|
+
if type(max_bytes) is not int or not 1 <= max_bytes <= 1024 * 1024 * 1024:
|
|
511
|
+
raise ValueError("max_bytes must be 1..1073741824")
|
|
512
|
+
with Path(path).open("rb") as handle:
|
|
513
|
+
payload = handle.read(max_bytes + 1)
|
|
514
|
+
if len(payload) > max_bytes:
|
|
515
|
+
raise ValueError("aggregate input exceeds byte limit")
|
|
516
|
+
return json.loads(payload)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def aggregate_files(paths, *, config=None, on_error="raise", max_bytes=MAX_ARTIFACT_BYTES):
|
|
520
|
+
result = TraceAggregate(config)
|
|
521
|
+
if type(max_bytes) is not int or not 1 <= max_bytes <= 1024 * 1024 * 1024:
|
|
522
|
+
raise ValueError("max_bytes must be 1..1073741824")
|
|
523
|
+
if on_error not in {"raise", "skip"}:
|
|
524
|
+
raise ValueError("on_error must be raise or skip")
|
|
525
|
+
for path in paths:
|
|
526
|
+
try:
|
|
527
|
+
result.add(AgentTrace.from_dict(read_json(path, max_bytes=max_bytes)))
|
|
528
|
+
except (OSError, ValueError, TypeError, OverflowError):
|
|
529
|
+
if on_error == "raise":
|
|
530
|
+
raise
|
|
531
|
+
result._counts["invalid_inputs"] += 1
|
|
532
|
+
return result
|
agentloop/audit.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agentloop.costs import is_cost_evaluable
|
|
8
|
+
from agentloop.exporters import export_report_markdown
|
|
9
|
+
from agentloop.tracer import AgentTrace
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AuditResult:
|
|
14
|
+
report: dict[str, Any]
|
|
15
|
+
markdown_path: Path | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_audit(trace_path: str | Path, out_path: str | Path | None = None) -> AuditResult:
|
|
19
|
+
trace = AgentTrace.from_json(trace_path)
|
|
20
|
+
report = trace.report()
|
|
21
|
+
md_path = export_report_markdown(report, out_path) if out_path else None
|
|
22
|
+
return AuditResult(report=report, markdown_path=md_path)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def estimate_improvement(report: dict[str, Any]) -> dict[str, float | None]:
|
|
26
|
+
latency_savings = 0.0
|
|
27
|
+
for item in report.get("parallelism_opportunities", []):
|
|
28
|
+
latency_savings += item.get("estimated_savings_ms", 0.0)
|
|
29
|
+
retry_savings = report.get("retry_time_ms", 0.0) * 0.8
|
|
30
|
+
repeated_ratio = report.get("repeated_context_ratio", 0.0)
|
|
31
|
+
cost_savings = (
|
|
32
|
+
report.get("estimated_cost_usd", 0.0) * min(0.5, repeated_ratio)
|
|
33
|
+
if is_cost_evaluable(report.get("cost_status", "complete"))
|
|
34
|
+
else None
|
|
35
|
+
)
|
|
36
|
+
return {
|
|
37
|
+
"estimated_latency_savings_ms": round(latency_savings + retry_savings, 3),
|
|
38
|
+
"estimated_cost_savings_usd": (None if cost_savings is None else round(cost_savings, 6)),
|
|
39
|
+
}
|