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.
This commit is contained in:
Hunter Bown
2026-02-16 10:58:11 -06:00
parent ab2c708ca7
commit 6c05c3b3ac
16 changed files with 307 additions and 21 deletions
+16
View File
@@ -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
}
+16
View File
@@ -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
}
+16
View File
@@ -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
}
+16
View File
@@ -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
}
+16
View File
@@ -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
}
+16
View File
@@ -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
}
+16
View File
@@ -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
}
+16
View File
@@ -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
}
+16
View File
@@ -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
}
+16
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+7 -18
View File
@@ -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]
+111 -2
View File
@@ -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<String> = Vec::new();
let mut tools_used: Vec<String> = Vec::new();
let mut tasks_identified: Vec<String> = 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<String> {
// 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<SystemPrompt>,
+12
View File
@@ -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)
}
+12
View File
@@ -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,
},
+4
View File
@@ -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(),