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

@@ -77,3 +77,101 @@ class SessionContext:
def start_time(self) -> datetime:
"""Session start timestamp (UTC)."""
return self._start_time
def truncate_history(self, system_token_estimate: int = 0) -> int:
"""Drop oldest messages to bring token usage under budget.
Preserves the first user message and the most recent N messages
(configured by ``truncation_keep_recent``). Cleans up orphaned tool
messages after truncation.
Args:
system_token_estimate: Estimated tokens used by the system prompt.
Returns:
Number of messages dropped.
"""
budget = self._token_counter.budget
threshold = self._config.agent.truncation_threshold
keep_recent = self._config.agent.truncation_keep_recent
estimated = self._token_counter.estimate_messages_tokens(self._history) + system_token_estimate
if estimated < threshold * budget:
return 0
target = int(budget * 0.75) # headroom
if len(self._history) <= keep_recent + 1:
return 0
# Split: first user message | droppable middle | recent tail
first_msg = self._history[0] if self._history and self._history[0].role == "user" else None
start_idx = 1 if first_msg else 0
tail_start = max(start_idx, len(self._history) - keep_recent)
dropped = 0
drop_indices: set[int] = set()
for i in range(start_idx, tail_start):
drop_indices.add(i)
dropped += 1
# Recalculate with remaining messages
remaining = [m for j, m in enumerate(self._history) if j not in drop_indices]
est = self._token_counter.estimate_messages_tokens(remaining) + system_token_estimate
if est < target:
break
if dropped == 0:
return 0
self._history = [m for j, m in enumerate(self._history) if j not in drop_indices]
# Clean up orphaned tool messages
self._cleanup_orphaned_tool_messages()
return dropped
def _cleanup_orphaned_tool_messages(self) -> None:
"""Remove tool messages whose tool_call_id doesn't match any assistant tool_call."""
# Collect all tool_call IDs from assistant messages
valid_tc_ids: set[str] = set()
for msg in self._history:
if msg.role == "assistant" and msg.tool_calls:
for tc in msg.tool_calls:
valid_tc_ids.add(tc.id)
# Remove tool messages referencing missing tool calls
self._history = [
msg for msg in self._history
if msg.role != "tool" or (msg.tool_call_id and msg.tool_call_id in valid_tc_ids)
]
def to_serializable(self) -> dict:
"""Export messages and token state for session persistence.
Returns:
Dict with messages and token usage data.
"""
return {
"messages": [m.model_dump(exclude_none=True) for m in self._history],
"token_usage": self._token_counter.cumulative_usage.model_dump(),
}
def restore_from(self, data: dict) -> None:
"""Clear and replay from serialized data.
Args:
data: Dict with messages and optional token_usage as produced by to_serializable().
"""
self._history.clear()
self._message_count = 0
for msg_data in data.get("messages", []):
msg = Message(**msg_data)
self._history.append(msg)
self._message_count += 1
token_data = data.get("token_usage")
if token_data:
from app.utils.token_counter import TokenUsage
usage = TokenUsage(**token_data)
self._token_counter.count_usage(usage)

View File

@@ -7,7 +7,7 @@ from app.agent.context import SessionContext
from app.models.config import AppConfig
from app.models.message import Message
from app.models.tool_call import ToolCall, ToolResult, ToolResultStatus
from app.services.llm import LLMClient, LLMConnectionError, LLMError
from app.services.llm import LLMClient, LLMConnectionError, LLMError, LLMStreamError
from app.services.permissions import PermissionsService
from app.services.streaming import StreamHandler
from app.tools.registry import ToolRegistry
@@ -51,6 +51,11 @@ class AgentLoop:
self._permissions = permissions
self._tools_schema = registry.get_openai_tools_schema()
self._system_prompt = self._build_system_prompt()
self._cancelled = False
def cancel(self) -> None:
"""Request cancellation of the current agent turn."""
self._cancelled = True
def _build_system_prompt(self) -> str:
"""Build the system prompt including tool schemas and agent instructions."""
@@ -81,15 +86,25 @@ class AgentLoop:
user_input: The user's message text.
"""
self._ctx.add_message("user", user_input)
self._cancelled = False
max_iter = self._config.agent.max_iterations
reasoning_only_streak = 0
for iteration in range(1, max_iter + 1):
# Check token budget
if self._ctx.token_counter.is_over_budget():
print_warning("Token budget exceeded. Stopping agent loop.")
if self._cancelled:
print_warning("Agent loop cancelled.")
break
# Check token budget — try truncation before giving up
if self._ctx.token_counter.is_over_budget():
system_tokens = self._ctx.token_counter.estimate_tokens(self._system_prompt)
dropped = self._ctx.truncate_history(system_tokens)
if dropped > 0:
print_warning(f"Token budget pressure: dropped {dropped} oldest messages.")
else:
print_warning("Token budget exceeded, cannot truncate further. Stopping.")
break
if iteration > 1:
print_iteration_header(iteration, max_iter)
@@ -169,18 +184,25 @@ class AgentLoop:
async def _llm_step(self) -> Message | None:
"""Stream one LLM response and return the accumulated Message.
Uses retry-enabled streaming. On mid-stream errors, attempts to recover
partial content if available.
Returns:
The assistant Message, or None if an error occurred.
"""
messages = self._get_messages_with_system_prompt()
try:
chunk_iter = self._client.stream_chat(messages, tools=self._tools_schema)
chunk_iter = self._client.stream_chat_with_retry(messages, tools=self._tools_schema)
return await self._handler.process_stream(chunk_iter)
except KeyboardInterrupt:
print_warning("Response interrupted.")
self._handler.reset()
return None
except LLMConnectionError as e:
except (LLMConnectionError, LLMStreamError) as e:
partial = self._handler.get_partial_message()
if partial is not None:
print_warning(f"Stream interrupted ({e}), returning partial response.")
return partial
print_error(f"Connection error: {e}")
return None
except LLMError as e: