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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user