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.
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+