Fix ACD turn summary loss and add /dump command
ACD (Aggressive Context Dehydration) fixes: - Fixed dehydrate_context() to extract turn summary from context window instead of using the passed-in final_response (which contained only the timing footer, not the actual LLM response) - Removed final_response parameter from dehydrate_context() since it now self-extracts the last assistant message as the summary - This ensures the actual turn summary is preserved after dehydration, not just the timing footer New /dump command: - Added /dump command to dump entire context window to tmp/ for debugging - Shows message index, role, kind, content length, and full content - Available in both console and machine modes UTF-8 safety: - Fixed truncate_to_word_boundary() to use character indices instead of byte indices, preventing panics on multi-byte UTF-8 characters - Added UTF-8 string slicing guidance to AGENTS.md Agent: g3
This commit is contained in:
13
AGENTS.md
13
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
|
||||
|
||||
|
||||
@@ -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<W: UiWriter>(
|
||||
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<W: UiWriter>(
|
||||
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 <fragment_id>");
|
||||
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 <fragment_id>");
|
||||
} 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" => {
|
||||
|
||||
671
crates/g3-core/src/acd.rs
Normal file
671
crates/g3-core/src/acd.rs
Normal file
@@ -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/<session_id>/fragments/`
|
||||
//! as JSON files named `fragment_<id>.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<Message>,
|
||||
/// 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<String, usize>,
|
||||
/// Estimated token count for this fragment
|
||||
pub estimated_tokens: u32,
|
||||
/// Brief topic hints extracted from the conversation
|
||||
pub topics: Vec<String>,
|
||||
/// ID of the preceding fragment in the chain (None for first fragment)
|
||||
pub preceding_fragment_id: Option<String>,
|
||||
}
|
||||
|
||||
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<Message>, preceding_fragment_id: Option<String>) -> 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<String> = 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::<Vec<_>>()
|
||||
.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<PathBuf> {
|
||||
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<Self> {
|
||||
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<String, usize> {
|
||||
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<String> {
|
||||
// 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::<ToolCall>(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::<ToolCall>(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<usize> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
// 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<Vec<Fragment>> {
|
||||
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::<Fragment>(&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<Option<String>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,8 @@ pub async fn perform_compaction<W: UiWriter>(
|
||||
// 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,
|
||||
|
||||
@@ -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<String>,
|
||||
stub: Option<String>,
|
||||
) -> 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);
|
||||
|
||||
@@ -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<W: UiWriter> {
|
||||
agent_name: Option<String>,
|
||||
/// Whether auto-memory reminders are enabled (--auto-memory flag)
|
||||
auto_memory: bool,
|
||||
/// Whether aggressive context dehydration is enabled (--acd flag)
|
||||
acd_enabled: bool,
|
||||
}
|
||||
|
||||
impl<W: UiWriter> Agent<W> {
|
||||
@@ -296,6 +299,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
is_agent_mode: false,
|
||||
agent_name: None,
|
||||
auto_memory: false,
|
||||
acd_enabled: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1369,6 +1373,130 @@ impl<W: UiWriter> Agent<W> {
|
||||
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<String> = 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<W: UiWriter> Agent<W> {
|
||||
id: String::new(),
|
||||
images: Vec::new(),
|
||||
content: content.to_string(),
|
||||
kind: g3_providers::MessageKind::Regular,
|
||||
cache_control: None,
|
||||
});
|
||||
}
|
||||
@@ -1529,6 +1658,7 @@ impl<W: UiWriter> Agent<W> {
|
||||
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<W: UiWriter> Agent<W> {
|
||||
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<W: UiWriter> Agent<W> {
|
||||
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<W: UiWriter> Agent<W> {
|
||||
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<W: UiWriter> Agent<W> {
|
||||
|
||||
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<W: UiWriter> Agent<W> {
|
||||
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<W: UiWriter> Agent<W> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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/<session_id>/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_id>/session.json
|
||||
pub fn get_session_file(session_id: &str) -> PathBuf {
|
||||
|
||||
@@ -288,6 +288,22 @@ fn create_core_tools(exclude_research: bool) -> Vec<Tool> {
|
||||
}),
|
||||
});
|
||||
|
||||
// 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"));
|
||||
|
||||
@@ -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<W: UiWriter>(
|
||||
// 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,
|
||||
|
||||
273
crates/g3-core/src/tools/acd.rs
Normal file
273
crates/g3-core/src/tools/acd.rs
Normal file
@@ -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<W: UiWriter>(
|
||||
tool_call: &ToolCall,
|
||||
ctx: &mut ToolContext<'_, W>,
|
||||
) -> Result<String> {
|
||||
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<RwLock<Option<Arc<tokio::sync::Mutex<WebDriverSession>>>>>,
|
||||
webdriver_process: Arc<RwLock<Option<tokio::process::Child>>>,
|
||||
background_process_manager: Arc<BackgroundProcessManager>,
|
||||
todo_content: Arc<RwLock<String>>,
|
||||
pending_images: Vec<g3_providers::ImageContent>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -61,7 +61,17 @@ pub async fn execute_shell<W: UiWriter>(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)),
|
||||
|
||||
311
crates/g3-core/tests/test_acd_integration.rs
Normal file
311
crates/g3-core/tests/test_acd_integration.rs
Normal file
@@ -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"));
|
||||
}
|
||||
@@ -94,6 +94,8 @@ pub struct Message {
|
||||
pub images: Vec<ImageContent>,
|
||||
#[serde(skip)]
|
||||
pub id: String,
|
||||
#[serde(skip)]
|
||||
pub kind: MessageKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_control: Option<CacheControl>,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user