From d4a7adaaf11d460bf22267d0272a1a5e5bbdf7d8 Mon Sep 17 00:00:00 2001 From: sk-dev-ai Date: Thu, 10 Sep 2026 22:25:11 +0530 Subject: [PATCH 1/4] feat(context): proactive tool-result clearing at send time --- crates/jcode-app-core/src/agent.rs | 41 +++++- crates/jcode-app-core/src/agent_tests.rs | 131 ++++++++++++++++++ crates/jcode-base/src/config.rs | 1 + crates/jcode-base/src/config/env_overrides.rs | 7 + crates/jcode-config-types/src/lib.rs | 9 ++ 5 files changed, 187 insertions(+), 2 deletions(-) diff --git a/crates/jcode-app-core/src/agent.rs b/crates/jcode-app-core/src/agent.rs index eff52572bf..054a0babc1 100644 --- a/crates/jcode-app-core/src/agent.rs +++ b/crates/jcode-app-core/src/agent.rs @@ -728,6 +728,43 @@ impl Agent { Ok(()) } + /// Results shorter than this are never stubbed: clearing them saves + /// nothing and only adds noise. + const TOOL_RESULT_CLEAR_MIN_CHARS: usize = 200; + + /// Proactive tool-result clearing (Anthropic `clear_tool_uses` primitive, + /// deterministic edition). Stubs the content of tool results older than + /// the configured window, keeping ToolUse blocks and result IDs intact + /// so provider tool-pairing never breaks. Operates on the send view + /// only — the session file keeps the full history for later compaction. + /// Off when unconfigured: input returns unchanged. + pub(crate) fn apply_tool_result_clearing(messages: Vec) -> Vec { + let keep = match crate::config::config() + .compaction + .clear_tool_results_older_than + { + Some(keep) => keep, + None => return messages, + }; + if messages.len() <= keep { + return messages; + } + let mut messages = messages; + let cutoff = messages.len() - keep; + for message in messages.iter_mut().take(cutoff) { + for block in message.content.iter_mut() { + if let ContentBlock::ToolResult { content, .. } = block + && content.len() > Self::TOOL_RESULT_CLEAR_MIN_CHARS + && !content.starts_with("[cleared by retention") + { + let was = content.len(); + *content = format!("[cleared by retention: was {was} chars]"); + } + } + } + messages + } + fn messages_for_provider(&mut self) -> (Vec, Option) { if self.provider.supports_compaction() || self.session.compaction.is_some() { let compaction = self.registry.compaction(); @@ -779,7 +816,7 @@ impl Agent { user_count, assistant_count, )); - return (messages, event); + return (Self::apply_tool_result_clearing(messages), event); } Err(_) => { logging::info("messages_for_provider: compaction lock failed, using session"); @@ -800,7 +837,7 @@ impl Agent { user_count, assistant_count, )); - (messages, None) + (Self::apply_tool_result_clearing(messages), None) } fn record_client_cache_request(&mut self, messages: &[Message]) { diff --git a/crates/jcode-app-core/src/agent_tests.rs b/crates/jcode-app-core/src/agent_tests.rs index a3f596c610..e866ad1dd1 100644 --- a/crates/jcode-app-core/src/agent_tests.rs +++ b/crates/jcode-app-core/src/agent_tests.rs @@ -212,6 +212,137 @@ fn message_text(message: &Message) -> &str { content_text(&message.content) } +#[test] +fn tool_result_clearing_is_off_by_default() { + let _guard = crate::storage::lock_test_env(); + let messages = vec![Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call_1".to_string(), + content: "x".repeat(5000), + is_error: None, + }], + timestamp: None, + tool_duration_ms: None, + }]; + let out = Agent::apply_tool_result_clearing(messages.clone()); + assert_eq!(format!("{out:?}"), format!("{messages:?}")); +} + +#[test] +fn tool_result_clearing_stubs_old_keeps_recent_and_pairing() { + let _guard = crate::storage::lock_test_env(); + let prev = std::env::var_os("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN"); + crate::env::set_var("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN", "2"); + crate::config::Config::invalidate_cache(); + + let big_old = "o".repeat(5000); + let big_recent = "r".repeat(5000); + let tool_use = ContentBlock::ToolUse { + id: "call_old".to_string(), + name: "read".to_string(), + input: serde_json::json!({"path": "/x"}), + thought_signature: None, + }; + let messages = vec![ + Message { + role: Role::Assistant, + content: vec![tool_use], + timestamp: None, + tool_duration_ms: None, + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call_old".to_string(), + content: big_old, + is_error: None, + }], + timestamp: None, + tool_duration_ms: None, + }, + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "keep going".to_string(), + cache_control: None, + }], + timestamp: None, + tool_duration_ms: None, + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call_new".to_string(), + content: big_recent.clone(), + is_error: None, + }], + timestamp: None, + tool_duration_ms: None, + }, + ]; + let out = Agent::apply_tool_result_clearing(messages); + // Index 1 is older than the last 2: stubbed, but the id survives. + match &out[1].content[0] { + ContentBlock::ToolResult { + tool_use_id, + content, + .. + } => { + assert_eq!(tool_use_id, "call_old"); + assert!( + content.starts_with("[cleared by retention: was 5000 chars]"), + "got: {content}" + ); + } + other => panic!("result block must survive, got: {other:?}"), + } + // ToolUse intent untouched. + assert!(matches!(out[0].content[0], ContentBlock::ToolUse { .. })); + // Recent result untouched. + match &out[3].content[0] { + ContentBlock::ToolResult { content, .. } => assert_eq!(content, &big_recent), + other => panic!("recent result must survive, got: {other:?}"), + } + + match prev { + Some(value) => crate::env::set_var("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN", value), + None => crate::env::remove_var("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN"), + } + crate::config::Config::invalidate_cache(); +} + +#[test] +fn tool_result_clearing_keeps_small_results() { + let _guard = crate::storage::lock_test_env(); + let prev = std::env::var_os("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN"); + crate::env::set_var("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN", "0"); + crate::config::Config::invalidate_cache(); + + let small = "ok".to_string(); + let messages = vec![Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call_1".to_string(), + content: small.clone(), + is_error: None, + }], + timestamp: None, + tool_duration_ms: None, + }]; + let out = Agent::apply_tool_result_clearing(messages); + match &out[0].content[0] { + ContentBlock::ToolResult { content, .. } => assert_eq!(content, &small), + other => panic!("small result must survive, got: {other:?}"), + } + + match prev { + Some(value) => crate::env::set_var("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN", value), + None => crate::env::remove_var("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN"), + } + crate::config::Config::invalidate_cache(); +} + #[test] fn agent_drop_removes_its_configured_session_tool_policy() { let provider: Arc = Arc::new(NativeAutoCompactionProvider); diff --git a/crates/jcode-base/src/config.rs b/crates/jcode-base/src/config.rs index 255d970db6..1f221d739a 100644 --- a/crates/jcode-base/src/config.rs +++ b/crates/jcode-base/src/config.rs @@ -90,6 +90,7 @@ const CONFIG_ENV_KEYS: &[&str] = &[ "JCODE_GATEWAY_ENABLED", "JCODE_GATEWAY_PORT", "JCODE_HOME", + "JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN", "JCODE_HOOK_PRE_TOOL", "JCODE_HOOK_PRE_TOOL_TIMEOUT_MS", "JCODE_HOOK_POST_TOOL", diff --git a/crates/jcode-base/src/config/env_overrides.rs b/crates/jcode-base/src/config/env_overrides.rs index fc5213e98a..1a37f4f40a 100644 --- a/crates/jcode-base/src/config/env_overrides.rs +++ b/crates/jcode-base/src/config/env_overrides.rs @@ -138,6 +138,13 @@ impl Config { self.tools.mcp_tools_token_threshold = parsed; } + // Compaction / retention + if let Ok(v) = std::env::var("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN") + && let Ok(parsed) = v.trim().parse::() + { + self.compaction.clear_tool_results_older_than = Some(parsed); + } + // ACP adapter if let Ok(v) = std::env::var("JCODE_ACP_PROFILE") { let trimmed = v.trim().to_ascii_lowercase(); diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index e75449cae9..e875d6207b 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -391,6 +391,14 @@ pub struct CompactionConfig { /// on large-window providers. This bounds the compaction trigger budget, /// not the final request size when recent messages cannot be compacted. pub max_context_tokens: usize, + /// Proactive tool-result clearing: when set to N, tool results older than + /// the last N provider-bound messages are stubbed at send time + /// (`[cleared by retention: was N chars]`). The ToolUse blocks (name + + /// input) and result IDs are always kept, so provider tool-pairing never + /// breaks; results under 200 chars are left alone. The session file is + /// never modified — clearing applies to the send view only, so a later + /// compaction still summarizes the full history. Off when unset. + pub clear_tool_results_older_than: Option, } impl Default for CompactionConfig { @@ -407,6 +415,7 @@ impl Default for CompactionConfig { relevance_keep_threshold: 0.65, goal_window_turns: 5, max_context_tokens: 0, + clear_tool_results_older_than: None, } } } From 11caacc5bd011a17b883e9fe13d5ccb1f6446539 Mon Sep 17 00:00:00 2001 From: sk-dev-ai Date: Sat, 19 Sep 2026 17:37:12 +0530 Subject: [PATCH 2/4] fix(review): count characters not bytes in clearing threshold A 100-CJK-char result is 300 bytes; the byte-length check stubbed it under a 200-char policy and reported a byte count as chars. Both the threshold and the reported count use chars now. Regression test with 100-char (survives) and 300-char (stubs as 300 chars) CJK payloads. --- crates/jcode-app-core/src/agent.rs | 6 ++- crates/jcode-app-core/src/agent_tests.rs | 59 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/jcode-app-core/src/agent.rs b/crates/jcode-app-core/src/agent.rs index 054a0babc1..1585ab6374 100644 --- a/crates/jcode-app-core/src/agent.rs +++ b/crates/jcode-app-core/src/agent.rs @@ -754,10 +754,12 @@ impl Agent { for message in messages.iter_mut().take(cutoff) { for block in message.content.iter_mut() { if let ContentBlock::ToolResult { content, .. } = block - && content.len() > Self::TOOL_RESULT_CLEAR_MIN_CHARS + // Character count, not byte length: a 100-CJK-char result + // is 300 bytes but reads as 100 chars of context. + && content.chars().count() > Self::TOOL_RESULT_CLEAR_MIN_CHARS && !content.starts_with("[cleared by retention") { - let was = content.len(); + let was = content.chars().count(); *content = format!("[cleared by retention: was {was} chars]"); } } diff --git a/crates/jcode-app-core/src/agent_tests.rs b/crates/jcode-app-core/src/agent_tests.rs index e866ad1dd1..631a0f7c0e 100644 --- a/crates/jcode-app-core/src/agent_tests.rs +++ b/crates/jcode-app-core/src/agent_tests.rs @@ -343,6 +343,65 @@ fn tool_result_clearing_keeps_small_results() { crate::config::Config::invalidate_cache(); } +#[test] +fn tool_result_clearing_counts_characters_not_bytes() { + let _guard = crate::storage::lock_test_env(); + let prev = std::env::var_os("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN"); + crate::env::set_var("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN", "0"); + crate::config::Config::invalidate_cache(); + + // 100 CJK chars = 300 bytes: under the 200-char policy, must survive. + let cjk = "\u{4e2d}".repeat(100); + assert_eq!(cjk.len(), 300); + let messages = vec![Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call_cjk".to_string(), + content: cjk.clone(), + is_error: None, + }], + timestamp: None, + tool_duration_ms: None, + }]; + // keep=0 with a single message: len 1 <= keep... use keep path via two + // messages so index 0 clears-or-keeps by size only. + let two = vec![messages[0].clone(), messages[0].clone()]; + let out = Agent::apply_tool_result_clearing(two); + match &out[0].content[0] { + ContentBlock::ToolResult { content, .. } => assert_eq!(content, &cjk), + other => panic!("CJK result under policy must survive, got: {other:?}"), + } + // 300 CJK chars = 900 bytes: over policy, stubbed with char count. + let big_cjk = "\u{4e2d}".repeat(300); + let two_big = vec![ + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call_big".to_string(), + content: big_cjk, + is_error: None, + }], + timestamp: None, + tool_duration_ms: None, + }, + messages[0].clone(), + ]; + let out = Agent::apply_tool_result_clearing(two_big); + match &out[0].content[0] { + ContentBlock::ToolResult { content, .. } => assert!( + content.starts_with("[cleared by retention: was 300 chars]"), + "got: {content}" + ), + other => panic!("big CJK result must stub with char count, got: {other:?}"), + } + + match prev { + Some(value) => crate::env::set_var("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN", value), + None => crate::env::remove_var("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN"), + } + crate::config::Config::invalidate_cache(); +} + #[test] fn agent_drop_removes_its_configured_session_tool_policy() { let provider: Arc = Arc::new(NativeAutoCompactionProvider); From 93f6dd5dd7961d600117442a6ed0f86a2ab3905c Mon Sep 17 00:00:00 2001 From: sk-dev-ai Date: Sat, 19 Sep 2026 19:04:29 +0530 Subject: [PATCH 3/4] feat(research): clear tool-returned images past the cutoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Research pass (Anthropic context engineering: tool payloads are artifacts, only facts matter): tool-returned images ride in the same message as the ToolResult as base64, often 100KB-1MB each — the largest context hog, and the old code stubbed the text while leaving them intact. Past-cutoff Image blocks now become text placeholders (media type plus size); pairing IDs untouched, recent images kept, session file keeps full history. --- crates/jcode-app-core/src/agent.rs | 17 ++++++ crates/jcode-app-core/src/agent_tests.rs | 68 ++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/crates/jcode-app-core/src/agent.rs b/crates/jcode-app-core/src/agent.rs index 1585ab6374..5eb107a0cd 100644 --- a/crates/jcode-app-core/src/agent.rs +++ b/crates/jcode-app-core/src/agent.rs @@ -752,6 +752,23 @@ impl Agent { let mut messages = messages; let cutoff = messages.len() - keep; for message in messages.iter_mut().take(cutoff) { + // Tool-returned images ride in the same message as the ToolResult + // (tool_output_to_content_blocks) as base64, often 100KB-1MB each: + // the largest context hog and the first thing to go. Image blocks + // carry no tool-pairing ID, so a text placeholder keeps message + // structure intact while providers never miss them. + for block in message.content.iter_mut() { + if let ContentBlock::Image { media_type, data } = block { + let was = data.len(); + let media_type = media_type.clone(); + *block = ContentBlock::Text { + text: format!( + "[cleared image by retention: was {media_type}, ~{was} base64 chars]" + ), + cache_control: None, + }; + } + } for block in message.content.iter_mut() { if let ContentBlock::ToolResult { content, .. } = block // Character count, not byte length: a 100-CJK-char result diff --git a/crates/jcode-app-core/src/agent_tests.rs b/crates/jcode-app-core/src/agent_tests.rs index 631a0f7c0e..2f340f6ff7 100644 --- a/crates/jcode-app-core/src/agent_tests.rs +++ b/crates/jcode-app-core/src/agent_tests.rs @@ -313,6 +313,74 @@ fn tool_result_clearing_stubs_old_keeps_recent_and_pairing() { } #[test] +#[test] +fn tool_result_clearing_stubs_sibling_images_past_cutoff() { + use crate::message::{ContentBlock, Message, Role}; + let img = ContentBlock::Image { + media_type: "image/png".to_string(), + data: "A".repeat(200_000), + }; + let old_msg = Message { + role: Role::User, + content: vec![ + ContentBlock::ToolResult { + tool_use_id: "t-old".to_string(), + content: "tiny".to_string(), + is_error: None, + }, + img, + ], + timestamp: None, + tool_duration_ms: None, + }; + let recent_msg = Message { + role: Role::User, + content: vec![ContentBlock::Image { + media_type: "image/png".to_string(), + data: "B".repeat(200_000), + }], + timestamp: None, + tool_duration_ms: None, + }; + // Seed the window so only old_msg falls past the cutoff. + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("temp dir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + let mut cfg = crate::config::Config::default(); + cfg.compaction.clear_tool_results_older_than = Some(1); + cfg.save().expect("save config"); + crate::config::Config::invalidate_cache(); + let out = Agent::apply_tool_result_clearing(vec![old_msg, recent_msg]); + // Old text kept (tiny), old image stubbed with pairing ID intact. + match &out[0].content[0] { + ContentBlock::ToolResult { + tool_use_id, + content, + .. + } => { + assert_eq!(tool_use_id, "t-old"); + assert_eq!(content, "tiny"); + } + other => panic!("expected ToolResult, got {other:?}"), + } + match &out[0].content[1] { + ContentBlock::Text { text, .. } => { + assert!(text.contains("cleared image by retention"), "{text}"); + assert!(text.contains("image/png"), "{text}"); + } + other => panic!("expected stub Text, got {other:?}"), + } + // Recent image untouched. + assert!(matches!(&out[1].content[0], ContentBlock::Image { .. })); + if let Some(prev) = prev_home { + crate::env::set_var("JCODE_HOME", prev); + } else { + crate::env::remove_var("JCODE_HOME"); + } + crate::config::Config::invalidate_cache(); +} + fn tool_result_clearing_keeps_small_results() { let _guard = crate::storage::lock_test_env(); let prev = std::env::var_os("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN"); From f4fbf85b6f1b4fc65560734fbcc31ae70f5ca2fe Mon Sep 17 00:00:00 2001 From: sk-dev-ai Date: Sat, 19 Sep 2026 19:39:06 +0530 Subject: [PATCH 4/4] fix(review): clear only tool-message images, keep user uploads --- crates/jcode-app-core/src/agent.rs | 16 ++++++-- crates/jcode-app-core/src/agent_tests.rs | 51 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/jcode-app-core/src/agent.rs b/crates/jcode-app-core/src/agent.rs index 5eb107a0cd..6831ae2422 100644 --- a/crates/jcode-app-core/src/agent.rs +++ b/crates/jcode-app-core/src/agent.rs @@ -754,9 +754,19 @@ impl Agent { for message in messages.iter_mut().take(cutoff) { // Tool-returned images ride in the same message as the ToolResult // (tool_output_to_content_blocks) as base64, often 100KB-1MB each: - // the largest context hog and the first thing to go. Image blocks - // carry no tool-pairing ID, so a text placeholder keeps message - // structure intact while providers never miss them. + // the largest context hog and the first thing to go. Only images + // in a message that also carries a ToolResult are tool output; + // user-uploaded images arrive in plain user messages and must + // survive — clearing those would destroy user-provided vision + // context. Image blocks carry no tool-pairing ID, so a text + // placeholder keeps message structure intact. + let is_tool_message = message + .content + .iter() + .any(|block| matches!(block, ContentBlock::ToolResult { .. })); + if !is_tool_message { + continue; + } for block in message.content.iter_mut() { if let ContentBlock::Image { media_type, data } = block { let was = data.len(); diff --git a/crates/jcode-app-core/src/agent_tests.rs b/crates/jcode-app-core/src/agent_tests.rs index 2f340f6ff7..59e544cc74 100644 --- a/crates/jcode-app-core/src/agent_tests.rs +++ b/crates/jcode-app-core/src/agent_tests.rs @@ -381,6 +381,57 @@ fn tool_result_clearing_stubs_sibling_images_past_cutoff() { crate::config::Config::invalidate_cache(); } +#[test] +fn tool_result_clearing_keeps_user_uploaded_images() { + use crate::message::{ContentBlock, Message, Role}; + // Image-only user message past the cutoff: no ToolResult, so this is a + // user upload, not tool output. Must survive clearing intact. + let upload = Message { + role: Role::User, + content: vec![ + ContentBlock::Text { + text: "what does this screenshot show?".to_string(), + cache_control: None, + }, + ContentBlock::Image { + media_type: "image/png".to_string(), + data: "U".repeat(200_000), + }, + ], + timestamp: None, + tool_duration_ms: None, + }; + let recent = Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "t-new".to_string(), + content: "fresh".to_string(), + is_error: None, + }], + timestamp: None, + tool_duration_ms: None, + }; + let _guard = crate::storage::lock_test_env(); + let temp = tempfile::TempDir::new().expect("temp dir"); + let prev_home = std::env::var_os("JCODE_HOME"); + crate::env::set_var("JCODE_HOME", temp.path()); + let mut cfg = crate::config::Config::default(); + cfg.compaction.clear_tool_results_older_than = Some(1); + cfg.save().expect("save config"); + crate::config::Config::invalidate_cache(); + let out = Agent::apply_tool_result_clearing(vec![upload, recent]); + assert!(matches!(&out[0].content[1], ContentBlock::Image { .. })); + if let ContentBlock::Image { data, .. } = &out[0].content[1] { + assert_eq!(data.len(), 200_000); + } + if let Some(prev) = prev_home { + crate::env::set_var("JCODE_HOME", prev); + } else { + crate::env::remove_var("JCODE_HOME"); + } + crate::config::Config::invalidate_cache(); +} + fn tool_result_clearing_keeps_small_results() { let _guard = crate::storage::lock_test_env(); let prev = std::env::var_os("JCODE_COMPACTION_CLEAR_TOOL_RESULTS_OLDER_THAN");