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
agentloop/metrics.py ADDED
@@ -0,0 +1,342 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
7
+ from agentloop.costs import CostEstimate, PricingTable, estimate_cost, load_pricing_table
8
+ from agentloop.graph import ExecutionGraph
9
+ from agentloop.judgments import read_judgments
10
+ from agentloop.operations import operation_counts, operation_kind
11
+ from agentloop.parallelism import parallelization_candidates
12
+ from agentloop.rules import AnalysisContext, run_rules
13
+ from agentloop.semantic_waste import read_semantic_waste
14
+ from agentloop.structured_quality import decision_span_count, read_quality_evidence
15
+ from agentloop.timing import cumulative_span_time_ms, elapsed_runtime_ms
16
+ from agentloop.tokens import (
17
+ describe_token_status,
18
+ provenance_counts,
19
+ provenance_grade,
20
+ token_status,
21
+ )
22
+ from agentloop.workflow_types import stage_summary, workflow_summary
23
+
24
+
25
+ def build_report(trace: Any, *, _aggregate_only: bool = False) -> dict[str, Any]:
26
+ from agentloop.retention import read_retention, retained_report
27
+
28
+ retained = retained_report(trace)
29
+ if retained is not None:
30
+ return retained
31
+ events = trace.events
32
+ model_events = [e for e in events if e.event_type == "model_call"]
33
+ tool_events = [e for e in events if e.event_type == "tool_call"]
34
+ retry_events = [e for e in events if e.event_type == "retry"]
35
+
36
+ repeated = repeated_context_stats([e for e in model_events if operation_kind(e) == "model"])
37
+ parallel = [] if _aggregate_only else parallelism_opportunities(events)
38
+ cumulative_time = cumulative_span_time_ms(events)
39
+ cost = cost_breakdown(model_events)
40
+ tokens = token_breakdown(model_events)
41
+
42
+ report = {
43
+ "run_id": trace.run_id,
44
+ "name": trace.name,
45
+ "event_count": len(events),
46
+ "operation_counts": operation_counts(events),
47
+ "model_call_count": len(model_events),
48
+ "tool_call_count": len(tool_events),
49
+ "retry_count": len(retry_events),
50
+ "total_runtime_ms": round(elapsed_runtime_ms(trace), 3),
51
+ "cumulative_span_time_ms": round(cumulative_time, 3),
52
+ "model_time_ms": round(sum(e.duration_ms for e in model_events), 3),
53
+ "tool_time_ms": round(sum(e.duration_ms for e in tool_events), 3),
54
+ "retry_time_ms": round(sum(e.duration_ms for e in retry_events), 3),
55
+ "input_tokens": sum(e.input_tokens for e in model_events),
56
+ "output_tokens": sum(e.output_tokens for e in model_events),
57
+ # How exact those two totals are. Only trust them as a measurement when
58
+ # `token_status` is "exact" (or "empty") — anything else means at least
59
+ # one model call approximated its counts, did not have them, or predates
60
+ # provenance. See agentloop/tokens.py and docs/TRACE_SCHEMA.md.
61
+ "token_status": tokens["token_status"],
62
+ "token_provenance_counts": tokens["token_provenance_counts"],
63
+ # Sum of *known* costs only (calculated + provider-reported). A model with
64
+ # no known rate contributes nothing here rather than a fabricated rate.
65
+ # `cost_status` and `cost_breakdown` record whether that number is a
66
+ # complete measurement or a lower bound, so consumers reading the flat
67
+ # field can tell. Only trust the total as exact when cost_status is
68
+ # "complete" (or "empty"). See docs/PRICING.md.
69
+ "estimated_cost_usd": cost["known_cost_usd"],
70
+ "cost_status": cost["cost_status"],
71
+ "cost_breakdown": cost,
72
+ "repeated_context_tokens": repeated["repeated_context_tokens"],
73
+ "repeated_context_ratio": repeated["repeated_context_ratio"],
74
+ "parallelism_opportunities": parallel,
75
+ "recommendations": [],
76
+ "events": [] if _aggregate_only else [e.to_dict() for e in events],
77
+ }
78
+ from agentloop.interoperability.evidence import qualify_report
79
+
80
+ qualify_report(trace, report)
81
+ from agentloop.integrations.harbor.trial_evidence import qualify_trial_report
82
+
83
+ qualify_trial_report(trace, report)
84
+ from agentloop.integrations.omnigent.evidence import qualify_report as qualify_omnigent_report
85
+
86
+ qualify_omnigent_report(trace, report)
87
+ if _aggregate_only:
88
+ return report
89
+ execution = workflow_summary(getattr(trace, "metadata", {}))
90
+ if execution is not None:
91
+ report["execution"] = execution
92
+ stages = {event.event_id: stage_summary(event) for event in events}
93
+ stages = {key: value for key, value in stages.items() if value is not None}
94
+ if stages:
95
+ report["stages"] = stages
96
+ quality = read_quality_evidence(trace)
97
+ if quality is not None:
98
+ report["quality_evidence"] = quality
99
+ report["quality_score"] = quality["score"]
100
+ report["decision_count"] = decision_span_count(trace)
101
+ report["error_span_count"] = sum(event.status == "error" for event in events)
102
+ report["decision_count_basis"] = "recorded_classifier_or_rule_spans"
103
+ judgments = read_judgments(trace)
104
+ if judgments is not None:
105
+ report["semantic_judgments"] = judgments
106
+ report["evidence_categories"] = {
107
+ "observed_facts": "Recorded spans, statuses, durations and usage with their source provenance.",
108
+ "deterministic_inference": "Finding rules and savings estimates; assumptions still require validation.",
109
+ "semantic_judgments": "Explicit offline judge answers; separate from observed facts and task quality.",
110
+ }
111
+ semantic_waste = read_semantic_waste(trace, judgments=judgments)
112
+ if semantic_waste is not None:
113
+ report["semantic_waste"] = semantic_waste
114
+ report.setdefault(
115
+ "evidence_categories",
116
+ {
117
+ "observed_facts": "Recorded spans, statuses, durations and usage with their source provenance.",
118
+ "deterministic_inference": "Finding rules and savings estimates; assumptions still require validation.",
119
+ "semantic_judgments": "Explicit offline judge answers; separate from observed facts and task quality.",
120
+ },
121
+ )
122
+ candidates, errors = run_rules(
123
+ AnalysisContext(report=report, graph=ExecutionGraph.from_trace(trace))
124
+ )
125
+ report["finding_candidates"] = [candidate.to_dict() for candidate in candidates]
126
+ report["rule_errors"] = errors
127
+ report["analysis_complete"] = not errors
128
+ report["recommendations"] = [
129
+ {
130
+ "title": item.title,
131
+ "description": item.why,
132
+ "rule_id": item.rule_id,
133
+ "rule_version": item.rule_version,
134
+ }
135
+ for item in candidates
136
+ ]
137
+ if errors:
138
+ report["recommendations"].append(
139
+ {
140
+ "title": "Analysis incomplete",
141
+ "description": "Some finding rules failed: "
142
+ + ", ".join(item["rule_id"] for item in errors),
143
+ }
144
+ )
145
+ if not report["recommendations"]:
146
+ report["recommendations"] = [
147
+ {
148
+ "title": "No major pattern detected",
149
+ "description": "Collect more traces for stronger recommendations.",
150
+ }
151
+ ]
152
+ retention = read_retention(trace)
153
+ if retention is not None:
154
+ report["retention"] = retention
155
+ return report
156
+
157
+
158
+ def _event_cost_estimate(event: Any, pricing: PricingTable) -> CostEstimate:
159
+ """Estimate one model call's cost, reading provider metadata from the event.
160
+
161
+ Provider, cached-input tokens, billing mode, and any provider-reported cost
162
+ ride in ``event.metadata`` (a free-form field already in the event schema),
163
+ so this stays compatible with existing serialized traces — a trace without
164
+ these keys simply falls back to model-name resolution.
165
+
166
+ Metadata is untrusted: a non-finite/negative reported cost (which would raise
167
+ from :func:`estimate_cost`) is caught and turned into an ``unknown`` estimate
168
+ with an ``invalid_metadata`` reason rather than crashing report construction
169
+ or fabricating a number.
170
+ """
171
+ try:
172
+ raw_metadata = getattr(event, "metadata", None)
173
+ if raw_metadata is None:
174
+ metadata: Mapping[str, Any] = {}
175
+ elif isinstance(raw_metadata, Mapping):
176
+ metadata = raw_metadata
177
+ else:
178
+ raise ValueError("metadata must be a mapping")
179
+ if getattr(event, "token_provenance", None) == "external_reported" or (
180
+ metadata.get("external_evidence_schema") == "1.0"
181
+ ):
182
+ return CostEstimate(
183
+ state="unknown",
184
+ amount_usd=None,
185
+ model=event.model,
186
+ provider=None,
187
+ pricing_source=None,
188
+ pricing_as_of=None,
189
+ input_tokens=event.input_tokens,
190
+ output_tokens=event.output_tokens,
191
+ cached_input_tokens=0,
192
+ unknown_reason="external_usage_not_provider_accounting",
193
+ )
194
+ provider = metadata.get("provider")
195
+ billing_mode = metadata.get("billing_mode")
196
+ if provider is not None and not isinstance(provider, str):
197
+ raise ValueError("metadata provider must be a string")
198
+ if billing_mode is not None and not isinstance(billing_mode, str):
199
+ raise ValueError("metadata billing_mode must be a string")
200
+
201
+ reported = metadata.get("provider_reported_cost_usd", metadata.get("cost_usd"))
202
+ return estimate_cost(
203
+ event.model,
204
+ event.input_tokens,
205
+ event.output_tokens,
206
+ provider=provider,
207
+ cached_input_tokens=metadata.get("cached_input_tokens", 0),
208
+ billing_mode=billing_mode,
209
+ provider_reported_cost_usd=None if reported is None else float(reported),
210
+ pricing=pricing,
211
+ )
212
+ except (AttributeError, OverflowError, TypeError, ValueError):
213
+ raw_metadata = getattr(event, "metadata", None)
214
+ metadata = raw_metadata if isinstance(raw_metadata, Mapping) else {}
215
+ provider = metadata.get("provider")
216
+ provider = provider if isinstance(provider, str) else None
217
+ return CostEstimate(
218
+ state="unknown",
219
+ amount_usd=None,
220
+ model=event.model,
221
+ provider=provider,
222
+ pricing_source=None,
223
+ pricing_as_of=None,
224
+ input_tokens=event.input_tokens,
225
+ output_tokens=event.output_tokens,
226
+ cached_input_tokens=0,
227
+ unknown_reason="invalid_metadata",
228
+ )
229
+
230
+
231
+ def cost_breakdown(model_events: list[Any], pricing: PricingTable | None = None) -> dict[str, Any]:
232
+ """Aggregate per-call cost estimates, distinguishing the three cost kinds.
233
+
234
+ Returns known totals split by source (calculated vs. provider-reported), a
235
+ count and list of model calls whose pricing was unavailable, and the
236
+ provenance (sources and ``as_of`` dates) behind the calculated portion, so a
237
+ report can show exactly how complete and how current its cost number is.
238
+ """
239
+ table = pricing if pricing is not None or not model_events else load_pricing_table()
240
+ estimates = [_event_cost_estimate(event, table) for event in model_events]
241
+
242
+ calculated = round(sum(e.amount_usd or 0.0 for e in estimates if e.state == "calculated"), 6)
243
+ provider_reported = round(
244
+ sum(e.amount_usd or 0.0 for e in estimates if e.state == "provider_reported"), 6
245
+ )
246
+ unknown = [e for e in estimates if e.state == "unknown"]
247
+ sources = sorted(
248
+ {e.pricing_source for e in estimates if e.state == "calculated" and e.pricing_source}
249
+ )
250
+ as_of = sorted(
251
+ {e.pricing_as_of for e in estimates if e.state == "calculated" and e.pricing_as_of}
252
+ )
253
+ unknown_models = sorted({str(e.model) for e in unknown})
254
+ unknown_reasons = sorted({e.unknown_reason for e in unknown if e.unknown_reason})
255
+
256
+ return {
257
+ "cost_status": _cost_status(len(estimates), len(unknown)),
258
+ # A calculated cost is a rate multiplied by a token count, so it is only
259
+ # as exact as that count. Carrying the token basis alongside the cost
260
+ # stops a dollar amount derived from word estimates from reading as an
261
+ # exact calculated cost (issue #133).
262
+ "token_status": token_status(model_events),
263
+ "known_cost_usd": round(calculated + provider_reported, 6),
264
+ "calculated_usd": calculated,
265
+ "provider_reported_usd": provider_reported,
266
+ "priced_model_call_count": len(estimates) - len(unknown),
267
+ "unavailable_model_call_count": len(unknown),
268
+ "has_unknown_cost": bool(unknown),
269
+ "pricing_sources": sources,
270
+ "pricing_as_of": as_of,
271
+ "unknown_models": unknown_models,
272
+ "unknown_reasons": unknown_reasons,
273
+ "model_calls": [e.to_dict() for e in estimates],
274
+ }
275
+
276
+
277
+ def token_breakdown(model_events: list[Any]) -> dict[str, Any]:
278
+ """Summarize how exact a trace's token counts are, and why.
279
+
280
+ The counts in ``input_tokens``/``output_tokens`` mix provider-reported usage
281
+ with a whitespace word-count fallback, so a total on its own cannot say
282
+ whether it is a measurement. This returns the aggregate
283
+ :func:`agentloop.tokens.token_status` plus the per-provenance call counts
284
+ behind it, so a report can show exactly how its token number was obtained.
285
+ """
286
+
287
+ status = token_status(model_events)
288
+ return {
289
+ "token_status": status,
290
+ "token_status_detail": describe_token_status(status),
291
+ "token_provenance_counts": provenance_counts(model_events),
292
+ "exact_token_call_count": sum(
293
+ 1
294
+ for e in model_events
295
+ if provenance_grade(getattr(e, "token_provenance", None)) == "exact"
296
+ ),
297
+ "estimated_token_call_count": sum(
298
+ 1
299
+ for e in model_events
300
+ if provenance_grade(getattr(e, "token_provenance", None)) == "estimated"
301
+ ),
302
+ }
303
+
304
+
305
+ def _cost_status(total_calls: int, unknown_calls: int) -> str:
306
+ """Summarize how complete a trace's cost total is.
307
+
308
+ - ``empty`` — no model calls, so there is no cost to know.
309
+ - ``complete`` — every model call had a known (calculated/reported) cost.
310
+ - ``unknown`` — no model call could be priced.
311
+ - ``partial`` — some priced, some not; the total is a lower bound.
312
+
313
+ Consumers should treat anything other than ``complete``/``empty`` as "do not
314
+ present the cost total or its deltas as a full, comparable measurement."
315
+ """
316
+ if total_calls == 0:
317
+ return "empty"
318
+ if unknown_calls == 0:
319
+ return "complete"
320
+ if unknown_calls == total_calls:
321
+ return "unknown"
322
+ return "partial"
323
+
324
+
325
+ def repeated_context_stats(model_events: list[Any]) -> dict[str, Any]:
326
+ prefixes = []
327
+ total_tokens = sum(e.input_tokens for e in model_events)
328
+ for event in model_events:
329
+ words = (event.input_text or "").split()
330
+ if words:
331
+ prefixes.append((" ".join(words[:80]), min(len(words), 80)))
332
+ counts = Counter(prefix for prefix, _ in prefixes)
333
+ repeated_tokens = sum(tokens for prefix, tokens in prefixes if counts[prefix] > 1)
334
+ avoidable = max(0, repeated_tokens // 2)
335
+ ratio = avoidable / total_tokens if total_tokens else 0.0
336
+ return {"repeated_context_tokens": avoidable, "repeated_context_ratio": round(ratio, 4)}
337
+
338
+
339
+ def parallelism_opportunities(tool_events: list[Any]) -> list[dict[str, Any]]:
340
+ return [
341
+ {"tool_name": group["name"], **group} for group in parallelization_candidates(tool_events)
342
+ ]
@@ -0,0 +1,303 @@
1
+ """Versioned schema migrations shared by :mod:`agentloop.store`'s two backends.
2
+
3
+ Each :class:`Migration` carries dialect-specific, idempotent SQL statements.
4
+ Idempotency (``IF NOT EXISTS`` / ``ON CONFLICT ... DO NOTHING``) lets concurrent
5
+ ``init()`` calls apply the same pending migration twice without corrupting state
6
+ or racing on `schema_migrations` bookkeeping, which stands in for a real
7
+ distributed lock that neither backend otherwise provides here.
8
+
9
+ A fresh install and an existing pre-migration database both start with no rows
10
+ in `schema_migrations`; migration 0001 reproduces the exact schema `init()`
11
+ used to create inline, so both converge on the same structure.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+
19
+ class MigrationError(RuntimeError):
20
+ """Raised when a migration statement fails to apply."""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Migration:
25
+ version: int
26
+ name: str
27
+ sqlite_statements: tuple[str, ...]
28
+ postgres_statements: tuple[str, ...]
29
+
30
+
31
+ _BASELINE_SQLITE = (
32
+ """
33
+ CREATE TABLE IF NOT EXISTS projects (
34
+ project_id TEXT PRIMARY KEY,
35
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
36
+ )
37
+ """,
38
+ """
39
+ CREATE TABLE IF NOT EXISTS api_keys (
40
+ key_hash TEXT PRIMARY KEY,
41
+ project_id TEXT NOT NULL,
42
+ name TEXT NOT NULL,
43
+ prefix TEXT NOT NULL,
44
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
45
+ last_used_at TEXT,
46
+ FOREIGN KEY(project_id) REFERENCES projects(project_id)
47
+ )
48
+ """,
49
+ """
50
+ CREATE TABLE IF NOT EXISTS traces (
51
+ run_id TEXT PRIMARY KEY,
52
+ project_id TEXT NOT NULL,
53
+ name TEXT NOT NULL,
54
+ started_at TEXT,
55
+ payload_json TEXT NOT NULL,
56
+ total_runtime_ms REAL DEFAULT 0,
57
+ estimated_cost_usd REAL DEFAULT 0,
58
+ model_call_count INTEGER DEFAULT 0,
59
+ tool_call_count INTEGER DEFAULT 0,
60
+ retry_count INTEGER DEFAULT 0,
61
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
62
+ FOREIGN KEY(project_id) REFERENCES projects(project_id)
63
+ )
64
+ """,
65
+ """
66
+ CREATE TABLE IF NOT EXISTS usage_events (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ project_id TEXT NOT NULL,
69
+ run_id TEXT NOT NULL,
70
+ total_runtime_ms REAL DEFAULT 0,
71
+ estimated_cost_usd REAL DEFAULT 0,
72
+ input_tokens INTEGER DEFAULT 0,
73
+ output_tokens INTEGER DEFAULT 0,
74
+ model_call_count INTEGER DEFAULT 0,
75
+ tool_call_count INTEGER DEFAULT 0,
76
+ retry_count INTEGER DEFAULT 0,
77
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
78
+ )
79
+ """,
80
+ """
81
+ CREATE TABLE IF NOT EXISTS trace_findings (
82
+ project_id TEXT NOT NULL,
83
+ run_id TEXT NOT NULL,
84
+ finding_id TEXT NOT NULL,
85
+ type TEXT NOT NULL,
86
+ severity TEXT NOT NULL,
87
+ title TEXT NOT NULL,
88
+ confidence TEXT NOT NULL,
89
+ patchable INTEGER DEFAULT 0,
90
+ status TEXT DEFAULT 'detected',
91
+ estimated_latency_savings_ms REAL DEFAULT 0,
92
+ estimated_cost_savings_usd REAL DEFAULT 0,
93
+ payload_json TEXT NOT NULL,
94
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
95
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
96
+ PRIMARY KEY(project_id, run_id, finding_id),
97
+ FOREIGN KEY(project_id) REFERENCES projects(project_id),
98
+ FOREIGN KEY(run_id) REFERENCES traces(run_id)
99
+ )
100
+ """,
101
+ "INSERT OR IGNORE INTO projects(project_id) VALUES ('default')",
102
+ )
103
+
104
+ _BASELINE_POSTGRES = (
105
+ "CREATE TABLE IF NOT EXISTS projects ("
106
+ "project_id TEXT PRIMARY KEY, created_at TIMESTAMPTZ DEFAULT now())",
107
+ "CREATE TABLE IF NOT EXISTS api_keys ("
108
+ "key_hash TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(project_id), "
109
+ "name TEXT NOT NULL, prefix TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now(), "
110
+ "last_used_at TIMESTAMPTZ)",
111
+ "CREATE TABLE IF NOT EXISTS traces ("
112
+ "run_id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(project_id), "
113
+ "name TEXT NOT NULL, started_at TEXT, payload_json JSONB NOT NULL, "
114
+ "total_runtime_ms DOUBLE PRECISION DEFAULT 0, estimated_cost_usd DOUBLE PRECISION DEFAULT 0, "
115
+ "model_call_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, "
116
+ "retry_count INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now())",
117
+ "CREATE TABLE IF NOT EXISTS usage_events ("
118
+ "id BIGSERIAL PRIMARY KEY, project_id TEXT NOT NULL, run_id TEXT NOT NULL, "
119
+ "total_runtime_ms DOUBLE PRECISION DEFAULT 0, estimated_cost_usd DOUBLE PRECISION DEFAULT 0, "
120
+ "input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, "
121
+ "model_call_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, "
122
+ "retry_count INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now())",
123
+ "CREATE TABLE IF NOT EXISTS trace_findings ("
124
+ "project_id TEXT NOT NULL REFERENCES projects(project_id), "
125
+ "run_id TEXT NOT NULL REFERENCES traces(run_id), finding_id TEXT NOT NULL, "
126
+ "type TEXT NOT NULL, severity TEXT NOT NULL, title TEXT NOT NULL, confidence TEXT NOT NULL, "
127
+ "patchable BOOLEAN DEFAULT false, status TEXT DEFAULT 'detected', "
128
+ "estimated_latency_savings_ms DOUBLE PRECISION DEFAULT 0, "
129
+ "estimated_cost_savings_usd DOUBLE PRECISION DEFAULT 0, payload_json JSONB NOT NULL, "
130
+ "created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(), "
131
+ "PRIMARY KEY(project_id, run_id, finding_id))",
132
+ "INSERT INTO projects(project_id) VALUES ('default') ON CONFLICT DO NOTHING",
133
+ )
134
+
135
+ # usage_events had no uniqueness constraint, so retried save_trace() calls appended
136
+ # a new row per retry instead of updating one. De-duplicate existing data (keep the
137
+ # most recent row per project/run) before the constraint can be added, or the
138
+ # index creation fails on any store that already has duplicates.
139
+ _DEDUPE_AND_CONSTRAIN_USAGE_EVENTS = (
140
+ "DELETE FROM usage_events WHERE id NOT IN "
141
+ "(SELECT MAX(id) FROM usage_events GROUP BY project_id, run_id)",
142
+ "CREATE UNIQUE INDEX IF NOT EXISTS ux_usage_events_project_run "
143
+ "ON usage_events(project_id, run_id)",
144
+ )
145
+
146
+ _PAGINATION_INDEXES = (
147
+ "CREATE INDEX IF NOT EXISTS ix_traces_project_created "
148
+ "ON traces(project_id, created_at, run_id)",
149
+ "CREATE INDEX IF NOT EXISTS ix_trace_findings_project_status_updated "
150
+ "ON trace_findings(project_id, status, updated_at, run_id, finding_id)",
151
+ )
152
+
153
+ _COST_COMPLETENESS_SQLITE = (
154
+ "ALTER TABLE traces ADD COLUMN cost_status TEXT NOT NULL DEFAULT 'complete'",
155
+ "ALTER TABLE traces ADD COLUMN priced_model_call_count INTEGER NOT NULL DEFAULT 0",
156
+ "ALTER TABLE traces ADD COLUMN unavailable_model_call_count INTEGER NOT NULL DEFAULT 0",
157
+ "ALTER TABLE usage_events ADD COLUMN cost_status TEXT NOT NULL DEFAULT 'complete'",
158
+ "ALTER TABLE usage_events ADD COLUMN priced_model_call_count INTEGER NOT NULL DEFAULT 0",
159
+ "ALTER TABLE usage_events ADD COLUMN unavailable_model_call_count INTEGER NOT NULL DEFAULT 0",
160
+ "UPDATE traces SET cost_status = CASE WHEN model_call_count = 0 THEN 'empty' ELSE 'complete' END, "
161
+ "priced_model_call_count = model_call_count, unavailable_model_call_count = 0",
162
+ "UPDATE usage_events SET cost_status = CASE WHEN model_call_count = 0 THEN 'empty' ELSE 'complete' END, "
163
+ "priced_model_call_count = model_call_count, unavailable_model_call_count = 0",
164
+ )
165
+
166
+ _COST_COMPLETENESS_POSTGRES = (
167
+ "ALTER TABLE traces ADD COLUMN IF NOT EXISTS cost_status TEXT NOT NULL DEFAULT 'complete'",
168
+ "ALTER TABLE traces ADD COLUMN IF NOT EXISTS priced_model_call_count INTEGER NOT NULL DEFAULT 0",
169
+ "ALTER TABLE traces ADD COLUMN IF NOT EXISTS unavailable_model_call_count INTEGER NOT NULL DEFAULT 0",
170
+ "ALTER TABLE usage_events ADD COLUMN IF NOT EXISTS cost_status TEXT NOT NULL DEFAULT 'complete'",
171
+ "ALTER TABLE usage_events ADD COLUMN IF NOT EXISTS priced_model_call_count INTEGER NOT NULL DEFAULT 0",
172
+ "ALTER TABLE usage_events ADD COLUMN IF NOT EXISTS unavailable_model_call_count INTEGER NOT NULL DEFAULT 0",
173
+ "UPDATE traces SET cost_status = CASE WHEN model_call_count = 0 THEN 'empty' ELSE 'complete' END, "
174
+ "priced_model_call_count = model_call_count, unavailable_model_call_count = 0",
175
+ "UPDATE usage_events SET cost_status = CASE WHEN model_call_count = 0 THEN 'empty' ELSE 'complete' END, "
176
+ "priced_model_call_count = model_call_count, unavailable_model_call_count = 0",
177
+ )
178
+
179
+ MIGRATIONS: tuple[Migration, ...] = (
180
+ Migration(
181
+ version=1,
182
+ name="baseline",
183
+ sqlite_statements=_BASELINE_SQLITE,
184
+ postgres_statements=_BASELINE_POSTGRES,
185
+ ),
186
+ Migration(
187
+ version=2,
188
+ name="idempotent_usage_events",
189
+ sqlite_statements=_DEDUPE_AND_CONSTRAIN_USAGE_EVENTS,
190
+ postgres_statements=_DEDUPE_AND_CONSTRAIN_USAGE_EVENTS,
191
+ ),
192
+ Migration(
193
+ version=3,
194
+ name="pagination_indexes",
195
+ sqlite_statements=_PAGINATION_INDEXES,
196
+ postgres_statements=_PAGINATION_INDEXES,
197
+ ),
198
+ Migration(
199
+ version=4,
200
+ name="cost_completeness",
201
+ sqlite_statements=_COST_COMPLETENESS_SQLITE,
202
+ postgres_statements=_COST_COMPLETENESS_POSTGRES,
203
+ ),
204
+ Migration(
205
+ version=5,
206
+ name="intervention_ledger",
207
+ sqlite_statements=(
208
+ "CREATE TABLE IF NOT EXISTS interventions ("
209
+ "project_id TEXT NOT NULL REFERENCES projects(project_id), "
210
+ "intervention_id TEXT NOT NULL, baseline_run_id TEXT NOT NULL REFERENCES traces(run_id), "
211
+ "candidate_run_id TEXT NOT NULL REFERENCES traces(run_id), payload_json TEXT NOT NULL, "
212
+ "PRIMARY KEY(project_id, intervention_id))",
213
+ ),
214
+ postgres_statements=(
215
+ "CREATE TABLE IF NOT EXISTS interventions ("
216
+ "project_id TEXT NOT NULL REFERENCES projects(project_id), "
217
+ "intervention_id TEXT NOT NULL, baseline_run_id TEXT NOT NULL REFERENCES traces(run_id), "
218
+ "candidate_run_id TEXT NOT NULL REFERENCES traces(run_id), payload_json TEXT NOT NULL, "
219
+ "PRIMARY KEY(project_id, intervention_id))",
220
+ ),
221
+ ),
222
+ )
223
+
224
+
225
+ def _ensure_migrations_table_sqlite(conn) -> None:
226
+ conn.execute(
227
+ """
228
+ CREATE TABLE IF NOT EXISTS schema_migrations (
229
+ version INTEGER PRIMARY KEY,
230
+ name TEXT NOT NULL,
231
+ applied_at TEXT DEFAULT CURRENT_TIMESTAMP
232
+ )
233
+ """
234
+ )
235
+
236
+
237
+ def _ensure_migrations_table_postgres(conn) -> None:
238
+ conn.execute(
239
+ "CREATE TABLE IF NOT EXISTS schema_migrations ("
240
+ "version INTEGER PRIMARY KEY, name TEXT NOT NULL, "
241
+ "applied_at TIMESTAMPTZ DEFAULT now())"
242
+ )
243
+
244
+
245
+ def apply_sqlite_migrations(conn) -> None:
246
+ """Apply pending migrations to a SQLite connection inside its open transaction."""
247
+ import sqlite3
248
+
249
+ _ensure_migrations_table_sqlite(conn)
250
+ applied = {row[0] for row in conn.execute("SELECT version FROM schema_migrations").fetchall()}
251
+ for migration in MIGRATIONS:
252
+ if migration.version in applied:
253
+ continue
254
+ try:
255
+ for statement in migration.sqlite_statements:
256
+ try:
257
+ conn.execute(statement)
258
+ except sqlite3.OperationalError as exc:
259
+ # SQLite has no `ADD COLUMN IF NOT EXISTS`. Concurrent
260
+ # initializers can both observe migration 4 as pending;
261
+ # after one commits the column, the other receives this
262
+ # narrow duplicate-column error. Treat only that verified
263
+ # race as idempotent and preserve every other SQL failure.
264
+ if not (
265
+ statement.lstrip().upper().startswith("ALTER TABLE")
266
+ and "duplicate column name" in str(exc).lower()
267
+ ):
268
+ raise
269
+ conn.execute(
270
+ "INSERT OR IGNORE INTO schema_migrations(version, name) VALUES (?, ?)",
271
+ (migration.version, migration.name),
272
+ )
273
+ except sqlite3.Error as exc:
274
+ raise MigrationError(
275
+ f"migration {migration.version} ({migration.name}) failed: {exc}. "
276
+ "The transaction was rolled back; the store is unchanged. Fix the "
277
+ "underlying issue (e.g. free disk space, resolve a lock) and retry."
278
+ ) from exc
279
+
280
+
281
+ def apply_postgres_migrations(conn) -> None:
282
+ """Apply pending migrations to a psycopg connection inside its open transaction."""
283
+ import psycopg
284
+
285
+ _ensure_migrations_table_postgres(conn)
286
+ applied = {row[0] for row in conn.execute("SELECT version FROM schema_migrations").fetchall()}
287
+ for migration in MIGRATIONS:
288
+ if migration.version in applied:
289
+ continue
290
+ try:
291
+ for statement in migration.postgres_statements:
292
+ conn.execute(statement)
293
+ conn.execute(
294
+ "INSERT INTO schema_migrations(version, name) VALUES (%s, %s) "
295
+ "ON CONFLICT (version) DO NOTHING",
296
+ (migration.version, migration.name),
297
+ )
298
+ except psycopg.Error as exc:
299
+ raise MigrationError(
300
+ f"migration {migration.version} ({migration.name}) failed: {exc}. "
301
+ "The transaction was rolled back; the store is unchanged. Fix the "
302
+ "underlying issue (e.g. permissions, a blocking lock) and retry."
303
+ ) from exc