Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions crates/jcode-app-core/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,72 @@ 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<Message>) -> Vec<Message> {
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) {
// 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. 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();
let media_type = media_type.clone();
*block = ContentBlock::Text {
text: format!(
"[cleared image by retention: was {media_type}, ~{was} base64 chars]"
),
cache_control: None,
};
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
for block in message.content.iter_mut() {
if let ContentBlock::ToolResult { content, .. } = block
// 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.chars().count();
*content = format!("[cleared by retention: was {was} chars]");
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
}
messages
}

fn messages_for_provider(&mut self) -> (Vec<Message>, Option<CompactionEvent>) {
if self.provider.supports_compaction() || self.session.compaction.is_some() {
let compaction = self.registry.compaction();
Expand Down Expand Up @@ -779,7 +845,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");
Expand All @@ -800,7 +866,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]) {
Expand Down
Loading