agentpause 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0/src/agentpause.egg-info → agentpause-0.3.0}/PKG-INFO +107 -9
- {agentpause-0.2.0 → agentpause-0.3.0}/README.md +106 -8
- {agentpause-0.2.0 → agentpause-0.3.0}/pyproject.toml +1 -1
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause/__init__.py +11 -1
- agentpause-0.3.0/src/agentpause/adapters/anthropic.py +118 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause/adapters/langgraph.py +18 -3
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause/adapters/litellm.py +31 -2
- agentpause-0.3.0/src/agentpause/adapters/openai_compat.py +271 -0
- agentpause-0.3.0/src/agentpause/coordinator.py +185 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause/estimator.py +72 -9
- agentpause-0.3.0/src/agentpause/refill.py +75 -0
- agentpause-0.3.0/src/agentpause/regression.py +235 -0
- agentpause-0.3.0/src/agentpause/risk.py +224 -0
- agentpause-0.3.0/src/agentpause/router.py +185 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause/scheduler.py +83 -6
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause/state.py +47 -0
- agentpause-0.3.0/src/agentpause/tools.py +77 -0
- {agentpause-0.2.0 → agentpause-0.3.0/src/agentpause.egg-info}/PKG-INFO +107 -9
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause.egg-info/SOURCES.txt +19 -0
- agentpause-0.3.0/tests/test_anthropic_adapter.py +118 -0
- agentpause-0.3.0/tests/test_coordinator.py +121 -0
- agentpause-0.3.0/tests/test_estimator_evolution.py +78 -0
- agentpause-0.3.0/tests/test_field_adoptions.py +96 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_io_tpm.py +1 -1
- agentpause-0.3.0/tests/test_latency_budget.py +77 -0
- agentpause-0.3.0/tests/test_openai_compat.py +172 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_quantile.py +19 -0
- agentpause-0.3.0/tests/test_refill_wait.py +86 -0
- agentpause-0.3.0/tests/test_regime.py +98 -0
- agentpause-0.3.0/tests/test_regression.py +109 -0
- agentpause-0.3.0/tests/test_router.py +140 -0
- agentpause-0.3.0/tests/test_smart_context.py +109 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_state.py +26 -0
- agentpause-0.3.0/tests/test_tool_quota.py +74 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_wait_and_rpm.py +3 -3
- agentpause-0.2.0/src/agentpause/risk.py +0 -138
- {agentpause-0.2.0 → agentpause-0.3.0}/LICENSE +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/setup.cfg +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause/adapters/__init__.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause/breaker.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause/errors.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause/fallback.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause/retry.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause.egg-info/dependency_links.txt +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause.egg-info/requires.txt +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/src/agentpause.egg-info/top_level.txt +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_async.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_breaker.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_estimator.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_fallback.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_langgraph_adapter.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_litellm_adapter.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_money_and_hooks.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_risk.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_robustness.py +0 -0
- {agentpause-0.2.0 → agentpause-0.3.0}/tests/test_scheduler.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentpause
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Predictive scheduling for autonomous LLM agents: suspend gracefully before rate limits, resume without losing work.
|
|
5
5
|
Author: Andrea Lelio Ciampolillo
|
|
6
6
|
License: MIT
|
|
@@ -40,11 +40,49 @@ plugin for self-hosted runtimes (llama.cpp, vLLM).
|
|
|
40
40
|
| End-to-end A/B (thinking models, 4B/8B) | recovery **54×–93× faster**; total task time −19% |
|
|
41
41
|
|
|
42
42
|
Reproduce them yourself with a free key: `python scripts/benchmark_groq.py`.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
Live run (2026-07-08, Groq free tier, 12 steps, ~1k-token context,
|
|
44
|
+
refill-aware chunked waiting, leveled windows):
|
|
45
|
+
|
|
46
|
+
| | reactive baseline | agentpause |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| 429 errors suffered | 7 | **0** |
|
|
49
|
+
| steps redone | 7 | **0** |
|
|
50
|
+
| tokens re-sent (waste) | 12,840 | **0** |
|
|
51
|
+
| telemetry overhead (pings) | 0 | 259 |
|
|
52
|
+
| wall-clock | 82 s | 86 s |
|
|
53
|
+
|
|
54
|
+
Zero errors and zero waste at wall-clock parity: the sensor costs ~2% of
|
|
55
|
+
what crashes waste (259 vs 12,840 tokens) — and waste is money on paid tiers.
|
|
56
|
+
|
|
57
|
+
**Context slimming vs. answer quality** (`python scripts/quality_ab.py`,
|
|
58
|
+
live 2026-07-10, Groq `llama-3.1-8b-instant`): six facts planted early in a
|
|
59
|
+
verbose conversation, same final quiz under three histories.
|
|
60
|
+
|
|
61
|
+
| condition | prompt chars | facts recalled |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| A — full history | 8,984 | **6/6** |
|
|
64
|
+
| B — `compact()` (blind truncation) | 4,370 | **0/6** — and the model *invented* plausible replacements |
|
|
65
|
+
| C — `summarize_with()` (one summary call) | 3,608 | **6/6** |
|
|
66
|
+
|
|
67
|
+
A 2.2× larger run (`--big`, 19,145 chars — near the free tier's whole TPM
|
|
68
|
+
window, the physical ceiling for a single call) repeated the pattern:
|
|
69
|
+
A 6/6, B 0/6, C 6/6 at a third of the prompt.
|
|
70
|
+
|
|
71
|
+
The instructive failure is B's, and it is *erratic*: in one run it answered
|
|
72
|
+
confidently with fabricated values (fake codename, fake budget, fake city);
|
|
73
|
+
in the larger run it honestly declined. You cannot know in advance which
|
|
74
|
+
failure you get — and the hallucinating one is the dangerous one. Blind
|
|
75
|
+
truncation is an emergency exit (§8.6 overflow, no LLM available), not a
|
|
76
|
+
strategy; semantic summarization recalled everything at a fraction of the
|
|
77
|
+
prompt, earning its one extra call.
|
|
78
|
+
A 20-step live stress test (2026-07-10) closed the loop: at the §8.6 wall the
|
|
79
|
+
scheduler suspended, compacted the checkpoint offline, resumed slim, and
|
|
80
|
+
completed 20/20 steps with zero 429s.
|
|
81
|
+
|
|
82
|
+
> Status: **0.3.0**. Core scheduler, direct + LiteLLM + Anthropic adapters,
|
|
83
|
+
> LangGraph integration, multi-provider routing, multi-agent shared budgets,
|
|
84
|
+
> feature-based cost/latency estimation, runnable examples, and a full test
|
|
85
|
+
> suite are in place; the optional KV-cache plugin is next.
|
|
48
86
|
|
|
49
87
|
## Quick example
|
|
50
88
|
|
|
@@ -167,6 +205,54 @@ Resume the paused thread with `graph.invoke(Command(resume=True), config)`.
|
|
|
167
205
|
On resume the guard re-reads telemetry fresh — never from the checkpoint.
|
|
168
206
|
Install with `pip install -e ".[langgraph]"`; see `examples/langgraph_quickstart.py`.
|
|
169
207
|
|
|
208
|
+
## Scaling up: many providers, many agents, smarter estimates (v0.3)
|
|
209
|
+
|
|
210
|
+
The same predictive idea — read the budget first, act before the error —
|
|
211
|
+
extends in three directions, and they compose:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from agentpause import BudgetRouter, MultiAgentCoordinator, FeatureEstimator
|
|
215
|
+
from agentpause.adapters.openai_compat import OpenAICompatAdapter
|
|
216
|
+
from agentpause.adapters.anthropic import AnthropicAdapter
|
|
217
|
+
|
|
218
|
+
# 1. Route each call to the provider with the most headroom (predictive
|
|
219
|
+
# fallback: switch BEFORE the 429, not after). Providers in cooldown
|
|
220
|
+
# after a real 429 are skipped until their window resets.
|
|
221
|
+
router = BudgetRouter(
|
|
222
|
+
("groq", OpenAICompatAdapter.for_model("groq/llama-3.1-8b-instant")),
|
|
223
|
+
("claude", AnthropicAdapter("claude-haiku-4-5")),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# 2. Share ONE rate-limit window across a fleet: every granted call
|
|
227
|
+
# reserves its predicted cost (estimate + k·σ) from the shared pool,
|
|
228
|
+
# so agents can't overcommit the window together. Contention is
|
|
229
|
+
# arbitrated by priority, then longest-waiting.
|
|
230
|
+
coord = MultiAgentCoordinator(telemetry=router.budget)
|
|
231
|
+
coord.register("researcher", priority=1)
|
|
232
|
+
coord.register("summarizer")
|
|
233
|
+
|
|
234
|
+
est = FeatureEstimator() # 3. learn cost from features,
|
|
235
|
+
est.set_context(tool="web_search") # not just context size
|
|
236
|
+
|
|
237
|
+
d = coord.request("researcher", estimated=est.estimate(1200),
|
|
238
|
+
sigma=est.sigma(fallback_estimate=1200))
|
|
239
|
+
if d.action == "continue":
|
|
240
|
+
reply, used = router.backend(messages) # router picks the provider
|
|
241
|
+
coord.complete("researcher", actual_tokens=used)
|
|
242
|
+
est.record(1200, used)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`FeatureEstimator` is a drop-in for the default estimator
|
|
246
|
+
(`PredictiveScheduler(estimator=FeatureEstimator())`): a dependency-free
|
|
247
|
+
ridge regression over features you declare (tool, model, temperature, …)
|
|
248
|
+
that also learns per-step **latency**, feeding the optional time budget
|
|
249
|
+
(`PredictiveScheduler(time_budget_s=...)`) — if the predicted step can't
|
|
250
|
+
finish before the deadline, the answer is checkpoint, never wait (time,
|
|
251
|
+
unlike tokens, does not refill).
|
|
252
|
+
|
|
253
|
+
Runnable demo without any key: `python examples/fleet_quickstart.py`
|
|
254
|
+
(routing switch + predictive WAIT under a shared window, in 30 lines of loop).
|
|
255
|
+
|
|
170
256
|
## Why
|
|
171
257
|
|
|
172
258
|
Current agent frameworks persist state *reactively*: they checkpoint after a step
|
|
@@ -178,16 +264,23 @@ from the exact step.
|
|
|
178
264
|
This library is the engineering counterpart of the research preprint *"A
|
|
179
265
|
Resource-Aware Predictive Scheduler for Autonomous LLM Agents"*.
|
|
180
266
|
|
|
181
|
-
## Components
|
|
267
|
+
## Components
|
|
182
268
|
|
|
183
269
|
| Module | Role |
|
|
184
270
|
|--------|------|
|
|
185
271
|
| `PredictiveScheduler` / `Session` | the high-level API: `session()`, `should_suspend()`, `call()`, `checkpoint()` |
|
|
186
272
|
| `Estimator` | predicts next-step token cost with a moving-average error correction (ε) and tracks σ |
|
|
273
|
+
| `FeatureEstimator` | drop-in replacement that learns cost *and latency* from declared features (tool, model, …) via dependency-free ridge regression |
|
|
187
274
|
| `should_checkpoint` / `RiskModel` | the suspension rule (`remaining < estimated + k·σ`) and a diagnostic risk score |
|
|
188
|
-
| `Budget` / `decide` |
|
|
189
|
-
| `StateStore` / `Checkpoint` | atomic logical checkpointing with idempotency keys — works on any provider |
|
|
275
|
+
| `Budget` / `decide` | multi-dimensional telemetry (TPM, RPM, reset time, deadline) and the three-valued rule: continue / wait / checkpoint |
|
|
276
|
+
| `StateStore` / `Checkpoint` | atomic logical checkpointing with idempotency keys — works on any provider; `compact()` / `summarize_with()` shrink a suspended checkpoint offline |
|
|
277
|
+
| `BudgetRouter` | predictive multi-provider routing: reads every provider's budget first, routes to the most headroom, cools down 429'd providers |
|
|
278
|
+
| `MultiAgentCoordinator` | one shared rate-limit window across many agents: granted calls reserve their predicted cost; `arbitrate()` resolves contention by priority + fairness |
|
|
279
|
+
| `ToolQuota` | client-side sliding window for rate-limited tools that expose no headers |
|
|
280
|
+
| `CircuitBreaker` / `FallbackBackend` | reactive safety nets: fail fast on a broken provider, try the next one in order |
|
|
190
281
|
| `adapters.litellm.LiteLLMAdapter` | backend + telemetry for any LiteLLM-supported provider (headers → budget, stale reading → 1-token ping) |
|
|
282
|
+
| `adapters.openai_compat.OpenAICompatAdapter` | direct HTTP adapter for OpenAI-compatible APIs (Groq, OpenAI, …) — no litellm dependency |
|
|
283
|
+
| `adapters.anthropic.AnthropicAdapter` | direct adapter for the Anthropic Messages API, with `cache_control` prompt caching and measured `cache_read/write_tokens` |
|
|
191
284
|
| `adapters.langgraph.AgentPauseGuard` | predictive gate for LangGraph nodes: `check()` interrupts the graph before the fatal call, `record()` trains the estimator |
|
|
192
285
|
|
|
193
286
|
## Install (from source, during development)
|
|
@@ -206,6 +299,11 @@ pytest
|
|
|
206
299
|
- [x] Runnable quickstart example (no keys)
|
|
207
300
|
- [x] LiteLLM adapter (works with any provider)
|
|
208
301
|
- [x] LangGraph adapter (interrupt + checkpointer)
|
|
302
|
+
- [x] Direct adapters (OpenAI-compatible, Anthropic with prompt caching)
|
|
303
|
+
- [x] Predictive multi-provider routing (`BudgetRouter`)
|
|
304
|
+
- [x] Shared budget across agents (`MultiAgentCoordinator`)
|
|
305
|
+
- [x] Feature-based cost & latency estimator (`FeatureEstimator`)
|
|
306
|
+
- [ ] CrewAI / AutoGen / LlamaIndex adapters
|
|
209
307
|
- [ ] Optional KV-cache plugin for llama.cpp / vLLM (true warm start)
|
|
210
308
|
|
|
211
309
|
## License
|
|
@@ -17,11 +17,49 @@ plugin for self-hosted runtimes (llama.cpp, vLLM).
|
|
|
17
17
|
| End-to-end A/B (thinking models, 4B/8B) | recovery **54×–93× faster**; total task time −19% |
|
|
18
18
|
|
|
19
19
|
Reproduce them yourself with a free key: `python scripts/benchmark_groq.py`.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
Live run (2026-07-08, Groq free tier, 12 steps, ~1k-token context,
|
|
21
|
+
refill-aware chunked waiting, leveled windows):
|
|
22
|
+
|
|
23
|
+
| | reactive baseline | agentpause |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| 429 errors suffered | 7 | **0** |
|
|
26
|
+
| steps redone | 7 | **0** |
|
|
27
|
+
| tokens re-sent (waste) | 12,840 | **0** |
|
|
28
|
+
| telemetry overhead (pings) | 0 | 259 |
|
|
29
|
+
| wall-clock | 82 s | 86 s |
|
|
30
|
+
|
|
31
|
+
Zero errors and zero waste at wall-clock parity: the sensor costs ~2% of
|
|
32
|
+
what crashes waste (259 vs 12,840 tokens) — and waste is money on paid tiers.
|
|
33
|
+
|
|
34
|
+
**Context slimming vs. answer quality** (`python scripts/quality_ab.py`,
|
|
35
|
+
live 2026-07-10, Groq `llama-3.1-8b-instant`): six facts planted early in a
|
|
36
|
+
verbose conversation, same final quiz under three histories.
|
|
37
|
+
|
|
38
|
+
| condition | prompt chars | facts recalled |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| A — full history | 8,984 | **6/6** |
|
|
41
|
+
| B — `compact()` (blind truncation) | 4,370 | **0/6** — and the model *invented* plausible replacements |
|
|
42
|
+
| C — `summarize_with()` (one summary call) | 3,608 | **6/6** |
|
|
43
|
+
|
|
44
|
+
A 2.2× larger run (`--big`, 19,145 chars — near the free tier's whole TPM
|
|
45
|
+
window, the physical ceiling for a single call) repeated the pattern:
|
|
46
|
+
A 6/6, B 0/6, C 6/6 at a third of the prompt.
|
|
47
|
+
|
|
48
|
+
The instructive failure is B's, and it is *erratic*: in one run it answered
|
|
49
|
+
confidently with fabricated values (fake codename, fake budget, fake city);
|
|
50
|
+
in the larger run it honestly declined. You cannot know in advance which
|
|
51
|
+
failure you get — and the hallucinating one is the dangerous one. Blind
|
|
52
|
+
truncation is an emergency exit (§8.6 overflow, no LLM available), not a
|
|
53
|
+
strategy; semantic summarization recalled everything at a fraction of the
|
|
54
|
+
prompt, earning its one extra call.
|
|
55
|
+
A 20-step live stress test (2026-07-10) closed the loop: at the §8.6 wall the
|
|
56
|
+
scheduler suspended, compacted the checkpoint offline, resumed slim, and
|
|
57
|
+
completed 20/20 steps with zero 429s.
|
|
58
|
+
|
|
59
|
+
> Status: **0.3.0**. Core scheduler, direct + LiteLLM + Anthropic adapters,
|
|
60
|
+
> LangGraph integration, multi-provider routing, multi-agent shared budgets,
|
|
61
|
+
> feature-based cost/latency estimation, runnable examples, and a full test
|
|
62
|
+
> suite are in place; the optional KV-cache plugin is next.
|
|
25
63
|
|
|
26
64
|
## Quick example
|
|
27
65
|
|
|
@@ -144,6 +182,54 @@ Resume the paused thread with `graph.invoke(Command(resume=True), config)`.
|
|
|
144
182
|
On resume the guard re-reads telemetry fresh — never from the checkpoint.
|
|
145
183
|
Install with `pip install -e ".[langgraph]"`; see `examples/langgraph_quickstart.py`.
|
|
146
184
|
|
|
185
|
+
## Scaling up: many providers, many agents, smarter estimates (v0.3)
|
|
186
|
+
|
|
187
|
+
The same predictive idea — read the budget first, act before the error —
|
|
188
|
+
extends in three directions, and they compose:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from agentpause import BudgetRouter, MultiAgentCoordinator, FeatureEstimator
|
|
192
|
+
from agentpause.adapters.openai_compat import OpenAICompatAdapter
|
|
193
|
+
from agentpause.adapters.anthropic import AnthropicAdapter
|
|
194
|
+
|
|
195
|
+
# 1. Route each call to the provider with the most headroom (predictive
|
|
196
|
+
# fallback: switch BEFORE the 429, not after). Providers in cooldown
|
|
197
|
+
# after a real 429 are skipped until their window resets.
|
|
198
|
+
router = BudgetRouter(
|
|
199
|
+
("groq", OpenAICompatAdapter.for_model("groq/llama-3.1-8b-instant")),
|
|
200
|
+
("claude", AnthropicAdapter("claude-haiku-4-5")),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# 2. Share ONE rate-limit window across a fleet: every granted call
|
|
204
|
+
# reserves its predicted cost (estimate + k·σ) from the shared pool,
|
|
205
|
+
# so agents can't overcommit the window together. Contention is
|
|
206
|
+
# arbitrated by priority, then longest-waiting.
|
|
207
|
+
coord = MultiAgentCoordinator(telemetry=router.budget)
|
|
208
|
+
coord.register("researcher", priority=1)
|
|
209
|
+
coord.register("summarizer")
|
|
210
|
+
|
|
211
|
+
est = FeatureEstimator() # 3. learn cost from features,
|
|
212
|
+
est.set_context(tool="web_search") # not just context size
|
|
213
|
+
|
|
214
|
+
d = coord.request("researcher", estimated=est.estimate(1200),
|
|
215
|
+
sigma=est.sigma(fallback_estimate=1200))
|
|
216
|
+
if d.action == "continue":
|
|
217
|
+
reply, used = router.backend(messages) # router picks the provider
|
|
218
|
+
coord.complete("researcher", actual_tokens=used)
|
|
219
|
+
est.record(1200, used)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`FeatureEstimator` is a drop-in for the default estimator
|
|
223
|
+
(`PredictiveScheduler(estimator=FeatureEstimator())`): a dependency-free
|
|
224
|
+
ridge regression over features you declare (tool, model, temperature, …)
|
|
225
|
+
that also learns per-step **latency**, feeding the optional time budget
|
|
226
|
+
(`PredictiveScheduler(time_budget_s=...)`) — if the predicted step can't
|
|
227
|
+
finish before the deadline, the answer is checkpoint, never wait (time,
|
|
228
|
+
unlike tokens, does not refill).
|
|
229
|
+
|
|
230
|
+
Runnable demo without any key: `python examples/fleet_quickstart.py`
|
|
231
|
+
(routing switch + predictive WAIT under a shared window, in 30 lines of loop).
|
|
232
|
+
|
|
147
233
|
## Why
|
|
148
234
|
|
|
149
235
|
Current agent frameworks persist state *reactively*: they checkpoint after a step
|
|
@@ -155,16 +241,23 @@ from the exact step.
|
|
|
155
241
|
This library is the engineering counterpart of the research preprint *"A
|
|
156
242
|
Resource-Aware Predictive Scheduler for Autonomous LLM Agents"*.
|
|
157
243
|
|
|
158
|
-
## Components
|
|
244
|
+
## Components
|
|
159
245
|
|
|
160
246
|
| Module | Role |
|
|
161
247
|
|--------|------|
|
|
162
248
|
| `PredictiveScheduler` / `Session` | the high-level API: `session()`, `should_suspend()`, `call()`, `checkpoint()` |
|
|
163
249
|
| `Estimator` | predicts next-step token cost with a moving-average error correction (ε) and tracks σ |
|
|
250
|
+
| `FeatureEstimator` | drop-in replacement that learns cost *and latency* from declared features (tool, model, …) via dependency-free ridge regression |
|
|
164
251
|
| `should_checkpoint` / `RiskModel` | the suspension rule (`remaining < estimated + k·σ`) and a diagnostic risk score |
|
|
165
|
-
| `Budget` / `decide` |
|
|
166
|
-
| `StateStore` / `Checkpoint` | atomic logical checkpointing with idempotency keys — works on any provider |
|
|
252
|
+
| `Budget` / `decide` | multi-dimensional telemetry (TPM, RPM, reset time, deadline) and the three-valued rule: continue / wait / checkpoint |
|
|
253
|
+
| `StateStore` / `Checkpoint` | atomic logical checkpointing with idempotency keys — works on any provider; `compact()` / `summarize_with()` shrink a suspended checkpoint offline |
|
|
254
|
+
| `BudgetRouter` | predictive multi-provider routing: reads every provider's budget first, routes to the most headroom, cools down 429'd providers |
|
|
255
|
+
| `MultiAgentCoordinator` | one shared rate-limit window across many agents: granted calls reserve their predicted cost; `arbitrate()` resolves contention by priority + fairness |
|
|
256
|
+
| `ToolQuota` | client-side sliding window for rate-limited tools that expose no headers |
|
|
257
|
+
| `CircuitBreaker` / `FallbackBackend` | reactive safety nets: fail fast on a broken provider, try the next one in order |
|
|
167
258
|
| `adapters.litellm.LiteLLMAdapter` | backend + telemetry for any LiteLLM-supported provider (headers → budget, stale reading → 1-token ping) |
|
|
259
|
+
| `adapters.openai_compat.OpenAICompatAdapter` | direct HTTP adapter for OpenAI-compatible APIs (Groq, OpenAI, …) — no litellm dependency |
|
|
260
|
+
| `adapters.anthropic.AnthropicAdapter` | direct adapter for the Anthropic Messages API, with `cache_control` prompt caching and measured `cache_read/write_tokens` |
|
|
168
261
|
| `adapters.langgraph.AgentPauseGuard` | predictive gate for LangGraph nodes: `check()` interrupts the graph before the fatal call, `record()` trains the estimator |
|
|
169
262
|
|
|
170
263
|
## Install (from source, during development)
|
|
@@ -183,6 +276,11 @@ pytest
|
|
|
183
276
|
- [x] Runnable quickstart example (no keys)
|
|
184
277
|
- [x] LiteLLM adapter (works with any provider)
|
|
185
278
|
- [x] LangGraph adapter (interrupt + checkpointer)
|
|
279
|
+
- [x] Direct adapters (OpenAI-compatible, Anthropic with prompt caching)
|
|
280
|
+
- [x] Predictive multi-provider routing (`BudgetRouter`)
|
|
281
|
+
- [x] Shared budget across agents (`MultiAgentCoordinator`)
|
|
282
|
+
- [x] Feature-based cost & latency estimator (`FeatureEstimator`)
|
|
283
|
+
- [ ] CrewAI / AutoGen / LlamaIndex adapters
|
|
186
284
|
- [ ] Optional KV-cache plugin for llama.cpp / vLLM (true warm start)
|
|
187
285
|
|
|
188
286
|
## License
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentpause"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Predictive scheduling for autonomous LLM agents: suspend gracefully before rate limits, resume without losing work."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -14,18 +14,24 @@ from .errors import (
|
|
|
14
14
|
)
|
|
15
15
|
from .breaker import CircuitBreaker, CircuitOpenError
|
|
16
16
|
from .estimator import Estimator
|
|
17
|
+
from .regression import FeatureEstimator
|
|
17
18
|
from .fallback import FallbackBackend
|
|
19
|
+
from .refill import RegimeDetector
|
|
18
20
|
from .retry import RetryPolicy
|
|
19
21
|
from .risk import Budget, Decision, RiskModel, decide, should_checkpoint
|
|
22
|
+
from .router import BudgetRouter
|
|
23
|
+
from .coordinator import MultiAgentCoordinator
|
|
20
24
|
from .state import Checkpoint, StateStore
|
|
25
|
+
from .tools import ToolQuota
|
|
21
26
|
from .scheduler import PredictiveScheduler, Session
|
|
22
27
|
|
|
23
|
-
__version__ = "0.
|
|
28
|
+
__version__ = "0.3.0"
|
|
24
29
|
|
|
25
30
|
__all__ = [
|
|
26
31
|
"PredictiveScheduler",
|
|
27
32
|
"Session",
|
|
28
33
|
"Estimator",
|
|
34
|
+
"FeatureEstimator",
|
|
29
35
|
"RiskModel",
|
|
30
36
|
"Budget",
|
|
31
37
|
"Decision",
|
|
@@ -35,8 +41,12 @@ __all__ = [
|
|
|
35
41
|
"StateStore",
|
|
36
42
|
"RetryPolicy",
|
|
37
43
|
"FallbackBackend",
|
|
44
|
+
"BudgetRouter",
|
|
45
|
+
"MultiAgentCoordinator",
|
|
38
46
|
"CircuitBreaker",
|
|
39
47
|
"CircuitOpenError",
|
|
48
|
+
"RegimeDetector",
|
|
49
|
+
"ToolQuota",
|
|
40
50
|
"AgentPauseError",
|
|
41
51
|
"RateLimitHit",
|
|
42
52
|
"TelemetryError",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Direct adapter for Anthropic's Messages API (F9.6), cache-aware (F9.5).
|
|
2
|
+
|
|
3
|
+
Anthropic is not OpenAI-compatible — different endpoint (``/v1/messages``),
|
|
4
|
+
different auth (``x-api-key``), ``system`` as a top-level field, usage split
|
|
5
|
+
into input/output tokens — so it gets its own thin adapter, built on the
|
|
6
|
+
same telemetry machinery as :class:`OpenAICompatAdapter` (whose header
|
|
7
|
+
parser already understands ``anthropic-ratelimit-*`` names and RFC 3339
|
|
8
|
+
resets).
|
|
9
|
+
|
|
10
|
+
Prompt caching — the cloud analog of the KV warm start
|
|
11
|
+
------------------------------------------------------
|
|
12
|
+
True KV-cache export is impossible on cloud APIs, but Anthropic caches
|
|
13
|
+
byte-identical prompt prefixes server-side (cache reads cost ~10% of the
|
|
14
|
+
input price). agentpause's resume already re-sends the prefix verbatim by
|
|
15
|
+
design, so with ``cache_prompt=True`` (default) the adapter plants
|
|
16
|
+
``cache_control`` breakpoints on the stable prefix — the system message and
|
|
17
|
+
the last message before the fresh question — and MEASURES what came back
|
|
18
|
+
from cache (``cache_read_tokens``). Two honest caveats: the cache TTL is
|
|
19
|
+
minutes (helps waits and short suspensions, not tomorrow's resume), and
|
|
20
|
+
cache reads discount price/latency, not the rate-limit token count.
|
|
21
|
+
|
|
22
|
+
from agentpause.adapters.anthropic import AnthropicAdapter
|
|
23
|
+
|
|
24
|
+
adapter = AnthropicAdapter("claude-haiku-4-5")
|
|
25
|
+
sched = PredictiveScheduler(backend=adapter.backend, telemetry=adapter.budget)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
31
|
+
|
|
32
|
+
from .openai_compat import OpenAICompatAdapter
|
|
33
|
+
|
|
34
|
+
__all__ = ["AnthropicAdapter"]
|
|
35
|
+
|
|
36
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AnthropicAdapter(OpenAICompatAdapter):
|
|
40
|
+
"""Backend + telemetry for Anthropic models, with prompt-cache breakpoints.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
model: e.g. ``"claude-haiku-4-5"``.
|
|
44
|
+
api_key: literal key, or None to read ``ANTHROPIC_API_KEY``.
|
|
45
|
+
cache_prompt: plant ``cache_control`` breakpoints on the stable
|
|
46
|
+
prefix (system + last message before the fresh question).
|
|
47
|
+
default_max_tokens: the Messages API requires ``max_tokens``.
|
|
48
|
+
Everything else as in :class:`OpenAICompatAdapter`.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
model: str,
|
|
54
|
+
api_key: Optional[str] = None,
|
|
55
|
+
cache_prompt: bool = True,
|
|
56
|
+
default_max_tokens: int = 1024,
|
|
57
|
+
**kwargs: Any,
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__(
|
|
60
|
+
model,
|
|
61
|
+
base_url="https://api.anthropic.com/v1",
|
|
62
|
+
api_key=api_key,
|
|
63
|
+
api_key_env="ANTHROPIC_API_KEY",
|
|
64
|
+
**kwargs,
|
|
65
|
+
)
|
|
66
|
+
self._auth = {
|
|
67
|
+
"x-api-key": self._api_key,
|
|
68
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
69
|
+
"content-type": "application/json",
|
|
70
|
+
}
|
|
71
|
+
self.cache_prompt = cache_prompt
|
|
72
|
+
self.default_max_tokens = default_max_tokens
|
|
73
|
+
# measured cache effect (F9.5): never assumed, always counted
|
|
74
|
+
self.cache_read_tokens = 0
|
|
75
|
+
self.cache_write_tokens = 0
|
|
76
|
+
|
|
77
|
+
# -- protocol translation ---------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def _call(self, messages: List[Dict[str, str]], **extra: Any) -> Dict[str, Any]:
|
|
80
|
+
system: Optional[str] = None
|
|
81
|
+
msgs = messages
|
|
82
|
+
if msgs and msgs[0].get("role") == "system":
|
|
83
|
+
system = msgs[0].get("content") or ""
|
|
84
|
+
msgs = msgs[1:]
|
|
85
|
+
payload: Dict[str, Any] = {
|
|
86
|
+
"model": self.model,
|
|
87
|
+
"max_tokens": extra.pop("max_tokens", self.default_max_tokens),
|
|
88
|
+
"messages": [dict(m) for m in msgs],
|
|
89
|
+
**extra,
|
|
90
|
+
}
|
|
91
|
+
if system is not None:
|
|
92
|
+
block: Dict[str, Any] = {"type": "text", "text": system}
|
|
93
|
+
if self.cache_prompt:
|
|
94
|
+
block["cache_control"] = {"type": "ephemeral"}
|
|
95
|
+
payload["system"] = [block]
|
|
96
|
+
if self.cache_prompt and len(payload["messages"]) >= 2:
|
|
97
|
+
# the stable prefix ends just before the fresh question: closing
|
|
98
|
+
# the breakpoint there lets a resumed (byte-identical) history
|
|
99
|
+
# hit the server-side cache
|
|
100
|
+
closer = payload["messages"][-2]
|
|
101
|
+
content = closer.get("content")
|
|
102
|
+
if isinstance(content, str):
|
|
103
|
+
closer["content"] = [{"type": "text", "text": content,
|
|
104
|
+
"cache_control": {"type": "ephemeral"}}]
|
|
105
|
+
body = self._post_and_check(f"{self.base_url}/messages", payload)
|
|
106
|
+
usage = body.get("usage") or {}
|
|
107
|
+
self.cache_read_tokens += int(usage.get("cache_read_input_tokens") or 0)
|
|
108
|
+
self.cache_write_tokens += int(usage.get("cache_creation_input_tokens") or 0)
|
|
109
|
+
return body
|
|
110
|
+
|
|
111
|
+
def backend(self, messages: List[Dict[str, str]]) -> Tuple[str, int]:
|
|
112
|
+
body = self._call(messages, **self.request_kwargs)
|
|
113
|
+
self._prev_ping = self._sample_point() # real call starts a sampling chain
|
|
114
|
+
text = "".join(b.get("text", "") for b in (body.get("content") or [])
|
|
115
|
+
if b.get("type") == "text")
|
|
116
|
+
usage = body.get("usage") or {}
|
|
117
|
+
used = int((usage.get("input_tokens") or 0) + (usage.get("output_tokens") or 0))
|
|
118
|
+
return text, used
|
|
@@ -97,6 +97,8 @@ class AgentPauseGuard:
|
|
|
97
97
|
wait_threshold_s: float = 15.0,
|
|
98
98
|
sleep_fn: Callable[[float], None] = time.sleep,
|
|
99
99
|
async_sleep_fn: Optional[Callable[[float], Any]] = None,
|
|
100
|
+
chunk_s: float = 10.0,
|
|
101
|
+
while_waiting: Optional[Callable[[float], None]] = None,
|
|
100
102
|
max_resumes: int = 100,
|
|
101
103
|
) -> None:
|
|
102
104
|
self.telemetry = telemetry
|
|
@@ -107,6 +109,8 @@ class AgentPauseGuard:
|
|
|
107
109
|
self.wait_threshold_s = wait_threshold_s
|
|
108
110
|
self.sleep_fn = sleep_fn
|
|
109
111
|
self.async_sleep_fn = async_sleep_fn
|
|
112
|
+
self.chunk_s = chunk_s
|
|
113
|
+
self.while_waiting = while_waiting
|
|
110
114
|
self.max_resumes = max_resumes
|
|
111
115
|
|
|
112
116
|
# -- internals -----------------------------------------------------------
|
|
@@ -144,8 +148,18 @@ class AgentPauseGuard:
|
|
|
144
148
|
if d.action == "continue":
|
|
145
149
|
return
|
|
146
150
|
if d.action == "wait":
|
|
147
|
-
# reset is imminent: sleeping beats a suspend/resume cycle
|
|
148
|
-
|
|
151
|
+
# reset is imminent: sleeping beats a suspend/resume cycle.
|
|
152
|
+
# Sleep in chunks and re-read: if the bucket refills sooner,
|
|
153
|
+
# resume sooner (and the samples feed regime detection).
|
|
154
|
+
# Useful waits (F9.2): if the app registered while_waiting,
|
|
155
|
+
# hand it the time — indexing, memory compression, prompt
|
|
156
|
+
# prep are all work that needs no LLM.
|
|
157
|
+
wait_s = min(d.wait_seconds or budget.reset_seconds or 1.0,
|
|
158
|
+
self.chunk_s) + 0.5
|
|
159
|
+
if self.while_waiting is not None:
|
|
160
|
+
self.while_waiting(wait_s)
|
|
161
|
+
else:
|
|
162
|
+
self.sleep_fn(wait_s)
|
|
149
163
|
continue
|
|
150
164
|
# interrupt() raises on the first pass (the node aborts here);
|
|
151
165
|
# on a resume pass it RETURNS, and the loop re-reads telemetry.
|
|
@@ -177,7 +191,8 @@ class AgentPauseGuard:
|
|
|
177
191
|
if d.action == "continue":
|
|
178
192
|
return
|
|
179
193
|
if d.action == "wait":
|
|
180
|
-
|
|
194
|
+
wait_s = d.wait_seconds or budget.reset_seconds or 1.0
|
|
195
|
+
delay = min(wait_s, self.chunk_s) + 0.5
|
|
181
196
|
if self.async_sleep_fn is not None:
|
|
182
197
|
await self.async_sleep_fn(delay)
|
|
183
198
|
else:
|
|
@@ -71,11 +71,23 @@ _INSTALL_HINT = ("The LiteLLM adapter needs the 'litellm' package. "
|
|
|
71
71
|
"Install it with: pip install agentpause[litellm]")
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def _enable_headers() -> None:
|
|
75
|
+
"""Ask litellm to surface provider response headers.
|
|
76
|
+
|
|
77
|
+
Without this flag litellm does NOT populate
|
|
78
|
+
``_hidden_params["additional_headers"]`` — telemetry would be blind.
|
|
79
|
+
(Found in the field on the very first real-provider run.)
|
|
80
|
+
"""
|
|
81
|
+
import litellm
|
|
82
|
+
litellm.return_response_headers = True
|
|
83
|
+
|
|
84
|
+
|
|
74
85
|
def _default_completion() -> Callable[..., Any]:
|
|
75
86
|
try:
|
|
76
87
|
from litellm import completion
|
|
77
88
|
except ImportError as exc: # pragma: no cover - depends on environment
|
|
78
89
|
raise ImportError(_INSTALL_HINT) from exc
|
|
90
|
+
_enable_headers()
|
|
79
91
|
return completion
|
|
80
92
|
|
|
81
93
|
|
|
@@ -84,6 +96,7 @@ def _default_acompletion() -> Callable[..., Any]:
|
|
|
84
96
|
from litellm import acompletion
|
|
85
97
|
except ImportError as exc: # pragma: no cover - depends on environment
|
|
86
98
|
raise ImportError(_INSTALL_HINT) from exc
|
|
99
|
+
_enable_headers()
|
|
87
100
|
return acompletion
|
|
88
101
|
|
|
89
102
|
|
|
@@ -207,11 +220,23 @@ class LiteLLMAdapter:
|
|
|
207
220
|
return Budget(
|
|
208
221
|
remaining_tokens=await self.atelemetry(),
|
|
209
222
|
remaining_requests=self._remaining_requests,
|
|
210
|
-
reset_seconds=self.
|
|
223
|
+
reset_seconds=self._aged_reset(),
|
|
211
224
|
remaining_input_tokens=self._remaining_input,
|
|
212
225
|
remaining_output_tokens=self._remaining_output,
|
|
213
226
|
)
|
|
214
227
|
|
|
228
|
+
def _aged_reset(self) -> Optional[float]:
|
|
229
|
+
"""Reset countdown advanced by the age of the cached reading.
|
|
230
|
+
|
|
231
|
+
The header value was true at capture time; served from cache up to
|
|
232
|
+
``max_age_s`` later, it overstates the wait. Subtract the elapsed
|
|
233
|
+
time (floor 0) so callers never wait on already-elapsed seconds.
|
|
234
|
+
"""
|
|
235
|
+
if self._reset_seconds is None or self._read_at is None:
|
|
236
|
+
return self._reset_seconds
|
|
237
|
+
age = max(0.0, self._clock() - self._read_at)
|
|
238
|
+
return max(0.0, self._reset_seconds - age)
|
|
239
|
+
|
|
215
240
|
def count_tokens(self, text: str) -> int:
|
|
216
241
|
"""Per-model token count via litellm, with a safe heuristic fallback.
|
|
217
242
|
|
|
@@ -239,13 +264,17 @@ class LiteLLMAdapter:
|
|
|
239
264
|
return Budget(
|
|
240
265
|
remaining_tokens=self.telemetry(),
|
|
241
266
|
remaining_requests=self._remaining_requests,
|
|
242
|
-
reset_seconds=self.
|
|
267
|
+
reset_seconds=self._aged_reset(),
|
|
243
268
|
remaining_input_tokens=self._remaining_input,
|
|
244
269
|
remaining_output_tokens=self._remaining_output,
|
|
245
270
|
)
|
|
246
271
|
|
|
247
272
|
# -- internals -------------------------------------------------------------
|
|
248
273
|
|
|
274
|
+
def invalidate(self) -> None:
|
|
275
|
+
"""Mark the cached telemetry stale; the next read will ping."""
|
|
276
|
+
self._read_at = None
|
|
277
|
+
|
|
249
278
|
def ping(self) -> None:
|
|
250
279
|
"""Issue a deliberately tiny call whose only purpose is fresh headers."""
|
|
251
280
|
response = self._call(
|