yc1838 commited on
Commit
ca06887
·
1 Parent(s): 259c7bc

docs: refine gemini cooldown implementation plan

Browse files
docs/superpowers/specs/2026-05-06-gemini-rate-limit-cooldown-implementation-plan.md ADDED
@@ -0,0 +1,1881 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gemini Rate-Limit Cooldown Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Implement Gemini 3 Flash / Gemini 3.1 Pro rate-limit protection for GAIA batch runs, including correct 429 detection, shared lane cooldown, per-question streak skip, cross-task batch pause, and daily-quota batch abort.
6
+
7
+ **Architecture:** `models.py` owns provider exception classification, Gemini lane cooldown state, per-question 429 scopes, and the shared batch-window counters. `runner.py` owns task-level retry, fresh ephemeral memory per retry, batch pause sleeps, non-success rate-limit answers, and diagnostic abort markers. Tests use fake models/graphs and monkeypatched sleep/time so no external APIs or real waiting are needed.
8
+
9
+ **Tech Stack:** Python, LangChain `BaseChatModel` / `Runnable`, Tenacity, pytest, standard-library `contextvars`, `collections.deque`, `time.monotonic`, and existing Lilith runner/checkpoint helpers.
10
+
11
+ **Design Source:** `docs/superpowers/specs/2026-05-06-gemini-rate-limit-cooldown-design.md`
12
+
13
+ ---
14
+
15
+ ## File Structure
16
+
17
+ - Modify: `src/lilith_agent/models.py`
18
+ - Replace type-only retry classification with `is_retryable_rate_limit`.
19
+ - Add optional `google.genai.errors.ClientError` support.
20
+ - Add `RateLimitCooldownError`, `QuestionRateLimitStreakError`, and `BatchAbortRateLimitError`.
21
+ - Add Gemini lane profiles and shared in-process cooldown registry.
22
+ - Add per-question rate-limit context scope.
23
+ - Add batch sliding-window helpers.
24
+ - Update `_RetryWrapper` and `_BoundRetryWrapper` sync and async paths to use lane metadata, cooldown checks, and exhausted-retry signal conversion.
25
+
26
+ - Modify: `src/lilith_agent/runner.py`
27
+ - Import rate-limit signals/helpers lazily inside `run_agent_on_questions`.
28
+ - Factor one task invocation into a helper path so retry uses a fresh `ephemeral_memory()` block.
29
+ - Catch `RateLimitCooldownError`, sleep, retry same task once, and avoid success checkpoints on rate-limit failure.
30
+ - Catch `QuestionRateLimitStreakError`, append `AGENT ERROR: RATE LIMITED`, and continue.
31
+ - Catch `BatchAbortRateLimitError`, append `AGENT ERROR: RATE LIMITED`, write `<checkpoint_dir>/rate_limit_abort.json`, and stop the batch.
32
+ - Check the batch sliding window between tasks and sleep when it trips.
33
+
34
+ - Modify: `tests/test_models.py`
35
+ - Add unit tests for retry predicates, cooldown registry behavior, daily quota classification, same-lane sharing, lane isolation, monotonic timing, bound wrapper propagation, async parity, and question streak.
36
+
37
+ - Modify: `tests/test_runner.py`
38
+ - Add runner tests for retry-once behavior, fresh ephemeral memory block per retry, no success checkpoint on rate-limit failure, batch abort marker, and batch pause trigger.
39
+
40
+ ---
41
+
42
+ ## Task 1: Add Rate-Limit Exception Classification Tests
43
+
44
+ **Files:**
45
+ - Modify: `tests/test_models.py`
46
+ - Modify: `src/lilith_agent/models.py`
47
+
48
+ - [ ] **Step 1: Write failing tests for Gemini `ClientError` classification**
49
+
50
+ Add these imports near the top of `tests/test_models.py`:
51
+
52
+ ```python
53
+ import pytest
54
+
55
+ from lilith_agent.models import is_retryable_rate_limit
56
+ ```
57
+
58
+ Add this helper and tests to `tests/test_models.py`:
59
+
60
+ ```python
61
+ def _make_genai_client_error(code: int):
62
+ pytest.importorskip("google.genai.errors")
63
+ from google.genai.errors import ClientError
64
+
65
+ return ClientError(
66
+ code,
67
+ {
68
+ "error": {
69
+ "code": code,
70
+ "status": "RESOURCE_EXHAUSTED" if code == 429 else "INVALID_ARGUMENT",
71
+ "message": "test error",
72
+ }
73
+ },
74
+ )
75
+
76
+
77
+ def test_genai_client_error_429_is_retryable():
78
+ exc = _make_genai_client_error(429)
79
+
80
+ assert is_retryable_rate_limit(exc) is True
81
+
82
+
83
+ def test_genai_client_error_400_is_not_retryable():
84
+ exc = _make_genai_client_error(400)
85
+
86
+ assert is_retryable_rate_limit(exc) is False
87
+ ```
88
+
89
+ - [ ] **Step 2: Run tests to verify they fail**
90
+
91
+ Run:
92
+
93
+ ```bash
94
+ pytest tests/test_models.py::test_genai_client_error_429_is_retryable tests/test_models.py::test_genai_client_error_400_is_not_retryable -v
95
+ ```
96
+
97
+ Expected: FAIL because `is_retryable_rate_limit` is not defined/exported yet.
98
+
99
+ - [ ] **Step 3: Implement retryable predicate**
100
+
101
+ In `src/lilith_agent/models.py`, replace the `RETRY_EXCEPTIONS` block and old static retry-parameter dict with this shape.
102
+
103
+ Add imports at the top:
104
+
105
+ ```python
106
+ import asyncio
107
+ import time
108
+ from typing import Any
109
+
110
+ from tenacity import AsyncRetrying, Retrying, retry_if_exception, stop_after_attempt, wait_exponential
111
+ ```
112
+
113
+ Replace the optional exception setup with:
114
+
115
+ ```python
116
+ try:
117
+ from google.api_core.exceptions import ResourceExhausted
118
+ except ImportError:
119
+ ResourceExhausted = None
120
+
121
+ try:
122
+ from google.genai.errors import ClientError as GenAIClientError
123
+ except ImportError:
124
+ GenAIClientError = None
125
+
126
+ try:
127
+ from anthropic import RateLimitError as AnthropicRateLimitError
128
+ except ImportError:
129
+ AnthropicRateLimitError = None
130
+
131
+ try:
132
+ from openai import RateLimitError as OpenAIRateLimitError
133
+ except ImportError:
134
+ OpenAIRateLimitError = None
135
+ ```
136
+
137
+ Add below the logger/constants:
138
+
139
+ ```python
140
+ def is_retryable_rate_limit(exc: BaseException) -> bool:
141
+ if ResourceExhausted is not None and isinstance(exc, ResourceExhausted):
142
+ return True
143
+ if GenAIClientError is not None and isinstance(exc, GenAIClientError):
144
+ return getattr(exc, "code", None) == 429
145
+ if AnthropicRateLimitError is not None and isinstance(exc, AnthropicRateLimitError):
146
+ return True
147
+ if OpenAIRateLimitError is not None and isinstance(exc, OpenAIRateLimitError):
148
+ return True
149
+ return False
150
+ ```
151
+
152
+ Add retry parameter factories:
153
+
154
+ ```python
155
+ def _tenacity_sleep(seconds: float) -> None:
156
+ time.sleep(seconds)
157
+
158
+
159
+ async def _async_tenacity_sleep(seconds: float) -> None:
160
+ await asyncio.sleep(seconds)
161
+
162
+
163
+ def _base_retry_params() -> dict[str, Any]:
164
+ return dict(
165
+ retry=retry_if_exception(is_retryable_rate_limit),
166
+ wait=wait_exponential(multiplier=2, min=4, max=60),
167
+ stop=stop_after_attempt(5),
168
+ before_sleep=lambda retry_state: log.warning(
169
+ f"LLM Rate Limit (429) hit. Retrying in {retry_state.next_action.sleep}s... "
170
+ f"(Attempt {retry_state.attempt_number}/5)"
171
+ ),
172
+ reraise=True,
173
+ )
174
+
175
+
176
+ def _sync_retry_params() -> dict[str, Any]:
177
+ return {**_base_retry_params(), "sleep": _tenacity_sleep}
178
+
179
+
180
+ def _async_retry_params() -> dict[str, Any]:
181
+ return {**_base_retry_params(), "sleep": _async_tenacity_sleep}
182
+ ```
183
+
184
+ Update existing retry loops to call the factories immediately so the old static retry dict can be removed without leaving dead references:
185
+
186
+ ```python
187
+ def _generate(self, *args, **kwargs):
188
+ for attempt in Retrying(**_sync_retry_params()):
189
+ with attempt:
190
+ return self.inner._generate(*args, **kwargs)
191
+
192
+
193
+ async def _agenerate(self, *args, **kwargs):
194
+ async for attempt in AsyncRetrying(**_async_retry_params()):
195
+ with attempt:
196
+ return await self.inner._agenerate(*args, **kwargs)
197
+ ```
198
+
199
+ In `_BoundRetryWrapper`, update both retry loops the same way:
200
+
201
+ ```python
202
+ def invoke(self, input, config=None, **kwargs):
203
+ for attempt in Retrying(**_sync_retry_params()):
204
+ with attempt:
205
+ return self._bound.invoke(input, config=config, **kwargs)
206
+
207
+
208
+ async def ainvoke(self, input, config=None, **kwargs):
209
+ async for attempt in AsyncRetrying(**_async_retry_params()):
210
+ with attempt:
211
+ return await self._bound.ainvoke(input, config=config, **kwargs)
212
+ ```
213
+
214
+ - [ ] **Step 4: Run tests to verify they pass**
215
+
216
+ Run:
217
+
218
+ ```bash
219
+ pytest tests/test_models.py::test_genai_client_error_429_is_retryable tests/test_models.py::test_genai_client_error_400_is_not_retryable -v
220
+ ```
221
+
222
+ Expected: PASS.
223
+
224
+ - [ ] **Step 5: Commit**
225
+
226
+ Run:
227
+
228
+ ```bash
229
+ git add src/lilith_agent/models.py tests/test_models.py
230
+ git commit -m "fix: classify gemini genai rate limits"
231
+ ```
232
+
233
+ ---
234
+
235
+ ## Task 2: Add Gemini Lane Cooldown Tests and Registry
236
+
237
+ **Files:**
238
+ - Modify: `tests/test_models.py`
239
+ - Modify: `src/lilith_agent/models.py`
240
+
241
+ - [ ] **Step 1: Write failing cooldown tests**
242
+
243
+ Add these imports to `tests/test_models.py`:
244
+
245
+ ```python
246
+ from langchain_core.outputs import ChatGeneration, ChatResult
247
+
248
+ from lilith_agent.models import RateLimitCooldownError, _reset_rate_limit_state_for_tests
249
+ ```
250
+
251
+ Add this fixture so Tenacity never uses real wall-clock backoff in model tests:
252
+
253
+ ```python
254
+ @pytest.fixture(autouse=True)
255
+ def _disable_retry_sleeps(monkeypatch):
256
+ async def _no_async_sleep(_seconds: float) -> None:
257
+ return None
258
+
259
+ monkeypatch.setattr("lilith_agent.models._tenacity_sleep", lambda _seconds: None, raising=False)
260
+ monkeypatch.setattr("lilith_agent.models._async_tenacity_sleep", _no_async_sleep, raising=False)
261
+ ```
262
+
263
+ Add these fake models and tests:
264
+
265
+ ```python
266
+ class _FailingGenerateModel:
267
+ _llm_type = "failing"
268
+
269
+ def __init__(self, exc: BaseException):
270
+ self.exc = exc
271
+ self.calls = 0
272
+
273
+ def _generate(self, *args, **kwargs):
274
+ self.calls += 1
275
+ raise self.exc
276
+
277
+ async def _agenerate(self, *args, **kwargs):
278
+ self.calls += 1
279
+ raise self.exc
280
+
281
+ def bind_tools(self, tools, **kwargs):
282
+ def _raise(_msgs):
283
+ raise self.exc
284
+
285
+ return RunnableLambda(_raise)
286
+
287
+
288
+ class _SuccessfulGenerateModel:
289
+ _llm_type = "success"
290
+
291
+ def __init__(self):
292
+ self.calls = 0
293
+
294
+ def _generate(self, *args, **kwargs):
295
+ self.calls += 1
296
+ return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
297
+
298
+ async def _agenerate(self, *args, **kwargs):
299
+ self.calls += 1
300
+ return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])
301
+
302
+ def bind_tools(self, tools, **kwargs):
303
+ return RunnableLambda(lambda msgs: AIMessage(content="ok"))
304
+
305
+
306
+ def test_retry_wrapper_records_first_gemini_cooldown(monkeypatch):
307
+ _reset_rate_limit_state_for_tests()
308
+ exc = _make_genai_client_error(429)
309
+ sleeps = []
310
+ monkeypatch.setattr("lilith_agent.models.time.sleep", sleeps.append)
311
+
312
+ wrapper = _RetryWrapper.model_construct(
313
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
314
+ )
315
+
316
+ with pytest.raises(RateLimitCooldownError) as raised:
317
+ wrapper._generate([])
318
+
319
+ assert raised.value.provider == "google"
320
+ assert raised.value.model == "gemini-3.1-pro"
321
+ assert raised.value.cooldown_seconds == 60
322
+ assert sleeps == []
323
+
324
+
325
+ def test_retry_wrapper_escalates_gemini_cooldowns(monkeypatch):
326
+ _reset_rate_limit_state_for_tests()
327
+ exc = _make_genai_client_error(429)
328
+ monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
329
+ wrapper = _RetryWrapper.model_construct(
330
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
331
+ )
332
+
333
+ with pytest.raises(RateLimitCooldownError) as first:
334
+ wrapper._generate([])
335
+ with pytest.raises(RateLimitCooldownError) as second:
336
+ wrapper._generate([])
337
+ with pytest.raises(RateLimitCooldownError) as third:
338
+ wrapper._generate([])
339
+
340
+ assert first.value.cooldown_seconds == 60
341
+ assert second.value.cooldown_seconds == 120
342
+ assert third.value.cooldown_seconds == 300
343
+
344
+
345
+ def test_success_resets_lane_failure_counter(monkeypatch):
346
+ _reset_rate_limit_state_for_tests()
347
+ exc = _make_genai_client_error(429)
348
+ monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
349
+ failing = _RetryWrapper.model_construct(
350
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
351
+ )
352
+ success = _RetryWrapper.model_construct(
353
+ inner=_SuccessfulGenerateModel(), provider="google", model_name="gemini-3.1-pro"
354
+ )
355
+
356
+ with pytest.raises(RateLimitCooldownError):
357
+ failing._generate([])
358
+ success._generate([])
359
+ with pytest.raises(RateLimitCooldownError) as raised:
360
+ failing._generate([])
361
+
362
+ assert raised.value.cooldown_seconds == 60
363
+ ```
364
+
365
+ - [ ] **Step 2: Run tests to verify they fail**
366
+
367
+ Run:
368
+
369
+ ```bash
370
+ pytest tests/test_models.py::test_retry_wrapper_records_first_gemini_cooldown tests/test_models.py::test_retry_wrapper_escalates_gemini_cooldowns tests/test_models.py::test_success_resets_lane_failure_counter -v
371
+ ```
372
+
373
+ Expected: FAIL because cooldown exceptions/state are not implemented.
374
+
375
+ - [ ] **Step 3: Implement lane exceptions and registry**
376
+
377
+ Add imports in `src/lilith_agent/models.py`:
378
+
379
+ ```python
380
+ import time
381
+ from dataclasses import dataclass
382
+ ```
383
+
384
+ Add constants and exceptions after `_NO_THINK`:
385
+
386
+ ```python
387
+ _GEMINI_COOLDOWN_MODELS = {"gemini-3-flash-preview", "gemini-3.1-pro"}
388
+ _COOLDOWN_LADDER_SECONDS = (60, 120, 300)
389
+ _cooldown_until: dict[tuple[str, str], float] = {}
390
+ _rate_limit_exhaustions: dict[tuple[str, str], int] = {}
391
+
392
+
393
+ @dataclass
394
+ class RateLimitCooldownError(Exception):
395
+ provider: str
396
+ model: str
397
+ cooldown_seconds: int
398
+ original_error: str
399
+
400
+ def __str__(self) -> str:
401
+ return (
402
+ f"rate limited provider={self.provider} model={self.model} "
403
+ f"cooldown={self.cooldown_seconds}s original={self.original_error}"
404
+ )
405
+
406
+
407
+ @dataclass
408
+ class QuestionRateLimitStreakError(Exception):
409
+ count: int
410
+
411
+ def __str__(self) -> str:
412
+ return f"rate limited question streak reached {self.count}"
413
+
414
+
415
+ @dataclass
416
+ class BatchAbortRateLimitError(Exception):
417
+ reason: str
418
+ original_error: str
419
+
420
+ def __str__(self) -> str:
421
+ return f"rate limit batch abort: {self.reason}: {self.original_error}"
422
+
423
+
424
+ def _reset_rate_limit_state_for_tests() -> None:
425
+ _cooldown_until.clear()
426
+ _rate_limit_exhaustions.clear()
427
+ ```
428
+
429
+ Add helpers:
430
+
431
+ ```python
432
+ def _gemini_lane(provider: str | None, model_name: str | None) -> tuple[str, str] | None:
433
+ if provider == "google" and model_name in _GEMINI_COOLDOWN_MODELS:
434
+ return (provider, model_name)
435
+ return None
436
+
437
+
438
+ def _cooldown_seconds_for_exhaustion(count: int) -> int:
439
+ idx = min(max(count, 1), len(_COOLDOWN_LADDER_SECONDS)) - 1
440
+ return _COOLDOWN_LADDER_SECONDS[idx]
441
+
442
+
443
+ def _sleep_active_cooldown(lane: tuple[str, str] | None) -> None:
444
+ if lane is None:
445
+ return
446
+ remaining = _cooldown_until.get(lane, 0.0) - time.monotonic()
447
+ if remaining > 0:
448
+ time.sleep(remaining)
449
+
450
+
451
+ async def _sleep_active_cooldown_async(lane: tuple[str, str] | None) -> None:
452
+ if lane is None:
453
+ return
454
+ remaining = _cooldown_until.get(lane, 0.0) - time.monotonic()
455
+ if remaining > 0:
456
+ await asyncio.sleep(remaining)
457
+
458
+
459
+ def _record_success(lane: tuple[str, str] | None) -> None:
460
+ if lane is None:
461
+ return
462
+ _rate_limit_exhaustions[lane] = 0
463
+ _cooldown_until.pop(lane, None)
464
+
465
+
466
+ def _record_exhausted_rate_limit(lane: tuple[str, str], exc: BaseException) -> RateLimitCooldownError:
467
+ count = _rate_limit_exhaustions.get(lane, 0) + 1
468
+ _rate_limit_exhaustions[lane] = count
469
+ cooldown = _cooldown_seconds_for_exhaustion(count)
470
+ _cooldown_until[lane] = time.monotonic() + cooldown
471
+ return RateLimitCooldownError(
472
+ provider=lane[0],
473
+ model=lane[1],
474
+ cooldown_seconds=cooldown,
475
+ original_error=str(exc),
476
+ )
477
+ ```
478
+
479
+ Update `_RetryWrapper` fields and methods:
480
+
481
+ ```python
482
+ class _RetryWrapper(BaseChatModel):
483
+ inner: BaseChatModel
484
+ provider: str | None = None
485
+ model_name: str | None = None
486
+
487
+ @property
488
+ def _llm_type(self) -> str:
489
+ return f"retry-{self.inner._llm_type}"
490
+
491
+ def _generate(self, *args, **kwargs):
492
+ lane = _gemini_lane(self.provider, self.model_name)
493
+ _sleep_active_cooldown(lane)
494
+ try:
495
+ for attempt in Retrying(**_sync_retry_params()):
496
+ with attempt:
497
+ result = self.inner._generate(*args, **kwargs)
498
+ _record_success(lane)
499
+ return result
500
+ except Exception as exc:
501
+ if lane is not None and is_retryable_rate_limit(exc):
502
+ raise _record_exhausted_rate_limit(lane, exc) from exc
503
+ raise
504
+ ```
505
+
506
+ Leave `_agenerate` unchanged in this task. Task 8 adds failing async tests and then replaces `_agenerate` with the async version of this cooldown/retry logic.
507
+
508
+ Update `_build._wrap`:
509
+
510
+ ```python
511
+ def _wrap(m):
512
+ return _RetryWrapper(inner=m, provider=provider, model_name=model)
513
+ ```
514
+
515
+ - [ ] **Step 4: Run cooldown tests**
516
+
517
+ Run:
518
+
519
+ ```bash
520
+ pytest tests/test_models.py::test_retry_wrapper_records_first_gemini_cooldown tests/test_models.py::test_retry_wrapper_escalates_gemini_cooldowns tests/test_models.py::test_success_resets_lane_failure_counter -v
521
+ ```
522
+
523
+ Expected: PASS.
524
+
525
+ - [ ] **Step 5: Commit**
526
+
527
+ Run:
528
+
529
+ ```bash
530
+ git add src/lilith_agent/models.py tests/test_models.py
531
+ git commit -m "feat: add gemini cooldown registry"
532
+ ```
533
+
534
+ ---
535
+
536
+ ## Task 3: Add Same-Lane Sharing, Lane Isolation, and Monotonic Tests
537
+
538
+ **Files:**
539
+ - Modify: `tests/test_models.py`
540
+ - Modify: `src/lilith_agent/models.py`
541
+
542
+ - [ ] **Step 1: Write failing sharing/isolation/monotonic tests**
543
+
544
+ Add tests:
545
+
546
+ ```python
547
+ def test_same_gemini_lane_shares_cooldown(monkeypatch):
548
+ _reset_rate_limit_state_for_tests()
549
+ exc = _make_genai_client_error(429)
550
+ now = [1000.0]
551
+ sleeps = []
552
+ monkeypatch.setattr("lilith_agent.models.time.monotonic", lambda: now[0])
553
+ monkeypatch.setattr("lilith_agent.models.time.sleep", sleeps.append)
554
+ first = _RetryWrapper.model_construct(
555
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
556
+ )
557
+ second = _RetryWrapper.model_construct(
558
+ inner=_SuccessfulGenerateModel(), provider="google", model_name="gemini-3.1-pro"
559
+ )
560
+
561
+ with pytest.raises(RateLimitCooldownError):
562
+ first._generate([])
563
+ now[0] = 1005.0
564
+ second._generate([])
565
+
566
+ assert sleeps == [55.0]
567
+
568
+
569
+ def test_other_gemini_lane_does_not_sleep(monkeypatch):
570
+ _reset_rate_limit_state_for_tests()
571
+ exc = _make_genai_client_error(429)
572
+ now = [1000.0]
573
+ sleeps = []
574
+ monkeypatch.setattr("lilith_agent.models.time.monotonic", lambda: now[0])
575
+ monkeypatch.setattr("lilith_agent.models.time.sleep", sleeps.append)
576
+ pro = _RetryWrapper.model_construct(
577
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
578
+ )
579
+ flash = _RetryWrapper.model_construct(
580
+ inner=_SuccessfulGenerateModel(), provider="google", model_name="gemini-3-flash-preview"
581
+ )
582
+
583
+ with pytest.raises(RateLimitCooldownError):
584
+ pro._generate([])
585
+ now[0] = 1005.0
586
+ flash._generate([])
587
+
588
+ assert sleeps == []
589
+
590
+
591
+ def test_unknown_google_model_does_not_get_gemini_cooldown(monkeypatch):
592
+ _reset_rate_limit_state_for_tests()
593
+ exc = _make_genai_client_error(429)
594
+ monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
595
+ wrapper = _RetryWrapper.model_construct(
596
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-unknown"
597
+ )
598
+
599
+ with pytest.raises(type(exc)):
600
+ wrapper._generate([])
601
+ ```
602
+
603
+ - [ ] **Step 2: Run tests**
604
+
605
+ Run:
606
+
607
+ ```bash
608
+ pytest tests/test_models.py::test_same_gemini_lane_shares_cooldown tests/test_models.py::test_other_gemini_lane_does_not_sleep tests/test_models.py::test_unknown_google_model_does_not_get_gemini_cooldown -v
609
+ ```
610
+
611
+ Expected: PASS if Task 2 implementation is correct. If same-lane sleep fails, fix `_sleep_active_cooldown` to use `_cooldown_until[lane] - time.monotonic()`.
612
+
613
+ - [ ] **Step 3: Commit tests/fixes**
614
+
615
+ Run:
616
+
617
+ ```bash
618
+ git add src/lilith_agent/models.py tests/test_models.py
619
+ git commit -m "test: cover gemini cooldown lane behavior"
620
+ ```
621
+
622
+ ---
623
+
624
+ ## Task 4: Add Daily Quota / Batch Abort Classification
625
+
626
+ **Files:**
627
+ - Modify: `tests/test_models.py`
628
+ - Modify: `src/lilith_agent/models.py`
629
+
630
+ - [ ] **Step 1: Write failing tests for daily quota metadata**
631
+
632
+ Add helper and tests:
633
+
634
+ ```python
635
+ def _make_genai_quota_error(details: list[dict]):
636
+ pytest.importorskip("google.genai.errors")
637
+ from google.genai.errors import ClientError
638
+
639
+ return ClientError(
640
+ 429,
641
+ {
642
+ "error": {
643
+ "code": 429,
644
+ "status": "RESOURCE_EXHAUSTED",
645
+ "message": "quota exceeded",
646
+ "details": details,
647
+ }
648
+ },
649
+ )
650
+
651
+
652
+ def test_daily_quota_metadata_raises_batch_abort(monkeypatch):
653
+ _reset_rate_limit_state_for_tests()
654
+ exc = _make_genai_quota_error(
655
+ [
656
+ {
657
+ "@type": "type.googleapis.com/google.rpc.QuotaFailure",
658
+ "violations": [{"quotaId": "GenerateRequestsPerDayPerProjectPerModel"}],
659
+ }
660
+ ]
661
+ )
662
+ monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
663
+ wrapper = _RetryWrapper.model_construct(
664
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
665
+ )
666
+
667
+ with pytest.raises(BatchAbortRateLimitError) as raised:
668
+ wrapper._generate([])
669
+
670
+ assert "daily" in raised.value.reason.lower()
671
+
672
+
673
+ def test_long_retry_delay_raises_batch_abort(monkeypatch):
674
+ _reset_rate_limit_state_for_tests()
675
+ exc = _make_genai_quota_error(
676
+ [
677
+ {
678
+ "@type": "type.googleapis.com/google.rpc.RetryInfo",
679
+ "retryDelay": "900s",
680
+ }
681
+ ]
682
+ )
683
+ monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
684
+ wrapper = _RetryWrapper.model_construct(
685
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
686
+ )
687
+
688
+ with pytest.raises(BatchAbortRateLimitError) as raised:
689
+ wrapper._generate([])
690
+
691
+ assert "retry" in raised.value.reason.lower()
692
+ ```
693
+
694
+ - [ ] **Step 2: Run tests to verify they fail**
695
+
696
+ Run:
697
+
698
+ ```bash
699
+ pytest tests/test_models.py::test_daily_quota_metadata_raises_batch_abort tests/test_models.py::test_long_retry_delay_raises_batch_abort -v
700
+ ```
701
+
702
+ Expected: FAIL because metadata parsing is not implemented.
703
+
704
+ - [ ] **Step 3: Implement metadata parsing**
705
+
706
+ Add helpers to `src/lilith_agent/models.py`:
707
+
708
+ ```python
709
+ def _iter_error_details(exc: BaseException) -> list[dict[str, Any]]:
710
+ details = getattr(exc, "details", None)
711
+ if isinstance(details, dict):
712
+ nested = details.get("error", {}).get("details", details.get("details", []))
713
+ return nested if isinstance(nested, list) else []
714
+ return details if isinstance(details, list) else []
715
+
716
+
717
+ def _parse_retry_delay_seconds(value: Any) -> float | None:
718
+ if isinstance(value, str) and value.endswith("s"):
719
+ try:
720
+ return float(value[:-1])
721
+ except ValueError:
722
+ return None
723
+ if isinstance(value, (int, float)):
724
+ return float(value)
725
+ return None
726
+
727
+
728
+ def _batch_abort_reason(exc: BaseException) -> str | None:
729
+ for detail in _iter_error_details(exc):
730
+ retry_delay = _parse_retry_delay_seconds(detail.get("retryDelay"))
731
+ if retry_delay is not None and retry_delay > 600:
732
+ return f"retry delay {retry_delay:g}s exceeds batch threshold"
733
+ violations = detail.get("violations", [])
734
+ if isinstance(violations, list):
735
+ for violation in violations:
736
+ if isinstance(violation, dict) and "PerDay" in str(violation.get("quotaId", "")):
737
+ return f"daily quota exhausted: {violation.get('quotaId')}"
738
+ if "PerDay" in str(detail.get("quotaId", "")):
739
+ return f"daily quota exhausted: {detail.get('quotaId')}"
740
+ return None
741
+ ```
742
+
743
+ Update `_RetryWrapper._generate` exception path:
744
+
745
+ ```python
746
+ if lane is not None and is_retryable_rate_limit(exc):
747
+ reason = _batch_abort_reason(exc)
748
+ if reason is not None:
749
+ raise BatchAbortRateLimitError(reason=reason, original_error=str(exc)) from exc
750
+ raise _record_exhausted_rate_limit(lane, exc) from exc
751
+ ```
752
+
753
+ - [ ] **Step 4: Run tests**
754
+
755
+ Run:
756
+
757
+ ```bash
758
+ pytest tests/test_models.py::test_daily_quota_metadata_raises_batch_abort tests/test_models.py::test_long_retry_delay_raises_batch_abort -v
759
+ ```
760
+
761
+ Expected: PASS.
762
+
763
+ - [ ] **Step 5: Commit**
764
+
765
+ Run:
766
+
767
+ ```bash
768
+ git add src/lilith_agent/models.py tests/test_models.py
769
+ git commit -m "feat: abort batch on gemini daily quota"
770
+ ```
771
+
772
+ ---
773
+
774
+ ## Task 5: Add Per-Question Streak Scope
775
+
776
+ **Files:**
777
+ - Modify: `tests/test_models.py`
778
+ - Modify: `src/lilith_agent/models.py`
779
+
780
+ - [ ] **Step 1: Write failing streak tests**
781
+
782
+ Add imports:
783
+
784
+ ```python
785
+ from lilith_agent.models import rate_limit_question_scope
786
+ ```
787
+
788
+ Add tests:
789
+
790
+ ```python
791
+ def test_question_rate_limit_scope_raises_after_50_events():
792
+ _reset_rate_limit_state_for_tests()
793
+ exc = _make_genai_client_error(429)
794
+
795
+ with pytest.raises(QuestionRateLimitStreakError) as raised:
796
+ with rate_limit_question_scope():
797
+ for _ in range(50):
798
+ try:
799
+ raise exc
800
+ except BaseException as caught:
801
+ from lilith_agent.models import record_rate_limit_observation
802
+
803
+ record_rate_limit_observation(caught)
804
+
805
+ assert raised.value.count == 50
806
+
807
+
808
+ def test_question_rate_limit_scope_resets_after_success():
809
+ _reset_rate_limit_state_for_tests()
810
+ exc = _make_genai_client_error(429)
811
+
812
+ with rate_limit_question_scope():
813
+ from lilith_agent.models import record_rate_limit_observation, record_rate_limit_success
814
+
815
+ for _ in range(49):
816
+ record_rate_limit_observation(exc)
817
+ record_rate_limit_success()
818
+ for _ in range(49):
819
+ record_rate_limit_observation(exc)
820
+ ```
821
+
822
+ - [ ] **Step 2: Run tests to verify they fail**
823
+
824
+ Run:
825
+
826
+ ```bash
827
+ pytest tests/test_models.py::test_question_rate_limit_scope_raises_after_50_events tests/test_models.py::test_question_rate_limit_scope_resets_after_success -v
828
+ ```
829
+
830
+ Expected: FAIL because question scope helpers are missing.
831
+
832
+ - [ ] **Step 3: Implement contextvar scope**
833
+
834
+ Add imports in `models.py`:
835
+
836
+ ```python
837
+ from contextlib import contextmanager
838
+ from contextvars import ContextVar
839
+ ```
840
+
841
+ Add constants/state:
842
+
843
+ ```python
844
+ _QUESTION_STREAK_LIMIT = 50
845
+ _question_rate_limit_streak: ContextVar[int | None] = ContextVar("question_rate_limit_streak", default=None)
846
+ ```
847
+
848
+ Add helpers:
849
+
850
+ ```python
851
+ @contextmanager
852
+ def rate_limit_question_scope():
853
+ token = _question_rate_limit_streak.set(0)
854
+ try:
855
+ yield
856
+ finally:
857
+ _question_rate_limit_streak.reset(token)
858
+
859
+
860
+ def record_rate_limit_observation(exc: BaseException) -> None:
861
+ current = _question_rate_limit_streak.get()
862
+ if current is None or not is_retryable_rate_limit(exc):
863
+ return
864
+ current += 1
865
+ _question_rate_limit_streak.set(current)
866
+ if current >= _QUESTION_STREAK_LIMIT:
867
+ raise QuestionRateLimitStreakError(count=current) from exc
868
+
869
+
870
+ def record_rate_limit_success() -> None:
871
+ if _question_rate_limit_streak.get() is not None:
872
+ _question_rate_limit_streak.set(0)
873
+ ```
874
+
875
+ Update `_RetryWrapper._generate` so each retryable exception observed by tenacity records an observation. The 50-event budget is counted per lower-level retry attempt, not per outer GAIA task or per exhausted wrapper call. Use a Tenacity `after` callback or wrap the inner call in a `try/except` inside the attempt:
876
+
877
+ ```python
878
+ try:
879
+ result = self.inner._generate(*args, **kwargs)
880
+ except Exception as observed:
881
+ record_rate_limit_observation(observed)
882
+ raise
883
+ record_rate_limit_success()
884
+ _record_success(lane)
885
+ return result
886
+ ```
887
+
888
+ Do the same in `_BoundRetryWrapper.invoke`.
889
+
890
+ - [ ] **Step 4: Run tests**
891
+
892
+ Run:
893
+
894
+ ```bash
895
+ pytest tests/test_models.py::test_question_rate_limit_scope_raises_after_50_events tests/test_models.py::test_question_rate_limit_scope_resets_after_success -v
896
+ ```
897
+
898
+ Expected: PASS.
899
+
900
+ - [ ] **Step 5: Commit**
901
+
902
+ Run:
903
+
904
+ ```bash
905
+ git add src/lilith_agent/models.py tests/test_models.py
906
+ git commit -m "feat: track per-question rate limit streaks"
907
+ ```
908
+
909
+ ---
910
+
911
+ ## Task 6: Add Batch Sliding Window Helpers
912
+
913
+ **Files:**
914
+ - Modify: `tests/test_models.py`
915
+ - Modify: `src/lilith_agent/models.py`
916
+
917
+ - [ ] **Step 1: Write failing batch-window tests**
918
+
919
+ Add imports:
920
+
921
+ ```python
922
+ from lilith_agent.models import batch_rate_limit_pause_seconds, clear_batch_rate_limit_window
923
+ ```
924
+
925
+ Add test:
926
+
927
+ ```python
928
+ def test_batch_window_triggers_pause_after_70_rate_limits_in_100_outcomes():
929
+ _reset_rate_limit_state_for_tests()
930
+ exc = _make_genai_client_error(429)
931
+ from lilith_agent.models import record_rate_limit_observation, record_rate_limit_success
932
+
933
+ for _ in range(70):
934
+ record_rate_limit_observation(exc)
935
+ for _ in range(30):
936
+ record_rate_limit_success()
937
+
938
+ assert batch_rate_limit_pause_seconds() == 300
939
+ clear_batch_rate_limit_window()
940
+ assert batch_rate_limit_pause_seconds() is None
941
+
942
+
943
+ def test_batch_window_does_not_trigger_below_threshold():
944
+ _reset_rate_limit_state_for_tests()
945
+ exc = _make_genai_client_error(429)
946
+ from lilith_agent.models import record_rate_limit_observation, record_rate_limit_success
947
+
948
+ for _ in range(69):
949
+ record_rate_limit_observation(exc)
950
+ for _ in range(31):
951
+ record_rate_limit_success()
952
+
953
+ assert batch_rate_limit_pause_seconds() is None
954
+ ```
955
+
956
+ - [ ] **Step 2: Run tests to verify they fail**
957
+
958
+ Run:
959
+
960
+ ```bash
961
+ pytest tests/test_models.py::test_batch_window_triggers_pause_after_70_rate_limits_in_100_outcomes tests/test_models.py::test_batch_window_does_not_trigger_below_threshold -v
962
+ ```
963
+
964
+ Expected: FAIL because batch-window helpers are missing.
965
+
966
+ - [ ] **Step 3: Implement sliding-window state**
967
+
968
+ Add import:
969
+
970
+ ```python
971
+ from collections import deque
972
+ ```
973
+
974
+ Add constants/state:
975
+
976
+ ```python
977
+ _BATCH_WINDOW_SIZE = 100
978
+ _BATCH_WINDOW_RATE_LIMIT_THRESHOLD = 70
979
+ _BATCH_PAUSE_LADDER_SECONDS = (300, 600, 1200)
980
+ _batch_rate_limit_window: deque[bool] = deque(maxlen=_BATCH_WINDOW_SIZE)
981
+ _batch_pause_count = 0
982
+ ```
983
+
984
+ Update `_reset_rate_limit_state_for_tests()`:
985
+
986
+ ```python
987
+ def _reset_rate_limit_state_for_tests() -> None:
988
+ global _batch_pause_count
989
+ _cooldown_until.clear()
990
+ _rate_limit_exhaustions.clear()
991
+ _batch_rate_limit_window.clear()
992
+ _batch_pause_count = 0
993
+ ```
994
+
995
+ Update `record_rate_limit_observation` and `record_rate_limit_success`:
996
+
997
+ ```python
998
+ def record_rate_limit_observation(exc: BaseException) -> None:
999
+ if not is_retryable_rate_limit(exc):
1000
+ return
1001
+ _batch_rate_limit_window.append(True)
1002
+ current = _question_rate_limit_streak.get()
1003
+ if current is None:
1004
+ return
1005
+ current += 1
1006
+ _question_rate_limit_streak.set(current)
1007
+ if current >= _QUESTION_STREAK_LIMIT:
1008
+ raise QuestionRateLimitStreakError(count=current) from exc
1009
+
1010
+
1011
+ def record_rate_limit_success() -> None:
1012
+ _batch_rate_limit_window.append(False)
1013
+ if _question_rate_limit_streak.get() is not None:
1014
+ _question_rate_limit_streak.set(0)
1015
+ ```
1016
+
1017
+ Add helpers:
1018
+
1019
+ ```python
1020
+ def batch_rate_limit_pause_seconds() -> int | None:
1021
+ global _batch_pause_count
1022
+ if len(_batch_rate_limit_window) < _BATCH_WINDOW_SIZE:
1023
+ return None
1024
+ if sum(_batch_rate_limit_window) < _BATCH_WINDOW_RATE_LIMIT_THRESHOLD:
1025
+ return None
1026
+ _batch_pause_count += 1
1027
+ idx = min(_batch_pause_count - 1, len(_BATCH_PAUSE_LADDER_SECONDS) - 1)
1028
+ return _BATCH_PAUSE_LADDER_SECONDS[idx]
1029
+
1030
+
1031
+ def clear_batch_rate_limit_window() -> None:
1032
+ _batch_rate_limit_window.clear()
1033
+ ```
1034
+
1035
+ - [ ] **Step 4: Run tests**
1036
+
1037
+ Run:
1038
+
1039
+ ```bash
1040
+ pytest tests/test_models.py::test_batch_window_triggers_pause_after_70_rate_limits_in_100_outcomes tests/test_models.py::test_batch_window_does_not_trigger_below_threshold -v
1041
+ ```
1042
+
1043
+ Expected: PASS.
1044
+
1045
+ - [ ] **Step 5: Commit**
1046
+
1047
+ Run:
1048
+
1049
+ ```bash
1050
+ git add src/lilith_agent/models.py tests/test_models.py
1051
+ git commit -m "feat: track batch rate limit window"
1052
+ ```
1053
+
1054
+ ---
1055
+
1056
+ ## Task 7: Wire Bound Retry Wrapper to Lane Metadata
1057
+
1058
+ **Files:**
1059
+ - Modify: `tests/test_models.py`
1060
+ - Modify: `src/lilith_agent/models.py`
1061
+
1062
+ - [ ] **Step 1: Write failing bound-wrapper cooldown test**
1063
+
1064
+ Add test:
1065
+
1066
+ ```python
1067
+ def test_bound_retry_wrapper_raises_cooldown_for_gemini_lane(monkeypatch):
1068
+ _reset_rate_limit_state_for_tests()
1069
+ exc = _make_genai_client_error(429)
1070
+ monkeypatch.setattr("lilith_agent.models.time.sleep", lambda _: None)
1071
+ wrapper = _RetryWrapper.model_construct(
1072
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
1073
+ )
1074
+
1075
+ bound = wrapper.bind_tools([])
1076
+
1077
+ with pytest.raises(RateLimitCooldownError) as raised:
1078
+ bound.invoke([("user", "hi")])
1079
+
1080
+ assert raised.value.model == "gemini-3.1-pro"
1081
+ ```
1082
+
1083
+ - [ ] **Step 2: Run test to verify it fails**
1084
+
1085
+ Run:
1086
+
1087
+ ```bash
1088
+ pytest tests/test_models.py::test_bound_retry_wrapper_raises_cooldown_for_gemini_lane -v
1089
+ ```
1090
+
1091
+ Expected: FAIL because `_BoundRetryWrapper` does not receive provider/model metadata.
1092
+
1093
+ - [ ] **Step 3: Update `_BoundRetryWrapper`**
1094
+
1095
+ Update `bind_tools` in `_RetryWrapper`:
1096
+
1097
+ ```python
1098
+ def bind_tools(self, tools: Any, **kwargs: Any):
1099
+ bound = self.inner.bind_tools(tools, **kwargs)
1100
+ return _BoundRetryWrapper(bound=bound, provider=self.provider, model_name=self.model_name)
1101
+ ```
1102
+
1103
+ Update `_BoundRetryWrapper`:
1104
+
1105
+ ```python
1106
+ class _BoundRetryWrapper(Runnable):
1107
+ def __init__(self, bound, provider: str | None = None, model_name: str | None = None):
1108
+ self._bound = bound
1109
+ self._provider = provider
1110
+ self._model_name = model_name
1111
+
1112
+ def invoke(self, input, config=None, **kwargs):
1113
+ lane = _gemini_lane(self._provider, self._model_name)
1114
+ _sleep_active_cooldown(lane)
1115
+ try:
1116
+ for attempt in Retrying(**_sync_retry_params()):
1117
+ with attempt:
1118
+ try:
1119
+ result = self._bound.invoke(input, config=config, **kwargs)
1120
+ except Exception as observed:
1121
+ record_rate_limit_observation(observed)
1122
+ raise
1123
+ record_rate_limit_success()
1124
+ _record_success(lane)
1125
+ return result
1126
+ except Exception as exc:
1127
+ if lane is not None and is_retryable_rate_limit(exc):
1128
+ reason = _batch_abort_reason(exc)
1129
+ if reason is not None:
1130
+ raise BatchAbortRateLimitError(reason=reason, original_error=str(exc)) from exc
1131
+ raise _record_exhausted_rate_limit(lane, exc) from exc
1132
+ raise
1133
+ ```
1134
+
1135
+ - [ ] **Step 4: Run bound-wrapper tests**
1136
+
1137
+ Run:
1138
+
1139
+ ```bash
1140
+ pytest tests/test_models.py::test_retry_wrapper_bind_tools_returns_runnable tests/test_models.py::test_retry_wrapper_bound_invoke_passes_through tests/test_models.py::test_bound_retry_wrapper_raises_cooldown_for_gemini_lane -v
1141
+ ```
1142
+
1143
+ Expected: PASS.
1144
+
1145
+ - [ ] **Step 5: Commit**
1146
+
1147
+ Run:
1148
+
1149
+ ```bash
1150
+ git add src/lilith_agent/models.py tests/test_models.py
1151
+ git commit -m "feat: apply cooldown to bound model calls"
1152
+ ```
1153
+
1154
+ ---
1155
+
1156
+ ## Task 8: Add Async Retry/Cooldown Parity
1157
+
1158
+ **Files:**
1159
+ - Modify: `tests/test_models.py`
1160
+ - Modify: `src/lilith_agent/models.py`
1161
+
1162
+ - [ ] **Step 1: Write failing async parity tests**
1163
+
1164
+ Add tests:
1165
+
1166
+ ```python
1167
+ @pytest.mark.asyncio
1168
+ async def test_async_retry_wrapper_raises_cooldown_for_gemini_lane(monkeypatch):
1169
+ _reset_rate_limit_state_for_tests()
1170
+ exc = _make_genai_client_error(429)
1171
+ monkeypatch.setattr("lilith_agent.models.asyncio.sleep", AsyncMock())
1172
+ wrapper = _RetryWrapper.model_construct(
1173
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
1174
+ )
1175
+
1176
+ with pytest.raises(RateLimitCooldownError) as raised:
1177
+ await wrapper._agenerate([])
1178
+
1179
+ assert raised.value.model == "gemini-3.1-pro"
1180
+
1181
+
1182
+ @pytest.mark.asyncio
1183
+ async def test_async_bound_retry_wrapper_raises_cooldown_for_gemini_lane(monkeypatch):
1184
+ _reset_rate_limit_state_for_tests()
1185
+ exc = _make_genai_client_error(429)
1186
+ monkeypatch.setattr("lilith_agent.models.asyncio.sleep", AsyncMock())
1187
+ wrapper = _RetryWrapper.model_construct(
1188
+ inner=_FailingGenerateModel(exc), provider="google", model_name="gemini-3.1-pro"
1189
+ )
1190
+
1191
+ bound = wrapper.bind_tools([])
1192
+
1193
+ with pytest.raises(RateLimitCooldownError) as raised:
1194
+ await bound.ainvoke([("user", "hi")])
1195
+
1196
+ assert raised.value.model == "gemini-3.1-pro"
1197
+ ```
1198
+
1199
+ Add this import if missing:
1200
+
1201
+ ```python
1202
+ from unittest.mock import AsyncMock
1203
+ ```
1204
+
1205
+ - [ ] **Step 2: Run tests to verify they fail**
1206
+
1207
+ Run:
1208
+
1209
+ ```bash
1210
+ pytest tests/test_models.py::test_async_retry_wrapper_raises_cooldown_for_gemini_lane tests/test_models.py::test_async_bound_retry_wrapper_raises_cooldown_for_gemini_lane -v
1211
+ ```
1212
+
1213
+ Expected: FAIL because `_agenerate` and `ainvoke` still use the old generic retry behavior.
1214
+
1215
+ - [ ] **Step 3: Implement async parity in wrappers**
1216
+
1217
+ Update `_RetryWrapper._agenerate`:
1218
+
1219
+ ```python
1220
+ async def _agenerate(self, *args, **kwargs):
1221
+ lane = _gemini_lane(self.provider, self.model_name)
1222
+ await _sleep_active_cooldown_async(lane)
1223
+ try:
1224
+ async for attempt in AsyncRetrying(**_async_retry_params()):
1225
+ with attempt:
1226
+ try:
1227
+ result = await self.inner._agenerate(*args, **kwargs)
1228
+ except Exception as observed:
1229
+ record_rate_limit_observation(observed)
1230
+ raise
1231
+ record_rate_limit_success()
1232
+ _record_success(lane)
1233
+ return result
1234
+ except Exception as exc:
1235
+ if lane is not None and is_retryable_rate_limit(exc):
1236
+ reason = _batch_abort_reason(exc)
1237
+ if reason is not None:
1238
+ raise BatchAbortRateLimitError(reason=reason, original_error=str(exc)) from exc
1239
+ raise _record_exhausted_rate_limit(lane, exc) from exc
1240
+ raise
1241
+ ```
1242
+
1243
+ Update `_BoundRetryWrapper.ainvoke`:
1244
+
1245
+ ```python
1246
+ async def ainvoke(self, input, config=None, **kwargs):
1247
+ lane = _gemini_lane(self._provider, self._model_name)
1248
+ await _sleep_active_cooldown_async(lane)
1249
+ try:
1250
+ async for attempt in AsyncRetrying(**_async_retry_params()):
1251
+ with attempt:
1252
+ try:
1253
+ result = await self._bound.ainvoke(input, config=config, **kwargs)
1254
+ except Exception as observed:
1255
+ record_rate_limit_observation(observed)
1256
+ raise
1257
+ record_rate_limit_success()
1258
+ _record_success(lane)
1259
+ return result
1260
+ except Exception as exc:
1261
+ if lane is not None and is_retryable_rate_limit(exc):
1262
+ reason = _batch_abort_reason(exc)
1263
+ if reason is not None:
1264
+ raise BatchAbortRateLimitError(reason=reason, original_error=str(exc)) from exc
1265
+ raise _record_exhausted_rate_limit(lane, exc) from exc
1266
+ raise
1267
+ ```
1268
+
1269
+ - [ ] **Step 4: Run async parity tests**
1270
+
1271
+ Run:
1272
+
1273
+ ```bash
1274
+ pytest tests/test_models.py::test_async_retry_wrapper_raises_cooldown_for_gemini_lane tests/test_models.py::test_async_bound_retry_wrapper_raises_cooldown_for_gemini_lane -v
1275
+ ```
1276
+
1277
+ Expected: PASS.
1278
+
1279
+ - [ ] **Step 5: Commit**
1280
+
1281
+ Run:
1282
+
1283
+ ```bash
1284
+ git add src/lilith_agent/models.py tests/test_models.py
1285
+ git commit -m "feat: apply gemini cooldown to async model calls"
1286
+ ```
1287
+
1288
+ ---
1289
+
1290
+ ## Task 9: Wire Runner Retry-Once Behavior
1291
+
1292
+ **Files:**
1293
+ - Modify: `tests/test_runner.py`
1294
+ - Modify: `src/lilith_agent/runner.py`
1295
+
1296
+ - [ ] **Step 1: Write failing runner retry test**
1297
+
1298
+ Add imports to `tests/test_runner.py`:
1299
+
1300
+ ```python
1301
+ from langchain_core.messages import AIMessage
1302
+
1303
+ from lilith_agent.config import Config
1304
+ from lilith_agent.models import RateLimitCooldownError
1305
+ from lilith_agent.runner import run_agent_on_questions
1306
+ ```
1307
+
1308
+ Add shared runner-test fixtures, fake graph, and test:
1309
+
1310
+ ```python
1311
+ @pytest.fixture
1312
+ def runner_test_config() -> Config:
1313
+ return Config(
1314
+ cheap_provider="google",
1315
+ cheap_model="gemini-3-flash-preview",
1316
+ strong_provider="google",
1317
+ strong_model="gemini-3.1-pro",
1318
+ extra_strong_provider="google",
1319
+ extra_strong_model="gemini-3.1-pro",
1320
+ vision_provider="fal",
1321
+ vision_model="gemini-3-flash-preview",
1322
+ fal_vision_api_key="",
1323
+ api_url="",
1324
+ checkpoint_dir="",
1325
+ whisper_model="base",
1326
+ anthropic_api_key="",
1327
+ google_api_key="",
1328
+ huggingface_api_key="",
1329
+ tavily_api_key="",
1330
+ lmstudio_base_url="",
1331
+ max_tokens=1024,
1332
+ llm_formatter_enabled=True,
1333
+ )
1334
+
1335
+
1336
+ @pytest.fixture(autouse=True)
1337
+ def _isolate_runner_model_setup(monkeypatch, runner_test_config):
1338
+ monkeypatch.setattr(Config, "from_env", classmethod(lambda cls: runner_test_config))
1339
+ monkeypatch.setattr("lilith_agent.models.get_cheap_model", lambda cfg: object())
1340
+
1341
+
1342
+ class _GraphFailsOnceWithCooldown:
1343
+ def __init__(self):
1344
+ self.calls = 0
1345
+ self.thread_ids = []
1346
+
1347
+ def invoke(self, state, config):
1348
+ self.calls += 1
1349
+ self.thread_ids.append(config["configurable"]["thread_id"])
1350
+ if self.calls == 1:
1351
+ raise RateLimitCooldownError(
1352
+ provider="google",
1353
+ model="gemini-3.1-pro",
1354
+ cooldown_seconds=12,
1355
+ original_error="429",
1356
+ )
1357
+ return {"messages": [AIMessage(content="Final Answer: 42")]}
1358
+
1359
+
1360
+ def test_runner_retries_same_question_once_after_cooldown(monkeypatch, tmp_path: Path):
1361
+ monkeypatch.setattr("lilith_agent.runner._final_formatting_cleanup", lambda model, question, raw, llm_formatter_enabled=True: raw)
1362
+ sleeps = []
1363
+ monkeypatch.setattr("lilith_agent.runner.time.sleep", sleeps.append)
1364
+ graph = _GraphFailsOnceWithCooldown()
1365
+
1366
+ answers = run_agent_on_questions(
1367
+ graph,
1368
+ [{"task_id": "task-1", "question": "What is 6*7?"}],
1369
+ tmp_path,
1370
+ )
1371
+
1372
+ assert graph.calls == 2
1373
+ assert graph.thread_ids == ["task-1", "task-1"]
1374
+ assert sleeps == [12]
1375
+ assert answers == [{"task_id": "task-1", "submitted_answer": "Final Answer: 42"}]
1376
+ assert (tmp_path / "task-1.json").exists()
1377
+ ```
1378
+
1379
+ - [ ] **Step 2: Run test to verify it fails**
1380
+
1381
+ Run:
1382
+
1383
+ ```bash
1384
+ pytest tests/test_runner.py::test_runner_retries_same_question_once_after_cooldown -v
1385
+ ```
1386
+
1387
+ Expected: FAIL because runner does not catch `RateLimitCooldownError` specially.
1388
+
1389
+ - [ ] **Step 3: Implement retry-once flow**
1390
+
1391
+ Add import at top of `runner.py`:
1392
+
1393
+ ```python
1394
+ import time
1395
+ ```
1396
+
1397
+ Inside `run_agent_on_questions`, add lazy imports near existing config imports:
1398
+
1399
+ ```python
1400
+ from lilith_agent.models import RateLimitCooldownError, rate_limit_question_scope
1401
+ ```
1402
+
1403
+ Create local helper inside `run_agent_on_questions` before the `for` loop:
1404
+
1405
+ ```python
1406
+ def _invoke_task_once(task_state: dict, task_id: str):
1407
+ from lilith_agent.memory import ephemeral_memory
1408
+
1409
+ with rate_limit_question_scope():
1410
+ with ephemeral_memory():
1411
+ return graph.invoke(task_state, {"configurable": {"thread_id": task_id}})
1412
+ ```
1413
+
1414
+ Replace the existing `try/except` around `ephemeral_memory()` with:
1415
+
1416
+ ```python
1417
+ try:
1418
+ try:
1419
+ result = _invoke_task_once(state, task_id)
1420
+ except RateLimitCooldownError as exc:
1421
+ log_runner.warning(
1422
+ "[runner] task=%s rate limited provider=%s model=%s cooldown=%s",
1423
+ task_id,
1424
+ exc.provider,
1425
+ exc.model,
1426
+ exc.cooldown_seconds,
1427
+ )
1428
+ time.sleep(exc.cooldown_seconds)
1429
+ result = _invoke_task_once(state, task_id)
1430
+ except RateLimitCooldownError as exc:
1431
+ log_runner.warning("[runner] task=%s rate limited after retry: %s", task_id, exc)
1432
+ answers.append({"task_id": task_id, "submitted_answer": "AGENT ERROR: RATE LIMITED"})
1433
+ continue
1434
+ except Exception as exc:
1435
+ log_runner.warning("[runner] task=%s agent error: %s", task_id, exc)
1436
+ answers.append({"task_id": task_id, "submitted_answer": f"AGENT ERROR: {exc}"})
1437
+ continue
1438
+ ```
1439
+
1440
+ - [ ] **Step 4: Run test**
1441
+
1442
+ Run:
1443
+
1444
+ ```bash
1445
+ pytest tests/test_runner.py::test_runner_retries_same_question_once_after_cooldown -v
1446
+ ```
1447
+
1448
+ Expected: PASS.
1449
+
1450
+ - [ ] **Step 5: Commit**
1451
+
1452
+ Run:
1453
+
1454
+ ```bash
1455
+ git add src/lilith_agent/runner.py tests/test_runner.py
1456
+ git commit -m "feat: retry gaia task once after cooldown"
1457
+ ```
1458
+
1459
+ ---
1460
+
1461
+ ## Task 10: Runner Rate-Limit Failure Does Not Checkpoint
1462
+
1463
+ **Files:**
1464
+ - Modify: `tests/test_runner.py`
1465
+ - Modify: `src/lilith_agent/runner.py`
1466
+
1467
+ - [ ] **Step 1: Write failing no-checkpoint test**
1468
+
1469
+ Add fake graph and test:
1470
+
1471
+ ```python
1472
+ class _GraphAlwaysCooldown:
1473
+ def __init__(self):
1474
+ self.calls = 0
1475
+
1476
+ def invoke(self, state, config):
1477
+ self.calls += 1
1478
+ raise RateLimitCooldownError(
1479
+ provider="google",
1480
+ model="gemini-3.1-pro",
1481
+ cooldown_seconds=3,
1482
+ original_error="429",
1483
+ )
1484
+
1485
+
1486
+ def test_runner_does_not_checkpoint_when_rate_limited_twice(monkeypatch, tmp_path: Path):
1487
+ monkeypatch.setattr("lilith_agent.runner._final_formatting_cleanup", lambda model, question, raw, llm_formatter_enabled=True: raw)
1488
+ monkeypatch.setattr("lilith_agent.runner.time.sleep", lambda _: None)
1489
+ graph = _GraphAlwaysCooldown()
1490
+
1491
+ answers = run_agent_on_questions(
1492
+ graph,
1493
+ [{"task_id": "task-rl", "question": "rate limited?"}],
1494
+ tmp_path,
1495
+ )
1496
+
1497
+ assert graph.calls == 2
1498
+ assert answers == [{"task_id": "task-rl", "submitted_answer": "AGENT ERROR: RATE LIMITED"}]
1499
+ assert not (tmp_path / "task-rl.json").exists()
1500
+ ```
1501
+
1502
+ - [ ] **Step 2: Run test**
1503
+
1504
+ Run:
1505
+
1506
+ ```bash
1507
+ pytest tests/test_runner.py::test_runner_does_not_checkpoint_when_rate_limited_twice -v
1508
+ ```
1509
+
1510
+ Expected: PASS if Task 9 implementation is correct. If it fails by writing a checkpoint, ensure the rate-limit exception path `continue`s before formatting/checkpointing.
1511
+
1512
+ - [ ] **Step 3: Commit test/fix**
1513
+
1514
+ Run:
1515
+
1516
+ ```bash
1517
+ git add src/lilith_agent/runner.py tests/test_runner.py
1518
+ git commit -m "test: avoid checkpoint on rate limit failure"
1519
+ ```
1520
+
1521
+ ---
1522
+
1523
+ ## Task 11: Runner Fresh Ephemeral Memory Per Retry
1524
+
1525
+ **Files:**
1526
+ - Modify: `tests/test_runner.py`
1527
+ - Modify: `src/lilith_agent/runner.py`
1528
+
1529
+ - [ ] **Step 1: Write failing fresh-memory test**
1530
+
1531
+ Add test:
1532
+
1533
+ ```python
1534
+ def test_runner_uses_fresh_ephemeral_memory_for_retry(monkeypatch, tmp_path: Path):
1535
+ graph = _GraphFailsOnceWithCooldown()
1536
+ events = []
1537
+
1538
+ class _FakeEphemeralMemory:
1539
+ def __enter__(self):
1540
+ events.append("enter")
1541
+
1542
+ def __exit__(self, exc_type, exc, tb):
1543
+ events.append("exit")
1544
+
1545
+ monkeypatch.setattr("lilith_agent.memory.ephemeral_memory", lambda: _FakeEphemeralMemory())
1546
+ monkeypatch.setattr("lilith_agent.runner._final_formatting_cleanup", lambda model, question, raw, llm_formatter_enabled=True: raw)
1547
+ monkeypatch.setattr("lilith_agent.runner.time.sleep", lambda _: None)
1548
+
1549
+ run_agent_on_questions(
1550
+ graph,
1551
+ [{"task_id": "task-memory", "question": "What is isolated?"}],
1552
+ tmp_path,
1553
+ )
1554
+
1555
+ assert events == ["enter", "exit", "enter", "exit"]
1556
+ ```
1557
+
1558
+ - [ ] **Step 2: Run test**
1559
+
1560
+ Run:
1561
+
1562
+ ```bash
1563
+ pytest tests/test_runner.py::test_runner_uses_fresh_ephemeral_memory_for_retry -v
1564
+ ```
1565
+
1566
+ Expected: PASS if `_invoke_task_once` imports and opens `ephemeral_memory()` per attempt. If it fails, ensure the helper wraps exactly one graph invocation.
1567
+
1568
+ - [ ] **Step 3: Commit**
1569
+
1570
+ Run:
1571
+
1572
+ ```bash
1573
+ git add src/lilith_agent/runner.py tests/test_runner.py
1574
+ git commit -m "test: verify fresh memory on cooldown retry"
1575
+ ```
1576
+
1577
+ ---
1578
+
1579
+ ## Task 12: Runner Question Streak and Batch Abort Handling
1580
+
1581
+ **Files:**
1582
+ - Modify: `tests/test_runner.py`
1583
+ - Modify: `src/lilith_agent/runner.py`
1584
+
1585
+ - [ ] **Step 1: Write failing runner tests**
1586
+
1587
+ Add imports:
1588
+
1589
+ ```python
1590
+ import json
1591
+
1592
+ from lilith_agent.models import BatchAbortRateLimitError, QuestionRateLimitStreakError
1593
+ ```
1594
+
1595
+ Add fake graphs and tests:
1596
+
1597
+ ```python
1598
+ class _GraphQuestionStreak:
1599
+ def invoke(self, state, config):
1600
+ raise QuestionRateLimitStreakError(count=50)
1601
+
1602
+
1603
+ class _GraphBatchAbort:
1604
+ def invoke(self, state, config):
1605
+ raise BatchAbortRateLimitError(reason="daily quota exhausted", original_error="429")
1606
+
1607
+
1608
+ def test_runner_skips_question_on_rate_limit_streak(tmp_path: Path):
1609
+ answers = run_agent_on_questions(
1610
+ _GraphQuestionStreak(),
1611
+ [
1612
+ {"task_id": "task-streak", "question": "first"},
1613
+ {"task_id": "task-next", "question": "second"},
1614
+ ],
1615
+ tmp_path,
1616
+ )
1617
+
1618
+ assert answers[0] == {"task_id": "task-streak", "submitted_answer": "AGENT ERROR: RATE LIMITED"}
1619
+ assert not (tmp_path / "task-streak.json").exists()
1620
+
1621
+
1622
+ def test_runner_stops_batch_and_writes_abort_marker_on_daily_quota(tmp_path: Path):
1623
+ answers = run_agent_on_questions(
1624
+ _GraphBatchAbort(),
1625
+ [
1626
+ {"task_id": "task-abort", "question": "first"},
1627
+ {"task_id": "task-never", "question": "second"},
1628
+ ],
1629
+ tmp_path,
1630
+ )
1631
+
1632
+ assert answers == [{"task_id": "task-abort", "submitted_answer": "AGENT ERROR: RATE LIMITED"}]
1633
+ marker = tmp_path / "rate_limit_abort.json"
1634
+ assert marker.exists()
1635
+ data = json.loads(marker.read_text())
1636
+ assert data["task_id"] == "task-abort"
1637
+ assert data["reason"] == "daily quota exhausted"
1638
+ assert not (tmp_path / "task-abort.json").exists()
1639
+ assert not (tmp_path / "task-never.json").exists()
1640
+ ```
1641
+
1642
+ - [ ] **Step 2: Run tests to verify they fail**
1643
+
1644
+ Run:
1645
+
1646
+ ```bash
1647
+ pytest tests/test_runner.py::test_runner_skips_question_on_rate_limit_streak tests/test_runner.py::test_runner_stops_batch_and_writes_abort_marker_on_daily_quota -v
1648
+ ```
1649
+
1650
+ Expected: FAIL because runner does not catch these exceptions specially.
1651
+
1652
+ - [ ] **Step 3: Implement runner handlers**
1653
+
1654
+ Update runner lazy imports:
1655
+
1656
+ ```python
1657
+ from lilith_agent.models import (
1658
+ BatchAbortRateLimitError,
1659
+ QuestionRateLimitStreakError,
1660
+ RateLimitCooldownError,
1661
+ rate_limit_question_scope,
1662
+ )
1663
+ ```
1664
+
1665
+ Add exception handlers before the generic `except Exception`:
1666
+
1667
+ ```python
1668
+ except QuestionRateLimitStreakError as exc:
1669
+ log_runner.warning("[runner] task=%s rate limit streak: %s", task_id, exc)
1670
+ answers.append({"task_id": task_id, "submitted_answer": "AGENT ERROR: RATE LIMITED"})
1671
+ continue
1672
+ except BatchAbortRateLimitError as exc:
1673
+ log_runner.warning("[runner] task=%s batch abort rate limit: %s", task_id, exc)
1674
+ answers.append({"task_id": task_id, "submitted_answer": "AGENT ERROR: RATE LIMITED"})
1675
+ _write_checkpoint_atomic(
1676
+ checkpoint_root / "rate_limit_abort.json",
1677
+ {
1678
+ "task_id": task_id,
1679
+ "reason": exc.reason,
1680
+ "original_error": exc.original_error,
1681
+ },
1682
+ )
1683
+ return answers
1684
+ ```
1685
+
1686
+ Ensure these handlers catch failures from both the first invocation and the retry invocation.
1687
+
1688
+ - [ ] **Step 4: Run tests**
1689
+
1690
+ Run:
1691
+
1692
+ ```bash
1693
+ pytest tests/test_runner.py::test_runner_skips_question_on_rate_limit_streak tests/test_runner.py::test_runner_stops_batch_and_writes_abort_marker_on_daily_quota -v
1694
+ ```
1695
+
1696
+ Expected: PASS.
1697
+
1698
+ - [ ] **Step 5: Commit**
1699
+
1700
+ Run:
1701
+
1702
+ ```bash
1703
+ git add src/lilith_agent/runner.py tests/test_runner.py
1704
+ git commit -m "feat: handle rate limit streak and batch abort"
1705
+ ```
1706
+
1707
+ ---
1708
+
1709
+ ## Task 13: Runner Batch Pause Between Tasks
1710
+
1711
+ **Files:**
1712
+ - Modify: `tests/test_runner.py`
1713
+ - Modify: `src/lilith_agent/runner.py`
1714
+
1715
+ - [ ] **Step 1: Write failing batch-pause test**
1716
+
1717
+ Add fake graph and test:
1718
+
1719
+ ```python
1720
+ class _GraphAlwaysSucceeds:
1721
+ def __init__(self):
1722
+ self.calls = 0
1723
+
1724
+ def invoke(self, state, config):
1725
+ self.calls += 1
1726
+ return {"messages": [AIMessage(content=f"answer-{self.calls}")]}
1727
+
1728
+
1729
+ def test_runner_pauses_batch_when_window_trips(monkeypatch, tmp_path: Path):
1730
+ pauses = [300, None]
1731
+ sleeps = []
1732
+ monkeypatch.setattr("lilith_agent.models.batch_rate_limit_pause_seconds", lambda: pauses.pop(0))
1733
+ monkeypatch.setattr("lilith_agent.models.clear_batch_rate_limit_window", lambda: None)
1734
+ monkeypatch.setattr("lilith_agent.runner.time.sleep", sleeps.append)
1735
+ monkeypatch.setattr("lilith_agent.runner._final_formatting_cleanup", lambda model, question, raw, llm_formatter_enabled=True: raw)
1736
+
1737
+ answers = run_agent_on_questions(
1738
+ _GraphAlwaysSucceeds(),
1739
+ [
1740
+ {"task_id": "task-a", "question": "a"},
1741
+ {"task_id": "task-b", "question": "b"},
1742
+ ],
1743
+ tmp_path,
1744
+ )
1745
+
1746
+ assert sleeps == [300]
1747
+ assert [answer["task_id"] for answer in answers] == ["task-a", "task-b"]
1748
+ ```
1749
+
1750
+ - [ ] **Step 2: Run test to verify it fails**
1751
+
1752
+ Run:
1753
+
1754
+ ```bash
1755
+ pytest tests/test_runner.py::test_runner_pauses_batch_when_window_trips -v
1756
+ ```
1757
+
1758
+ Expected: FAIL because runner does not check batch pause helper.
1759
+
1760
+ - [ ] **Step 3: Implement pause check after each task**
1761
+
1762
+ Update imports:
1763
+
1764
+ ```python
1765
+ from lilith_agent.models import clear_batch_rate_limit_window, batch_rate_limit_pause_seconds
1766
+ ```
1767
+
1768
+ After appending each successful answer and after each non-batch-abort rate-limit answer, call:
1769
+
1770
+ ```python
1771
+ pause_seconds = batch_rate_limit_pause_seconds()
1772
+ if pause_seconds is not None:
1773
+ log_runner.warning("[runner] pausing batch for %ss due to rate limit window", pause_seconds)
1774
+ time.sleep(pause_seconds)
1775
+ clear_batch_rate_limit_window()
1776
+ ```
1777
+
1778
+ To avoid duplicating this block, define a local helper inside `run_agent_on_questions`:
1779
+
1780
+ ```python
1781
+ def _maybe_pause_for_batch_rate_limit() -> None:
1782
+ pause_seconds = batch_rate_limit_pause_seconds()
1783
+ if pause_seconds is None:
1784
+ return
1785
+ log_runner.warning("[runner] pausing batch for %ss due to rate limit window", pause_seconds)
1786
+ time.sleep(pause_seconds)
1787
+ clear_batch_rate_limit_window()
1788
+ ```
1789
+
1790
+ Call `_maybe_pause_for_batch_rate_limit()` immediately before each `continue` for non-abort task failures and after appending successful answers.
1791
+
1792
+ - [ ] **Step 4: Run test**
1793
+
1794
+ Run:
1795
+
1796
+ ```bash
1797
+ pytest tests/test_runner.py::test_runner_pauses_batch_when_window_trips -v
1798
+ ```
1799
+
1800
+ Expected: PASS.
1801
+
1802
+ - [ ] **Step 5: Commit**
1803
+
1804
+ Run:
1805
+
1806
+ ```bash
1807
+ git add src/lilith_agent/runner.py tests/test_runner.py
1808
+ git commit -m "feat: pause batch on high rate limit window"
1809
+ ```
1810
+
1811
+ ---
1812
+
1813
+ ## Task 14: Full Verification and Cleanup
1814
+
1815
+ **Files:**
1816
+ - Modify only if verification reveals regressions: `src/lilith_agent/models.py`, `src/lilith_agent/runner.py`, `tests/test_models.py`, `tests/test_runner.py`
1817
+
1818
+ - [ ] **Step 1: Run focused test suite**
1819
+
1820
+ Run:
1821
+
1822
+ ```bash
1823
+ pytest tests/test_models.py tests/test_runner.py -v
1824
+ ```
1825
+
1826
+ Expected: all tests pass.
1827
+
1828
+ - [ ] **Step 2: Run broader tests**
1829
+
1830
+ Run:
1831
+
1832
+ ```bash
1833
+ pytest -q
1834
+ ```
1835
+
1836
+ Expected: all tests pass. If any test fails, classify it before accepting it: either fix the regression, or document the exact failing test name, command output, and why it is an existing environment-bound failure unrelated to this patch. Do not claim a full pass unless `pytest -q` exits 0.
1837
+
1838
+ - [ ] **Step 3: Inspect final diff**
1839
+
1840
+ Run:
1841
+
1842
+ ```bash
1843
+ git diff -- src/lilith_agent/models.py src/lilith_agent/runner.py tests/test_models.py tests/test_runner.py
1844
+ ```
1845
+
1846
+ Expected:
1847
+
1848
+ - No edits to `memory.py`.
1849
+ - No edits to `app.py` web-search dedup.
1850
+ - No embedding dependencies/schema changes.
1851
+ - No modifications to unrelated untracked files.
1852
+
1853
+ - [ ] **Step 4: Commit any verification fixes**
1854
+
1855
+ If Step 1 or Step 2 required fixes, run:
1856
+
1857
+ ```bash
1858
+ git add src/lilith_agent/models.py src/lilith_agent/runner.py tests/test_models.py tests/test_runner.py
1859
+ git commit -m "fix: stabilize gemini cooldown tests"
1860
+ ```
1861
+
1862
+ If no fixes were required, skip this commit.
1863
+
1864
+ ---
1865
+
1866
+ ## Self-Review Against Spec
1867
+
1868
+ - [ ] Correct Gemini 429 detection: Task 1.
1869
+ - [ ] Non-429 `ClientError` does not retry: Task 1.
1870
+ - [ ] Shared lane cooldown with 60/120/300 seconds: Task 2.
1871
+ - [ ] Same-lane sharing and other-lane isolation: Task 3.
1872
+ - [ ] Monotonic time for cooldown: Task 3.
1873
+ - [ ] Daily/RPD batch abort: Task 4 and Task 12.
1874
+ - [ ] Per-question 50-event streak: Task 5 and Task 12.
1875
+ - [ ] Cross-task sliding window and batch pause: Task 6 and Task 13.
1876
+ - [ ] Bound wrapper cooldown propagation: Task 7.
1877
+ - [ ] Async `_agenerate` / `ainvoke` cooldown parity: Task 8.
1878
+ - [ ] Fresh `ephemeral_memory()` retry: Task 9 and Task 11.
1879
+ - [ ] No success checkpoint on rate-limit failure: Task 10.
1880
+ - [ ] Unknown model does not get Gemini cooldown profile: Task 3.
1881
+ - [ ] No `memory.py`, `app.py`, embedding, or unrelated-file changes: Task 14.