Add Phase 7: polish and hardening — retry, truncation, sessions, shutdown
- Config extensions: retry backoff, truncation threshold, session persistence - LLM retry with exponential backoff + jitter on transient errors (5xx, connection) - Conversation truncation: drops oldest messages preserving first user + recent N - Session persistence: auto-save/restore with atomic writes, cleanup of old files - Graceful shutdown: SIGTERM handler, cancel() on AgentLoop, save-on-exit - Partial message recovery on mid-stream interruption - New slash commands: /save, /session - 18 new tests (5 retry, 5 truncation, 4 session, 4 integration workflows) - README.md and docs/tools.md documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
"""LLM client wrapper for Ollama / OpenAI-compatible endpoints."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any, Self
|
||||
|
||||
@@ -162,6 +164,61 @@ class LLMClient:
|
||||
except httpx.HTTPError as e:
|
||||
raise LLMError(f"HTTP error communicating with LLM: {e}") from e
|
||||
|
||||
async def stream_chat_with_retry(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
) -> AsyncIterator[dict]:
|
||||
"""Stream chat with automatic retry on transient errors.
|
||||
|
||||
Retries on LLMConnectionError and LLMResponseError with status >= 500.
|
||||
Does NOT retry on 4xx errors (client-side, not transient).
|
||||
Uses exponential backoff with jitter.
|
||||
|
||||
Args:
|
||||
messages: Conversation history to send to the model.
|
||||
tools: Optional OpenAI function-calling tool schemas.
|
||||
|
||||
Yields:
|
||||
Parsed JSON dicts from each SSE data line.
|
||||
|
||||
Raises:
|
||||
LLMConnectionError: After exhausting retries on connection failures.
|
||||
LLMResponseError: After exhausting retries on server errors, or immediately on 4xx.
|
||||
"""
|
||||
max_retries = self._config.max_retries
|
||||
last_exception: LLMError | None = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
async for chunk in self.stream_chat(messages, tools=tools):
|
||||
yield chunk
|
||||
return
|
||||
except LLMConnectionError as e:
|
||||
last_exception = e
|
||||
except LLMResponseError as e:
|
||||
if e.status_code is not None and e.status_code < 500:
|
||||
raise
|
||||
last_exception = e
|
||||
except LLMStreamError as e:
|
||||
last_exception = e
|
||||
|
||||
if attempt < max_retries:
|
||||
backoff = min(
|
||||
self._config.retry_backoff_base * (2 ** attempt) + random.uniform(0, 1),
|
||||
self._config.retry_backoff_max,
|
||||
)
|
||||
logger.warning(
|
||||
"llm_retry",
|
||||
attempt=attempt + 1,
|
||||
max_retries=max_retries,
|
||||
backoff_seconds=round(backoff, 2),
|
||||
error=str(last_exception),
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
|
||||
raise last_exception # type: ignore[misc]
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying HTTP client."""
|
||||
await self._client.aclose()
|
||||
|
||||
Reference in New Issue
Block a user