Files
optimclaw/src/agent/compaction.rs
T
3f135bdde9 fix: persist turns after approval and add agent-level tests (#250)
* fix: persist turns after approval and add agent-level tests

Port relevant changes from PR #112 that were not carried over to #237:

- Add persist_turn calls in process_approval for the response, error,
  and auth-required paths. Previously, turns completed after tool
  approval were never persisted to DB — if the process crashed after
  approval the entire turn (user message + assistant response) was lost.

- Add agent-level unit tests: StaticLlmProvider mock, make_test_agent
  helper, tests for auto-approval logic, destructive shell command
  detection, and PendingApproval backward-compatible deserialization
  (without deferred_tool_calls field).

- Remove unused _thread_state binding in process_approval.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address 14 audit findings in src/agent/

Audit of the agent module found 2 High, 7 Medium, 3 Low, and 2 Nit
severity issues. This commit fixes all of them:

High:
- Remove 4 `.expect()` calls in session.rs (entry API, match, direct
  indexing, if-let) to eliminate panic paths in production
- Add typed RoutineError enum replacing Result<_, String> across
  routine.rs, routine_engine.rs, and callers in history/store.rs and
  db/libsql/mod.rs

Medium:
- Sanitize routine names in path construction to prevent directory
  traversal (routine_engine.rs)
- Log warnings for 5 silently-swallowed errors in scheduler.rs,
  compaction.rs, and worker.rs
- Extract shared handle_auth_intercept helper to deduplicate auth
  interception in thread_ops.rs
- Add session count warning threshold in session_manager.rs
- Make FullJob stub degradation visible via warn-level log and
  prepended warning in output

Low:
- Restrict dead code visibility with #[cfg(test)] on 19 unused items
  in submission.rs, task.rs, and undo.rs
- Narrow pub to pub(crate) on self_repair.rs builder methods
- Remove TaskStatus from mod.rs re-exports (test-only type)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review comments

- Reorder persist_turn before persist_response_chain so the
  conversation row exists before the metadata UPDATE runs
- Add persist_response_chain call to handle_auth_intercept so
  auth-required paths preserve the response chain
- Harden sanitize_routine_name to use allowlist (alphanumeric,
  dash, underscore) instead of denylist replacements
- Fix stale active_thread ID in get_or_create_thread: fall back
  to create_thread() when the stored ID is missing from the map
- Persist turn on approval rejection so user messages survive
  crashes after a tool is rejected

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-20 02:28:15 +00:00

343 lines
10 KiB
Rust

//! Context compaction for preserving and summarizing conversation history.
//!
//! When the context window approaches its limit, compaction:
//! 1. Summarizes old turns
//! 2. Writes the summary to the workspace daily log
//! 3. Trims the context to keep only recent turns
use std::sync::Arc;
use chrono::Utc;
use crate::agent::context_monitor::{CompactionStrategy, ContextBreakdown};
use crate::agent::session::Thread;
use crate::error::Error;
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider};
use crate::workspace::Workspace;
/// Result of a compaction operation.
#[derive(Debug)]
pub struct CompactionResult {
/// Number of turns removed.
pub turns_removed: usize,
/// Tokens before compaction.
pub tokens_before: usize,
/// Tokens after compaction.
pub tokens_after: usize,
/// Whether a summary was written to workspace.
pub summary_written: bool,
/// The generated summary (if any).
pub summary: Option<String>,
}
/// Compacts conversation context to stay within limits.
pub struct ContextCompactor {
llm: Arc<dyn LlmProvider>,
}
impl ContextCompactor {
/// Create a new context compactor.
pub fn new(llm: Arc<dyn LlmProvider>) -> Self {
Self { llm }
}
/// Compact a thread's context using the given strategy.
pub async fn compact(
&self,
thread: &mut Thread,
strategy: CompactionStrategy,
workspace: Option<&Workspace>,
) -> Result<CompactionResult, Error> {
let messages = thread.messages();
let tokens_before = ContextBreakdown::analyze(&messages).total_tokens;
let result = match strategy {
CompactionStrategy::Summarize { keep_recent } => {
self.compact_with_summary(thread, keep_recent, workspace)
.await?
}
CompactionStrategy::Truncate { keep_recent } => {
self.compact_truncate(thread, keep_recent)
}
CompactionStrategy::MoveToWorkspace => {
self.compact_to_workspace(thread, workspace).await?
}
};
let messages_after = thread.messages();
let tokens_after = ContextBreakdown::analyze(&messages_after).total_tokens;
Ok(CompactionResult {
turns_removed: result.turns_removed,
tokens_before,
tokens_after,
summary_written: result.summary_written,
summary: result.summary,
})
}
/// Compact by summarizing old turns.
async fn compact_with_summary(
&self,
thread: &mut Thread,
keep_recent: usize,
workspace: Option<&Workspace>,
) -> Result<CompactionPartial, Error> {
if thread.turns.len() <= keep_recent {
return Ok(CompactionPartial::empty());
}
// Get turns to summarize
let turns_to_remove = thread.turns.len() - keep_recent;
let old_turns = &thread.turns[..turns_to_remove];
// Build messages for summarization
let mut to_summarize = Vec::new();
for turn in old_turns {
to_summarize.push(ChatMessage::user(&turn.user_input));
if let Some(ref response) = turn.response {
to_summarize.push(ChatMessage::assistant(response));
}
}
// Generate summary
let summary = self.generate_summary(&to_summarize).await?;
// Write to workspace if available
let summary_written = if let Some(ws) = workspace {
match self.write_summary_to_workspace(ws, &summary).await {
Ok(()) => true,
Err(e) => {
tracing::warn!(
"Compaction summary write failed (turns will still be truncated): {}",
e
);
false
}
}
} else {
false
};
// Truncate thread
thread.truncate_turns(keep_recent);
Ok(CompactionPartial {
turns_removed: turns_to_remove,
summary_written,
summary: Some(summary),
})
}
/// Compact by simple truncation (no summary).
fn compact_truncate(&self, thread: &mut Thread, keep_recent: usize) -> CompactionPartial {
let turns_before = thread.turns.len();
thread.truncate_turns(keep_recent);
let turns_removed = turns_before - thread.turns.len();
CompactionPartial {
turns_removed,
summary_written: false,
summary: None,
}
}
/// Move context to workspace without summarization.
async fn compact_to_workspace(
&self,
thread: &mut Thread,
workspace: Option<&Workspace>,
) -> Result<CompactionPartial, Error> {
let Some(ws) = workspace else {
// Fall back to truncation if no workspace
return Ok(self.compact_truncate(thread, 5));
};
// Keep more turns when moving to workspace (we have a backup)
let keep_recent = 10;
if thread.turns.len() <= keep_recent {
return Ok(CompactionPartial::empty());
}
let turns_to_remove = thread.turns.len() - keep_recent;
let old_turns = &thread.turns[..turns_to_remove];
// Format turns for storage
let content = format_turns_for_storage(old_turns);
// Write to workspace
let written = match self.write_context_to_workspace(ws, &content).await {
Ok(()) => true,
Err(e) => {
tracing::warn!(
"Compaction context write failed (turns will still be truncated): {}",
e
);
false
}
};
// Truncate
thread.truncate_turns(keep_recent);
Ok(CompactionPartial {
turns_removed: turns_to_remove,
summary_written: written,
summary: None,
})
}
/// Generate a summary of messages using the LLM.
async fn generate_summary(&self, messages: &[ChatMessage]) -> Result<String, Error> {
let prompt = ChatMessage::system(
r#"Summarize the following conversation concisely. Focus on:
- Key decisions made
- Important information exchanged
- Actions taken
- Outcomes achieved
Be brief but capture all important details. Use bullet points."#,
);
let mut request_messages = vec![prompt];
// Add a user message with the conversation to summarize
let formatted = messages
.iter()
.map(|m| {
let role_str = match m.role {
crate::llm::Role::User => "User",
crate::llm::Role::Assistant => "Assistant",
crate::llm::Role::System => "System",
crate::llm::Role::Tool => {
return format!(
"Tool {}: {}",
m.name.as_deref().unwrap_or("unknown"),
m.content
);
}
};
format!("{}: {}", role_str, m.content)
})
.collect::<Vec<_>>()
.join("\n\n");
request_messages.push(ChatMessage::user(format!(
"Please summarize this conversation:\n\n{}",
formatted
)));
let request = CompletionRequest::new(request_messages)
.with_max_tokens(1024)
.with_temperature(0.3);
let response = self.llm.complete(request).await?;
Ok(response.content)
}
/// Write a summary to the workspace daily log.
async fn write_summary_to_workspace(
&self,
workspace: &Workspace,
summary: &str,
) -> Result<(), Error> {
let date = Utc::now().format("%Y-%m-%d");
let entry = format!(
"\n## Context Summary ({})\n\n{}\n",
Utc::now().format("%H:%M UTC"),
summary
);
workspace
.append(&format!("daily/{}.md", date), &entry)
.await?;
Ok(())
}
/// Write full context to workspace for archival.
async fn write_context_to_workspace(
&self,
workspace: &Workspace,
content: &str,
) -> Result<(), Error> {
let date = Utc::now().format("%Y-%m-%d");
let entry = format!(
"\n## Archived Context ({})\n\n{}\n",
Utc::now().format("%H:%M UTC"),
content
);
workspace
.append(&format!("daily/{}.md", date), &entry)
.await?;
Ok(())
}
}
/// Partial result during compaction (internal).
struct CompactionPartial {
turns_removed: usize,
summary_written: bool,
summary: Option<String>,
}
impl CompactionPartial {
fn empty() -> Self {
Self {
turns_removed: 0,
summary_written: false,
summary: None,
}
}
}
/// Format turns for storage in workspace.
fn format_turns_for_storage(turns: &[crate::agent::session::Turn]) -> String {
turns
.iter()
.map(|turn| {
let mut s = format!("**Turn {}**\n", turn.turn_number + 1);
s.push_str(&format!("User: {}\n", turn.user_input));
if let Some(ref response) = turn.response {
s.push_str(&format!("Agent: {}\n", response));
}
if !turn.tool_calls.is_empty() {
s.push_str("Tools: ");
let tools: Vec<_> = turn.tool_calls.iter().map(|t| t.name.as_str()).collect();
s.push_str(&tools.join(", "));
s.push('\n');
}
s
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::session::Thread;
use uuid::Uuid;
#[test]
fn test_format_turns() {
let mut thread = Thread::new(Uuid::new_v4());
thread.start_turn("Hello");
thread.complete_turn("Hi there");
thread.start_turn("How are you?");
thread.complete_turn("I'm good!");
let formatted = format_turns_for_storage(&thread.turns);
assert!(formatted.contains("Turn 1"));
assert!(formatted.contains("Hello"));
assert!(formatted.contains("Turn 2"));
}
#[test]
fn test_compaction_partial_empty() {
let partial = CompactionPartial::empty();
assert_eq!(partial.turns_removed, 0);
assert!(!partial.summary_written);
}
}