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,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
+ }