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/budgets.py ADDED
@@ -0,0 +1,539 @@
1
+ """Atomic admission budgets for explicitly supported Python dispatch boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import json
7
+ import time
8
+ from collections.abc import Callable, Iterable
9
+ from dataclasses import asdict, dataclass, field
10
+ from fractions import Fraction
11
+ from typing import Any
12
+
13
+ from agentloop.budget_types import (
14
+ COUNT_KEYS,
15
+ RETRY_SOURCES,
16
+ BudgetSnapshot,
17
+ BudgetValidationError,
18
+ Reservation,
19
+ amount,
20
+ count,
21
+ money_text,
22
+ number,
23
+ )
24
+ from agentloop.harness import BOUNDARIES, Decision, Hook, HookContext, Policy, _callable_kind
25
+
26
+ _COUNT_BOUNDARY = {"model": "model_calls", "tool": "tool_calls", "iteration": "iterations"}
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class BudgetLimits:
31
+ """Optional per-run limits; deadline_at is an absolute monotonic-clock value."""
32
+
33
+ max_model_calls: int | None = None
34
+ max_tool_calls: int | None = None
35
+ max_iterations: int | None = None
36
+ max_retries: int | None = None
37
+ max_tokens: int | None = None
38
+ max_cost_usd: float | None = None
39
+ deadline_at: float | None = None
40
+
41
+ def __post_init__(self) -> None:
42
+ for key in (*COUNT_KEYS, "tokens"):
43
+ count(getattr(self, f"max_{key}"), key)
44
+ number(self.max_cost_usd, "max_cost_usd")
45
+ number(self.deadline_at, "deadline_at")
46
+
47
+
48
+ @dataclass
49
+ class _Pending:
50
+ counts: dict[str, int]
51
+ metered: bool
52
+ tokens: int | None
53
+ cost: Fraction | None
54
+ accounted: bool
55
+
56
+
57
+ @dataclass
58
+ class _State:
59
+ scope_id: str
60
+ committed: dict[str, int] = field(default_factory=lambda: dict.fromkeys(COUNT_KEYS, 0))
61
+ refunded: dict[str, int] = field(default_factory=lambda: dict.fromkeys(COUNT_KEYS, 0))
62
+ retries: dict[str, int] = field(default_factory=lambda: dict.fromkeys(RETRY_SOURCES, 0))
63
+ pending: dict[str, _Pending] = field(default_factory=dict)
64
+ seen_usage: dict[str, str] = field(default_factory=dict)
65
+ metered_calls: int = 0
66
+ tokens_known: int = 0
67
+ cost_known: Fraction = Fraction(0)
68
+ tokens_held: int = 0
69
+ cost_held: Fraction = Fraction(0)
70
+ unknown_calls: int = 0
71
+ unknown_tokens: int = 0
72
+ unknown_cost: int = 0
73
+ tokens_refunded: int = 0
74
+ cost_refunded: Fraction = Fraction(0)
75
+ last_clock: float | None = None
76
+ reserved_counts: dict[str, int] = field(default_factory=lambda: dict.fromkeys(COUNT_KEYS, 0))
77
+ reserved_metered: int = 0
78
+ reserved_tokens: int = 0
79
+ reserved_cost: Fraction = Fraction(0)
80
+ reserved_unknown_tokens: int = 0
81
+ reserved_unknown_cost: int = 0
82
+
83
+
84
+ class _BudgetEvaluator:
85
+ def __init__(
86
+ self,
87
+ limits: BudgetLimits,
88
+ unknown_usage: str,
89
+ soft_fraction: float | None,
90
+ metered: frozenset[str],
91
+ clock: Callable[[], float],
92
+ ) -> None:
93
+ self.limits = limits
94
+ self.unknown_usage = unknown_usage
95
+ self.soft_fraction = None if soft_fraction is None else Fraction(str(soft_fraction))
96
+ self.metered = metered
97
+ self.clock = clock
98
+ self.cost_limit = None if limits.max_cost_usd is None else amount(limits.max_cost_usd)
99
+
100
+ def _now(self, state: _State) -> float | None:
101
+ if self.limits.deadline_at is None:
102
+ return None
103
+ try:
104
+ reading = self.clock()
105
+ except Exception:
106
+ raise BudgetValidationError("monotonic clock could not be read") from None
107
+ if inspect.iscoroutine(reading):
108
+ reading.close()
109
+ now = number(reading, "monotonic clock", optional=False)
110
+ if state.last_clock is not None and now < state.last_clock:
111
+ raise BudgetValidationError("monotonic clock moved backwards")
112
+ state.last_clock = now
113
+ return now
114
+
115
+ def _pending(self, context: HookContext, *, accounted: bool) -> _Pending:
116
+ counts = dict.fromkeys(COUNT_KEYS, 0)
117
+ if context.hook.boundary in _COUNT_BOUNDARY:
118
+ counts[_COUNT_BOUNDARY[context.hook.boundary]] = 1
119
+ if context.dispatch.retry_source is not None:
120
+ counts["retries"] = 1
121
+ metered = context.hook.boundary in self.metered
122
+ reservation = context.dispatch.reservation or Reservation()
123
+ return _Pending(
124
+ counts,
125
+ metered,
126
+ reservation.tokens if reservation.tokens_known else None if metered else 0,
127
+ amount(reservation.cost_usd)
128
+ if reservation.cost_known
129
+ else None
130
+ if metered
131
+ else Fraction(0),
132
+ accounted,
133
+ )
134
+
135
+ def _in_flight(self, state: _State) -> dict[str, Any]:
136
+ return {
137
+ **state.reserved_counts,
138
+ "metered_calls": state.reserved_metered,
139
+ "tokens_reserved": state.reserved_tokens,
140
+ "cost_reserved_usd": state.reserved_cost,
141
+ "unknown_token_reservations": state.reserved_unknown_tokens,
142
+ "unknown_cost_reservations": state.reserved_unknown_cost,
143
+ }
144
+
145
+ def _reserve(self, state: _State, pending: _Pending, direction: int) -> None:
146
+ if not pending.accounted:
147
+ return
148
+ for key, value in pending.counts.items():
149
+ state.reserved_counts[key] += direction * value
150
+ if pending.metered:
151
+ state.reserved_metered += direction
152
+ state.reserved_tokens += direction * (pending.tokens or 0)
153
+ state.reserved_cost += direction * (pending.cost or Fraction(0))
154
+ state.reserved_unknown_tokens += direction * (pending.tokens is None)
155
+ state.reserved_unknown_cost += direction * (pending.cost is None)
156
+
157
+ def _used(self, state: _State) -> dict[str, Any]:
158
+ inflight = self._in_flight(state)
159
+ return {
160
+ **{key: state.committed[key] + inflight[key] for key in COUNT_KEYS},
161
+ "tokens": state.tokens_known + state.tokens_held + inflight["tokens_reserved"],
162
+ "cost_usd": state.cost_known + state.cost_held + inflight["cost_reserved_usd"],
163
+ }
164
+
165
+ def _limits(self) -> dict[str, Any]:
166
+ return {
167
+ **{key: getattr(self.limits, f"max_{key}") for key in (*COUNT_KEYS, "tokens")},
168
+ "cost_usd": self.cost_limit,
169
+ }
170
+
171
+ def _soft(self, state: _State) -> list[str]:
172
+ if self.soft_fraction is None:
173
+ return []
174
+ used = self._used(state)
175
+ return sorted(
176
+ key
177
+ for key, limit in self._limits().items()
178
+ if limit is not None and used[key] >= limit * self.soft_fraction
179
+ )
180
+
181
+ def _snapshot(
182
+ self, context: HookContext, state: _State, codes: set[str], now: float | None
183
+ ) -> BudgetSnapshot:
184
+ inflight = self._in_flight(state)
185
+ inflight["cost_reserved_usd"] = money_text(inflight["cost_reserved_usd"])
186
+ limits = self._limits()
187
+ limits["cost_usd"] = None if self.cost_limit is None else money_text(self.cost_limit)
188
+ limits["deadline_at"] = self.limits.deadline_at
189
+ tokens_total = None if state.unknown_tokens or not self.metered else state.tokens_known
190
+ cost_total = (
191
+ None if state.unknown_cost or not self.metered else money_text(state.cost_known)
192
+ )
193
+ token_overshoot = (
194
+ None
195
+ if tokens_total is None or self.limits.max_tokens is None
196
+ else max(0, tokens_total - self.limits.max_tokens)
197
+ )
198
+ cost_overshoot = (
199
+ None
200
+ if cost_total is None or self.cost_limit is None
201
+ else money_text(max(Fraction(0), state.cost_known - self.cost_limit))
202
+ )
203
+ return BudgetSnapshot.from_dict(
204
+ {
205
+ "schema_version": "1.0",
206
+ "budget_scope_id": state.scope_id,
207
+ "scope": "wrapped_dispatch",
208
+ "hard_spend_cap": False,
209
+ "spend_enforcement": "not_configured"
210
+ if self.cost_limit is None
211
+ else "shadow"
212
+ if context.mode == "shadow"
213
+ else "best_effort",
214
+ "token_enforcement": "not_configured"
215
+ if self.limits.max_tokens is None
216
+ else "shadow"
217
+ if context.mode == "shadow"
218
+ else "best_effort",
219
+ "possible_spend_overshoot": None if self.cost_limit is None else True,
220
+ "unknown_usage_policy": self.unknown_usage,
221
+ "soft_fraction": None if self.soft_fraction is None else float(self.soft_fraction),
222
+ "deadline_enforcement": "cooperative_admission",
223
+ "metered_boundaries": sorted(self.metered),
224
+ "limits": limits,
225
+ "committed": {
226
+ **state.committed,
227
+ "metered_calls": state.metered_calls,
228
+ "tokens_known": state.tokens_known,
229
+ "cost_known_usd": money_text(state.cost_known),
230
+ },
231
+ "in_flight": inflight,
232
+ "refunded": {
233
+ **state.refunded,
234
+ "tokens_bound": state.tokens_refunded,
235
+ "cost_bound_usd": money_text(state.cost_refunded),
236
+ },
237
+ "unknown_usage": {
238
+ "calls": state.unknown_calls,
239
+ "tokens": state.unknown_tokens,
240
+ "cost": state.unknown_cost,
241
+ "tokens_held": state.tokens_held,
242
+ "cost_held_usd": money_text(state.cost_held),
243
+ },
244
+ "total_usage": {"tokens": tokens_total, "cost_usd": cost_total},
245
+ "retry_sources": dict(state.retries),
246
+ "status_codes": sorted(codes or {"ok"}),
247
+ "soft_exceeded": self._soft(state),
248
+ "overshoot": {"tokens": token_overshoot, "cost_usd": cost_overshoot},
249
+ "deadline_remaining_s": None
250
+ if self.limits.deadline_at is None or now is None
251
+ else max(0.0, self.limits.deadline_at - now),
252
+ "last_usage": None if context.usage is None else context.usage.to_dict(),
253
+ "usage_error": context.usage_error,
254
+ "reservation": None
255
+ if context.dispatch.reservation is None
256
+ else context.dispatch.reservation.to_dict(),
257
+ }
258
+ )
259
+
260
+ def _decision(
261
+ self,
262
+ context: HookContext,
263
+ state: _State,
264
+ action: str,
265
+ reason: str,
266
+ codes: set[str],
267
+ now: float | None,
268
+ ) -> Decision:
269
+ refs = (
270
+ list(context.dispatch.reservation.evidence_refs) if context.dispatch.reservation else []
271
+ )
272
+ if context.usage is not None and context.usage.usage_id is not None:
273
+ refs.append(context.usage.usage_id)
274
+ return Decision(
275
+ action,
276
+ reason,
277
+ evidence_refs=tuple(refs),
278
+ budget_snapshot=self._snapshot(context, state, codes, now),
279
+ )
280
+
281
+ def _before(self, context: HookContext, state: _State, now: float | None) -> Decision:
282
+ pending = self._pending(context, accounted=False)
283
+ used = self._used(state)
284
+ requested = {
285
+ **pending.counts,
286
+ "tokens": (pending.tokens or 0) if pending.metered else 0,
287
+ "cost_usd": (pending.cost or Fraction(0)) if pending.metered else Fraction(0),
288
+ }
289
+ codes = {
290
+ f"limit_{key}"
291
+ for key, limit in self._limits().items()
292
+ if limit is not None and used[key] + requested[key] > limit
293
+ }
294
+ inflight = self._in_flight(state)
295
+ unknown = set()
296
+ if self.limits.max_tokens is not None and (
297
+ state.unknown_tokens
298
+ or inflight["unknown_token_reservations"]
299
+ or (pending.metered and pending.tokens is None)
300
+ ):
301
+ unknown.add("unknown_tokens")
302
+ if self.cost_limit is not None and (
303
+ state.unknown_cost
304
+ or inflight["unknown_cost_reservations"]
305
+ or (pending.metered and pending.cost is None)
306
+ ):
307
+ unknown.add("unknown_cost")
308
+ clock_invalid = self.limits.deadline_at is not None and now is None
309
+ deadline = (
310
+ self.limits.deadline_at is not None
311
+ and now is not None
312
+ and now >= self.limits.deadline_at
313
+ )
314
+ if clock_invalid:
315
+ codes.add("clock_invalid")
316
+ if deadline:
317
+ codes.add("deadline_expired")
318
+ limited = bool(codes)
319
+ codes |= unknown
320
+ if clock_invalid:
321
+ action, reason = "escalate", "budget_clock_invalid"
322
+ elif deadline:
323
+ action, reason = "stop", "budget_deadline"
324
+ elif unknown and self.unknown_usage == "escalate":
325
+ action, reason = "escalate", "budget_usage_unknown"
326
+ elif limited:
327
+ action, reason = "deny", "budget_limit"
328
+ elif unknown and self.unknown_usage == "deny":
329
+ action, reason = "deny", "budget_usage_unknown"
330
+ else:
331
+ action, reason = "continue", "budget_monitor_unknown" if unknown else "budget_available"
332
+ # Enforced denials do not occupy capacity. Shadow still tracks the work
333
+ # that will actually run, even when its proposed admission was denied.
334
+ pending.accounted = action == "continue" or context.mode == "shadow"
335
+ if context.call_id in state.pending:
336
+ raise BudgetValidationError("duplicate budget admission")
337
+ state.pending[context.call_id] = pending
338
+ self._reserve(state, pending, 1)
339
+ if action == "continue" and self._soft(state):
340
+ reason = "budget_soft_limit"
341
+ return self._decision(context, state, action, reason, codes, now)
342
+
343
+ def _refund(self, state: _State, pending: _Pending) -> None:
344
+ if not pending.accounted:
345
+ return
346
+ for key, value in pending.counts.items():
347
+ state.refunded[key] += value
348
+ if pending.metered:
349
+ state.tokens_refunded += pending.tokens or 0
350
+ state.cost_refunded += pending.cost or Fraction(0)
351
+
352
+ def _after(self, context: HookContext, state: _State, now: float | None) -> Decision:
353
+ pending = state.pending.pop(context.call_id, None)
354
+ if pending is None:
355
+ pending = self._pending(context, accounted=False)
356
+ else:
357
+ self._reserve(state, pending, -1)
358
+ clock_invalid = self.limits.deadline_at is not None and now is None
359
+ if not context.dispatched:
360
+ self._refund(state, pending)
361
+ return self._decision(
362
+ context,
363
+ state,
364
+ "continue",
365
+ "budget_not_dispatched",
366
+ {"not_admitted"} | ({"clock_invalid"} if clock_invalid else set()),
367
+ now,
368
+ )
369
+ for key, value in pending.counts.items():
370
+ state.committed[key] += value
371
+ if context.dispatch.retry_source is not None:
372
+ state.retries[context.dispatch.retry_source] += 1
373
+ codes: set[str] = set()
374
+ unknown: set[str] = set()
375
+ bound_exceeded = False
376
+ if pending.metered:
377
+ state.metered_calls += 1
378
+ usage = context.usage
379
+ tokens_known = usage is not None and usage.tokens_known and context.usage_error is None
380
+ cost_known = usage is not None and usage.cost_known and context.usage_error is None
381
+ duplicate = False
382
+ if (
383
+ usage is not None
384
+ and usage.complete
385
+ and usage.exclusive
386
+ and context.usage_error is None
387
+ and usage.usage_id is not None
388
+ and (tokens_known or cost_known)
389
+ ):
390
+ serialized = json.dumps(usage.to_dict(), sort_keys=True, separators=(",", ":"))
391
+ previous = state.seen_usage.get(usage.usage_id)
392
+ if previous is None:
393
+ state.seen_usage[usage.usage_id] = serialized
394
+ elif previous == serialized:
395
+ duplicate = True
396
+ tokens_known = cost_known = True
397
+ codes.add("duplicate_usage")
398
+ else:
399
+ tokens_known = cost_known = False
400
+ codes.add("usage_conflict")
401
+ if context.usage_error is not None:
402
+ codes.add("usage_collector_error")
403
+ if tokens_known:
404
+ actual_tokens = 0 if duplicate else usage.tokens
405
+ state.tokens_known += actual_tokens
406
+ if pending.tokens is not None:
407
+ if pending.accounted:
408
+ state.tokens_refunded += max(0, pending.tokens - actual_tokens)
409
+ if actual_tokens > pending.tokens and self.limits.max_tokens is not None:
410
+ bound_exceeded = True
411
+ else:
412
+ state.unknown_tokens += 1
413
+ state.tokens_held += pending.tokens or 0
414
+ codes.add("unknown_tokens")
415
+ if self.limits.max_tokens is not None:
416
+ unknown.add("tokens")
417
+ if cost_known:
418
+ actual_cost = Fraction(0) if duplicate else amount(usage.cost_usd)
419
+ state.cost_known += actual_cost
420
+ if pending.cost is not None:
421
+ if pending.accounted:
422
+ state.cost_refunded += max(Fraction(0), pending.cost - actual_cost)
423
+ if actual_cost > pending.cost and self.cost_limit is not None:
424
+ bound_exceeded = True
425
+ else:
426
+ state.unknown_cost += 1
427
+ state.cost_held += pending.cost or Fraction(0)
428
+ codes.add("unknown_cost")
429
+ if self.cost_limit is not None:
430
+ unknown.add("cost")
431
+ if not tokens_known or not cost_known:
432
+ state.unknown_calls += 1
433
+ used = self._used(state)
434
+ exceeded = {
435
+ f"limit_{key}"
436
+ for key, limit in self._limits().items()
437
+ if limit is not None and used[key] > limit
438
+ }
439
+ codes |= exceeded
440
+ if bound_exceeded:
441
+ codes.add("bound_exceeded")
442
+ if clock_invalid:
443
+ codes.add("clock_invalid")
444
+ action, reason = "escalate", "budget_clock_invalid"
445
+ elif bound_exceeded:
446
+ action, reason = "escalate", "budget_bound_exceeded"
447
+ elif unknown and self.unknown_usage == "escalate":
448
+ action, reason = "escalate", "budget_usage_unknown"
449
+ elif exceeded or (unknown and self.unknown_usage == "deny"):
450
+ action, reason = "stop", "budget_limit" if exceeded else "budget_usage_unknown"
451
+ else:
452
+ action, reason = (
453
+ "continue",
454
+ "budget_monitor_unknown" if unknown else "budget_reconciled",
455
+ )
456
+ if (
457
+ self.limits.deadline_at is not None
458
+ and now is not None
459
+ and now >= self.limits.deadline_at
460
+ ):
461
+ codes.add("deadline_expired")
462
+ return self._decision(context, state, action, reason, codes, now)
463
+
464
+ def __call__(self, context: HookContext) -> Decision:
465
+ state = context.state.get("budget")
466
+ if state is None:
467
+ state = context.state["budget"] = _State(scope_id=f"budget:{context.decision_id}")
468
+ try:
469
+ now = self._now(state)
470
+ except BudgetValidationError:
471
+ now = None
472
+ return (
473
+ self._before(context, state, now)
474
+ if context.hook.phase == "before"
475
+ else self._after(context, state, now)
476
+ )
477
+
478
+
479
+ def budget_policy(
480
+ limits: BudgetLimits,
481
+ *,
482
+ unknown_usage: str = "deny",
483
+ soft_fraction: float | None = None,
484
+ boundaries: Iterable[str] = BOUNDARIES,
485
+ metered_boundaries: Iterable[str] = ("model",),
486
+ clock: Callable[[], float] = time.monotonic,
487
+ policy_id: str = "budget",
488
+ priority: int = 100,
489
+ ) -> Policy:
490
+ """Build a reusable policy whose counters and reservations remain run-local.
491
+
492
+ Monetary amounts use exact arithmetic over their declared decimal values. This
493
+ does not make declared bounds, calculated prices, or adapter coverage a hard
494
+ spend cap. Deadlines only stop new admissions and never kill in-flight work.
495
+ """
496
+ if type(limits) is not BudgetLimits:
497
+ raise BudgetValidationError("limits must be BudgetLimits")
498
+ if not isinstance(unknown_usage, str) or unknown_usage not in {
499
+ "deny",
500
+ "escalate",
501
+ "monitor_only",
502
+ }:
503
+ raise BudgetValidationError("unknown_usage must be deny, escalate, or monitor_only")
504
+ soft = number(soft_fraction, "soft_fraction")
505
+ if soft is not None and soft > 1:
506
+ raise BudgetValidationError("soft_fraction must be between zero and one")
507
+ try:
508
+ selected, metered = frozenset(boundaries), frozenset(metered_boundaries)
509
+ except TypeError:
510
+ raise BudgetValidationError("budget boundaries must be named hooks") from None
511
+ if not selected or not selected <= BOUNDARIES or not metered <= {"model", "tool"}:
512
+ raise BudgetValidationError("unsupported budget boundary")
513
+ metered &= selected
514
+ for boundary, name in _COUNT_BOUNDARY.items():
515
+ if getattr(limits, f"max_{name}") is not None and boundary not in selected:
516
+ raise BudgetValidationError("a configured call limit lacks its dispatch boundary")
517
+ if (limits.max_tokens is not None or limits.max_cost_usd is not None) and not metered:
518
+ raise BudgetValidationError("token/cost limits require a metered boundary")
519
+ if not callable(clock) or _callable_kind(clock) != "sync":
520
+ raise BudgetValidationError("clock must be a synchronous callable")
521
+ return Policy(
522
+ policy_id,
523
+ "1.0",
524
+ _BudgetEvaluator(limits, unknown_usage, soft, metered, clock),
525
+ hooks=frozenset(
526
+ Hook(boundary, phase) for boundary in selected for phase in ("before", "after")
527
+ ),
528
+ actions=frozenset({"continue", "deny", "stop", "escalate"}),
529
+ priority=priority,
530
+ configuration={
531
+ "limits": asdict(limits),
532
+ "unknown_usage": unknown_usage,
533
+ "soft_fraction": soft,
534
+ "boundaries": sorted(selected),
535
+ "metered_boundaries": sorted(metered),
536
+ "hard_spend_cap": False,
537
+ "deadline_enforcement": "cooperative_admission",
538
+ },
539
+ )