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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user