Skip to content

Commit e3d7451

Browse files
committed
Add retry and code review step.
1 parent eea22ab commit e3d7451

12 files changed

Lines changed: 306 additions & 27 deletions

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ A Python port of the Emacs [gptel-agent-harness](https://github.com/beacoder/gpt
77
- **Agent loop with completion supervision** — the model is nudged (max 2) when it
88
tries to stop before the task is complete; the nudge counter resets on tool
99
calls; tool results are sanitized so a failed call never strands the loop.
10+
- **API retry with backoff** — transient failures (HTTP 429 / 5xx, connection
11+
errors) are retried automatically with exponential backoff + jitter
12+
(honoring `Retry-After`), so a rate limit or a dropped connection no longer
13+
kills the run; retries never duplicate streamed output and a Ctrl-C aborts
14+
the backoff wait promptly. Permanent errors (other 4xx) fail fast.
1015
- **Context management** — CJK-aware token estimation, per-model context
1116
windows (deepseek-v4/glm-5.2 1M, gpt-5 400k, kimi-k2.7 256k, claude 200k,
1217
...), self-calibrating estimates from API-reported input tokens, and

python_agent_harness/agent.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,9 @@ def safe_delta(text: str) -> None:
421421
# sub-agents must not stream into the parent's live
422422
# stream row — their text is private until returned
423423
on_delta=(safe_delta if self.top_level else None),
424+
# poll cancellation during retry backoff so Ctrl-C
425+
# aborts promptly instead of after the full sleep
426+
cancel_check=self._is_cancelled,
424427
)
425428
except Exception as e: # noqa: BLE001 - API errors become ERRS
426429
if self._is_cancelled():

python_agent_harness/client.py

Lines changed: 138 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import json
1111
import os
12+
import random
1213
import time
1314
from pathlib import Path
1415
from typing import Any, Callable, Iterator
@@ -23,6 +24,46 @@ class ApiError(Exception):
2324
"""Raised when the API call itself fails (network/HTTP)."""
2425

2526

27+
class RetryableApiError(ApiError):
28+
"""A transient failure (rate limit, server error) safe to retry.
29+
30+
Carries the server's ``Retry-After`` value (if any) so the retry
31+
backoff can honor it. Permanent errors remain a plain ApiError.
32+
"""
33+
34+
def __init__(self, message: str, retry_after: str | None = None) -> None:
35+
super().__init__(message)
36+
self.retry_after = retry_after
37+
38+
39+
def _retryable_status(status: int) -> bool:
40+
"""429 and 5xx are transient; every other error is permanent."""
41+
return status == 429 or status >= 500
42+
43+
44+
def _retry_delay(
45+
attempt: int,
46+
retry_after: str | None,
47+
base_delay: float,
48+
max_delay: float,
49+
) -> float:
50+
"""Backoff delay for the failed ATTEMPT (1 = first attempt).
51+
52+
Computed as ``base_delay`` doubled per attempt, capped at
53+
``max_delay``, plus jitter. A ``Retry-After`` header (seconds)
54+
from a 429 response wins when present.
55+
"""
56+
if isinstance(retry_after, str) and retry_after.strip():
57+
try:
58+
secs = float(retry_after.strip())
59+
except ValueError:
60+
pass
61+
else:
62+
return min(secs, max_delay) + random.uniform(0, 0.5)
63+
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
64+
return delay + random.uniform(0, delay * 0.3)
65+
66+
2667
def _llm_log_path() -> Path:
2768
"""Return the LLM log file path for a new session."""
2869
import uuid
@@ -113,12 +154,22 @@ def __init__(
113154
model: str | None = None,
114155
timeout: float = 600.0,
115156
verify: str | bool | None = None,
157+
retry_max: int | None = None,
158+
retry_base_delay: float | None = None,
159+
retry_max_delay: float | None = None,
116160
) -> None:
117161
self.base_url = (base_url or config.DEFAULT_BASE_URL).rstrip("/")
118162
self.api_key = api_key or _default_api_key()
119163
self.model = model or config.DEFAULT_MODEL
120164
self.timeout = timeout
121165
self.verify = verify if verify is not None else _resolve_ca_bundle()
166+
self.retry_max = config.API_RETRY_MAX if retry_max is None else retry_max
167+
self.retry_base_delay = (
168+
config.API_RETRY_BASE_DELAY if retry_base_delay is None else retry_base_delay
169+
)
170+
self.retry_max_delay = (
171+
config.API_RETRY_MAX_DELAY if retry_max_delay is None else retry_max_delay
172+
)
122173
self._http = httpx.Client(timeout=timeout, verify=self.verify)
123174
self.log_path: Path | None = _llm_log_path() if config.LLM_LOG_ENABLED else None
124175

@@ -201,6 +252,7 @@ def chat(
201252
on_delta: Callable[[str], None] | None = None,
202253
on_tool_call: Callable[[str, str, str], None] | None = None,
203254
stream: bool = True,
255+
cancel_check: Callable[[], bool] | None = None,
204256
) -> tuple[Message, Usage]:
205257
"""Send a chat request, return (assistant msg, usage).
206258
@@ -210,24 +262,62 @@ def chat(
210262
``stream`` False a single non-streaming POST is used; both
211263
callbacks fire once per text/tool-call with the complete values,
212264
so callers (agent loop, TUI) behave identically either way.
265+
266+
Transient failures (HTTP 429 / 5xx, connection errors) are
267+
retried with exponential backoff + jitter up to ``retry_max``
268+
attempts, honoring ``Retry-After`` when present. A retry only
269+
happens before any delta has been delivered to the callbacks,
270+
so streaming output is never duplicated for the caller. Other
271+
4xx errors are permanent and fail immediately. ``cancel_check``
272+
(when given) is polled during backoff sleeps so an abort lands
273+
promptly instead of after the full wait.
213274
"""
214275
payload = self._payload(
215276
messages, tools, stream=stream, temperature=temperature,
216277
max_tokens=max_tokens, system=system,
217278
reasoning_effort=reasoning_effort,
218279
)
219280
usage = Usage()
220-
try:
221-
if stream:
222-
content_parts, reasoning_parts, tc_index = self._stream_response(
223-
payload, on_delta, on_tool_call, usage
224-
)
225-
else:
226-
content_parts, reasoning_parts, tc_index = self._sync_response(
227-
payload, on_delta, on_tool_call, usage
228-
)
229-
except httpx.HTTPError as e:
230-
raise ApiError(f"network error: {e}") from e
281+
emitted = False
282+
283+
def wrap_delta(chunk: str) -> None:
284+
nonlocal emitted
285+
emitted = True
286+
if on_delta:
287+
on_delta(chunk)
288+
289+
def wrap_tool_call(name: str, call_id: str, fragment: str) -> None:
290+
nonlocal emitted
291+
emitted = True
292+
if on_tool_call:
293+
on_tool_call(name, call_id, fragment)
294+
295+
attempt = 0
296+
while True:
297+
attempt += 1
298+
try:
299+
if stream:
300+
content_parts, reasoning_parts, tc_index = self._stream_response(
301+
payload, wrap_delta, wrap_tool_call, usage
302+
)
303+
else:
304+
content_parts, reasoning_parts, tc_index = self._sync_response(
305+
payload, wrap_delta, wrap_tool_call, usage
306+
)
307+
break
308+
except RetryableApiError as e:
309+
if emitted or attempt >= self.retry_max:
310+
raise
311+
if self._sleep_backoff(attempt, e.retry_after, cancel_check):
312+
raise
313+
except httpx.HTTPError as e:
314+
# connection-level failures: connect errors, timeouts,
315+
# dropped streams — all transient unless a delta already
316+
# reached the caller (then a retry would duplicate it)
317+
if emitted or attempt >= self.retry_max:
318+
raise ApiError(f"network error: {e}") from e
319+
if self._sleep_backoff(attempt, None, cancel_check):
320+
raise ApiError(f"network error: {e}") from e
231321

232322
content = "".join(content_parts)
233323
tool_calls = None
@@ -249,6 +339,31 @@ def chat(
249339
_log_llm_interaction(self.log_path, payload, msg, usage)
250340
return msg, usage
251341

342+
def _sleep_backoff(
343+
self,
344+
attempt: int,
345+
retry_after: str | None,
346+
cancel_check: Callable[[], bool] | None,
347+
) -> bool:
348+
"""Sleep between retries; return True when aborted (cancelled).
349+
350+
``attempt`` is the number of the request that just failed (1 =
351+
first attempt); the delay doubles per attempt, capped, with
352+
jitter (``Retry-After`` wins for 429s). When ``cancel_check``
353+
is given it is polled in small increments so a Ctrl-C lands
354+
promptly instead of after the full backoff wait.
355+
"""
356+
deadline = time.monotonic() + _retry_delay(
357+
attempt, retry_after, self.retry_base_delay, self.retry_max_delay
358+
)
359+
while True:
360+
remaining = deadline - time.monotonic()
361+
if remaining <= 0:
362+
return False
363+
if cancel_check is not None and cancel_check():
364+
return True
365+
time.sleep(min(0.25, remaining))
366+
252367
def _stream_response(
253368
self,
254369
payload: dict[str, Any],
@@ -271,9 +386,12 @@ def _stream_response(
271386
) as resp:
272387
if resp.status_code >= 400:
273388
body = resp.read().decode("utf-8", "replace")
274-
raise ApiError(
275-
f"API error {resp.status_code}: {body[:500]}"
276-
)
389+
message = f"API error {resp.status_code}: {body[:500]}"
390+
if _retryable_status(resp.status_code):
391+
raise RetryableApiError(
392+
message, resp.headers.get("Retry-After")
393+
)
394+
raise ApiError(message)
277395
for chunk in _iter_sse(resp.iter_lines()):
278396
if not chunk:
279397
continue
@@ -324,9 +442,12 @@ def _sync_response(
324442
self._url(), headers=self._headers(stream=False), json=payload
325443
)
326444
if resp.status_code >= 400:
327-
raise ApiError(
328-
f"API error {resp.status_code}: {resp.text[:500]}"
329-
)
445+
message = f"API error {resp.status_code}: {resp.text[:500]}"
446+
if _retryable_status(resp.status_code):
447+
raise RetryableApiError(
448+
message, resp.headers.get("Retry-After")
449+
)
450+
raise ApiError(message)
330451
data = resp.json()
331452
u = data.get("usage")
332453
if u:

python_agent_harness/config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,15 @@
147147
MAX_TOKENS = 8192
148148
TEMPERATURE = 0.0
149149

150+
# ---- API retry / backoff -------------------------------------------------------
151+
# Transient API failures (HTTP 429 / 5xx, connection errors) are retried
152+
# with exponential backoff + jitter instead of killing the run. The
153+
# per-request attempt budget and delay bounds live here; a Client
154+
# instance can override them per call.
155+
API_RETRY_MAX = 3 # max attempts per request (initial + retries)
156+
API_RETRY_BASE_DELAY = 1.0 # base backoff (seconds), doubled per attempt
157+
API_RETRY_MAX_DELAY = 30.0 # per-attempt backoff cap (seconds)
158+
150159
# ---- tool execution ----------------------------------------------------------
151160
SUBAGENT_MAX_ROUNDS = 60
152161
# Max tool calls that may run CONCURRENTLY in one tool round (all tools

python_agent_harness/prompts/agent.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ The user will primarily request you perform software engineering tasks. This inc
6767
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
6868
- Implement the solution using all tools available to you
6969
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
70-
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time. You MUST also review the updated code carefully for any hidden issues before considering the task finished.
70+
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
7171
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
7272

7373
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.

python_agent_harness/prompts/task-completion-rules.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Completion criteria:
1212
Before finishing, you MUST:
1313

1414
- Explicitly check whether the task goal is achieved.
15+
- Carefully review the updated code for any hidden issues.
1516
- If there is any uncertainty, assume the task is NOT complete.
1617
- If a tool execution failed, you MUST retry or choose an alternative approach.
1718

tests/fake_openai_server.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
# Queue of non-streaming response bodies consumed in order; when
1010
# exhausted, NON_STREAM_RESPONSE / the default reply is used.
1111
NON_STREAM_SEQUENCE: list[dict] = []
12+
# Queue of HTTP statuses consumed in order; a non-200 entry makes the
13+
# server respond with that error status instead of a reply (used to
14+
# exercise client retry behavior). When exhausted, 200 is used.
15+
STATUS_QUEUE: list[int] = []
16+
# Optional Retry-After header (seconds) attached to error responses.
17+
RETRY_AFTER_HEADER: str | None = None
1218
# Every request body received, in order (for asserting payloads).
1319
REQUEST_BODIES: list[dict] = []
1420

@@ -17,14 +23,27 @@ def reset_state() -> None:
1723
global NON_STREAM_RESPONSE
1824
NON_STREAM_RESPONSE = None
1925
NON_STREAM_SEQUENCE.clear()
26+
STATUS_QUEUE.clear()
2027
REQUEST_BODIES.clear()
28+
global RETRY_AFTER_HEADER
29+
RETRY_AFTER_HEADER = None
2130

2231

2332
class Handler(BaseHTTPRequestHandler):
2433
def do_POST(self):
2534
length = int(self.headers.get("Content-Length", 0))
2635
body = json.loads(self.rfile.read(length) or b"{}")
2736
REQUEST_BODIES.append(body)
37+
status = STATUS_QUEUE.pop(0) if STATUS_QUEUE else 200
38+
if status != 200:
39+
err = b'{"error": "transient failure"}'
40+
self.send_response(status)
41+
if RETRY_AFTER_HEADER is not None:
42+
self.send_header("Retry-After", RETRY_AFTER_HEADER)
43+
self.send_header("Content-Length", str(len(err)))
44+
self.end_headers()
45+
self.wfile.write(err)
46+
return
2847
stream = body.get("stream", False)
2948
if stream:
3049
chunks = [

tests/test_agent.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,13 @@ def __init__(self, script):
2828
self.kwargs = []
2929

3030
def chat(self, messages, tools=None, system=None, temperature=None,
31-
max_tokens=None, reasoning_effort=None, on_delta=None, stream=True):
31+
max_tokens=None, reasoning_effort=None, on_delta=None, stream=True,
32+
cancel_check=None):
3233
self.calls.append([m.to_api() for m in messages])
3334
self.kwargs.append({
3435
"tools": tools, "system": system, "temperature": temperature,
3536
"max_tokens": max_tokens, "reasoning_effort": reasoning_effort,
36-
"stream": stream,
37+
"stream": stream, "cancel_check": cancel_check,
3738
})
3839
if not self.script:
3940
return Message(role="assistant", content="done"), Usage()

0 commit comments

Comments
 (0)