agentpause 0.2.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.
agentpause/errors.py ADDED
@@ -0,0 +1,66 @@
1
+ """Typed error hierarchy.
2
+
3
+ Everything agentpause raises derives from :class:`AgentPauseError`, so callers
4
+ can catch one base class — or handle each failure mode precisely:
5
+
6
+ * :class:`RateLimitHit` — the provider returned 429 despite the prediction
7
+ (estimates are statistical, not oracular). Carries ``retry_after`` when the
8
+ provider says how long to wait. The scheduler's retry policy handles these
9
+ automatically; you only see one if all retries are exhausted.
10
+ * :class:`TelemetryError` — the budget could not be read (missing headers).
11
+ * :class:`CheckpointError` — the checkpoint could not be written or read
12
+ (disk full, permissions, corrupted file).
13
+ * :class:`BackendError` — the LLM call failed for a non-rate-limit reason.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Optional
19
+
20
+ __all__ = [
21
+ "AgentPauseError",
22
+ "RateLimitHit",
23
+ "TelemetryError",
24
+ "CheckpointError",
25
+ "BackendError",
26
+ ]
27
+
28
+
29
+ class AgentPauseError(Exception):
30
+ """Base class for every error agentpause raises."""
31
+
32
+
33
+ class RateLimitHit(AgentPauseError):
34
+ """The provider rate-limited a call (HTTP 429).
35
+
36
+ Args:
37
+ retry_after: seconds the provider asks to wait, when reported.
38
+ """
39
+
40
+ def __init__(self, message: str = "provider returned 429 (rate limit)",
41
+ retry_after: Optional[float] = None) -> None:
42
+ super().__init__(message)
43
+ self.retry_after = retry_after
44
+
45
+
46
+ class TelemetryError(AgentPauseError):
47
+ """The rate-limit budget could not be read from the provider response."""
48
+
49
+
50
+ class CheckpointError(AgentPauseError):
51
+ """A checkpoint could not be persisted or restored."""
52
+
53
+
54
+ class BackendError(AgentPauseError):
55
+ """The LLM call failed for a reason other than rate limiting.
56
+
57
+ Args:
58
+ retriable: True for transient infrastructure failures (HTTP 5xx,
59
+ timeouts) that deserve a retry; False for request problems
60
+ (4xx, validation) where retrying only wastes budget.
61
+ """
62
+
63
+ def __init__(self, message: str = "LLM call failed",
64
+ retriable: bool = False) -> None:
65
+ super().__init__(message)
66
+ self.retriable = retriable
@@ -0,0 +1,106 @@
1
+ """Cost estimation for the next agent step.
2
+
3
+ The estimator predicts how many tokens the next LLM call will consume, so the
4
+ scheduler can decide whether it fits within the remaining rate-limit budget.
5
+
6
+ The estimate has two parts:
7
+
8
+ * a *base* estimate from observable quantities (current input size, the
9
+ generation cap, and a fixed tool overhead), and
10
+ * a data-driven correction ``epsilon`` — the moving average of recent
11
+ estimation errors — that adapts the base estimate to the actual behavior of
12
+ the model and workload.
13
+
14
+ It also tracks ``sigma``, the standard deviation of realized consumption, which
15
+ the decision rule uses as a safety margin.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections import deque
21
+ from typing import Deque, Optional
22
+
23
+
24
+ class Estimator:
25
+ """Predicts next-step token cost and tracks consumption statistics.
26
+
27
+ Args:
28
+ max_tokens: the generation cap passed to the LLM (upper bound on output).
29
+ tool_overhead: fixed token overhead attributed to tool calls.
30
+ error_window: how many recent estimation errors feed ``epsilon``.
31
+ min_estimate: a floor so the estimate is never absurdly small.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ max_tokens: int = 650,
37
+ tool_overhead: int = 280,
38
+ error_window: int = 6,
39
+ min_estimate: int = 200,
40
+ ) -> None:
41
+ self.max_tokens = max_tokens
42
+ self.tool_overhead = tool_overhead
43
+ self.min_estimate = min_estimate
44
+ self._errors: Deque[int] = deque(maxlen=error_window)
45
+ self._all_errors: list[int] = [] # full history, for quantile margins
46
+ self._realized: list[int] = []
47
+
48
+ # -- estimation ---------------------------------------------------------
49
+
50
+ def base_estimate(self, input_tokens: int) -> int:
51
+ """Estimate from observable quantities only, without the correction."""
52
+ return input_tokens + self.max_tokens + self.tool_overhead
53
+
54
+ def epsilon(self) -> int:
55
+ """Moving average of recent estimation errors (0 until data exists)."""
56
+ if not self._errors:
57
+ return 0
58
+ return int(sum(self._errors) / len(self._errors))
59
+
60
+ def estimate(self, input_tokens: int) -> int:
61
+ """Full next-step estimate: base plus the learned correction."""
62
+ return max(self.min_estimate, self.base_estimate(input_tokens) + self.epsilon())
63
+
64
+ def sigma(self, fallback_estimate: int) -> float:
65
+ """Std. dev. of realized consumption.
66
+
67
+ Falls back to a fraction of the current estimate until enough samples
68
+ exist (< 4), so early decisions stay prudent.
69
+ """
70
+ h = self._realized
71
+ if len(h) >= 4:
72
+ mean = sum(h) / len(h)
73
+ return (sum((x - mean) ** 2 for x in h) / len(h)) ** 0.5
74
+ return fallback_estimate * 0.27
75
+
76
+ # -- learning -----------------------------------------------------------
77
+
78
+ def record(self, input_tokens: int, realized: int) -> None:
79
+ """Update history with the actual consumption of a completed step."""
80
+ error = realized - self.base_estimate(input_tokens)
81
+ self._errors.append(error)
82
+ self._all_errors.append(error)
83
+ self._realized.append(realized)
84
+
85
+ def upper_estimate(self, input_tokens: int, q: float = 0.95,
86
+ min_samples: int = 8) -> Optional[int]:
87
+ """Quantile-based upper bound for the next step's cost.
88
+
89
+ Consumption distributions are heavy-tailed: the mean-plus-``k·sigma``
90
+ margin under-covers the tail. When enough history exists, the
91
+ empirical ``q``-quantile of past estimation errors gives a
92
+ statistically honest bound: base estimate + q-th worst error seen.
93
+ Returns ``None`` until ``min_samples`` steps are recorded (callers
94
+ fall back to the ``k·sigma`` rule).
95
+ """
96
+ n = len(self._all_errors)
97
+ if n < min_samples:
98
+ return None
99
+ ordered = sorted(self._all_errors)
100
+ idx = min(n - 1, max(0, int(q * n + 0.999999) - 1)) # ceil(q·n)-1, clamped
101
+ return max(self.min_estimate, self.base_estimate(input_tokens) + ordered[idx])
102
+
103
+ @property
104
+ def samples(self) -> int:
105
+ """Number of completed steps recorded so far."""
106
+ return len(self._realized)
agentpause/fallback.py ADDED
@@ -0,0 +1,74 @@
1
+ """Model fallback chain: route around a rate-limited or failing model.
2
+
3
+ The escalation ladder of the accompanying research (§5.3, "model switch")
4
+ as a composable piece: wrap any ordered list of backends and the chain tries
5
+ them in sequence when the current one is rate-limited or fails transiently.
6
+
7
+ from agentpause import FallbackBackend, PredictiveScheduler
8
+
9
+ chain = FallbackBackend(
10
+ adapter_opus.backend, # primary
11
+ adapter_haiku.backend, # cheaper fallback
12
+ on_fallback=lambda i, exc: log.warning("switched to backend %d: %s", i, exc),
13
+ )
14
+ sched = PredictiveScheduler(backend=chain, telemetry=adapter_opus.budget)
15
+
16
+ Only rate limits and *retriable* failures trigger the switch — a 400-style
17
+ error means the request itself is broken, and no other model will fix it.
18
+
19
+ Behavioral-consistency caveat (the "Sierra lesson"): different models produce
20
+ differently-shaped outputs. The chain exposes ``last_index`` and the
21
+ ``on_fallback`` callback so downstream code can validate outputs that came
22
+ from a fallback model instead of trusting them blindly.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any, Callable, Dict, List, Optional, Tuple
28
+
29
+ from .errors import BackendError, RateLimitHit
30
+
31
+ __all__ = ["FallbackBackend"]
32
+
33
+ Backend = Callable[[List[Dict[str, str]]], Tuple[str, int]]
34
+
35
+
36
+ class FallbackBackend:
37
+ """An ordered chain of backends, tried in sequence on transient failure.
38
+
39
+ Args:
40
+ *backends: two or more ``messages -> (reply, tokens_used)`` callables,
41
+ primary first, cheapest last.
42
+ on_fallback: optional callback ``(index, exception) -> None`` invoked
43
+ when the chain moves past a failing backend — use it for logging
44
+ or to flag outputs for extra validation.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ *backends: Backend,
50
+ on_fallback: Optional[Callable[[int, Exception], None]] = None,
51
+ ) -> None:
52
+ if len(backends) < 1:
53
+ raise ValueError("FallbackBackend needs at least one backend")
54
+ self.backends = list(backends)
55
+ self.on_fallback = on_fallback
56
+ self.last_index: Optional[int] = None # which backend served the last call
57
+
58
+ def __call__(self, messages: List[Dict[str, str]]) -> Tuple[str, int]:
59
+ last_exc: Optional[Exception] = None
60
+ for i, backend in enumerate(self.backends):
61
+ try:
62
+ result = backend(messages)
63
+ self.last_index = i
64
+ return result
65
+ except RateLimitHit as exc:
66
+ last_exc = exc
67
+ except BackendError as exc:
68
+ if not exc.retriable:
69
+ raise # request problem: no model will fix it
70
+ last_exc = exc
71
+ if self.on_fallback is not None and i + 1 < len(self.backends):
72
+ self.on_fallback(i + 1, last_exc)
73
+ assert last_exc is not None
74
+ raise last_exc
agentpause/retry.py ADDED
@@ -0,0 +1,57 @@
1
+ """Retry policy for rate-limit hits the prediction did not prevent.
2
+
3
+ Estimates are statistical: a 429 can still slip through. When it does, the
4
+ scheduler waits and retries instead of crashing — honoring the provider's
5
+ ``retry-after`` when given, otherwise backing off exponentially.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import random
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from typing import Awaitable, Callable, Optional
15
+
16
+ __all__ = ["RetryPolicy"]
17
+
18
+
19
+ @dataclass
20
+ class RetryPolicy:
21
+ """How to wait-and-retry after an unexpected 429.
22
+
23
+ Args:
24
+ max_retries: attempts after the first failure before giving up
25
+ (the original :class:`~agentpause.errors.RateLimitHit` is re-raised).
26
+ base_delay_s: first backoff delay.
27
+ factor: multiplier per attempt (exponential backoff).
28
+ max_delay_s: ceiling for any single delay.
29
+ sleep_fn: how to wait (defaults to ``time.sleep``; tests inject a fake).
30
+ async_sleep_fn: how to wait on the async path (defaults to
31
+ ``asyncio.sleep``; tests inject a fake).
32
+ jitter: randomize each delay by ±this fraction (default ±25%), so many
33
+ agents hitting the same window don't all retry in the same instant
34
+ (the "thundering herd" problem). Set 0.0 for deterministic delays.
35
+ """
36
+
37
+ max_retries: int = 3
38
+ base_delay_s: float = 1.0
39
+ factor: float = 2.0
40
+ max_delay_s: float = 60.0
41
+ sleep_fn: Callable[[float], None] = field(default=time.sleep)
42
+ async_sleep_fn: Optional[Callable[[float], Awaitable[None]]] = None
43
+ jitter: float = 0.25
44
+
45
+ def delay(self, attempt: int) -> float:
46
+ """Backoff delay for the given attempt number (0-based), with jitter."""
47
+ base = min(self.base_delay_s * self.factor ** attempt, self.max_delay_s)
48
+ if self.jitter <= 0:
49
+ return base
50
+ return base * (1 + self.jitter * (2 * random.random() - 1))
51
+
52
+ async def asleep(self, seconds: float) -> None:
53
+ """Async wait, honoring an injected ``async_sleep_fn``."""
54
+ if self.async_sleep_fn is not None:
55
+ await self.async_sleep_fn(seconds)
56
+ else:
57
+ await asyncio.sleep(seconds)
agentpause/risk.py ADDED
@@ -0,0 +1,138 @@
1
+ """Risk model and the suspension decision rules.
2
+
3
+ The pieces:
4
+
5
+ * :func:`should_checkpoint` — the hard token rule. Suspend when the remaining
6
+ token budget cannot cover the estimated next step plus a safety margin
7
+ ``k * sigma``.
8
+
9
+ * :class:`Budget` — what telemetry can report beyond a bare token count:
10
+ remaining requests (RPM limits exist alongside TPM limits) and the time
11
+ until the window resets.
12
+
13
+ * :func:`decide` — the three-valued rule built on top: ``continue`` when the
14
+ next step fits (both tokens AND requests), ``wait`` when it does not fit but
15
+ the window resets imminently (a short pause beats a full suspend/resume
16
+ cycle), ``checkpoint`` otherwise.
17
+
18
+ * :class:`RiskModel` — a soft, multi-dimensional risk score used for monitoring
19
+ and for richer policies. It aggregates rate-limit pressure, context-window
20
+ pressure, and monetary-budget pressure into a single number.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from typing import Optional
27
+
28
+
29
+ def should_checkpoint(remaining: int, estimated: int, sigma: float, k: float = 2.0) -> bool:
30
+ """Return True when the next step does not safely fit in the remaining budget.
31
+
32
+ Rule: ``remaining < estimated + k * sigma``.
33
+
34
+ Args:
35
+ remaining: tokens left in the current rate-limit window.
36
+ estimated: estimated cost of the next step.
37
+ sigma: observed std. dev. of consumption (the margin's scale).
38
+ k: safety factor; higher ``k`` suspends earlier (more cautious).
39
+ """
40
+ return remaining < estimated + k * sigma
41
+
42
+
43
+ @dataclass
44
+ class Budget:
45
+ """A telemetry reading richer than a bare token count.
46
+
47
+ Args:
48
+ remaining_tokens: tokens left in the current rate-limit window (TPM).
49
+ remaining_requests: requests left in the window (RPM), if known.
50
+ reset_seconds: seconds until the window resets, if known.
51
+ remaining_input_tokens: input-token budget, if the provider limits
52
+ input and output separately (Anthropic does).
53
+ remaining_output_tokens: output-token budget, if reported separately —
54
+ a reasoning-heavy agent can drain this while input budget abounds.
55
+ """
56
+
57
+ remaining_tokens: int
58
+ remaining_requests: Optional[int] = None
59
+ reset_seconds: Optional[float] = None
60
+ remaining_input_tokens: Optional[int] = None
61
+ remaining_output_tokens: Optional[int] = None
62
+
63
+
64
+ @dataclass
65
+ class Decision:
66
+ """The outcome of :func:`decide`, with its inputs kept for diagnostics."""
67
+
68
+ action: str # "continue" | "wait" | "checkpoint"
69
+ budget: Budget
70
+ estimated: int
71
+ sigma: float
72
+
73
+
74
+ def decide(
75
+ budget: Budget,
76
+ estimated: int,
77
+ sigma: float,
78
+ k: float = 2.0,
79
+ wait_threshold_s: float = 15.0,
80
+ estimated_input: Optional[int] = None,
81
+ estimated_output: Optional[int] = None,
82
+ ) -> Decision:
83
+ """The three-valued decision rule.
84
+
85
+ ``continue`` the next step fits: tokens cover ``estimated + k*sigma``
86
+ AND at least one request remains (when RPM is known).
87
+ ``wait`` it does not fit, but the window resets within
88
+ ``wait_threshold_s`` — pausing in place is cheaper than a
89
+ full checkpoint + relaunch.
90
+ ``checkpoint`` it does not fit and the reset is far away (or unknown).
91
+
92
+ Unknown dimensions never block: if the provider reports no RPM, reset,
93
+ or split input/output information, the rule degrades gracefully to the
94
+ combined token-only behavior.
95
+ """
96
+ tokens_fit = not should_checkpoint(budget.remaining_tokens, estimated, sigma, k)
97
+ requests_fit = budget.remaining_requests is None or budget.remaining_requests >= 1
98
+ input_fit = (budget.remaining_input_tokens is None or estimated_input is None
99
+ or estimated_input <= budget.remaining_input_tokens)
100
+ output_fit = (budget.remaining_output_tokens is None or estimated_output is None
101
+ or estimated_output <= budget.remaining_output_tokens)
102
+ if tokens_fit and requests_fit and input_fit and output_fit:
103
+ action = "continue"
104
+ elif budget.reset_seconds is not None and budget.reset_seconds <= wait_threshold_s:
105
+ action = "wait"
106
+ else:
107
+ action = "checkpoint"
108
+ return Decision(action=action, budget=budget, estimated=estimated, sigma=sigma)
109
+
110
+
111
+ @dataclass
112
+ class RiskModel:
113
+ """Weighted, multi-dimensional risk score (diagnostic / for rich policies).
114
+
115
+ ``risk = w1 * est/remaining + w2 * context/context_max + w3 * cost/budget``
116
+
117
+ The weights default to values validated in the accompanying research and can
118
+ be overridden or, in future, learned online.
119
+ """
120
+
121
+ w1: float = 0.55 # rate-limit pressure
122
+ w2: float = 0.20 # context-window pressure
123
+ w3: float = 0.25 # monetary-budget pressure
124
+
125
+ def score(
126
+ self,
127
+ estimated: int,
128
+ remaining: int,
129
+ context_tokens: int,
130
+ context_max: int,
131
+ cost_estimate: float = 0.0,
132
+ budget_remaining: float = 1.0,
133
+ ) -> float:
134
+ """Compute the aggregate risk score (>= 0; higher means riskier)."""
135
+ rate = estimated / max(remaining, 1)
136
+ ctx = context_tokens / max(context_max, 1)
137
+ econ = cost_estimate / max(budget_remaining, 1e-9)
138
+ return self.w1 * rate + self.w2 * ctx + self.w3 * econ