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:
2026-03-11 10:20:16 -05:00
parent 82846d6236
commit 76ba490aa2
16 changed files with 1550 additions and 12 deletions

View File

@@ -2,6 +2,7 @@
import argparse
import asyncio
import signal
import sys
from pathlib import Path
@@ -12,6 +13,7 @@ from app.agent.loop import AgentLoop
from app.models.config import AppConfig, load_config
from app.services.llm import LLMClient, LLMConnectionError, LLMError
from app.services.permissions import PermissionsService
from app.services.session import SessionManager
from app.services.streaming import StreamHandler
from app.tools.registry import create_default_registry
from app.utils.display import (
@@ -63,17 +65,62 @@ async def _preflight(config: AppConfig) -> None:
await client.preflight_check()
def _offer_session_resume(
session_mgr: SessionManager, ctx: SessionContext
) -> bool:
"""Check for a saved session and offer to resume it.
Args:
session_mgr: Session manager instance.
ctx: Session context to restore into.
Returns:
True if a session was restored.
"""
saved = session_mgr.load_latest()
if saved is None:
return False
msg_count = len(saved.messages)
print_info(f"Found previous session ({msg_count} messages, model: {saved.model})")
try:
answer = console.input("[bold cyan]Resume previous session? [y/N] [/bold cyan]").strip().lower()
except (KeyboardInterrupt, EOFError):
return False
if answer in ("y", "yes"):
session_mgr.restore(saved, ctx)
print_success(f"Session restored ({msg_count} messages).")
return True
return False
def _save_session_quiet(session_mgr: SessionManager, ctx: SessionContext) -> None:
"""Save session without raising on errors."""
try:
if ctx.message_count > 0:
session_mgr.save(ctx)
except OSError as e:
print_warning(f"Could not save session: {e}")
async def _run_repl(
ctx: SessionContext,
config: AppConfig,
session_mgr: SessionManager,
logger: structlog.stdlib.BoundLogger,
shutdown_event: asyncio.Event,
) -> None:
"""Run the interactive REPL loop with streaming LLM responses.
Args:
ctx: Session context for conversation state.
config: Application configuration.
session_mgr: Session manager for auto-save.
logger: Structured logger instance.
shutdown_event: Event signalling graceful shutdown.
"""
registry = create_default_registry(config.agent.workspace_root, config)
permissions = PermissionsService(config.permissions)
@@ -82,11 +129,12 @@ async def _run_repl(
handler = StreamHandler(config.display)
agent = AgentLoop(config, ctx, client, handler, registry, permissions)
while True:
while not shutdown_event.is_set():
try:
user_input = console.input("[bold cyan]> [/bold cyan]")
except (KeyboardInterrupt, EOFError):
console.print("\n[dim]Goodbye![/dim]")
_save_session_quiet(session_mgr, ctx)
console.print("\n[dim]Session saved. Goodbye![/dim]")
break
user_input = user_input.strip()
@@ -97,13 +145,24 @@ async def _run_repl(
if user_input.startswith("/"):
command = user_input.lower()
if command == "/quit":
console.print("[dim]Goodbye![/dim]")
_save_session_quiet(session_mgr, ctx)
console.print("[dim]Session saved. Goodbye![/dim]")
break
elif command == "/history":
print_history(ctx.get_history())
elif command == "/clear":
ctx.clear_history()
print_success("Conversation history cleared.")
elif command == "/save":
try:
path = session_mgr.save(ctx)
print_success(f"Session saved to {path}")
except OSError as e:
print_error(f"Failed to save session: {e}")
elif command == "/session":
print_info(f"Messages: {ctx.message_count}")
print_info(f"Tokens: ~{ctx.estimated_tokens:,} / {ctx.token_counter.budget:,}")
print_info(f"Started: {ctx.start_time.isoformat()}")
else:
print_warning(f"Unknown command: {user_input}")
continue
@@ -112,6 +171,15 @@ async def _run_repl(
await agent.run_turn(user_input)
logger.debug("turn_complete", message_count=ctx.message_count)
# Auto-save after each turn
if config.session.auto_save:
_save_session_quiet(session_mgr, ctx)
# Shutdown triggered by signal
if shutdown_event.is_set():
_save_session_quiet(session_mgr, ctx)
console.print("\n[dim]Session saved. Shutting down gracefully.[/dim]")
def main() -> None:
"""Main entrypoint: load config, setup logging, launch interactive REPL."""
@@ -154,12 +222,37 @@ def main() -> None:
print_success("Ollama connected, model ready.")
# Create session and start REPL
# Create session and session manager
ctx = SessionContext(config)
session_mgr = SessionManager(config.session, config.agent.workspace_root, config.llm.model)
# Clean up old session files
cleaned = session_mgr.cleanup_old()
if cleaned > 0:
logger.info("old_sessions_cleaned", count=cleaned)
# Offer to resume previous session
if config.session.offer_resume:
_offer_session_resume(session_mgr, ctx)
logger.info("startup_complete")
print_info("Commands: /quit, /history, /clear")
asyncio.run(_run_repl(ctx, config, logger))
# Setup shutdown event and SIGTERM handler
shutdown_event = asyncio.Event()
original_sigterm = signal.getsignal(signal.SIGTERM)
def _sigterm_handler(signum: int, frame: object) -> None:
shutdown_event.set()
signal.signal(signal.SIGTERM, _sigterm_handler)
print_info("Commands: /quit, /history, /clear, /save, /session")
try:
asyncio.run(_run_repl(ctx, config, session_mgr, logger, shutdown_event))
finally:
signal.signal(signal.SIGTERM, original_sigterm)
if __name__ == "__main__":