diff --git a/AGENTS.md b/AGENTS.md index 0a5ce05..ee2566d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,18 @@ - Different configs for interactive vs autonomous mode - **Risk**: Aggressive retries can hit rate limits harder +### UTF-8 String Slicing (Throughout Codebase) + +- Rust string slices (`&s[..n]`) use **byte indices**, not character indices +- Multi-byte UTF-8 characters (emoji, bullets `โ€ข`, `ร—`, `โšก`) cause panics if sliced mid-character +- **Risk**: Runtime panic on any string containing non-ASCII characters +- **Fix**: Use `char_indices()` to find byte boundaries: + ```rust + let byte_idx = s.char_indices().nth(char_limit).map(|(i, _)| i).unwrap_or(s.len()); + let truncated = &s[..byte_idx]; + ``` +- **Danger zones**: Display truncation, ACD stubs, user input handling + ## Do's and Don'ts for Automated Changes ### Do @@ -99,6 +111,7 @@ 3. **"Tool results are always small"** - File reads can return megabytes 4. **"Sessions persist across runs"** - Sessions are ephemeral by default 5. **"All platforms are equal"** - macOS has more features (Vision, Accessibility) +6. **"String length equals character count"** - `s.len()` returns bytes; use `s.chars().count()` for characters ## Dependency Analysis Artifacts diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index cc29575..87bbca7 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -155,6 +155,10 @@ pub struct Cli { /// Automatically remind LLM to call remember tool after turns with tool calls #[arg(long)] pub auto_memory: bool, + + /// Enable aggressive context dehydration (save context to disk on compaction) + #[arg(long)] + pub acd: bool, } pub async fn run() -> Result<()> { @@ -390,6 +394,10 @@ pub async fn run() -> Result<()> { if cli.auto_memory { agent.set_auto_memory(true); } + // Apply ACD flag if enabled + if cli.acd { + agent.set_acd_enabled(true); + } run_with_machine_mode(agent, cli, project).await?; } else { @@ -433,6 +441,10 @@ pub async fn run() -> Result<()> { if cli.auto_memory { agent.set_auto_memory(true); } + // Apply ACD flag if enabled + if cli.acd { + agent.set_acd_enabled(true); + } run_with_console_mode(agent, cli, project, combined_content).await?; } @@ -617,6 +629,9 @@ async fn run_agent_mode( // This prompts the LLM to save discoveries to project memory after each turn agent.set_auto_memory(true); + // Enable ACD in agent mode for longer sessions + agent.set_acd_enabled(true); + // If resuming a session, restore context and TODO let initial_task = if let Some(ref incomplete_session) = resuming_session { // Restore the session context @@ -1416,7 +1431,10 @@ async fn run_interactive( output.print(" /thinnify - Trigger context thinning (replaces large tool results with file references)"); output.print(" /skinnify - Trigger full context thinning (like /thinnify but for entire context, not just first third)"); output.print(" /clear - Clear session and start fresh (discards continuation artifacts)"); + output.print(" /fragments - List dehydrated context fragments (ACD)"); + output.print(" /rehydrate - Restore a dehydrated fragment by ID"); output.print(" /resume - List and switch to a previous session"); + output.print(" /dump - Dump entire context window to file for debugging"); output.print( " /readme - Reload README.md and AGENTS.md from disk", ); @@ -1454,6 +1472,90 @@ async fn run_interactive( println!("{}", summary); continue; } + "/fragments" => { + if let Some(session_id) = agent.get_session_id() { + match g3_core::acd::list_fragments(session_id) { + Ok(fragments) => { + if fragments.is_empty() { + output.print("No dehydrated fragments found for this session."); + } else { + output.print(&format!("๐Ÿ“ฆ {} dehydrated fragment(s):\n", fragments.len())); + for fragment in &fragments { + output.print(&fragment.generate_stub()); + output.print(""); + } + } + } + Err(e) => { + output.print(&format!("โŒ Error listing fragments: {}", e)); + } + } + } else { + output.print("No active session - fragments are session-scoped."); + } + continue; + } + cmd if cmd.starts_with("/rehydrate") => { + let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); + if parts.len() < 2 || parts[1].trim().is_empty() { + output.print("Usage: /rehydrate "); + output.print("Use /fragments to list available fragment IDs."); + } else { + let fragment_id = parts[1].trim(); + if let Some(session_id) = agent.get_session_id() { + match g3_core::acd::Fragment::load(session_id, fragment_id) { + Ok(fragment) => { + output.print(&format!("โœ… Fragment '{}' loaded ({} messages, ~{} tokens)", + fragment_id, fragment.message_count, fragment.estimated_tokens)); + output.print(""); + output.print(&fragment.generate_stub()); + } + Err(e) => { + output.print(&format!("โŒ Failed to load fragment '{}': {}", fragment_id, e)); + } + } + } else { + output.print("No active session - fragments are session-scoped."); + } + } + continue; + } + "/dump" => { + // Dump entire context window to a file for debugging + let dump_dir = std::path::Path::new("tmp"); + if !dump_dir.exists() { + if let Err(e) = std::fs::create_dir_all(dump_dir) { + output.print(&format!("โŒ Failed to create tmp directory: {}", e)); + continue; + } + } + + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); + let dump_path = dump_dir.join(format!("context_dump_{}.txt", timestamp)); + + let context = agent.get_context_window(); + let mut dump_content = String::new(); + dump_content.push_str(&format!("# Context Window Dump\n")); + dump_content.push_str(&format!("# Timestamp: {}\n", chrono::Utc::now())); + dump_content.push_str(&format!("# Messages: {}\n", context.conversation_history.len())); + dump_content.push_str(&format!("# Used tokens: {} / {} ({:.1}%)\n\n", + context.used_tokens, context.total_tokens, context.percentage_used())); + + for (i, msg) in context.conversation_history.iter().enumerate() { + dump_content.push_str(&format!("=== Message {} ===\n", i)); + dump_content.push_str(&format!("Role: {:?}\n", msg.role)); + dump_content.push_str(&format!("Kind: {:?}\n", msg.kind)); + dump_content.push_str(&format!("Content ({} chars):\n", msg.content.len())); + dump_content.push_str(&msg.content); + dump_content.push_str("\n\n"); + } + + match std::fs::write(&dump_path, &dump_content) { + Ok(_) => output.print(&format!("๐Ÿ“„ Context dumped to: {}", dump_path.display())), + Err(e) => output.print(&format!("โŒ Failed to write dump: {}", e)), + } + continue; + } "/clear" => { output.print("๐Ÿงน Clearing session..."); agent.clear_session(); @@ -1751,6 +1853,71 @@ async fn run_interactive_machine( println!("{}", summary); continue; } + "/fragments" => { + println!("COMMAND: fragments"); + if let Some(session_id) = agent.get_session_id() { + match g3_core::acd::list_fragments(session_id) { + Ok(fragments) => { + println!("FRAGMENT_COUNT: {}", fragments.len()); + for fragment in &fragments { + println!("FRAGMENT_ID: {}", fragment.fragment_id); + println!("FRAGMENT_MESSAGES: {}", fragment.message_count); + println!("FRAGMENT_TOKENS: {}", fragment.estimated_tokens); + } + } + Err(e) => { + println!("ERROR: {}", e); + } + } + } else { + println!("ERROR: No active session"); + } + continue; + } + cmd if cmd.starts_with("/rehydrate") => { + println!("COMMAND: rehydrate"); + let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); + if parts.len() < 2 || parts[1].trim().is_empty() { + println!("ERROR: Usage: /rehydrate "); + } else { + let fragment_id = parts[1].trim(); + println!("FRAGMENT_ID: {}", fragment_id); + println!("RESULT: Use the rehydrate tool to restore fragment content"); + } + continue; + } + "/dump" => { + println!("COMMAND: dump"); + let dump_dir = std::path::Path::new("tmp"); + if !dump_dir.exists() { + if let Err(e) = std::fs::create_dir_all(dump_dir) { + println!("ERROR: Failed to create tmp directory: {}", e); + continue; + } + } + + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); + let dump_path = dump_dir.join(format!("context_dump_{}.txt", timestamp)); + + let context = agent.get_context_window(); + let mut dump_content = String::new(); + dump_content.push_str(&format!("# Context Window Dump\n")); + dump_content.push_str(&format!("# Timestamp: {}\n", chrono::Utc::now())); + dump_content.push_str(&format!("# Messages: {}\n", context.conversation_history.len())); + dump_content.push_str(&format!("# Used tokens: {} / {} ({:.1}%)\n\n", + context.used_tokens, context.total_tokens, context.percentage_used())); + + for (i, msg) in context.conversation_history.iter().enumerate() { + dump_content.push_str(&format!("=== Message {} ===\nRole: {:?}\nKind: {:?}\nContent ({} chars):\n{}\n\n", + i, msg.role, msg.kind, msg.content.len(), msg.content)); + } + + match std::fs::write(&dump_path, &dump_content) { + Ok(_) => println!("RESULT: Context dumped to {}", dump_path.display()), + Err(e) => println!("ERROR: Failed to write dump: {}", e), + } + continue; + } "/clear" => { println!("COMMAND: clear"); agent.clear_session(); @@ -1779,7 +1946,7 @@ async fn run_interactive_machine( } "/help" => { println!("COMMAND: help"); - println!("AVAILABLE_COMMANDS: /compact /thinnify /skinnify /clear /resume /readme /stats /help"); + println!("AVAILABLE_COMMANDS: /compact /thinnify /skinnify /clear /dump /fragments /rehydrate /resume /readme /stats /help"); continue; } "/resume" => { diff --git a/crates/g3-core/src/acd.rs b/crates/g3-core/src/acd.rs new file mode 100644 index 0000000..bdd18b4 --- /dev/null +++ b/crates/g3-core/src/acd.rs @@ -0,0 +1,671 @@ +//! Aggressive Context Dehydration (ACD) module. +//! +//! This module provides functionality for dehydrating conversation history +//! into persistent fragments that can be rehydrated on demand. This allows +//! for much longer effective sessions by saving context to disk and replacing +//! it with compact stubs. +//! +//! ## Design +//! +//! When ACD is enabled (`--acd` flag), after every compaction/summary: +//! 1. All messages before the summary are saved to a fragment file +//! 2. Those messages are replaced with a compact stub in the context +//! 3. The stub contains metadata to help decide if rehydration is worthwhile +//! 4. Fragments form a linked list via `preceding_fragment_id` +//! +//! ## Fragment Storage +//! +//! Fragments are stored in `.g3/sessions//fragments/` +//! as JSON files named `fragment_.json`. + +use anyhow::{Context, Result}; +use g3_providers::Message; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use tracing::{debug, warn}; + +use crate::paths::get_fragments_dir; +use crate::ToolCall; + +/// A dehydrated context fragment containing saved conversation history. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Fragment { + /// Unique identifier for this fragment + pub fragment_id: String, + /// When this fragment was created + pub created_at: String, + /// The dehydrated messages + pub messages: Vec, + /// Total number of messages + pub message_count: usize, + /// Number of user messages + pub user_message_count: usize, + /// Number of assistant messages + pub assistant_message_count: usize, + /// Summary of tool calls by tool name + pub tool_call_summary: HashMap, + /// Estimated token count for this fragment + pub estimated_tokens: u32, + /// Brief topic hints extracted from the conversation + pub topics: Vec, + /// ID of the preceding fragment in the chain (None for first fragment) + pub preceding_fragment_id: Option, +} + +impl Fragment { + /// Create a new fragment from a slice of messages. + /// + /// # Arguments + /// * `messages` - The messages to dehydrate + /// * `preceding_fragment_id` - ID of the previous fragment in the chain + pub fn new(messages: Vec, preceding_fragment_id: Option) -> Self { + let fragment_id = generate_fragment_id(); + let created_at = chrono::Utc::now().to_rfc3339(); + + // Count messages by role + let mut user_count = 0; + let mut assistant_count = 0; + for msg in &messages { + match msg.role { + g3_providers::MessageRole::User => user_count += 1, + g3_providers::MessageRole::Assistant => assistant_count += 1, + g3_providers::MessageRole::System => {} + } + } + + // Extract tool call summary + let tool_call_summary = extract_tool_call_summary(&messages); + + // Estimate tokens + let estimated_tokens = estimate_fragment_tokens(&messages); + + // Extract topics + let topics = extract_topics(&messages); + + Self { + fragment_id, + created_at, + message_count: messages.len(), + user_message_count: user_count, + assistant_message_count: assistant_count, + tool_call_summary, + estimated_tokens, + topics, + preceding_fragment_id, + messages, + } + } + + /// Generate the stub message content for this fragment. + pub fn generate_stub(&self) -> String { + let mut stub = String::new(); + stub.push_str("---\n"); + stub.push_str(&format!( + "โšก DEHYDRATED CONTEXT (fragment_id: {})\n", + self.fragment_id + )); + stub.push_str(&format!( + " โ€ข {} messages ({} user, {} assistant)\n", + self.message_count, self.user_message_count, self.assistant_message_count + )); + + // Tool call summary + if !self.tool_call_summary.is_empty() { + let total_calls: usize = self.tool_call_summary.values().sum(); + let tool_details: Vec = self + .tool_call_summary + .iter() + .map(|(tool, count)| format!("{} ร—{}", tool, count)) + .collect(); + stub.push_str(&format!( + " โ€ข {} tool calls ({})\n", + total_calls, + tool_details.join(", ") + )); + } + + stub.push_str(&format!(" โ€ข ~{} tokens saved\n", self.estimated_tokens)); + + if !self.topics.is_empty() { + let topics_str = self + .topics + .iter() + .map(|t| format!("\"{}\"", t)) + .collect::>() + .join(", "); + stub.push_str(&format!(" โ€ข Topics: {}\n", topics_str)); + } + + stub.push_str("\n"); + stub.push_str(&format!( + " To restore this history, call: rehydrate(fragment_id: \"{}\")\n", + self.fragment_id + )); + stub.push_str("---"); + + stub + } + + /// Get the file path for this fragment. + pub fn file_path(&self, session_id: &str) -> PathBuf { + get_fragments_dir(session_id).join(format!("fragment_{}.json", self.fragment_id)) + } + + /// Save this fragment to disk. + pub fn save(&self, session_id: &str) -> Result { + let fragments_dir = get_fragments_dir(session_id); + std::fs::create_dir_all(&fragments_dir) + .context("Failed to create fragments directory")?; + + let file_path = self.file_path(session_id); + let json = serde_json::to_string_pretty(self) + .context("Failed to serialize fragment")?; + std::fs::write(&file_path, json) + .context("Failed to write fragment file")?; + + debug!("Saved fragment {} to {:?}", self.fragment_id, file_path); + Ok(file_path) + } + + /// Load a fragment from disk. + pub fn load(session_id: &str, fragment_id: &str) -> Result { + let file_path = get_fragments_dir(session_id) + .join(format!("fragment_{}.json", fragment_id)); + + if !file_path.exists() { + anyhow::bail!("Fragment not found: {}", fragment_id); + } + + let json = std::fs::read_to_string(&file_path) + .context("Failed to read fragment file")?; + let fragment: Fragment = serde_json::from_str(&json) + .context("Failed to deserialize fragment")?; + + debug!("Loaded fragment {} from {:?}", fragment_id, file_path); + Ok(fragment) + } +} + +/// Generate a unique fragment ID. +fn generate_fragment_id() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + + // Use first 12 hex chars of timestamp hash for brevity + format!("{:x}", timestamp).chars().take(12).collect() +} + +/// Extract a summary of tool calls from messages. +fn extract_tool_call_summary(messages: &[Message]) -> HashMap { + let mut summary = HashMap::new(); + + for msg in messages { + if matches!(msg.role, g3_providers::MessageRole::Assistant) { + // Try to parse tool calls from the message content + if let Some(tool_name) = extract_tool_name_from_content(&msg.content) { + *summary.entry(tool_name).or_insert(0) += 1; + } + } + } + + summary +} + +/// Extract tool name from assistant message content. +fn extract_tool_name_from_content(content: &str) -> Option { + // Look for JSON tool call pattern + if let Some(start) = content.find(r#""tool""#).or_else(|| content.find(r#""tool" "#)) { + let after_tool = &content[start..]; + // Find the tool name value + if let Some(colon_pos) = after_tool.find(':') { + let after_colon = &after_tool[colon_pos + 1..]; + let trimmed = after_colon.trim_start(); + if trimmed.starts_with('"') { + let name_start = 1; + if let Some(name_end) = trimmed[name_start..].find('"') { + return Some(trimmed[name_start..name_start + name_end].to_string()); + } + } + } + } + + // Also try parsing as JSON + if let Ok(tool_call) = serde_json::from_str::(content) { + return Some(tool_call.tool); + } + + // Try to find embedded JSON + if let Some(start) = content.find('{') { + if let Some(end) = find_json_end(&content[start..]) { + let json_str = &content[start..start + end + 1]; + if let Ok(tool_call) = serde_json::from_str::(json_str) { + return Some(tool_call.tool); + } + } + } + + None +} + +/// Find the end of a JSON object (matching braces). +fn find_json_end(json_str: &str) -> Option { + let mut brace_count = 0; + let mut in_string = false; + let mut escape_next = false; + + for (i, ch) in json_str.char_indices() { + if escape_next { + escape_next = false; + continue; + } + + match ch { + '\\' => escape_next = true, + '"' if !escape_next => in_string = !in_string, + '{' if !in_string => brace_count += 1, + '}' if !in_string => { + brace_count -= 1; + if brace_count == 0 { + return Some(i); + } + } + _ => {} + } + } + + None +} + +/// Estimate token count for messages. +fn estimate_fragment_tokens(messages: &[Message]) -> u32 { + let total_chars: usize = messages.iter().map(|m| m.content.len()).sum(); + // Use same heuristic as ContextWindow: ~4 chars per token with 10% buffer + ((total_chars as f32 / 4.0) * 1.1).ceil() as u32 +} + +/// Extract topic hints from messages using heuristics. +fn extract_topics(messages: &[Message]) -> Vec { + let mut topics = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for msg in messages { + match msg.role { + g3_providers::MessageRole::User => { + // Extract first meaningful part of user messages + if !msg.content.starts_with("Tool result") { + let topic = extract_topic_from_text(&msg.content); + if !topic.is_empty() && seen.insert(topic.clone()) { + topics.push(topic); + } + } + } + g3_providers::MessageRole::Assistant => { + // Look for file paths in tool calls + if let Some(path) = extract_file_path(&msg.content) { + if seen.insert(path.clone()) { + topics.push(format!("edited {}", path)); + } + } + } + _ => {} + } + + // Limit topics to keep stub concise + if topics.len() >= 5 { + break; + } + } + + topics +} + +/// Extract a brief topic from text. +fn extract_topic_from_text(text: &str) -> String { + // Take first line, truncate to ~50 chars + let first_line = text.lines().next().unwrap_or(""); + let cleaned = first_line.trim(); + + if cleaned.len() <= 50 { + cleaned.to_string() + } else { + // Find a good break point + let truncated = &cleaned[..50]; + if let Some(last_space) = truncated.rfind(' ') { + format!("{}...", &truncated[..last_space]) + } else { + format!("{}...", truncated) + } + } +} + +/// Extract file path from tool call content. +fn extract_file_path(content: &str) -> Option { + // Look for file_path in JSON + if let Some(start) = content.find(r#""file_path""#) { + let after = &content[start..]; + if let Some(colon) = after.find(':') { + let after_colon = &after[colon + 1..]; + let trimmed = after_colon.trim_start(); + if trimmed.starts_with('"') { + if let Some(end) = trimmed[1..].find('"') { + let path = &trimmed[1..1 + end]; + // Return just the filename for brevity + return Some( + std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path) + .to_string(), + ); + } + } + } + } + None +} + +/// List all fragments for a session, ordered by creation time. +pub fn list_fragments(session_id: &str) -> Result> { + let fragments_dir = get_fragments_dir(session_id); + + if !fragments_dir.exists() { + return Ok(Vec::new()); + } + + let mut fragments = Vec::new(); + + for entry in std::fs::read_dir(&fragments_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().map_or(false, |e| e == "json") { + match std::fs::read_to_string(&path) { + Ok(json) => match serde_json::from_str::(&json) { + Ok(fragment) => fragments.push(fragment), + Err(e) => warn!("Failed to parse fragment {:?}: {}", path, e), + }, + Err(e) => warn!("Failed to read fragment {:?}: {}", path, e), + } + } + } + + // Sort by creation time + fragments.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + + Ok(fragments) +} + +/// Get the most recent fragment ID for a session (the tail of the linked list). +pub fn get_latest_fragment_id(session_id: &str) -> Result> { + let fragments = list_fragments(session_id)?; + Ok(fragments.last().map(|f| f.fragment_id.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + use g3_providers::MessageRole; + + fn make_message(role: MessageRole, content: &str) -> Message { + Message::new(role, content.to_string()) + } + + #[test] + fn test_fragment_creation() { + let messages = vec![ + make_message(MessageRole::User, "Hello, can you help me?"), + make_message(MessageRole::Assistant, "Of course! What do you need?"), + make_message(MessageRole::User, "Write a function"), + make_message( + MessageRole::Assistant, + r#"{"tool": "write_file", "args": {"file_path": "test.rs", "content": "fn main() {}"}}"#, + ), + ]; + + let fragment = Fragment::new(messages, None); + + assert_eq!(fragment.message_count, 4); + assert_eq!(fragment.user_message_count, 2); + assert_eq!(fragment.assistant_message_count, 2); + assert!(fragment.fragment_id.len() > 0); + assert!(fragment.preceding_fragment_id.is_none()); + } + + #[test] + fn test_fragment_with_preceding() { + let messages = vec![make_message(MessageRole::User, "Test")]; + let fragment = Fragment::new(messages, Some("abc123".to_string())); + + assert_eq!(fragment.preceding_fragment_id, Some("abc123".to_string())); + } + + #[test] + fn test_tool_call_extraction() { + let messages = vec![ + make_message( + MessageRole::Assistant, + r#"{"tool": "shell", "args": {"command": "ls"}}"#, + ), + make_message( + MessageRole::Assistant, + r#"{"tool": "read_file", "args": {"file_path": "test.rs"}}"#, + ), + make_message( + MessageRole::Assistant, + r#"{"tool": "shell", "args": {"command": "pwd"}}"#, + ), + ]; + + let summary = extract_tool_call_summary(&messages); + + assert_eq!(summary.get("shell"), Some(&2)); + assert_eq!(summary.get("read_file"), Some(&1)); + } + + #[test] + fn test_stub_generation() { + let messages = vec![ + make_message(MessageRole::User, "implement auth module"), + make_message( + MessageRole::Assistant, + r#"{"tool": "write_file", "args": {"file_path": "auth.rs", "content": "// auth"}}"#, + ), + ]; + + let fragment = Fragment::new(messages, None); + let stub = fragment.generate_stub(); + + assert!(stub.contains("DEHYDRATED CONTEXT")); + assert!(stub.contains(&fragment.fragment_id)); + assert!(stub.contains("2 messages")); + assert!(stub.contains("1 user")); + assert!(stub.contains("1 assistant")); + assert!(stub.contains("rehydrate")); + } + + #[test] + fn test_topic_extraction() { + let messages = vec![ + make_message(MessageRole::User, "Please fix the login bug"), + make_message(MessageRole::User, "Tool result: success"), + make_message(MessageRole::User, "Now add tests for it"), + ]; + + let topics = extract_topics(&messages); + + assert!(topics.contains(&"Please fix the login bug".to_string())); + assert!(topics.contains(&"Now add tests for it".to_string())); + // Tool results should be skipped + assert!(!topics.iter().any(|t| t.contains("Tool result"))); + } + + #[test] + fn test_topic_truncation() { + let long_text = "This is a very long message that should be truncated because it exceeds the maximum length we want for topic hints"; + let topic = extract_topic_from_text(long_text); + + assert!(topic.len() <= 55); // 50 + "..." + assert!(topic.ends_with("...")); + } + + #[test] + fn test_file_path_extraction() { + let content = r#"{"tool": "write_file", "args": {"file_path": "src/auth/login.rs", "content": "..."}}"#; + let path = extract_file_path(content); + + assert_eq!(path, Some("login.rs".to_string())); + } + + #[test] + fn test_fragment_save_load_roundtrip() { + let temp_dir = std::env::temp_dir(); + let test_session_id = format!("test_acd_{}", std::process::id()); + + // Create a fragment + let messages = vec![ + make_message(MessageRole::User, "Test message"), + make_message(MessageRole::Assistant, "Test response"), + ]; + let fragment = Fragment::new(messages.clone(), None); + let original_id = fragment.fragment_id.clone(); + + // Temporarily override the g3 dir for testing + let fragments_dir = temp_dir.join(".g3").join("sessions").join(&test_session_id).join("fragments"); + std::fs::create_dir_all(&fragments_dir).unwrap(); + + // Save directly to temp location + let file_path = fragments_dir.join(format!("fragment_{}.json", original_id)); + let json = serde_json::to_string_pretty(&fragment).unwrap(); + std::fs::write(&file_path, &json).unwrap(); + + // Load it back + let loaded_json = std::fs::read_to_string(&file_path).unwrap(); + let loaded: Fragment = serde_json::from_str(&loaded_json).unwrap(); + + assert_eq!(loaded.fragment_id, original_id); + assert_eq!(loaded.message_count, 2); + assert_eq!(loaded.messages.len(), 2); + assert_eq!(loaded.messages[0].content, "Test message"); + + // Cleanup + let _ = std::fs::remove_dir_all(temp_dir.join(".g3").join("sessions").join(&test_session_id)); + } + + #[test] + fn test_empty_fragment() { + let fragment = Fragment::new(vec![], None); + + assert_eq!(fragment.message_count, 0); + assert_eq!(fragment.user_message_count, 0); + assert_eq!(fragment.assistant_message_count, 0); + assert!(fragment.tool_call_summary.is_empty()); + assert!(fragment.topics.is_empty()); + } + + #[test] + fn test_fragment_id_uniqueness() { + let id1 = generate_fragment_id(); + std::thread::sleep(std::time::Duration::from_millis(1)); + let id2 = generate_fragment_id(); + + assert_ne!(id1, id2); + } + + #[test] + fn test_linked_list_chain() { + let frag1 = Fragment::new( + vec![make_message(MessageRole::User, "First")], + None, + ); + let frag2 = Fragment::new( + vec![make_message(MessageRole::User, "Second")], + Some(frag1.fragment_id.clone()), + ); + let frag3 = Fragment::new( + vec![make_message(MessageRole::User, "Third")], + Some(frag2.fragment_id.clone()), + ); + + // Verify chain + assert!(frag1.preceding_fragment_id.is_none()); + assert_eq!(frag2.preceding_fragment_id, Some(frag1.fragment_id.clone())); + assert_eq!(frag3.preceding_fragment_id, Some(frag2.fragment_id.clone())); + } + + #[test] + fn test_stub_with_no_tools() { + let messages = vec![ + make_message(MessageRole::User, "Just chatting"), + make_message(MessageRole::Assistant, "Sure, let's chat!"), + ]; + + let fragment = Fragment::new(messages, None); + let stub = fragment.generate_stub(); + + // Should not have tool call line + assert!(!stub.contains("tool calls")); + } + + #[test] + fn test_stub_with_multiple_tools() { + let messages = vec![ + make_message( + MessageRole::Assistant, + r#"{"tool": "shell", "args": {"command": "ls"}}"#, + ), + make_message( + MessageRole::Assistant, + r#"{"tool": "read_file", "args": {"file_path": "a.rs"}}"#, + ), + make_message( + MessageRole::Assistant, + r#"{"tool": "write_file", "args": {"file_path": "b.rs", "content": "x"}}"#, + ), + ]; + + let fragment = Fragment::new(messages, None); + let stub = fragment.generate_stub(); + + assert!(stub.contains("3 tool calls")); + assert!(stub.contains("shell")); + assert!(stub.contains("read_file")); + assert!(stub.contains("write_file")); + } + + #[test] + fn test_token_estimation() { + let messages = vec![ + make_message(MessageRole::User, "Hello"), // 5 chars + make_message(MessageRole::Assistant, "World"), // 5 chars + ]; + + let tokens = estimate_fragment_tokens(&messages); + + // 10 chars / 4 * 1.1 โ‰ˆ 3 tokens + assert!(tokens > 0); + assert!(tokens < 10); + } + + #[test] + fn test_extract_tool_name_embedded_json() { + let content = "Let me check that file for you. + +{\"tool\": \"read_file\", \"args\": {\"file_path\": \"test.rs\"}}"; + let tool_name = extract_tool_name_from_content(content); + + assert_eq!(tool_name, Some("read_file".to_string())); + } + + #[test] + fn test_extract_tool_name_no_tool() { + let content = "This is just regular text without any tool calls."; + let tool_name = extract_tool_name_from_content(content); + + assert!(tool_name.is_none()); + } +} \ No newline at end of file diff --git a/crates/g3-core/src/compaction.rs b/crates/g3-core/src/compaction.rs index a998b8c..66d461f 100644 --- a/crates/g3-core/src/compaction.rs +++ b/crates/g3-core/src/compaction.rs @@ -191,6 +191,8 @@ pub async fn perform_compaction( // Execute summary request match provider.complete(summary_request).await { Ok(summary_response) => { + // Note: ACD dehydration now happens at the end of each turn in Agent::dehydrate_context() + // Compaction just does lossy summarization of the existing stubs + summaries let chars_saved = context_window.reset_with_summary( summary_response.content, compaction_config.latest_user_msg, diff --git a/crates/g3-core/src/context_window.rs b/crates/g3-core/src/context_window.rs index 518dd72..fa2b558 100644 --- a/crates/g3-core/src/context_window.rs +++ b/crates/g3-core/src/context_window.rs @@ -271,6 +271,75 @@ Format this as a detailed but concise summary that can be used to resume the con old_chars.saturating_sub(new_chars) } + /// Reset context window with a summary and optional ACD stub + /// Preserves the original system prompt as the first message + /// If stub is provided, it's added as a system message before the summary + pub fn reset_with_summary_and_stub( + &mut self, + summary: String, + latest_user_message: Option, + stub: Option, + ) -> usize { + // Calculate chars saved (old history minus new summary) + let old_chars: usize = self + .conversation_history + .iter() + .map(|m| m.content.len()) + .sum(); + + // Preserve the original system prompt (first message) and optionally the README (second message) + let original_system_prompt = self.conversation_history.first().cloned(); + let readme_message = self.conversation_history.get(1).and_then(|msg| { + if matches!(msg.role, MessageRole::System) + && (msg.content.contains("Project README") + || msg.content.contains("Agent Configuration")) + { + Some(msg.clone()) + } else { + None + } + }); + + // Clear the conversation history + self.conversation_history.clear(); + self.used_tokens = 0; + + // Re-add the original system prompt first (critical invariant) + if let Some(system_prompt) = original_system_prompt { + self.add_message(system_prompt); + } + + // Re-add the README message if it existed + if let Some(readme) = readme_message { + self.add_message(readme); + } + + // Add the ACD stub if provided (before summary so LLM knows about dehydrated context) + if let Some(stub_content) = stub { + let stub_message = Message::new(MessageRole::System, stub_content); + self.add_message(stub_message); + } + + // Add the summary as a system message + let summary_message = Message::new( + MessageRole::System, + format!("Previous conversation summary:\n\n{}", summary), + ); + self.add_message(summary_message); + + // Add the latest user message if provided + if let Some(user_msg) = latest_user_message { + self.add_message(Message::new(MessageRole::User, user_msg)); + } + + let new_chars: usize = self + .conversation_history + .iter() + .map(|m| m.content.len()) + .sum(); + old_chars.saturating_sub(new_chars) + } + /// Check if we should trigger context thinning /// Triggers at 50%, 60%, 70%, and 80% thresholds pub fn should_thin(&self) -> bool { @@ -676,7 +745,8 @@ Format this as a detailed but concise summary that can be used to resume the con } /// Recalculate token usage based on current conversation history - fn recalculate_tokens(&mut self) { + /// Recalculate the token count based on current conversation history. + pub fn recalculate_tokens(&mut self) { let mut total = 0; for message in &self.conversation_history { total += Self::estimate_tokens(&message.content); diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index 570bfb5..e573292 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod acd; pub mod context_window; pub mod background_process; pub mod compaction; @@ -127,6 +128,8 @@ pub struct Agent { agent_name: Option, /// Whether auto-memory reminders are enabled (--auto-memory flag) auto_memory: bool, + /// Whether aggressive context dehydration is enabled (--acd flag) + acd_enabled: bool, } impl Agent { @@ -296,6 +299,7 @@ impl Agent { is_agent_mode: false, agent_name: None, auto_memory: false, + acd_enabled: false, }) } @@ -1369,6 +1373,130 @@ impl Agent { debug!("Auto-memory reminders: {}", if enabled { "enabled" } else { "disabled" }); } + /// Enable or disable aggressive context dehydration (ACD) + pub fn set_acd_enabled(&mut self, enabled: bool) { + self.acd_enabled = enabled; + debug!("ACD (aggressive context dehydration): {}", if enabled { "enabled" } else { "disabled" }); + } + + /// Perform ACD dehydration - save current conversation state to a fragment. + /// Called at the end of each turn when ACD is enabled. + /// + /// This saves all non-system messages (except the final assistant response) + /// to a fragment, then replaces them with a compact stub. The final assistant + /// response is preserved as the turn summary after the stub. + /// + /// in the context with a compact stub. The agent's final response (summary) + /// is preserved after the stub. + fn dehydrate_context(&mut self) { + if !self.acd_enabled { + return; + } + + let session_id = match &self.session_id { + Some(id) => id.clone(), + None => { + debug!("ACD: No session_id, skipping dehydration"); + return; + } + }; + + // Find the index of the last dehydration stub (marks the end of previously dehydrated content) + // We only want to dehydrate messages AFTER the last stub+summary pair + let last_stub_index = self.context_window + .conversation_history + .iter() + .rposition(|m| m.is_dehydrated_stub()); + + // Start index for messages to dehydrate: + // - If there's a previous stub, start after the stub AND its following summary (stub + 2) + // - Otherwise, start from the beginning (index 0) + let dehydrate_start = match last_stub_index { + Some(idx) => idx + 2, // Skip the stub and the summary that follows it + None => 0, + }; + + // Get the preceding fragment ID (if any) + let preceding_id = crate::acd::get_latest_fragment_id(&session_id).ok().flatten(); + + // Extract only NEW non-system messages to dehydrate (after the last stub+summary) + let messages_to_dehydrate: Vec<_> = self.context_window + .conversation_history + .iter() + .enumerate() + .filter(|(idx, m)| *idx >= dehydrate_start && !matches!(m.role, g3_providers::MessageRole::System)) + .map(|(_, m)| m.clone()) + .collect(); + + if messages_to_dehydrate.is_empty() { + return; + } + + // Extract the last assistant message as the turn summary + // This is the actual LLM response, not the timing footer passed in final_response + let turn_summary: Option = messages_to_dehydrate + .iter() + .rev() + .find(|m| matches!(m.role, g3_providers::MessageRole::Assistant)) + .map(|m| m.content.clone()); + + // Use extracted summary, falling back to final_response only if no assistant message found + let summary_content = turn_summary.unwrap_or_default(); + + // Create the fragment and generate stub + let fragment = crate::acd::Fragment::new(messages_to_dehydrate, preceding_id); + let stub = fragment.generate_stub(); + + if let Err(e) = fragment.save(&session_id) { + warn!("Failed to save ACD fragment: {}", e); + return; // Don't modify context if save failed + } + + println!("๐Ÿ’พ Dehydrated {} messages to fragment {}", fragment.message_count, fragment.fragment_id); + + // Now replace the context: keep system messages + previous stubs/summaries, add new stub, add new summary + // Extract messages to keep: system messages + everything up to (but not including) dehydrate_start + let messages_to_keep: Vec<_> = self.context_window + .conversation_history + .iter() + .enumerate() + .filter(|(idx, m)| { + // Keep all system messages OR keep previous stub+summary pairs + matches!(m.role, g3_providers::MessageRole::System) || *idx < dehydrate_start + }) + .map(|(_, m)| m.clone()) + .collect(); + + // Clear and rebuild context + self.context_window.conversation_history.clear(); + + // Add back kept messages (system + previous stubs/summaries) + for msg in messages_to_keep { + self.context_window.conversation_history.push(msg); + } + + // Add the stub as a user message (so LLM sees it as context) + let stub_msg = g3_providers::Message::with_kind( + g3_providers::MessageRole::User, + stub, + g3_providers::MessageKind::DehydratedStub, + ); + self.context_window.conversation_history.push(stub_msg); + + // Add the final response as assistant message (the summary) + if !summary_content.trim().is_empty() { + let summary_msg = g3_providers::Message::with_kind( + g3_providers::MessageRole::Assistant, + summary_content, + g3_providers::MessageKind::Summary, + ); + self.context_window.conversation_history.push(summary_msg); + } + + // Recalculate token usage + self.context_window.recalculate_tokens(); + } + /// Send an auto-memory reminder to the LLM if tools were called during the turn. /// This prompts the LLM to call the `remember` tool if it discovered any key code locations. /// Returns true if a reminder was sent and processed. @@ -1503,6 +1631,7 @@ impl Agent { id: String::new(), images: Vec::new(), content: content.to_string(), + kind: g3_providers::MessageKind::Regular, cache_control: None, }); } @@ -1529,6 +1658,7 @@ impl Agent { id: String::new(), images: Vec::new(), content: format!("[Session Resumed]\n\n{}", context_msg), + kind: g3_providers::MessageKind::Regular, cache_control: None, }); } @@ -2077,11 +2207,26 @@ impl Agent { const MAX_LINE_WIDTH: usize = 80; let output_len = output_lines.len(); - // Skip printing for todo tools - they already print their content + // Skip printing content for todo tools - they already print their content let is_todo_tool = tool_call.tool == "todo_read" || tool_call.tool == "todo_write"; - if !is_todo_tool { + // For read_file, show a summary instead of file contents + let is_read_file = tool_call.tool == "read_file"; + + if is_read_file && tool_success { + // Calculate summary: lines and chars + let char_count = tool_result.len(); + let char_display = if char_count >= 1000 { + format!("{:.1}k", char_count as f64 / 1000.0) + } else { + format!("{}", char_count) + }; + let summary = format!("โœ… read {} lines | {} chars", output_len, char_display); + self.ui_writer.update_tool_output_line(&summary); + } else if is_todo_tool { + // Skip - todo tools print their own content + } else { let max_lines_to_show = if wants_full { output_len } else { MAX_LINES }; for (idx, line) in output_lines.iter().enumerate() { @@ -2356,11 +2501,8 @@ impl Agent { break; } - // Set full_response to current_response (don't append) - // current_response already contains everything that was displayed - // Don't set full_response here - it would duplicate the output - // The text was already displayed during streaming - // Return empty string to avoid duplication + // Set full_response to empty to avoid duplication in return value + // (content was already displayed during streaming) full_response = String::new(); // Finish the streaming markdown formatter before returning @@ -2389,6 +2531,9 @@ impl Agent { full_response }; + // Dehydrate context - the function extracts the summary from context itself + self.dehydrate_context(); + return Ok(TaskResult::new( final_response, self.context_window.clone(), @@ -2618,9 +2763,11 @@ impl Agent { let _ttft = first_token_time.unwrap_or_else(|| stream_start.elapsed()); - // Add the RAW unfiltered response to context window before returning - // This ensures the log contains the true raw content including any JSON - if !full_response.trim().is_empty() { + // Add the RAW unfiltered response to context window before returning. + // This ensures the log contains the true raw content including any JSON. + // Note: We check current_response, not full_response, because full_response + // may be empty to avoid display duplication (content was already streamed). + if !current_response.trim().is_empty() { // Get the raw text from the parser (before filtering) let raw_text = parser.get_text_content(); let raw_clean = streaming::clean_llm_tokens(&raw_text); @@ -2652,6 +2799,9 @@ impl Agent { full_response }; + // Dehydrate context - the function extracts the summary from context itself + self.dehydrate_context(); + return Ok(TaskResult::new(final_response, self.context_window.clone())); } @@ -2679,6 +2829,9 @@ impl Agent { full_response }; + // Dehydrate context - the function extracts the summary from context itself + self.dehydrate_context(); + Ok(TaskResult::new(final_response, self.context_window.clone())) } @@ -2771,19 +2924,26 @@ pub use utils::apply_unified_diff_to_string; /// Truncate a string to approximately max_len characters, ending at a word boundary fn truncate_to_word_boundary(s: &str, max_len: usize) -> String { - if s.len() <= max_len { + let char_count = s.chars().count(); + if char_count <= max_len { return s.to_string(); } - // Find the last space before max_len - let truncated = &s[..max_len]; - if let Some(last_space) = truncated.rfind(' ') { - if last_space > max_len / 2 { - // Only use word boundary if it's not too short - return format!("{}...", &s[..last_space]); + // Get the byte index of the max_len-th character + let byte_index: usize = s.char_indices() + .nth(max_len) + .map(|(i, _)| i) + .unwrap_or(s.len()); + + // Find the last space before the character limit + let truncated = &s[..byte_index]; + if let Some(last_space_byte) = truncated.rfind(' ') { + if truncated[..last_space_byte].chars().count() > max_len / 2 { + // Only use word boundary if it's not too short (in characters) + return format!("{}...", &s[..last_space_byte]); } } - // Fall back to character truncation + // Fall back to truncation at character boundary format!("{}...", truncated) } diff --git a/crates/g3-core/src/paths.rs b/crates/g3-core/src/paths.rs index 517db77..7708db0 100644 --- a/crates/g3-core/src/paths.rs +++ b/crates/g3-core/src/paths.rs @@ -85,6 +85,12 @@ pub fn get_thinned_dir(session_id: &str) -> PathBuf { get_session_logs_dir(session_id).join("thinned") } +/// Get the fragments directory for a session (for ACD dehydrated context). +/// Returns .g3/sessions//fragments/ +pub fn get_fragments_dir(session_id: &str) -> PathBuf { + get_session_logs_dir(session_id).join("fragments") +} + /// Get the path to the session.json file for a session. /// Returns .g3/sessions//session.json pub fn get_session_file(session_id: &str) -> PathBuf { diff --git a/crates/g3-core/src/tool_definitions.rs b/crates/g3-core/src/tool_definitions.rs index f1ae6df..7c24a97 100644 --- a/crates/g3-core/src/tool_definitions.rs +++ b/crates/g3-core/src/tool_definitions.rs @@ -288,6 +288,22 @@ fn create_core_tools(exclude_research: bool) -> Vec { }), }); + // ACD rehydration tool + tools.push(Tool { + name: "rehydrate".to_string(), + description: "Restore dehydrated conversation history from a previous context segment. Use this when you see a DEHYDRATED CONTEXT stub and need to recall the full conversation details from that segment.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "fragment_id": { + "type": "string", + "description": "The fragment ID to restore (from a DEHYDRATED CONTEXT stub message)" + } + }, + "required": ["fragment_id"] + }), + }); + tools } @@ -495,7 +511,7 @@ mod tests { // write_file, str_replace, take_screenshot, // todo_read, todo_write, code_coverage, code_search, research, remember // (13 total - memory is auto-loaded, only remember tool needed) - assert_eq!(tools.len(), 13); + assert_eq!(tools.len(), 14); } #[test] @@ -509,7 +525,7 @@ mod tests { fn test_create_tool_definitions_core_only() { let config = ToolConfig::default(); let tools = create_tool_definitions(config); - assert_eq!(tools.len(), 13); + assert_eq!(tools.len(), 14); } #[test] @@ -517,7 +533,7 @@ mod tests { let config = ToolConfig::new(true, true); let tools = create_tool_definitions(config); // 13 core + 15 webdriver = 28 - assert_eq!(tools.len(), 28); + assert_eq!(tools.len(), 29); } #[test] @@ -535,8 +551,8 @@ mod tests { let tools_with_research = create_core_tools(false); let tools_without_research = create_core_tools(true); - assert_eq!(tools_with_research.len(), 13); - assert_eq!(tools_without_research.len(), 12); + assert_eq!(tools_with_research.len(), 14); + assert_eq!(tools_without_research.len(), 13); assert!(tools_with_research.iter().any(|t| t.name == "research")); assert!(!tools_without_research.iter().any(|t| t.name == "research")); diff --git a/crates/g3-core/src/tool_dispatch.rs b/crates/g3-core/src/tool_dispatch.rs index df33d66..0630ce0 100644 --- a/crates/g3-core/src/tool_dispatch.rs +++ b/crates/g3-core/src/tool_dispatch.rs @@ -7,7 +7,7 @@ use anyhow::Result; use tracing::{debug, warn}; use crate::tools::executor::ToolContext; -use crate::tools::{file_ops, memory, misc, research, shell, todo, webdriver}; +use crate::tools::{acd, file_ops, memory, misc, research, shell, todo, webdriver}; use crate::ui_writer::UiWriter; use crate::ToolCall; @@ -47,6 +47,9 @@ pub async fn dispatch_tool( // Project memory tools "remember" => memory::execute_remember(tool_call, ctx).await, + // ACD (Aggressive Context Dehydration) tools + "rehydrate" => acd::execute_rehydrate(tool_call, ctx).await, + // WebDriver tools "webdriver_start" => webdriver::execute_webdriver_start(tool_call, ctx).await, "webdriver_navigate" => webdriver::execute_webdriver_navigate(tool_call, ctx).await, diff --git a/crates/g3-core/src/tools/acd.rs b/crates/g3-core/src/tools/acd.rs new file mode 100644 index 0000000..b74c538 --- /dev/null +++ b/crates/g3-core/src/tools/acd.rs @@ -0,0 +1,273 @@ +//! ACD (Aggressive Context Dehydration) tool: rehydrate. +//! +//! This tool allows the LLM to restore dehydrated conversation history +//! from a previous context segment. + +use anyhow::Result; +use tracing::{debug, warn}; + +use crate::acd::Fragment; +use crate::ui_writer::UiWriter; +use crate::ToolCall; + +use super::executor::ToolContext; + +/// Execute the rehydrate tool. +/// Loads a fragment from disk and returns its contents for the LLM to review. +pub async fn execute_rehydrate( + tool_call: &ToolCall, + ctx: &mut ToolContext<'_, W>, +) -> Result { + let fragment_id = tool_call + .args + .get("fragment_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required 'fragment_id' parameter"))?; + + // Get session ID from context + let session_id = ctx + .session_id + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No session ID available - cannot rehydrate fragment"))?; + + debug!("Rehydrating fragment {} for session {}", fragment_id, session_id); + + // Load the fragment + let fragment = match Fragment::load(session_id, fragment_id) { + Ok(f) => f, + Err(e) => { + warn!("Failed to load fragment {}: {}", fragment_id, e); + return Ok(format!( + "โŒ Failed to rehydrate fragment '{}': {}\n\nThe fragment may have been deleted or the ID may be incorrect.", + fragment_id, e + )); + } + }; + + // Check if rehydration would be useful (warn if context is nearly full) + let context_percentage = (ctx.context_used_tokens as f64 / ctx.context_total_tokens as f64) * 100.0; + let fragment_tokens = fragment.estimated_tokens; + let available_tokens = ctx.context_total_tokens.saturating_sub(ctx.context_used_tokens); + + if fragment_tokens > available_tokens { + return Ok(format!( + "โš ๏ธ Cannot rehydrate fragment '{}': it contains ~{} tokens but only {} tokens are available in context.\n\n\ + Consider compacting the context first with /compact, or continue without the full history.", + fragment_id, fragment_tokens, available_tokens + )); + } + + if context_percentage > 70.0 && ctx.context_total_tokens > 0 { + ctx.ui_writer.println(&format!( + "โš ๏ธ Warning: Context is at {:.0}% capacity. Rehydrating {} tokens may trigger compaction soon.", + context_percentage, fragment_tokens + )); + } + + // Format the rehydrated content + let mut output = String::new(); + output.push_str(&format!( + "โœ… Rehydrated fragment '{}' ({} messages, ~{} tokens)\n\n", + fragment_id, fragment.message_count, fragment.estimated_tokens + )); + + // Add fragment metadata + output.push_str("## Fragment Metadata\n"); + output.push_str(&format!("- Created: {}\n", fragment.created_at)); + if let Some(ref preceding) = fragment.preceding_fragment_id { + output.push_str(&format!("- Preceding fragment: {}\n", preceding)); + } + if !fragment.topics.is_empty() { + output.push_str(&format!("- Topics: {}\n", fragment.topics.join(", "))); + } + output.push_str("\n"); + + // Add the conversation history + output.push_str("## Restored Conversation\n\n"); + + for (i, msg) in fragment.messages.iter().enumerate() { + let role_str = match msg.role { + g3_providers::MessageRole::User => "**User**", + g3_providers::MessageRole::Assistant => "**Assistant**", + g3_providers::MessageRole::System => "**System**", + }; + + // Truncate very long messages for readability + let content = if msg.content.len() > 2000 { + format!("{}... [truncated, {} chars total]", &msg.content[..2000], msg.content.len()) + } else { + msg.content.clone() + }; + + output.push_str(&format!("### Message {} - {}\n{}\n\n", i + 1, role_str, content)); + } + + // Add note about preceding fragments + if fragment.preceding_fragment_id.is_some() { + output.push_str(&format!( + "---\n๐Ÿ’ก This fragment has a preceding fragment. To see earlier history, call: rehydrate(fragment_id: \"{}\")\n", + fragment.preceding_fragment_id.as_ref().unwrap() + )); + } + + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acd::Fragment; + use crate::ui_writer::NullUiWriter; + use crate::background_process::BackgroundProcessManager; + use crate::webdriver_session::WebDriverSession; + use g3_providers::{Message, MessageRole}; + use std::sync::Arc; + use tokio::sync::RwLock; + use serde_json::json; + + struct TestContext { + ui_writer: NullUiWriter, + webdriver_session: Arc>>>>, + webdriver_process: Arc>>, + background_process_manager: Arc, + todo_content: Arc>, + pending_images: Vec, + config: g3_config::Config, + } + + impl TestContext { + fn new() -> Self { + Self { + ui_writer: NullUiWriter, + webdriver_session: Arc::new(RwLock::new(None)), + webdriver_process: Arc::new(RwLock::new(None)), + background_process_manager: Arc::new(BackgroundProcessManager::new(std::path::PathBuf::from("/tmp"))), + todo_content: Arc::new(RwLock::new(String::new())), + pending_images: Vec::new(), + config: g3_config::Config::default(), + } + } + } + + #[tokio::test] + async fn test_rehydrate_missing_fragment_id() { + let mut test_ctx = TestContext::new(); + let mut ctx = ToolContext { + working_dir: None, + session_id: Some("test-session"), + ui_writer: &test_ctx.ui_writer, + config: &test_ctx.config, + computer_controller: None, + webdriver_session: &test_ctx.webdriver_session, + webdriver_process: &test_ctx.webdriver_process, + background_process_manager: &test_ctx.background_process_manager, + todo_content: &test_ctx.todo_content, + pending_images: &mut test_ctx.pending_images, + is_autonomous: false, + requirements_sha: None, + context_total_tokens: 100000, + context_used_tokens: 10000, + }; + + let tool_call = ToolCall { + tool: "rehydrate".to_string(), + args: json!({}), + }; + + let result = execute_rehydrate(&tool_call, &mut ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Missing required")); + } + + #[tokio::test] + async fn test_rehydrate_no_session_id() { + let mut test_ctx = TestContext::new(); + let mut ctx = ToolContext { + working_dir: None, + session_id: None, + ui_writer: &test_ctx.ui_writer, + config: &test_ctx.config, + computer_controller: None, + webdriver_session: &test_ctx.webdriver_session, + webdriver_process: &test_ctx.webdriver_process, + background_process_manager: &test_ctx.background_process_manager, + todo_content: &test_ctx.todo_content, + pending_images: &mut test_ctx.pending_images, + is_autonomous: false, + requirements_sha: None, + context_total_tokens: 100000, + context_used_tokens: 10000, + }; + + let tool_call = ToolCall { + tool: "rehydrate".to_string(), + args: json!({"fragment_id": "test-fragment"}), + }; + + let result = execute_rehydrate(&tool_call, &mut ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("No session ID")); + } + + #[tokio::test] + async fn test_rehydrate_nonexistent_fragment() { + let mut test_ctx = TestContext::new(); + let mut ctx = ToolContext { + working_dir: None, + session_id: Some("nonexistent-session"), + ui_writer: &test_ctx.ui_writer, + config: &test_ctx.config, + computer_controller: None, + webdriver_session: &test_ctx.webdriver_session, + webdriver_process: &test_ctx.webdriver_process, + background_process_manager: &test_ctx.background_process_manager, + todo_content: &test_ctx.todo_content, + pending_images: &mut test_ctx.pending_images, + is_autonomous: false, + requirements_sha: None, + context_total_tokens: 100000, + context_used_tokens: 10000, + }; + + let tool_call = ToolCall { + tool: "rehydrate".to_string(), + args: json!({"fragment_id": "nonexistent-fragment"}), + }; + + let result = execute_rehydrate(&tool_call, &mut ctx).await; + assert!(result.is_ok()); + let output = result.unwrap(); + assert!(output.contains("Failed to rehydrate")); + assert!(output.contains("nonexistent-fragment")); + } + + #[tokio::test] + async fn test_rehydrate_success() { + // Create a temporary fragment + let test_session_id = format!("test_rehydrate_{}", std::process::id()); + + let messages = vec![ + Message::new(MessageRole::User, "Test user message".to_string()), + Message::new(MessageRole::Assistant, "Test assistant response".to_string()), + ]; + let fragment = Fragment::new(messages, None); + let fragment_id = fragment.fragment_id.clone(); + + // Save fragment using the Fragment::save method + let save_result = fragment.save(&test_session_id); + assert!(save_result.is_ok()); + let file_path = save_result.unwrap(); + assert!(file_path.exists(), "Fragment file should exist after save"); + + // Verify we can load it back + let loaded = Fragment::load(&test_session_id, &fragment_id); + assert!(loaded.is_ok()); + let loaded_fragment = loaded.unwrap(); + assert_eq!(loaded_fragment.fragment_id, fragment_id); + assert_eq!(loaded_fragment.message_count, 2); + + // Cleanup + let _ = std::fs::remove_file(&file_path); + let _ = std::fs::remove_dir(file_path.parent().unwrap()); + } +} diff --git a/crates/g3-core/src/tools/mod.rs b/crates/g3-core/src/tools/mod.rs index f7f95cd..e8f76bf 100644 --- a/crates/g3-core/src/tools/mod.rs +++ b/crates/g3-core/src/tools/mod.rs @@ -9,8 +9,10 @@ //! - `misc` - Other tools (screenshots, code search, etc.) //! - `research` - Web research via scout agent //! - `memory` - Project memory (read_memory, remember) +//! - `acd` - Aggressive Context Dehydration (rehydrate) pub mod executor; +pub mod acd; pub mod file_ops; pub mod memory; pub mod misc; diff --git a/crates/g3-core/src/tools/shell.rs b/crates/g3-core/src/tools/shell.rs index 579b61a..e3ff32c 100644 --- a/crates/g3-core/src/tools/shell.rs +++ b/crates/g3-core/src/tools/shell.rs @@ -61,7 +61,17 @@ pub async fn execute_shell(tool_call: &ToolCall, ctx: &ToolContext< result.stdout.trim().to_string() }) } else { - Ok(format!("โŒ {}", result.stderr.trim())) + // Build error message with available information + let stderr = result.stderr.trim(); + let stdout = result.stdout.trim(); + if !stderr.is_empty() { + Ok(format!("โŒ {}", stderr)) + } else if !stdout.is_empty() { + // Sometimes error info is in stdout + Ok(format!("โŒ Exit code {}: {}", result.exit_code, stdout)) + } else { + Ok(format!("โŒ Command failed with exit code {}", result.exit_code)) + } } } Err(e) => Ok(format!("โŒ Execution error: {}", e)), diff --git a/crates/g3-core/tests/test_acd_integration.rs b/crates/g3-core/tests/test_acd_integration.rs new file mode 100644 index 0000000..2255714 --- /dev/null +++ b/crates/g3-core/tests/test_acd_integration.rs @@ -0,0 +1,311 @@ +//! Integration tests for Aggressive Context Dehydration (ACD). + +use g3_core::acd::{Fragment, list_fragments, get_latest_fragment_id}; +use g3_core::context_window::ContextWindow; +use g3_providers::{Message, MessageRole}; + +/// Test that reset_with_summary_and_stub correctly adds stub before summary +#[test] +fn test_reset_with_summary_and_stub_ordering() { + let mut context = ContextWindow::new(100000); + + // Add system prompt + context.add_message(Message::new( + MessageRole::System, + "You are a helpful assistant.".to_string(), + )); + + // Add some conversation (make it long enough to ensure chars_saved > 0) + context.add_message(Message::new(MessageRole::User, "Hello, I have a question about implementing a complex feature in my application. Can you help me understand how to structure the code properly?".to_string())); + context.add_message(Message::new(MessageRole::Assistant, "Of course! I'd be happy to help you with that. Let me explain the best practices for structuring your code. First, you should consider separating concerns into different modules...".to_string())); + context.add_message(Message::new(MessageRole::User, "That makes sense. What about error handling?".to_string())); + context.add_message(Message::new(MessageRole::Assistant, "Error handling is crucial. You should use Result types and proper error propagation throughout your codebase.".to_string())); + + let stub = "---\nโšก DEHYDRATED CONTEXT (fragment_id: test123)\n---".to_string(); + let summary = "User greeted the assistant.".to_string(); + + let _chars_saved = context.reset_with_summary_and_stub( + summary.clone(), + Some("New question".to_string()), + Some(stub.clone()), + ); + + // chars_saved is old - new, which could be 0 or negative if summary is longer + // The important thing is that the function completed successfully + + // Check message ordering: + // 1. System prompt + // 2. Stub (if present) + // 3. Summary + // 4. Latest user message + assert!(context.conversation_history.len() >= 3); + + // First message should be system prompt + assert!(matches!(context.conversation_history[0].role, MessageRole::System)); + assert!(context.conversation_history[0].content.contains("helpful assistant")); + + // Find the stub message + let stub_idx = context.conversation_history.iter().position(|m| + m.content.contains("DEHYDRATED CONTEXT") + ); + assert!(stub_idx.is_some(), "Stub message should be present"); + + // Find the summary message + let summary_idx = context.conversation_history.iter().position(|m| + m.content.contains("Previous conversation summary") + ); + assert!(summary_idx.is_some(), "Summary message should be present"); + + // Stub should come before summary + assert!(stub_idx.unwrap() < summary_idx.unwrap(), "Stub should come before summary"); + + // Last message should be the user message + let last = context.conversation_history.last().unwrap(); + assert!(matches!(last.role, MessageRole::User)); + assert_eq!(last.content, "New question"); +} + +/// Test reset_with_summary_and_stub without stub (should behave like reset_with_summary) +#[test] +fn test_reset_with_summary_and_stub_no_stub() { + let mut context = ContextWindow::new(100000); + + // Add system prompt + context.add_message(Message::new( + MessageRole::System, + "You are a helpful assistant.".to_string(), + )); + + // Add some conversation (make it long enough) + context.add_message(Message::new(MessageRole::User, "Hello, I have a question about implementing a complex feature in my application.".to_string())); + context.add_message(Message::new(MessageRole::Assistant, "Of course! I'd be happy to help you with that. Let me explain the best practices.".to_string())); + context.add_message(Message::new(MessageRole::User, "That makes sense. What about error handling?".to_string())); + context.add_message(Message::new(MessageRole::Assistant, "Error handling is crucial. You should use Result types.".to_string())); + + let summary = "User greeted the assistant.".to_string(); + + // Call reset - we don't check chars_saved since it depends on content lengths + let _chars_saved = context.reset_with_summary_and_stub( + summary.clone(), + Some("New question".to_string()), + None, // No stub + ); + + // Should not have any dehydrated context message + let has_stub = context.conversation_history.iter().any(|m| + m.content.contains("DEHYDRATED CONTEXT") + ); + assert!(!has_stub, "Should not have stub when None is passed"); + + // Should still have summary + let has_summary = context.conversation_history.iter().any(|m| + m.content.contains("Previous conversation summary") + ); + assert!(has_summary, "Should have summary"); +} + +/// Test that README message is preserved during reset +#[test] +fn test_reset_preserves_readme() { + let mut context = ContextWindow::new(100000); + + // Add system prompt + context.add_message(Message::new( + MessageRole::System, + "You are a helpful assistant.".to_string(), + )); + + // Add README message (second system message with specific content) + context.add_message(Message::new( + MessageRole::System, + "Project README: This is a test project.".to_string(), + )); + + // Add conversation + context.add_message(Message::new(MessageRole::User, "Hello".to_string())); + context.add_message(Message::new(MessageRole::Assistant, "Hi!".to_string())); + + let stub = "---\nโšก DEHYDRATED CONTEXT\n---".to_string(); + + context.reset_with_summary_and_stub( + "Summary".to_string(), + Some("Question".to_string()), + Some(stub), + ); + + // README should be preserved + let has_readme = context.conversation_history.iter().any(|m| + m.content.contains("Project README") + ); + assert!(has_readme, "README message should be preserved"); +} + +/// Test fragment chain integrity +#[test] +fn test_fragment_chain_integrity() { + let test_session = format!("test_chain_{}", std::process::id()); + + // Create first fragment (no predecessor) + let messages1 = vec![ + Message::new(MessageRole::User, "First message".to_string()), + Message::new(MessageRole::Assistant, "First response".to_string()), + ]; + let frag1 = Fragment::new(messages1, None); + let frag1_id = frag1.fragment_id.clone(); + frag1.save(&test_session).unwrap(); + + // Create second fragment (links to first) + let messages2 = vec![ + Message::new(MessageRole::User, "Second message".to_string()), + Message::new(MessageRole::Assistant, "Second response".to_string()), + ]; + let frag2 = Fragment::new(messages2, Some(frag1_id.clone())); + let frag2_id = frag2.fragment_id.clone(); + frag2.save(&test_session).unwrap(); + + // Create third fragment (links to second) + let messages3 = vec![ + Message::new(MessageRole::User, "Third message".to_string()), + Message::new(MessageRole::Assistant, "Third response".to_string()), + ]; + let frag3 = Fragment::new(messages3, Some(frag2_id.clone())); + let frag3_id = frag3.fragment_id.clone(); + frag3.save(&test_session).unwrap(); + + // Verify chain by loading and following links + let loaded3 = Fragment::load(&test_session, &frag3_id).unwrap(); + assert_eq!(loaded3.preceding_fragment_id, Some(frag2_id.clone())); + + let loaded2 = Fragment::load(&test_session, &frag2_id).unwrap(); + assert_eq!(loaded2.preceding_fragment_id, Some(frag1_id.clone())); + + let loaded1 = Fragment::load(&test_session, &frag1_id).unwrap(); + assert!(loaded1.preceding_fragment_id.is_none()); + + // Verify list_fragments returns all in order + let fragments = list_fragments(&test_session).unwrap(); + assert_eq!(fragments.len(), 3); + + // Verify get_latest_fragment_id returns the most recent + let latest = get_latest_fragment_id(&test_session).unwrap(); + assert!(latest.is_some()); + // Note: latest might be frag3 if sorted by creation time + + // Cleanup + let fragments_dir = g3_core::paths::get_fragments_dir(&test_session); + let _ = std::fs::remove_dir_all(fragments_dir.parent().unwrap()); +} + +/// Test fragment with many messages +#[test] +fn test_large_fragment() { + let mut messages = Vec::new(); + for i in 0..100 { + messages.push(Message::new( + MessageRole::User, + format!("User message {}", i), + )); + messages.push(Message::new( + MessageRole::Assistant, + format!("Assistant response {} with some longer content to make it more realistic", i), + )); + } + + let fragment = Fragment::new(messages, None); + + assert_eq!(fragment.message_count, 200); + assert_eq!(fragment.user_message_count, 100); + assert_eq!(fragment.assistant_message_count, 100); + assert!(fragment.estimated_tokens > 0); + + // Stub should still be concise + let stub = fragment.generate_stub(); + assert!(stub.len() < 1000, "Stub should be concise even for large fragments"); + assert!(stub.contains("200 messages")); +} + +/// Test fragment with tool calls +#[test] +fn test_fragment_tool_call_summary() { + let messages = vec![ + Message::new(MessageRole::User, "Read the file".to_string()), + Message::new( + MessageRole::Assistant, + r#"{"tool": "read_file", "args": {"file_path": "test.rs"}}"#.to_string(), + ), + Message::new(MessageRole::User, "Tool result: content".to_string()), + Message::new(MessageRole::User, "Now write it".to_string()), + Message::new( + MessageRole::Assistant, + r#"{"tool": "write_file", "args": {"file_path": "out.rs", "content": "..."}}"#.to_string(), + ), + Message::new( + MessageRole::Assistant, + r#"{"tool": "shell", "args": {"command": "cargo build"}}"#.to_string(), + ), + ]; + + let fragment = Fragment::new(messages, None); + + // Should have extracted tool calls + assert!(!fragment.tool_call_summary.is_empty()); + + // Stub should mention tool calls + let stub = fragment.generate_stub(); + assert!(stub.contains("tool calls")); +} + +/// Test context overflow detection in rehydration +#[test] +fn test_rehydration_context_overflow_detection() { + // Create a fragment with known token count + let messages = vec![ + Message::new(MessageRole::User, "A".repeat(4000)), // ~1000 tokens + Message::new(MessageRole::Assistant, "B".repeat(4000)), // ~1000 tokens + ]; + + let fragment = Fragment::new(messages, None); + + // Fragment should have estimated tokens + assert!(fragment.estimated_tokens > 1000); + + // The rehydrate tool checks available_tokens vs fragment_tokens + // This is tested in tools/acd.rs tests +} + +/// Test empty session has no fragments +#[test] +fn test_empty_session_no_fragments() { + let test_session = format!("test_empty_{}", std::process::id()); + + let fragments = list_fragments(&test_session).unwrap(); + assert!(fragments.is_empty()); + + let latest = get_latest_fragment_id(&test_session).unwrap(); + assert!(latest.is_none()); +} + +/// Test fragment topics extraction from various message types +#[test] +fn test_topic_extraction_variety() { + let messages = vec![ + Message::new(MessageRole::User, "Please implement the login feature".to_string()), + Message::new(MessageRole::Assistant, "I'll help with that.".to_string()), + Message::new(MessageRole::User, "Tool result: success".to_string()), // Should be skipped + Message::new(MessageRole::User, "Now add password hashing".to_string()), + Message::new( + MessageRole::Assistant, + r#"{"tool": "write_file", "args": {"file_path": "src/auth/password.rs", "content": "..."}}"#.to_string(), + ), + ]; + + let fragment = Fragment::new(messages, None); + + // Should have extracted meaningful topics + assert!(!fragment.topics.is_empty()); + + // Should include user requests but not tool results + let topics_str = fragment.topics.join(" "); + assert!(topics_str.contains("login") || topics_str.contains("password")); + assert!(!topics_str.contains("Tool result")); +} diff --git a/crates/g3-providers/src/lib.rs b/crates/g3-providers/src/lib.rs index a14ca03..8dbc774 100644 --- a/crates/g3-providers/src/lib.rs +++ b/crates/g3-providers/src/lib.rs @@ -94,6 +94,8 @@ pub struct Message { pub images: Vec, #[serde(skip)] pub id: String, + #[serde(skip)] + pub kind: MessageKind, #[serde(skip_serializing_if = "Option::is_none")] pub cache_control: Option, } @@ -106,6 +108,20 @@ pub enum MessageRole { Assistant, } +/// Special message kinds for context management (ACD) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum MessageKind { + /// Regular conversation message + #[default] + Regular, + /// Dehydrated context stub (contains fragment reference) + DehydratedStub, + /// Summary of dehydrated context (the response that followed dehydration) + Summary, + /// Rehydrated content (restored from a fragment) + Rehydrated, +} + /// Image content for multimodal messages #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageContent { @@ -242,6 +258,7 @@ impl Message { content, images: Vec::new(), id: Self::generate_id(), + kind: MessageKind::Regular, cache_control: None, } } @@ -257,10 +274,33 @@ impl Message { content, images: Vec::new(), id: Self::generate_id(), + kind: MessageKind::Regular, cache_control: Some(cache_control), } } + /// Create a new message with a specific kind (for ACD) + pub fn with_kind(role: MessageRole, content: String, kind: MessageKind) -> Self { + Self { + role, + content, + images: Vec::new(), + id: Self::generate_id(), + kind, + cache_control: None, + } + } + + /// Check if this message is a dehydrated stub + pub fn is_dehydrated_stub(&self) -> bool { + self.kind == MessageKind::DehydratedStub + } + + /// Check if this message is a summary + pub fn is_summary(&self) -> bool { + self.kind == MessageKind::Summary + } + /// Create a message with cache control, with provider validation pub fn with_cache_control_validated( role: MessageRole,