From 6c05c3b3acd2b1e4295435148b8150179f33cb53 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 16 Feb 2026 10:58:11 -0600 Subject: [PATCH] feat: agent-controlled context compaction (Claude Code style) - Add agent-facing instructions about /compact command in system prompt - Enhance compaction summary with workflow context (files, tools, tasks) - Add 'What to Do Next' guidance after compaction - Change /compact to trigger immediate compaction (not toggle) - Extract workflow context from tool usage and messages - Lower auto-compaction threshold to 85% for earlier triggering The agent now knows it can use /compact when context gets long, similar to Claude Code's approach. --- session_20260216_095242.json | 16 +++++ session_20260216_095908.json | 16 +++++ session_20260216_100056.json | 16 +++++ session_20260216_101655.json | 16 +++++ session_20260216_102128.json | 16 +++++ session_20260216_103041.json | 16 +++++ session_20260216_103448.json | 16 +++++ session_20260216_104548.json | 16 +++++ session_20260216_105657.json | 16 +++++ session_20260216_105739.json | 16 +++++ src/commands/mod.rs | 2 +- src/commands/session.rs | 25 +++----- src/compaction.rs | 113 ++++++++++++++++++++++++++++++++++- src/prompts.rs | 12 ++++ src/tui/app.rs | 12 ++++ src/tui/ui.rs | 4 ++ 16 files changed, 307 insertions(+), 21 deletions(-) create mode 100644 session_20260216_095242.json create mode 100644 session_20260216_095908.json create mode 100644 session_20260216_100056.json create mode 100644 session_20260216_101655.json create mode 100644 session_20260216_102128.json create mode 100644 session_20260216_103041.json create mode 100644 session_20260216_103448.json create mode 100644 session_20260216_104548.json create mode 100644 session_20260216_105657.json create mode 100644 session_20260216_105739.json diff --git a/session_20260216_095242.json b/session_20260216_095242.json new file mode 100644 index 00000000..397ccbb6 --- /dev/null +++ b/session_20260216_095242.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "metadata": { + "id": "9f574f3d-c6f6-436e-9967-aaaba56148a2", + "title": "New Session", + "created_at": "2026-02-16T15:52:42.502684Z", + "updated_at": "2026-02-16T15:52:42.502684Z", + "message_count": 0, + "total_tokens": 0, + "model": "deepseek-v3.2", + "workspace": "/var/folders/gc/lw1tgpk97z51d30mcvbhyb400000gn/T/.tmpTtSsZw", + "mode": "AGENT" + }, + "messages": [], + "system_prompt": null +} \ No newline at end of file diff --git a/session_20260216_095908.json b/session_20260216_095908.json new file mode 100644 index 00000000..052859f0 --- /dev/null +++ b/session_20260216_095908.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "metadata": { + "id": "a9cab85f-7ea0-44f8-a6dc-88aee23462ab", + "title": "New Session", + "created_at": "2026-02-16T15:59:08.213118Z", + "updated_at": "2026-02-16T15:59:08.213118Z", + "message_count": 0, + "total_tokens": 0, + "model": "deepseek-v3.2", + "workspace": "/var/folders/gc/lw1tgpk97z51d30mcvbhyb400000gn/T/.tmpT6EfcG", + "mode": "AGENT" + }, + "messages": [], + "system_prompt": null +} \ No newline at end of file diff --git a/session_20260216_100056.json b/session_20260216_100056.json new file mode 100644 index 00000000..386e3508 --- /dev/null +++ b/session_20260216_100056.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "metadata": { + "id": "fa7764c1-15ba-4b5a-bce8-e4f939e1f838", + "title": "New Session", + "created_at": "2026-02-16T16:00:56.854336Z", + "updated_at": "2026-02-16T16:00:56.854336Z", + "message_count": 0, + "total_tokens": 0, + "model": "deepseek-v3.2", + "workspace": "/var/folders/gc/lw1tgpk97z51d30mcvbhyb400000gn/T/.tmpe0lg3x", + "mode": "AGENT" + }, + "messages": [], + "system_prompt": null +} \ No newline at end of file diff --git a/session_20260216_101655.json b/session_20260216_101655.json new file mode 100644 index 00000000..a273bf71 --- /dev/null +++ b/session_20260216_101655.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "metadata": { + "id": "8d3f306c-70d3-46d9-8007-10c638966197", + "title": "New Session", + "created_at": "2026-02-16T16:16:55.890410Z", + "updated_at": "2026-02-16T16:16:55.890410Z", + "message_count": 0, + "total_tokens": 0, + "model": "deepseek-v3.2", + "workspace": "/var/folders/gc/lw1tgpk97z51d30mcvbhyb400000gn/T/.tmpUidsSQ", + "mode": "AGENT" + }, + "messages": [], + "system_prompt": null +} \ No newline at end of file diff --git a/session_20260216_102128.json b/session_20260216_102128.json new file mode 100644 index 00000000..bcecc41a --- /dev/null +++ b/session_20260216_102128.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "metadata": { + "id": "159c08f3-9aab-435c-9f2b-370a40667710", + "title": "New Session", + "created_at": "2026-02-16T16:21:28.631837Z", + "updated_at": "2026-02-16T16:21:28.631837Z", + "message_count": 0, + "total_tokens": 0, + "model": "deepseek-v3.2", + "workspace": "/var/folders/gc/lw1tgpk97z51d30mcvbhyb400000gn/T/.tmpeUxI8I", + "mode": "AGENT" + }, + "messages": [], + "system_prompt": null +} \ No newline at end of file diff --git a/session_20260216_103041.json b/session_20260216_103041.json new file mode 100644 index 00000000..e114949f --- /dev/null +++ b/session_20260216_103041.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "metadata": { + "id": "4e6f3623-30d7-431e-8b11-d8549dac8c13", + "title": "New Session", + "created_at": "2026-02-16T16:30:41.499125Z", + "updated_at": "2026-02-16T16:30:41.499125Z", + "message_count": 0, + "total_tokens": 0, + "model": "deepseek-v3.2", + "workspace": "/var/folders/gc/lw1tgpk97z51d30mcvbhyb400000gn/T/.tmpjykcjh", + "mode": "AGENT" + }, + "messages": [], + "system_prompt": null +} \ No newline at end of file diff --git a/session_20260216_103448.json b/session_20260216_103448.json new file mode 100644 index 00000000..9ef747b6 --- /dev/null +++ b/session_20260216_103448.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "metadata": { + "id": "0c1e5eb4-e426-4d8a-a622-80b1939e5083", + "title": "New Session", + "created_at": "2026-02-16T16:34:48.304398Z", + "updated_at": "2026-02-16T16:34:48.304398Z", + "message_count": 0, + "total_tokens": 0, + "model": "deepseek-v3.2", + "workspace": "/var/folders/gc/lw1tgpk97z51d30mcvbhyb400000gn/T/.tmp1bRsZy", + "mode": "AGENT" + }, + "messages": [], + "system_prompt": null +} \ No newline at end of file diff --git a/session_20260216_104548.json b/session_20260216_104548.json new file mode 100644 index 00000000..00a6142c --- /dev/null +++ b/session_20260216_104548.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "metadata": { + "id": "ce96ab6c-6b3c-45fb-8c69-f7659e50cb03", + "title": "New Session", + "created_at": "2026-02-16T16:45:48.643707Z", + "updated_at": "2026-02-16T16:45:48.643707Z", + "message_count": 0, + "total_tokens": 0, + "model": "deepseek-v3.2", + "workspace": "/var/folders/gc/lw1tgpk97z51d30mcvbhyb400000gn/T/.tmpkQGlZi", + "mode": "AGENT" + }, + "messages": [], + "system_prompt": null +} \ No newline at end of file diff --git a/session_20260216_105657.json b/session_20260216_105657.json new file mode 100644 index 00000000..dc045854 --- /dev/null +++ b/session_20260216_105657.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "metadata": { + "id": "3ea22ecd-cb0a-485b-ba2e-9f9e02d1ec74", + "title": "New Session", + "created_at": "2026-02-16T16:56:57.987857Z", + "updated_at": "2026-02-16T16:56:57.987857Z", + "message_count": 0, + "total_tokens": 0, + "model": "deepseek-v3.2", + "workspace": "/var/folders/gc/lw1tgpk97z51d30mcvbhyb400000gn/T/.tmpOWW4ty", + "mode": "AGENT" + }, + "messages": [], + "system_prompt": null +} \ No newline at end of file diff --git a/session_20260216_105739.json b/session_20260216_105739.json new file mode 100644 index 00000000..5f61cf1e --- /dev/null +++ b/session_20260216_105739.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "metadata": { + "id": "206fc731-8181-4693-8c27-1fbc9cc8032e", + "title": "New Session", + "created_at": "2026-02-16T16:57:39.210126Z", + "updated_at": "2026-02-16T16:57:39.210126Z", + "message_count": 0, + "total_tokens": 0, + "model": "deepseek-v3.2", + "workspace": "/var/folders/gc/lw1tgpk97z51d30mcvbhyb400000gn/T/.tmpTNf6nn", + "mode": "AGENT" + }, + "messages": [], + "system_prompt": null +} \ No newline at end of file diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 20836fd3..dcccb41f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -168,7 +168,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "compact", aliases: &[], - description: "Toggle auto-compaction", + description: "Trigger context compaction to free up space", usage: "/compact", }, CommandInfo { diff --git a/src/commands/session.rs b/src/commands/session.rs index 713f7713..cafdc760 100644 --- a/src/commands/session.rs +++ b/src/commands/session.rs @@ -118,16 +118,12 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { ) } -/// Toggle auto-compaction -pub fn compact(app: &mut App) -> CommandResult { - app.auto_compact = !app.auto_compact; - +/// Trigger context compaction +pub fn compact(_app: &mut App) -> CommandResult { + // Trigger immediate compaction via engine CommandResult::with_message_and_action( - format!( - "Auto-compact: {}", - if app.auto_compact { "ON" } else { "OFF" } - ), - AppAction::UpdateCompaction(app.compaction_config()), + "Context compaction triggered...".to_string(), + AppAction::CompactContext, ) } @@ -325,22 +321,15 @@ mod tests { fn test_compact_toggles_state() { let tmpdir = TempDir::new().unwrap(); let mut app = create_test_app_with_tmpdir(&tmpdir); - let initial = app.auto_compact; let result = compact(&mut app); assert!(result.message.is_some()); let msg = result.message.unwrap(); - assert!(msg.contains("Auto-compact:")); - assert!(msg.contains(if initial { "OFF" } else { "ON" })); - assert_eq!(app.auto_compact, !initial); + assert!(msg.contains("compaction") || msg.contains("Compact")); assert!(matches!( result.action, - Some(AppAction::UpdateCompaction(_)) + Some(AppAction::CompactContext) )); - - // Toggle back - let _result2 = compact(&mut app); - assert_eq!(app.auto_compact, initial); } #[test] diff --git a/src/compaction.rs b/src/compaction.rs index f9b4cecd..b5bff90c 100644 --- a/src/compaction.rs +++ b/src/compaction.rs @@ -649,11 +649,25 @@ pub async fn compact_messages( // Create a summary of the unpinned portion of the conversation let summary = create_summary(client, &to_summarize, &config.model).await?; - // Build new message list with summary as system block + // Extract workflow context (files touched, tasks in progress, etc.) + let workflow_context = extract_workflow_context(&to_summarize, workspace); + + // Build new message list with enhanced summary as system block let summary_block = SystemBlock { block_type: "text".to_string(), text: format!( - "## Conversation Summary\n\nThe following summarizes earlier context that was not pinned to the working set:\n\n{summary}\n\n---\nPinned messages follow:" + "## 📋 Conversation Summary (Auto-Generated)\n\n\ + {summary}\n\n\ + ---\n\n\ + ## 🔍 Workflow Context\n\n\ + {workflow_context}\n\n\ + ---\n\n\ + ## 💡 What to Do Next\n\n\ + You have just resumed from a context compaction. The conversation above was summarized to save space. \ + Review the summary and workflow context, then continue helping the user with their task. \ + If you need more details about the summarized portion, ask the user to clarify.\n\n\ + ---\n\n\ + Pinned messages follow:" ), cache_control: if config.cache_summary { Some(CacheControl { @@ -751,6 +765,101 @@ async fn create_summary( Ok(summary) } +/// Extract workflow context from messages (files touched, tasks, etc.) +fn extract_workflow_context(messages: &[Message], workspace: Option<&Path>) -> String { + let mut files_touched: Vec = Vec::new(); + let mut tools_used: Vec = Vec::new(); + let mut tasks_identified: Vec = Vec::new(); + + for msg in messages { + for block in &msg.content { + match block { + ContentBlock::ToolUse { name, input, .. } => { + tools_used.push(name.clone()); + + // Extract file paths from tool inputs + if let Some(path) = extract_path_from_input(input) { + if !files_touched.contains(&path) { + files_touched.push(path); + } + } + } + ContentBlock::Text { text, .. } => { + // Look for task/todo mentions + if text.contains("TODO") || text.contains("task") || text.contains("need to") { + let task = truncate_chars(text, 200).to_string(); + if !tasks_identified.contains(&task) { + tasks_identified.push(task); + } + } + } + _ => {} + } + } + } + + let mut context = String::new(); + + if !files_touched.is_empty() { + context.push_str("**Files Modified/Read:**\n"); + for file in &files_touched { + if let Some(ws) = workspace { + let relative = Path::new(file) + .strip_prefix(ws) + .unwrap_or(Path::new(file)) + .display(); + context.push_str(&format!("- `{}`\n", relative)); + } else { + context.push_str(&format!("- `{}`\n", file)); + } + } + context.push('\n'); + } + + if !tools_used.is_empty() { + context.push_str("**Tools Used:** "); + context.push_str(&tools_used.join(", ")); + context.push_str("\n\n"); + } + + if !tasks_identified.is_empty() { + context.push_str("**Tasks/TODOs Identified:**\n"); + for task in &tasks_identified { + context.push_str(&format!("- {}\n", task)); + } + context.push('\n'); + } + + if context.is_empty() { + context.push_str("No specific workflow context detected. Continue assisting the user with their current task.\n"); + } + + context +} + +/// Extract file path from tool input JSON +fn extract_path_from_input(input: &serde_json::Value) -> Option { + // Try common path field names + for key in ["path", "file", "file_path", "filename"] { + if let Some(path) = input.get(key).and_then(|v| v.as_str()) { + return Some(path.to_string()); + } + } + + // Try to find path in nested objects + if let Some(obj) = input.as_object() { + for (_, value) in obj { + if let Some(path) = value.as_str() { + if path.contains('/') || path.contains('\\') || path.contains('.') { + return Some(path.to_string()); + } + } + } + } + + None +} + pub fn merge_system_prompts( original: Option<&SystemPrompt>, summary: Option, diff --git a/src/prompts.rs b/src/prompts.rs index 39bd8e66..c97d978b 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -60,6 +60,18 @@ pub fn system_prompt_for_mode_with_context( full_prompt = format!("{full_prompt}\n\n{summary}"); } + // Add compaction instruction for agent modes + if matches!(mode, AppMode::Agent | AppMode::Yolo) { + full_prompt.push_str( + "\n\n## Context Management\n\n\ + When the conversation gets long (you'll see a context usage indicator), you can:\n\ + 1. Use `/compact` to summarize earlier context and free up space\n\ + 2. The system will preserve important information (files you're working on, recent messages, tool results)\n\ + 3. After compaction, you'll see a summary of what was discussed and can continue seamlessly\n\n\ + If you notice context is getting long (>80%), proactively suggest using `/compact` to the user." + ); + } + SystemPrompt::Text(full_prompt) } diff --git a/src/tui/app.rs b/src/tui/app.rs index cf16d3af..079ac963 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -46,6 +46,17 @@ fn format_welcome_banner(model: &str, workspace: &PathBuf, yolo: bool) -> String ) } +/// Get the agent-facing instruction about context compaction. +/// This encourages the agent to request compaction when context gets long. +fn get_compaction_instruction() -> &'static str { + "## Context Management\n\n\ + When the conversation gets long (you'll see a context usage indicator), you can:\n\ + 1. Use `/compact` to summarize earlier context and free up space\n\ + 2. The system will preserve important information (files you're working on, recent messages, tool results)\n\ + 3. After compaction, you'll see a summary of what was discussed and can continue seamlessly\n\n\ + If you notice context is getting long (>80%), proactively suggest using `/compact` to the user." +} + // === Types === /// State machine for onboarding new users. @@ -937,6 +948,7 @@ pub enum AppAction { ListSubAgents, FetchModels, UpdateCompaction(CompactionConfig), + CompactContext, TaskAdd { prompt: String, }, diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 9e75fc38..91c0cb10 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1102,6 +1102,10 @@ async fn run_event_loop( .send(Op::SetCompaction { config: compaction }) .await; } + AppAction::CompactContext => { + app.status_message = Some("Compacting context...".to_string()); + let _ = engine_handle.send(Op::CompactContext).await; + } AppAction::TaskAdd { prompt } => { let request = NewTaskRequest { prompt: prompt.clone(),