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/__init__.py +46 -0
- agentpause/adapters/__init__.py +8 -0
- agentpause/adapters/langgraph.py +205 -0
- agentpause/adapters/litellm.py +338 -0
- agentpause/breaker.py +88 -0
- agentpause/errors.py +66 -0
- agentpause/estimator.py +106 -0
- agentpause/fallback.py +74 -0
- agentpause/retry.py +57 -0
- agentpause/risk.py +138 -0
- agentpause/scheduler.py +336 -0
- agentpause/state.py +99 -0
- agentpause-0.2.0.dist-info/METADATA +213 -0
- agentpause-0.2.0.dist-info/RECORD +17 -0
- agentpause-0.2.0.dist-info/WHEEL +5 -0
- agentpause-0.2.0.dist-info/licenses/LICENSE +21 -0
- agentpause-0.2.0.dist-info/top_level.txt +1 -0
agentpause/scheduler.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""The high-level, provider-agnostic scheduler API.
|
|
2
|
+
|
|
3
|
+
This is the piece users actually touch. It ties the pure components
|
|
4
|
+
(:class:`~agentpause.estimator.Estimator`, the risk rule,
|
|
5
|
+
:class:`~agentpause.state.StateStore`) into a small workflow:
|
|
6
|
+
|
|
7
|
+
sched = PredictiveScheduler(backend=..., telemetry=...)
|
|
8
|
+
with sched.session("task-1") as s: # resumes automatically if a checkpoint exists
|
|
9
|
+
for question in questions[s.step:]: # skip steps already done before a suspend
|
|
10
|
+
s.add_user(question)
|
|
11
|
+
if s.should_suspend(): # predictive check, before the call
|
|
12
|
+
s.checkpoint()
|
|
13
|
+
break
|
|
14
|
+
reply = s.call() # perform the LLM call, record consumption
|
|
15
|
+
else:
|
|
16
|
+
s.complete() # task finished: drop the checkpoint
|
|
17
|
+
|
|
18
|
+
``backend`` and ``telemetry`` are injected callables, so the scheduler depends on
|
|
19
|
+
no particular provider. Adapters (LiteLLM, LangGraph, per-provider telemetry)
|
|
20
|
+
supply them; tests supply stubs.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
from .errors import BackendError, RateLimitHit
|
|
28
|
+
from .estimator import Estimator
|
|
29
|
+
from .retry import RetryPolicy
|
|
30
|
+
from .risk import Budget, Decision, decide, should_checkpoint
|
|
31
|
+
from .state import Checkpoint, StateStore
|
|
32
|
+
|
|
33
|
+
# messages -> (reply_text, tokens_used)
|
|
34
|
+
Backend = Callable[[List[Dict[str, str]]], Tuple[str, int]]
|
|
35
|
+
# async variant: messages -> awaitable (reply_text, tokens_used)
|
|
36
|
+
AsyncBackend = Callable[[List[Dict[str, str]]], "Any"]
|
|
37
|
+
# () -> remaining tokens (int) or a full Budget reading
|
|
38
|
+
Telemetry = Callable[[], "int | Budget"]
|
|
39
|
+
# text -> token count
|
|
40
|
+
TokenCounter = Callable[[str], int]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _default_token_counter(text: str) -> int:
|
|
44
|
+
"""Rough offline token estimate (~4 chars/token). Replace via ``count_tokens``."""
|
|
45
|
+
return max(1, len(text) // 4)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Session:
|
|
49
|
+
"""A single resumable agent run, bound to a ``session_id``."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, scheduler: "PredictiveScheduler", session_id: str) -> None:
|
|
52
|
+
self._sched = scheduler
|
|
53
|
+
self.session_id = session_id
|
|
54
|
+
cp = scheduler.store.load(session_id)
|
|
55
|
+
self.resumed = cp is not None and cp.step > 0
|
|
56
|
+
self._cp = cp if cp is not None else Checkpoint(session_id=session_id)
|
|
57
|
+
|
|
58
|
+
# -- read-only view -----------------------------------------------------
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def step(self) -> int:
|
|
62
|
+
return self._cp.step
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def messages(self) -> List[Dict[str, str]]:
|
|
66
|
+
return self._cp.messages
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def total_tokens_used(self) -> int:
|
|
70
|
+
return self._cp.total_tokens_used
|
|
71
|
+
|
|
72
|
+
# -- building the conversation -----------------------------------------
|
|
73
|
+
|
|
74
|
+
def add_message(self, role: str, content: str) -> None:
|
|
75
|
+
self._cp.messages.append({"role": role, "content": content})
|
|
76
|
+
|
|
77
|
+
def add_user(self, content: str) -> None:
|
|
78
|
+
self.add_message("user", content)
|
|
79
|
+
|
|
80
|
+
def add_system(self, content: str) -> None:
|
|
81
|
+
self.add_message("system", content)
|
|
82
|
+
|
|
83
|
+
# -- the predictive decision -------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _input_tokens(self) -> int:
|
|
86
|
+
return sum(self._sched.count_tokens(m["content"]) for m in self._cp.messages)
|
|
87
|
+
|
|
88
|
+
def _read_budget(self) -> Budget:
|
|
89
|
+
"""Read telemetry fresh; accept a plain int (legacy) or a Budget."""
|
|
90
|
+
raw = self._sched.telemetry()
|
|
91
|
+
return raw if isinstance(raw, Budget) else Budget(remaining_tokens=int(raw))
|
|
92
|
+
|
|
93
|
+
def should_suspend(self) -> bool:
|
|
94
|
+
"""True if the next call would not safely fit the remaining budget.
|
|
95
|
+
|
|
96
|
+
Telemetry is read fresh on every call — never from the checkpoint —
|
|
97
|
+
because a serialized budget value goes stale after a window reset.
|
|
98
|
+
Considers both the token budget (TPM) and, when reported, the
|
|
99
|
+
request budget (RPM).
|
|
100
|
+
"""
|
|
101
|
+
return self.next_action().action != "continue"
|
|
102
|
+
|
|
103
|
+
def next_action(self) -> Decision:
|
|
104
|
+
"""The full three-valued decision: ``continue`` / ``wait`` / ``checkpoint``.
|
|
105
|
+
|
|
106
|
+
``wait`` means the budget does not fit but the provider window resets
|
|
107
|
+
within ``wait_threshold_s`` — sleeping briefly beats a suspend/resume
|
|
108
|
+
cycle. The returned :class:`Decision` carries the budget reading and
|
|
109
|
+
the estimate for logging.
|
|
110
|
+
"""
|
|
111
|
+
budget = self._read_budget()
|
|
112
|
+
input_tokens = self._input_tokens()
|
|
113
|
+
estimated = self._sched.estimator.estimate(input_tokens)
|
|
114
|
+
sigma = self._sched.estimator.sigma(estimated)
|
|
115
|
+
if self._sched.quantile is not None:
|
|
116
|
+
upper = self._sched.estimator.upper_estimate(input_tokens, self._sched.quantile)
|
|
117
|
+
if upper is not None:
|
|
118
|
+
# the quantile bound already covers the tail: no extra margin
|
|
119
|
+
estimated, sigma = upper, 0.0
|
|
120
|
+
d = decide(budget, estimated, sigma,
|
|
121
|
+
self._sched.safety_k, self._sched.wait_threshold_s,
|
|
122
|
+
estimated_input=input_tokens,
|
|
123
|
+
estimated_output=self._sched.estimator.max_tokens)
|
|
124
|
+
# monetary hard constraint: money does not reset by waiting,
|
|
125
|
+
# so an overrun always means checkpoint — never wait
|
|
126
|
+
price = self._sched.price_per_1k_tokens
|
|
127
|
+
remaining_money = self._sched.money_remaining
|
|
128
|
+
if price is not None and remaining_money is not None:
|
|
129
|
+
estimated_cost = estimated / 1000.0 * price
|
|
130
|
+
if estimated_cost > remaining_money:
|
|
131
|
+
d = Decision(action="checkpoint", budget=budget,
|
|
132
|
+
estimated=estimated, sigma=sigma)
|
|
133
|
+
self._sched._emit("decision", {
|
|
134
|
+
"action": d.action, "estimated": estimated,
|
|
135
|
+
"remaining_tokens": budget.remaining_tokens,
|
|
136
|
+
"session_id": self.session_id, "step": self.step,
|
|
137
|
+
})
|
|
138
|
+
return d
|
|
139
|
+
|
|
140
|
+
# -- performing a step --------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def call(self) -> str:
|
|
143
|
+
"""Perform the LLM call, record consumption, advance the step.
|
|
144
|
+
|
|
145
|
+
An unexpected 429 (:class:`~agentpause.errors.RateLimitHit`) is
|
|
146
|
+
retried per the scheduler's :class:`~agentpause.retry.RetryPolicy`,
|
|
147
|
+
and each hit adapts the safety factor upward (the prediction was too
|
|
148
|
+
optimistic). If retries run out the error propagates — and the session
|
|
149
|
+
state is left UNTOUCHED, so a checkpoint/resume stays clean.
|
|
150
|
+
"""
|
|
151
|
+
input_tokens = self._input_tokens()
|
|
152
|
+
retry = self._sched.retry
|
|
153
|
+
attempt = 0
|
|
154
|
+
while True:
|
|
155
|
+
try:
|
|
156
|
+
reply, used = self._sched.backend(self._cp.messages)
|
|
157
|
+
break
|
|
158
|
+
except RateLimitHit as hit:
|
|
159
|
+
self._sched._register_rate_limit_hit()
|
|
160
|
+
if attempt >= retry.max_retries:
|
|
161
|
+
raise
|
|
162
|
+
delay = hit.retry_after if hit.retry_after is not None else retry.delay(attempt)
|
|
163
|
+
self._sched._emit("retry", {"attempt": attempt + 1, "delay_s": delay})
|
|
164
|
+
retry.sleep_fn(delay)
|
|
165
|
+
attempt += 1
|
|
166
|
+
except BackendError as err:
|
|
167
|
+
# transient infrastructure failures (5xx) deserve a retry;
|
|
168
|
+
# request problems (4xx) do not — retrying wastes budget
|
|
169
|
+
if not err.retriable or attempt >= retry.max_retries:
|
|
170
|
+
raise
|
|
171
|
+
delay = retry.delay(attempt)
|
|
172
|
+
self._sched._emit("retry", {"attempt": attempt + 1, "delay_s": delay})
|
|
173
|
+
retry.sleep_fn(delay)
|
|
174
|
+
attempt += 1
|
|
175
|
+
# state mutations happen only after a successful call
|
|
176
|
+
self._record_success(input_tokens, used, reply)
|
|
177
|
+
return reply
|
|
178
|
+
|
|
179
|
+
def _record_success(self, input_tokens: int, used: int, reply: str) -> None:
|
|
180
|
+
self._sched.estimator.record(input_tokens, used)
|
|
181
|
+
self._cp.total_tokens_used += used
|
|
182
|
+
self._cp.step += 1
|
|
183
|
+
self.add_message("assistant", reply)
|
|
184
|
+
if self._sched.price_per_1k_tokens is not None:
|
|
185
|
+
self._sched.money_spent += used / 1000.0 * self._sched.price_per_1k_tokens
|
|
186
|
+
self._sched._emit("step_completed", {
|
|
187
|
+
"session_id": self.session_id, "step": self._cp.step,
|
|
188
|
+
"tokens_used": used, "money_spent": self._sched.money_spent,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
async def acall(self) -> str:
|
|
192
|
+
"""Async twin of :meth:`call`: same rule, same retry, same guarantees.
|
|
193
|
+
|
|
194
|
+
Requires the scheduler to be built with ``async_backend=...``
|
|
195
|
+
(e.g. ``LiteLLMAdapter(...).abackend``).
|
|
196
|
+
"""
|
|
197
|
+
if self._sched.async_backend is None:
|
|
198
|
+
raise RuntimeError(
|
|
199
|
+
"acall() needs an async backend: "
|
|
200
|
+
"PredictiveScheduler(..., async_backend=adapter.abackend)"
|
|
201
|
+
)
|
|
202
|
+
input_tokens = self._input_tokens()
|
|
203
|
+
retry = self._sched.retry
|
|
204
|
+
attempt = 0
|
|
205
|
+
while True:
|
|
206
|
+
try:
|
|
207
|
+
reply, used = await self._sched.async_backend(self._cp.messages)
|
|
208
|
+
break
|
|
209
|
+
except RateLimitHit as hit:
|
|
210
|
+
self._sched._register_rate_limit_hit()
|
|
211
|
+
if attempt >= retry.max_retries:
|
|
212
|
+
raise
|
|
213
|
+
delay = hit.retry_after if hit.retry_after is not None else retry.delay(attempt)
|
|
214
|
+
await retry.asleep(delay)
|
|
215
|
+
attempt += 1
|
|
216
|
+
except BackendError as err:
|
|
217
|
+
if not err.retriable or attempt >= retry.max_retries:
|
|
218
|
+
raise
|
|
219
|
+
await retry.asleep(retry.delay(attempt))
|
|
220
|
+
attempt += 1
|
|
221
|
+
self._record_success(input_tokens, used, reply)
|
|
222
|
+
return reply
|
|
223
|
+
|
|
224
|
+
# -- persistence --------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def checkpoint(self) -> str:
|
|
227
|
+
"""Serialize the logical state so the run can resume later."""
|
|
228
|
+
return self._sched.store.save(self._cp)
|
|
229
|
+
|
|
230
|
+
def complete(self) -> None:
|
|
231
|
+
"""Mark the task finished and drop its checkpoint."""
|
|
232
|
+
self._sched.store.clear(self.session_id)
|
|
233
|
+
|
|
234
|
+
# -- context manager ----------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def __enter__(self) -> "Session":
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
240
|
+
return False # never suppress exceptions
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class PredictiveScheduler:
|
|
244
|
+
"""Factory and configuration holder for :class:`Session` runs.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
backend: callable ``messages -> (reply, tokens_used)`` performing the LLM call.
|
|
248
|
+
telemetry: callable ``() -> remaining_tokens`` reading the rate-limit window.
|
|
249
|
+
store: where checkpoints live (defaults to ``.agentpause/`` on disk).
|
|
250
|
+
estimator: cost estimator (defaults to a fresh :class:`Estimator`).
|
|
251
|
+
count_tokens: text->token counter (defaults to a ~4 chars/token heuristic).
|
|
252
|
+
safety_k: safety factor; higher suspends earlier.
|
|
253
|
+
wait_threshold_s: if the window resets within this many seconds,
|
|
254
|
+
``next_action()`` returns ``wait`` instead of ``checkpoint``.
|
|
255
|
+
retry: how to wait-and-retry on unexpected 429s (see
|
|
256
|
+
:class:`~agentpause.retry.RetryPolicy`).
|
|
257
|
+
k_bump: how much ``safety_k`` grows after each unexpected 429 —
|
|
258
|
+
the hit is feedback that the estimates run optimistic.
|
|
259
|
+
k_max: ceiling for the adaptive safety factor.
|
|
260
|
+
price_per_1k_tokens: model price, for the monetary hard constraint.
|
|
261
|
+
money_budget: spending cap for this scheduler. When the estimated
|
|
262
|
+
next step would overrun it, the decision is ``checkpoint`` —
|
|
263
|
+
never ``wait``, because money does not reset with the window.
|
|
264
|
+
on_event: observability hook ``(event_name, info_dict) -> None``.
|
|
265
|
+
Events: ``decision``, ``step_completed``, ``rate_limit_hit``,
|
|
266
|
+
``retry``. Exceptions inside the hook are swallowed — telemetry
|
|
267
|
+
must never take down the agent.
|
|
268
|
+
quantile: when set (e.g. ``0.95``) and enough history exists, the
|
|
269
|
+
decision uses the empirical q-quantile of past estimation errors
|
|
270
|
+
instead of the ``k·sigma`` margin — statistically honest with
|
|
271
|
+
heavy-tailed consumption. Falls back to ``k·sigma`` until
|
|
272
|
+
8 steps are recorded.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
def __init__(
|
|
276
|
+
self,
|
|
277
|
+
backend: Optional[Backend],
|
|
278
|
+
telemetry: Telemetry,
|
|
279
|
+
store: Optional[StateStore] = None,
|
|
280
|
+
estimator: Optional[Estimator] = None,
|
|
281
|
+
count_tokens: Optional[TokenCounter] = None,
|
|
282
|
+
safety_k: float = 2.0,
|
|
283
|
+
wait_threshold_s: float = 15.0,
|
|
284
|
+
retry: Optional[RetryPolicy] = None,
|
|
285
|
+
k_bump: float = 0.25,
|
|
286
|
+
k_max: float = 4.0,
|
|
287
|
+
async_backend: Optional[AsyncBackend] = None,
|
|
288
|
+
price_per_1k_tokens: Optional[float] = None,
|
|
289
|
+
money_budget: Optional[float] = None,
|
|
290
|
+
on_event: Optional[Callable[[str, Dict[str, Any]], None]] = None,
|
|
291
|
+
quantile: Optional[float] = None,
|
|
292
|
+
) -> None:
|
|
293
|
+
if backend is None and async_backend is None:
|
|
294
|
+
raise ValueError("Provide backend=, async_backend=, or both.")
|
|
295
|
+
self.backend = backend
|
|
296
|
+
self.async_backend = async_backend
|
|
297
|
+
self.telemetry = telemetry
|
|
298
|
+
self.store = store if store is not None else StateStore()
|
|
299
|
+
self.estimator = estimator if estimator is not None else Estimator()
|
|
300
|
+
self.count_tokens = count_tokens if count_tokens is not None else _default_token_counter
|
|
301
|
+
self.safety_k = safety_k
|
|
302
|
+
self.wait_threshold_s = wait_threshold_s
|
|
303
|
+
self.retry = retry if retry is not None else RetryPolicy()
|
|
304
|
+
self.k_bump = k_bump
|
|
305
|
+
self.k_max = k_max
|
|
306
|
+
self.rate_limit_hits = 0
|
|
307
|
+
self.price_per_1k_tokens = price_per_1k_tokens
|
|
308
|
+
self.money_budget = money_budget
|
|
309
|
+
self.money_spent = 0.0
|
|
310
|
+
self.on_event = on_event
|
|
311
|
+
self.quantile = quantile
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def money_remaining(self) -> Optional[float]:
|
|
315
|
+
if self.money_budget is None:
|
|
316
|
+
return None
|
|
317
|
+
return self.money_budget - self.money_spent
|
|
318
|
+
|
|
319
|
+
def _emit(self, name: str, info: Dict[str, Any]) -> None:
|
|
320
|
+
"""Fire the observability hook; never let it break the run."""
|
|
321
|
+
if self.on_event is not None:
|
|
322
|
+
try:
|
|
323
|
+
self.on_event(name, info)
|
|
324
|
+
except Exception:
|
|
325
|
+
pass
|
|
326
|
+
|
|
327
|
+
def _register_rate_limit_hit(self) -> None:
|
|
328
|
+
"""A 429 slipped through: count it and grow the safety margin."""
|
|
329
|
+
self.rate_limit_hits += 1
|
|
330
|
+
self.safety_k = min(self.safety_k + self.k_bump, self.k_max)
|
|
331
|
+
self._emit("rate_limit_hit", {"total_hits": self.rate_limit_hits,
|
|
332
|
+
"safety_k": self.safety_k})
|
|
333
|
+
|
|
334
|
+
def session(self, session_id: str) -> Session:
|
|
335
|
+
"""Start (or resume) a run identified by ``session_id``."""
|
|
336
|
+
return Session(self, session_id)
|
agentpause/state.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Logical checkpoint store: the source of truth for suspend/resume.
|
|
2
|
+
|
|
3
|
+
This is the part that works on *any* provider, cloud or local, because it only
|
|
4
|
+
serializes application-level state — messages, step counter, and idempotency
|
|
5
|
+
keys — never model internals. (True KV-cache warm start lives in an optional
|
|
6
|
+
plugin and is not required here.)
|
|
7
|
+
|
|
8
|
+
Writes are atomic: the manifest is written to a temporary file and renamed, so a
|
|
9
|
+
crash mid-write can never corrupt a previous checkpoint.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from dataclasses import dataclass, field, asdict
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from .errors import CheckpointError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Checkpoint:
|
|
26
|
+
"""A resumable snapshot of an agent session."""
|
|
27
|
+
|
|
28
|
+
session_id: str
|
|
29
|
+
step: int = 0
|
|
30
|
+
messages: List[Dict[str, str]] = field(default_factory=list)
|
|
31
|
+
idempotency_keys: List[str] = field(default_factory=list)
|
|
32
|
+
total_tokens_used: int = 0
|
|
33
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
def new_idempotency_key(self, action: str) -> str:
|
|
36
|
+
"""Register and return a fresh idempotency key for a side-effecting action."""
|
|
37
|
+
key = f"{action}:{uuid.uuid4().hex[:8]}"
|
|
38
|
+
self.idempotency_keys.append(key)
|
|
39
|
+
return key
|
|
40
|
+
|
|
41
|
+
def has_run(self, key: str) -> bool:
|
|
42
|
+
"""Whether an action key was already executed before a checkpoint."""
|
|
43
|
+
return key in self.idempotency_keys
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StateStore:
|
|
47
|
+
"""Persists and restores :class:`Checkpoint` objects as JSON files."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, directory: str = ".agentpause") -> None:
|
|
50
|
+
self.directory = directory
|
|
51
|
+
try:
|
|
52
|
+
os.makedirs(directory, exist_ok=True)
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
raise CheckpointError(
|
|
55
|
+
f"Cannot create checkpoint directory '{directory}': {exc}"
|
|
56
|
+
) from exc
|
|
57
|
+
|
|
58
|
+
def _path(self, session_id: str) -> str:
|
|
59
|
+
safe = session_id.replace(os.sep, "_")
|
|
60
|
+
return os.path.join(self.directory, f"{safe}.json")
|
|
61
|
+
|
|
62
|
+
def save(self, cp: Checkpoint) -> str:
|
|
63
|
+
"""Atomically persist a checkpoint; returns the file path."""
|
|
64
|
+
path = self._path(cp.session_id)
|
|
65
|
+
payload = {
|
|
66
|
+
"checkpoint_id": uuid.uuid4().hex,
|
|
67
|
+
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
68
|
+
"state": asdict(cp),
|
|
69
|
+
}
|
|
70
|
+
tmp = path + ".tmp"
|
|
71
|
+
try:
|
|
72
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
73
|
+
json.dump(payload, f, indent=2, ensure_ascii=False)
|
|
74
|
+
os.replace(tmp, path) # atomic on POSIX and Windows
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
raise CheckpointError(
|
|
77
|
+
f"Cannot write checkpoint '{path}': {exc}"
|
|
78
|
+
) from exc
|
|
79
|
+
return path
|
|
80
|
+
|
|
81
|
+
def load(self, session_id: str) -> Optional[Checkpoint]:
|
|
82
|
+
"""Restore a checkpoint, or return None if none exists."""
|
|
83
|
+
path = self._path(session_id)
|
|
84
|
+
if not os.path.exists(path):
|
|
85
|
+
return None
|
|
86
|
+
try:
|
|
87
|
+
with open(path, encoding="utf-8") as f:
|
|
88
|
+
data = json.load(f)
|
|
89
|
+
return Checkpoint(**data["state"])
|
|
90
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
91
|
+
raise CheckpointError(
|
|
92
|
+
f"Cannot read checkpoint '{path}' (corrupted?): {exc}"
|
|
93
|
+
) from exc
|
|
94
|
+
|
|
95
|
+
def clear(self, session_id: str) -> None:
|
|
96
|
+
"""Delete a session's checkpoint (e.g., after successful completion)."""
|
|
97
|
+
path = self._path(session_id)
|
|
98
|
+
if os.path.exists(path):
|
|
99
|
+
os.remove(path)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentpause
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Predictive scheduling for autonomous LLM agents: suspend gracefully before rate limits, resume without losing work.
|
|
5
|
+
Author: Andrea Lelio Ciampolillo
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: llm,agents,rate-limiting,checkpointing,scheduler
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
18
|
+
Provides-Extra: litellm
|
|
19
|
+
Requires-Dist: litellm>=1.40; extra == "litellm"
|
|
20
|
+
Provides-Extra: langgraph
|
|
21
|
+
Requires-Dist: langgraph>=0.2; extra == "langgraph"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# agentpause
|
|
25
|
+
|
|
26
|
+
**Predictive scheduling for autonomous LLM agents.** Suspend an agent gracefully
|
|
27
|
+
*before* it hits a provider rate limit, and resume it without redoing work.
|
|
28
|
+
|
|
29
|
+
The core works on any provider — cloud (OpenAI, Anthropic, Groq) or local — because
|
|
30
|
+
it only serializes application-level state. True KV-cache warm start is an optional
|
|
31
|
+
plugin for self-hosted runtimes (llama.cpp, vLLM).
|
|
32
|
+
|
|
33
|
+
## Measured results (from the accompanying research)
|
|
34
|
+
|
|
35
|
+
| Experiment | Result |
|
|
36
|
+
|---|---|
|
|
37
|
+
| Simulation, 300 runs/config | reactive baseline crashes up to 100% → predictive **0%** (k=4), token waste −80% |
|
|
38
|
+
| Real provider (Groq free, 6K TPM) | multi-window task completed with **zero 429 errors** |
|
|
39
|
+
| vs LangGraph's MemorySaver (200 runs) | LangGraph: 7.8 × 429/run, 9,239 tokens wasted; predictive: **0 and 0** |
|
|
40
|
+
| End-to-end A/B (thinking models, 4B/8B) | recovery **54×–93× faster**; total task time −19% |
|
|
41
|
+
|
|
42
|
+
Reproduce them yourself with a free key: `python scripts/benchmark_groq.py`.
|
|
43
|
+
|
|
44
|
+
> Status: **early alpha (0.1)**. Core components, the high-level
|
|
45
|
+
> `PredictiveScheduler` API, the LiteLLM adapter (any provider), the LangGraph
|
|
46
|
+
> adapter, runnable examples, and a full test suite are in place; the optional
|
|
47
|
+
> KV-cache plugin is next.
|
|
48
|
+
|
|
49
|
+
## Quick example
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from agentpause import PredictiveScheduler
|
|
53
|
+
|
|
54
|
+
sched = PredictiveScheduler(backend=my_llm_call, telemetry=my_rate_limit_reader)
|
|
55
|
+
|
|
56
|
+
with sched.session("task-1") as s: # resumes automatically if interrupted before
|
|
57
|
+
for question in questions[s.step:]: # skip steps finished before a suspend
|
|
58
|
+
s.add_user(question)
|
|
59
|
+
if s.should_suspend(): # predictive check, *before* the call
|
|
60
|
+
s.checkpoint()
|
|
61
|
+
break # stop cleanly; rerun to resume
|
|
62
|
+
reply = s.call()
|
|
63
|
+
else:
|
|
64
|
+
s.complete() # task done: drop the checkpoint
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`backend` is any callable `messages -> (reply, tokens_used)`; `telemetry` is any
|
|
68
|
+
callable `() -> remaining_tokens`. See `examples/quickstart.py` for a runnable demo
|
|
69
|
+
(no API keys needed) that suspends mid-task and resumes on the next run.
|
|
70
|
+
|
|
71
|
+
## Real providers via LiteLLM
|
|
72
|
+
|
|
73
|
+
The LiteLLM adapter supplies both callables for 100+ providers (OpenAI, Groq,
|
|
74
|
+
Anthropic, local servers, ...), reading the budget from each response's
|
|
75
|
+
rate-limit headers and refreshing stale readings with a tiny telemetry ping:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from agentpause import PredictiveScheduler
|
|
79
|
+
from agentpause.adapters.litellm import LiteLLMAdapter
|
|
80
|
+
|
|
81
|
+
adapter = LiteLLMAdapter(model="groq/llama-3.1-8b-instant")
|
|
82
|
+
sched = PredictiveScheduler(backend=adapter.backend, telemetry=adapter.telemetry)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Install with `pip install -e ".[litellm]"`; see `examples/litellm_groq.py`.
|
|
86
|
+
To validate against your own provider (frontier models included):
|
|
87
|
+
`python scripts/validate_provider.py gpt-4o-mini`.
|
|
88
|
+
|
|
89
|
+
Rate-limit headers by provider (defaults target the OpenAI-style names):
|
|
90
|
+
|
|
91
|
+
| Provider | remaining tokens | remaining requests | reset |
|
|
92
|
+
|---|---|---|---|
|
|
93
|
+
| OpenAI | `x-ratelimit-remaining-tokens` | `x-ratelimit-remaining-requests` | `x-ratelimit-reset-tokens` (`1s`, `6m0s`) |
|
|
94
|
+
| Groq | `x-ratelimit-remaining-tokens` | `x-ratelimit-remaining-requests` | `x-ratelimit-reset-tokens` (`7.66s`, `2m59.56s`) |
|
|
95
|
+
| Anthropic | `anthropic-ratelimit-tokens-remaining` | `anthropic-ratelimit-requests-remaining` | RFC 3339 timestamp — set `remaining_header=` etc. |
|
|
96
|
+
|
|
97
|
+
Header names differ? Override them: `LiteLLMAdapter(model=..., remaining_header="...", requests_header="...", reset_header="...")`.
|
|
98
|
+
|
|
99
|
+
## Beyond tokens: RPM and wait-vs-suspend
|
|
100
|
+
|
|
101
|
+
Telemetry can be richer than a token count. `adapter.budget` reports all
|
|
102
|
+
three dimensions providers expose — remaining tokens (TPM), remaining
|
|
103
|
+
requests (RPM), and seconds until the window resets — and unlocks the
|
|
104
|
+
three-valued decision:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
sched = PredictiveScheduler(backend=adapter.backend, telemetry=adapter.budget)
|
|
108
|
+
|
|
109
|
+
d = session.next_action() # "continue" | "wait" | "checkpoint"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`wait` fires when the budget does not fit **but** the window resets within
|
|
113
|
+
`wait_threshold_s` (default 15 s): a short in-place pause is cheaper than a
|
|
114
|
+
full suspend/resume cycle. Exhausted requests (RPM = 0) block the call even
|
|
115
|
+
with plenty of tokens left. Plain-int telemetry keeps working unchanged.
|
|
116
|
+
|
|
117
|
+
## Async
|
|
118
|
+
|
|
119
|
+
Every entry point has an async twin — same rules, same guarantees, never
|
|
120
|
+
blocks the event loop:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
adapter = LiteLLMAdapter(model="groq/llama-3.1-8b-instant")
|
|
124
|
+
sched = PredictiveScheduler(backend=None, async_backend=adapter.abackend,
|
|
125
|
+
telemetry=adapter.telemetry)
|
|
126
|
+
reply = await session.acall() # retry/backoff via asyncio.sleep
|
|
127
|
+
await guard.acheck(state["messages"]) # async LangGraph nodes
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
For sharper input estimates, wire the per-model tokenizer:
|
|
131
|
+
`PredictiveScheduler(..., count_tokens=adapter.count_tokens)` (falls back to
|
|
132
|
+
the ~4 chars/token heuristic if the tokenizer is unavailable).
|
|
133
|
+
|
|
134
|
+
## When prediction fails anyway
|
|
135
|
+
|
|
136
|
+
Estimates are statistical — a 429 can still slip through. agentpause survives
|
|
137
|
+
it instead of crashing:
|
|
138
|
+
|
|
139
|
+
- **typed errors**: everything derives from `AgentPauseError`
|
|
140
|
+
(`RateLimitHit`, `TelemetryError`, `CheckpointError`, `BackendError`);
|
|
141
|
+
- **retry with backoff**: unexpected 429s are retried (provider `retry-after`
|
|
142
|
+
honored, else exponential backoff — see `RetryPolicy`);
|
|
143
|
+
- **hits are feedback**: each one bumps `safety_k` up (capped at `k_max`),
|
|
144
|
+
so the scheduler grows more cautious on workloads it underestimates;
|
|
145
|
+
- **clean failure**: a failed call leaves the session state untouched —
|
|
146
|
+
no phantom steps, resumes stay consistent.
|
|
147
|
+
|
|
148
|
+
## LangGraph integration
|
|
149
|
+
|
|
150
|
+
`AgentPauseGuard` adds the predictive gate to any LangGraph node — LangGraph
|
|
151
|
+
persists reactively (after nodes), the guard pauses *before* the call that
|
|
152
|
+
would hit the rate limit, via LangGraph's own `interrupt()` + checkpointer:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from agentpause.adapters.langgraph import AgentPauseGuard
|
|
156
|
+
|
|
157
|
+
guard = AgentPauseGuard(telemetry=adapter.telemetry) # e.g. from LiteLLMAdapter
|
|
158
|
+
|
|
159
|
+
def agent_node(state):
|
|
160
|
+
guard.check(state["messages"]) # pauses the graph here if needed
|
|
161
|
+
reply = llm.invoke(state["messages"])
|
|
162
|
+
guard.record(state["messages"], reply.usage_metadata["total_tokens"])
|
|
163
|
+
...
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Resume the paused thread with `graph.invoke(Command(resume=True), config)`.
|
|
167
|
+
On resume the guard re-reads telemetry fresh — never from the checkpoint.
|
|
168
|
+
Install with `pip install -e ".[langgraph]"`; see `examples/langgraph_quickstart.py`.
|
|
169
|
+
|
|
170
|
+
## Why
|
|
171
|
+
|
|
172
|
+
Current agent frameworks persist state *reactively*: they checkpoint after a step
|
|
173
|
+
completes and crash when the provider returns HTTP 429. `agentpause` adds a
|
|
174
|
+
*predictive* layer that estimates the next step's cost, compares it against the
|
|
175
|
+
remaining rate-limit budget, and suspends cleanly before the error — then resumes
|
|
176
|
+
from the exact step.
|
|
177
|
+
|
|
178
|
+
This library is the engineering counterpart of the research preprint *"A
|
|
179
|
+
Resource-Aware Predictive Scheduler for Autonomous LLM Agents"*.
|
|
180
|
+
|
|
181
|
+
## Components (v0.1)
|
|
182
|
+
|
|
183
|
+
| Module | Role |
|
|
184
|
+
|--------|------|
|
|
185
|
+
| `PredictiveScheduler` / `Session` | the high-level API: `session()`, `should_suspend()`, `call()`, `checkpoint()` |
|
|
186
|
+
| `Estimator` | predicts next-step token cost with a moving-average error correction (ε) and tracks σ |
|
|
187
|
+
| `should_checkpoint` / `RiskModel` | the suspension rule (`remaining < estimated + k·σ`) and a diagnostic risk score |
|
|
188
|
+
| `Budget` / `decide` | three-dimensional telemetry (TPM, RPM, reset time) and the three-valued rule: continue / wait / checkpoint |
|
|
189
|
+
| `StateStore` / `Checkpoint` | atomic logical checkpointing with idempotency keys — works on any provider |
|
|
190
|
+
| `adapters.litellm.LiteLLMAdapter` | backend + telemetry for any LiteLLM-supported provider (headers → budget, stale reading → 1-token ping) |
|
|
191
|
+
| `adapters.langgraph.AgentPauseGuard` | predictive gate for LangGraph nodes: `check()` interrupts the graph before the fatal call, `record()` trains the estimator |
|
|
192
|
+
|
|
193
|
+
## Install (from source, during development)
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
git clone https://github.com/<user>/agentpause
|
|
197
|
+
cd agentpause
|
|
198
|
+
pip install -e ".[dev]"
|
|
199
|
+
pytest
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Roadmap
|
|
203
|
+
|
|
204
|
+
- [x] Core components + test suite
|
|
205
|
+
- [x] `PredictiveScheduler` high-level API (`session()`, `should_suspend()`, `call()`)
|
|
206
|
+
- [x] Runnable quickstart example (no keys)
|
|
207
|
+
- [x] LiteLLM adapter (works with any provider)
|
|
208
|
+
- [x] LangGraph adapter (interrupt + checkpointer)
|
|
209
|
+
- [ ] Optional KV-cache plugin for llama.cpp / vLLM (true warm start)
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
agentpause/__init__.py,sha256=SWYii0FaKN2Matip07Hz8yB6Ll1gkPn--qAsR2rINfM,1178
|
|
2
|
+
agentpause/breaker.py,sha256=PNoFs00OYl1SpGENXFexQtpFJWeFGibxpZCy_tLMw7w,3245
|
|
3
|
+
agentpause/errors.py,sha256=Sdh1OxjT0BltHE0_52jIgDAm6TohQ1TPOqcF9c9qo_8,2154
|
|
4
|
+
agentpause/estimator.py,sha256=9LUT04ax1EXMRJFTdL61_iyGnn2RSMpSLdSmmPhoyHU,4252
|
|
5
|
+
agentpause/fallback.py,sha256=YG0Nh-_LQBhbPMZH6pLmEEHJjyVPm2D0NXMuq3_nHA0,2950
|
|
6
|
+
agentpause/retry.py,sha256=owFuhBhPxBad3xqNzGRDbZZEh40V7KrXJ38AYYADCrU,2205
|
|
7
|
+
agentpause/risk.py,sha256=iidzZ10us2psexdSVuE-xgGxi085vbjcU_k4T8Ou4ek,5301
|
|
8
|
+
agentpause/scheduler.py,sha256=EzE7aoQFEnrCXu6rumK-MuiC0qfyy6Fg-RNFGIX75Nc,14755
|
|
9
|
+
agentpause/state.py,sha256=h_xtUPgpBgoriT0BaMb34A-c5IgO4ouygxpXalodjx4,3549
|
|
10
|
+
agentpause/adapters/__init__.py,sha256=QkTxXuIRFGgGHEzK7fgCht4UpOSdkPQ9rGc4Y6-sIpE,298
|
|
11
|
+
agentpause/adapters/langgraph.py,sha256=a5OER-WZYYgM6cKiy0wZPeVZiGtOEjkSU7hvgESMEuQ,9114
|
|
12
|
+
agentpause/adapters/litellm.py,sha256=lqIFjgUkVFJtRWk0QYo2cJ8qfar1FHIfGnePqs344Lo,14403
|
|
13
|
+
agentpause-0.2.0.dist-info/licenses/LICENSE,sha256=y3bmzJXJEc-s1EaKom6LLx0ZAd2qHVPbhGRycwVArSk,1075
|
|
14
|
+
agentpause-0.2.0.dist-info/METADATA,sha256=QcW71ehy_3CwoZKzxUNpN3Bwu5DnBdhQxAxA1Eur--c,9305
|
|
15
|
+
agentpause-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
16
|
+
agentpause-0.2.0.dist-info/top_level.txt,sha256=EWK3W-1VLkr7fmVAAS_Mkpt3SvkO9MjHrw-bI6huKzo,11
|
|
17
|
+
agentpause-0.2.0.dist-info/RECORD,,
|