mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +00:00
Integrate Codex patterns: task scheduler, TUI, sessions, compaction
Add Codex-inspired patterns for improved agent architecture: **Task Scheduler (Phase 1 & 4)** - New Task enum with Job, ToolExec, Background variants - TaskHandler trait for custom background tasks - Scheduler.spawn_subtask() and spawn_batch() for parallel execution - Worker executes multiple tools in parallel via futures::join_all **Tool Approval System (Phase 6)** - Tool.requires_approval() method (default false for sandboxed tools) - HttpTool marked as requiring approval (external network) - MCP protocol annotations: destructive_hint, side_effects_hint **Ratatui TUI CLI (Phase 2)** - Replace blocking stdin with event-driven TUI - ChatComposer with history navigation and tab completion - ApprovalOverlay modal with y/n/a keyboard shortcuts - Raw mode with proper terminal cleanup **Session/Turn Model (Phase 3)** - Session, Thread, Turn structs for conversation tracking - Submission enum for user input, approvals, undo, interrupt - UndoManager with checkpoint-based undo/redo **Context Compaction (Phase 5)** - ContextMonitor with token estimation and threshold checks - CompactionStrategy: Summarize, Truncate, MoveToWorkspace - ContextCompactor writes summaries to workspace daily logs Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d047c23b2d
commit
09032d69cb
@@ -0,0 +1,236 @@
|
||||
//! Context window monitoring and compaction triggers.
|
||||
//!
|
||||
//! Monitors the size of the conversation context and triggers
|
||||
//! compaction when approaching the limit.
|
||||
|
||||
use crate::llm::ChatMessage;
|
||||
|
||||
/// Default context window limit (conservative estimate).
|
||||
const DEFAULT_CONTEXT_LIMIT: usize = 100_000;
|
||||
|
||||
/// Compaction threshold as a percentage of the limit.
|
||||
const COMPACTION_THRESHOLD: f64 = 0.8;
|
||||
|
||||
/// Approximate tokens per word (rough estimate for English).
|
||||
const TOKENS_PER_WORD: f64 = 1.3;
|
||||
|
||||
/// Strategy for context compaction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CompactionStrategy {
|
||||
/// Summarize old messages and keep recent ones.
|
||||
Summarize {
|
||||
/// Number of recent turns to keep intact.
|
||||
keep_recent: usize,
|
||||
},
|
||||
/// Truncate old messages without summarization.
|
||||
Truncate {
|
||||
/// Number of recent turns to keep.
|
||||
keep_recent: usize,
|
||||
},
|
||||
/// Move context to workspace memory.
|
||||
MoveToWorkspace,
|
||||
}
|
||||
|
||||
impl Default for CompactionStrategy {
|
||||
fn default() -> Self {
|
||||
Self::Summarize { keep_recent: 5 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Monitors context size and suggests compaction.
|
||||
pub struct ContextMonitor {
|
||||
/// Maximum tokens allowed in context.
|
||||
context_limit: usize,
|
||||
/// Threshold ratio for triggering compaction.
|
||||
threshold_ratio: f64,
|
||||
}
|
||||
|
||||
impl ContextMonitor {
|
||||
/// Create a new context monitor with default settings.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
context_limit: DEFAULT_CONTEXT_LIMIT,
|
||||
threshold_ratio: COMPACTION_THRESHOLD,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with a custom context limit.
|
||||
pub fn with_limit(mut self, limit: usize) -> Self {
|
||||
self.context_limit = limit;
|
||||
self
|
||||
}
|
||||
|
||||
/// Create with a custom threshold ratio.
|
||||
pub fn with_threshold(mut self, ratio: f64) -> Self {
|
||||
self.threshold_ratio = ratio.clamp(0.5, 0.95);
|
||||
self
|
||||
}
|
||||
|
||||
/// Estimate the token count for a list of messages.
|
||||
pub fn estimate_tokens(&self, messages: &[ChatMessage]) -> usize {
|
||||
messages.iter().map(|m| estimate_message_tokens(m)).sum()
|
||||
}
|
||||
|
||||
/// Check if compaction is needed.
|
||||
pub fn needs_compaction(&self, messages: &[ChatMessage]) -> bool {
|
||||
let tokens = self.estimate_tokens(messages);
|
||||
let threshold = (self.context_limit as f64 * self.threshold_ratio) as usize;
|
||||
tokens >= threshold
|
||||
}
|
||||
|
||||
/// Get the current usage percentage.
|
||||
pub fn usage_percent(&self, messages: &[ChatMessage]) -> f64 {
|
||||
let tokens = self.estimate_tokens(messages);
|
||||
(tokens as f64 / self.context_limit as f64) * 100.0
|
||||
}
|
||||
|
||||
/// Suggest a compaction strategy based on current context.
|
||||
pub fn suggest_compaction(&self, messages: &[ChatMessage]) -> Option<CompactionStrategy> {
|
||||
if !self.needs_compaction(messages) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tokens = self.estimate_tokens(messages);
|
||||
let overage = tokens as f64 / self.context_limit as f64;
|
||||
|
||||
if overage > 0.95 {
|
||||
// Critical: aggressive truncation
|
||||
Some(CompactionStrategy::Truncate { keep_recent: 3 })
|
||||
} else if overage > 0.85 {
|
||||
// High: summarize and keep fewer
|
||||
Some(CompactionStrategy::Summarize { keep_recent: 5 })
|
||||
} else {
|
||||
// Moderate: move to workspace
|
||||
Some(CompactionStrategy::MoveToWorkspace)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the context limit.
|
||||
pub fn limit(&self) -> usize {
|
||||
self.context_limit
|
||||
}
|
||||
|
||||
/// Get the current threshold in tokens.
|
||||
pub fn threshold(&self) -> usize {
|
||||
(self.context_limit as f64 * self.threshold_ratio) as usize
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ContextMonitor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate tokens for a single message.
|
||||
fn estimate_message_tokens(message: &ChatMessage) -> usize {
|
||||
// Use word-based estimation as it's more accurate for varied content
|
||||
let word_count = message.content.split_whitespace().count();
|
||||
|
||||
// Add overhead for role and structure
|
||||
let overhead = 4; // ~4 tokens for role and message structure
|
||||
|
||||
(word_count as f64 * TOKENS_PER_WORD) as usize + overhead
|
||||
}
|
||||
|
||||
/// Estimate tokens for raw text.
|
||||
pub fn estimate_text_tokens(text: &str) -> usize {
|
||||
let word_count = text.split_whitespace().count();
|
||||
(word_count as f64 * TOKENS_PER_WORD) as usize
|
||||
}
|
||||
|
||||
/// Context size breakdown for reporting.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContextBreakdown {
|
||||
/// Total estimated tokens.
|
||||
pub total_tokens: usize,
|
||||
/// System message tokens.
|
||||
pub system_tokens: usize,
|
||||
/// User message tokens.
|
||||
pub user_tokens: usize,
|
||||
/// Assistant message tokens.
|
||||
pub assistant_tokens: usize,
|
||||
/// Tool result tokens.
|
||||
pub tool_tokens: usize,
|
||||
/// Number of messages.
|
||||
pub message_count: usize,
|
||||
}
|
||||
|
||||
impl ContextBreakdown {
|
||||
/// Analyze a list of messages.
|
||||
pub fn analyze(messages: &[ChatMessage]) -> Self {
|
||||
let mut breakdown = Self {
|
||||
total_tokens: 0,
|
||||
system_tokens: 0,
|
||||
user_tokens: 0,
|
||||
assistant_tokens: 0,
|
||||
tool_tokens: 0,
|
||||
message_count: messages.len(),
|
||||
};
|
||||
|
||||
for message in messages {
|
||||
let tokens = estimate_message_tokens(message);
|
||||
breakdown.total_tokens += tokens;
|
||||
|
||||
match message.role {
|
||||
crate::llm::Role::System => breakdown.system_tokens += tokens,
|
||||
crate::llm::Role::User => breakdown.user_tokens += tokens,
|
||||
crate::llm::Role::Assistant => breakdown.assistant_tokens += tokens,
|
||||
crate::llm::Role::Tool => breakdown.tool_tokens += tokens,
|
||||
}
|
||||
}
|
||||
|
||||
breakdown
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_token_estimation() {
|
||||
let msg = ChatMessage::user("Hello, how are you today?");
|
||||
let tokens = estimate_message_tokens(&msg);
|
||||
// 5 words * 1.3 + 4 overhead = ~10-11 tokens
|
||||
assert!(tokens > 0);
|
||||
assert!(tokens < 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_compaction() {
|
||||
let monitor = ContextMonitor::new().with_limit(100);
|
||||
|
||||
// Small context - no compaction needed
|
||||
let small: Vec<ChatMessage> = vec![ChatMessage::user("Hello")];
|
||||
assert!(!monitor.needs_compaction(&small));
|
||||
|
||||
// Large context - compaction needed
|
||||
let large_content = "word ".repeat(1000);
|
||||
let large: Vec<ChatMessage> = vec![ChatMessage::user(&large_content)];
|
||||
assert!(monitor.needs_compaction(&large));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_compaction() {
|
||||
let monitor = ContextMonitor::new().with_limit(100);
|
||||
|
||||
let small: Vec<ChatMessage> = vec![ChatMessage::user("Hello")];
|
||||
assert!(monitor.suggest_compaction(&small).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_breakdown() {
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are a helpful assistant."),
|
||||
ChatMessage::user("Hello"),
|
||||
ChatMessage::assistant("Hi there!"),
|
||||
];
|
||||
|
||||
let breakdown = ContextBreakdown::analyze(&messages);
|
||||
assert_eq!(breakdown.message_count, 3);
|
||||
assert!(breakdown.system_tokens > 0);
|
||||
assert!(breakdown.user_tokens > 0);
|
||||
assert!(breakdown.assistant_tokens > 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user