use crate::{
core::{
extensions::{
Extension, ExtensionCommand, ExtensionHook, ExtensionMode, ExtensionTool,
SubcommandDef, UiNotifyLevel,
},
provider::{AgentMessage, ContentBlock, estimate_tokens},
},
error::Result,
extensions::{
EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_SHAKE, command_arg, util::notify_plain,
},
modes::interactive::{
app::{App, MsgLevel},
handlers::register_slash_command,
},
utils::{display::plural, tokens},
};
use std::sync::{Arc, Mutex, OnceLock};
use strum_macros::{EnumString, IntoStaticStr};
const EXT: &str = "shake";
const CMD: &str = "shake";
const PROTECT_TOKENS: u32 = 4_000;
const FENCE_MIN_TOKENS: u64 = 400;
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static SHAKE_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_SHAKE,
make: || -> Arc<dyn Extension> { Arc::new(Shake) },
};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)]
#[strum(serialize_all = "lowercase")]
enum ShakeMode {
#[default]
Elide,
Images,
Thinking,
}
#[derive(Debug, Default, Clone, Copy)]
struct ShakeReport {
mode: ShakeMode,
tool_results: usize,
blocks: usize,
images: usize,
thinking: usize,
tokens_freed: u32,
}
impl ShakeReport {
fn new(mode: ShakeMode) -> ShakeReport {
ShakeReport {
mode,
..Default::default()
}
}
fn summary(&self) -> String {
match self.mode {
ShakeMode::Images => match self.images {
0 => "No images found in this session.".to_string(),
n => format!("Dropped {n} image{} from this session.", plural(n)),
},
ShakeMode::Thinking => match self.thinking {
0 => "No thinking blocks found in this session.".to_string(),
n => format!(
"Dropped {n} thinking block{} from this session (~{} tokens freed).",
plural(n),
crate::utils::display::format_tokens(self.tokens_freed as u64)
),
},
ShakeMode::Elide => {
let mut parts: Vec<String> = Vec::new();
if self.tool_results > 0 {
parts.push(format!(
"{} tool result{}",
self.tool_results,
plural(self.tool_results)
));
}
if self.blocks > 0 {
parts.push(format!("{} block{}", self.blocks, plural(self.blocks)));
}
if parts.is_empty() {
"Nothing to shake.".to_string()
} else {
format!(
"Shook {} (~{} tokens freed).",
parts.join(" + "),
crate::utils::display::format_tokens(self.tokens_freed as u64)
)
}
}
}
}
}
fn pending() -> &'static Mutex<Option<ShakeMode>> {
static P: OnceLock<Mutex<Option<ShakeMode>>> = OnceLock::new();
P.get_or_init(|| Mutex::new(None))
}
fn take_pending() -> Option<ShakeMode> {
pending().lock().unwrap_or_else(|e| e.into_inner()).take()
}
fn clear_pending() {
*pending().lock().unwrap_or_else(|e| e.into_inner()) = None;
}
pub struct Shake;
impl Extension for Shake {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
"Drop heavy content from the context on demand: /shake [elide|images|thinking] \
replaces tool results and oversized blocks with short placeholders, or strips \
image / thinking blocks. It applies before the next request; session files and the \
transcript are never rewritten."
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn default_enabled(&self) -> bool {
false
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![ExtensionCommand {
name: CMD.to_string(),
description: "Drop heavy content from the context (tool results, images, thinking)"
.to_string(),
busy_safe: true,
subcommands: vec![
SubcommandDef {
name: "elide",
description: "Strip tool results and oversized blocks (default)",
},
SubcommandDef {
name: "images",
description: "Strip image blocks",
},
SubcommandDef {
name: "thinking",
description: "Drop thinking blocks",
},
],
}]
}
fn on_registered(&self) {
register_slash_command(EXT, CMD, command_shake);
}
fn hooks(&self) -> Vec<ExtensionHook> {
if pending()
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
{
vec![ExtensionHook::TransformContext]
} else {
Vec::new()
}
}
fn on_enabled_changed(&self, enabled: bool) {
if !enabled {
clear_pending();
}
}
fn on_session_switched(&self, _session_path: Option<&str>, _messages: &[AgentMessage]) {
clear_pending();
}
fn transform_context(&self, messages: &mut Vec<AgentMessage>) -> Result<()> {
let Some(mode) = take_pending() else {
return Ok(());
};
let report = match mode {
ShakeMode::Elide => elide(messages),
ShakeMode::Images => drop_images(messages),
ShakeMode::Thinking => drop_thinking(messages),
};
notify_plain(report.summary(), UiNotifyLevel::Info);
Ok(())
}
}
fn command_shake(st: &mut App, raw: &str) -> bool {
let arg = command_arg(raw).trim().to_lowercase();
let mode = if arg.is_empty() {
ShakeMode::Elide
} else {
match arg.parse::<ShakeMode>() {
Ok(mode) => mode,
Err(_) => {
st.push_msg(
format!("Unknown /shake mode \"{arg}\". Use elide, images, or thinking."),
MsgLevel::Warning,
);
return false;
}
}
};
let name: &'static str = mode.into();
*pending().lock().unwrap_or_else(|e| e.into_inner()) = Some(mode);
st.push_msg(
format!("Shake ({name}) queued: heavy content drops before the next request."),
MsgLevel::Info,
);
false
}
fn elide(messages: &mut [AgentMessage]) -> ShakeReport {
let protect_from = protect_boundary(messages, PROTECT_TOKENS);
let mut report = ShakeReport::new(ShakeMode::Elide);
for message in messages.iter_mut().take(protect_from) {
let before = estimate_tokens(message);
let changed = match message.role.as_str() {
"toolResult" => elide_tool_result(message, &mut report),
"user" | "assistant" | "custom" => elide_message_blocks(message, &mut report),
_ => false,
};
if changed {
report.tokens_freed += before.saturating_sub(estimate_tokens(message));
}
}
report
}
fn elide_tool_result(message: &mut AgentMessage, report: &mut ShakeReport) -> bool {
let has_text = message
.content
.iter()
.any(|b| matches!(b, ContentBlock::Text { text, .. } if !text.trim().is_empty()));
if !has_text {
return false;
}
let tool = message
.tool_name
.as_deref()
.filter(|n| !n.is_empty())
.unwrap_or("unknown");
let mut kept: Vec<ContentBlock> = Vec::with_capacity(message.content.len());
kept.push(placeholder(&format!(
"[tool result elided by /shake: {tool} output dropped]"
)));
kept.extend(
message
.content
.drain(..)
.filter(|b| !matches!(b, ContentBlock::Text { .. })),
);
message.content = kept;
report.tool_results += 1;
true
}
fn elide_message_blocks(message: &mut AgentMessage, report: &mut ShakeReport) -> bool {
let mut changed = false;
for block in message.content.iter_mut() {
let ContentBlock::Text { text, .. } = block else {
continue;
};
let (replaced, count) = elide_large_blocks(text);
if count > 0 {
*text = replaced;
report.blocks += count;
changed = true;
}
}
changed
}
fn drop_images(messages: &mut [AgentMessage]) -> ShakeReport {
let mut report = ShakeReport::new(ShakeMode::Images);
for message in messages.iter_mut() {
let before = estimate_tokens(message);
let mut removed = 0usize;
message.content.retain(|block| {
if matches!(block, ContentBlock::Image { .. }) {
removed += 1;
false
} else {
true
}
});
if removed == 0 {
continue;
}
if message.content.is_empty() {
message
.content
.push(placeholder("[image removed by /shake]"));
}
report.images += removed;
report.tokens_freed += before.saturating_sub(estimate_tokens(message));
}
report
}
fn drop_thinking(messages: &mut [AgentMessage]) -> ShakeReport {
let mut report = ShakeReport::new(ShakeMode::Thinking);
let protected = messages.iter().rposition(|m| m.role == "assistant");
for (index, message) in messages.iter_mut().enumerate() {
if message.role != "assistant" || Some(index) == protected {
continue;
}
let before = estimate_tokens(message);
let mut removed = 0usize;
message.content.retain(|block| {
if matches!(block, ContentBlock::Thinking { .. }) {
removed += 1;
false
} else {
true
}
});
if removed == 0 {
continue;
}
report.thinking += removed;
report.tokens_freed += before.saturating_sub(estimate_tokens(message));
}
report
}
fn protect_boundary(messages: &[AgentMessage], budget: u32) -> usize {
let mut total = 0u32;
for (index, message) in messages.iter().enumerate().rev() {
total = total.saturating_add(estimate_tokens(message));
if total >= budget {
return index;
}
}
0
}
fn placeholder(text: &str) -> ContentBlock {
ContentBlock::Text {
text: text.to_string(),
text_signature: None,
}
}
fn elide_large_blocks(text: &str) -> (String, usize) {
let lines: Vec<&str> = text.split('\n').collect();
let enc = tokens::encoding_for(None);
let mut out: Vec<String> = Vec::with_capacity(lines.len());
let mut count = 0usize;
let mut index = 0usize;
while index < lines.len() {
let span = block_span(&lines, index).filter(|(end, _)| {
let body = lines[index..=*end].join("\n");
tokens::count(enc, &body) >= FENCE_MIN_TOKENS
});
match span {
Some((end, kind)) => {
let body = lines[index..=end].join("\n");
let tokens = tokens::count(enc, &body);
out.push(format!(
"[... /shake elided a {}-line {kind} block, about {tokens} tokens ...]",
end - index + 1
));
count += 1;
index = end + 1;
}
None => {
out.push(lines[index].to_string());
index += 1;
}
}
}
if count == 0 {
return (text.to_string(), 0);
}
(out.join("\n"), count)
}
fn block_span(lines: &[&str], start: usize) -> Option<(usize, &'static str)> {
let line = lines[start].trim();
if let Some((fence, len)) = fence_open(line) {
let end = (start + 1..lines.len()).find(|&i| fence_close(lines[i].trim(), fence, len))?;
return Some((end, "code"));
}
if let Some(tag) = xml_open(line) {
let closing = format!("</{tag}>");
let end = (start + 1..lines.len()).find(|&i| lines[i].trim() == closing)?;
return Some((end, "xml"));
}
None
}
fn fence_open(line: &str) -> Option<(char, usize)> {
let first = line.chars().next()?;
if first != '`' && first != '~' {
return None;
}
let len = line.chars().take_while(|c| *c == first).count();
if len < 3 {
return None;
}
Some((first, len))
}
fn fence_close(line: &str, fence: char, len: usize) -> bool {
let count = line.chars().count();
count >= len && line.chars().all(|c| c == fence)
}
fn xml_open(line: &str) -> Option<&str> {
let rest = line.strip_prefix('<')?;
if rest.starts_with('/') || rest.starts_with('!') || rest.starts_with('?') {
return None;
}
if !line.ends_with('>') || line.ends_with("/>") {
return None;
}
let name_end = rest.find(|c: char| c.is_whitespace() || c == '>')?;
let name = &rest[..name_end];
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_lowercase() || c == '_' || c == '-')
{
return None;
}
Some(name)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::provider::AgentMessage;
fn filler(tag: &str, repeats: usize) -> String {
(0..repeats)
.map(|i| format!("{tag} line {i} with some words to count"))
.collect::<Vec<_>>()
.join("\n")
}
fn tool_result(name: &str, text: &str) -> AgentMessage {
let mut message = AgentMessage::user_text(text);
message.role = "toolResult".into();
message.tool_call_id = Some("call_1".into());
message.tool_name = Some(name.into());
message
}
fn assistant(text: &str, thinking: Option<&str>) -> AgentMessage {
let mut message = AgentMessage::user_text(text);
message.role = "assistant".into();
if let Some(thinking) = thinking {
message.content.push(ContentBlock::Thinking {
thinking: thinking.into(),
thinking_signature: Some("sig".into()),
redacted: None,
});
}
message
}
#[test]
fn mode_parsing_is_lowercase_only() {
assert_eq!("elide".parse::<ShakeMode>(), Ok(ShakeMode::Elide));
assert_eq!("images".parse::<ShakeMode>(), Ok(ShakeMode::Images));
assert_eq!("thinking".parse::<ShakeMode>(), Ok(ShakeMode::Thinking));
assert!("Elide".parse::<ShakeMode>().is_err());
assert!("everything".parse::<ShakeMode>().is_err());
}
#[test]
fn command_declares_three_subcommands() {
let commands = Shake.commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].name, "shake");
assert!(commands[0].busy_safe, "handler 只落标记,不锁 agent");
let names: Vec<&str> = commands[0].subcommands.iter().map(|s| s.name).collect();
assert_eq!(names, vec!["elide", "images", "thinking"]);
}
#[test]
fn defaults_are_off_and_dev_only() {
assert!(!Shake.default_enabled(), "shake 应默认不启用");
assert_eq!(
Shake.modes(),
vec![ExtensionMode::Dev, ExtensionMode::Creator]
);
assert!(Shake.tools().is_empty(), "shake 不提供工具");
}
#[test]
fn hooks_only_declared_when_armed() {
clear_pending();
assert!(Shake.hooks().is_empty(), "空闲时不声明钩子");
*pending().lock().unwrap() = Some(ShakeMode::Images);
assert_eq!(Shake.hooks(), vec![ExtensionHook::TransformContext]);
clear_pending();
}
#[test]
fn transform_is_one_shot() {
clear_pending();
let mut messages = vec![
AgentMessage::user_text("go"),
assistant("done", Some("thinking hard")),
AgentMessage::user_text("next"),
assistant("next done", Some("more thinking")),
];
*pending().lock().unwrap() = Some(ShakeMode::Thinking);
Shake.transform_context(&mut messages).unwrap();
assert!(pending().lock().unwrap().is_none(), "一次性标记应被取走");
let thinking = messages
.iter()
.filter(|m| {
m.content
.iter()
.any(|b| matches!(b, ContentBlock::Thinking { .. }))
})
.count();
assert_eq!(thinking, 1, "只有最后一条 assistant 保留思考");
let before = messages.len();
Shake.transform_context(&mut messages).unwrap();
assert_eq!(messages.len(), before);
}
#[test]
fn elide_replaces_tool_results_and_keeps_images() {
clear_pending();
let mut message = tool_result("bash", "a very long tool output");
message.content.push(ContentBlock::Image {
data: "AAA".into(),
mime_type: "image/png".into(),
});
let mut tail = AgentMessage::user_text(&filler("tail", 900));
tail.role = "user".into();
let mut messages = vec![AgentMessage::user_text("go"), message, tail];
let report = elide(&mut messages);
assert_eq!(report.tool_results, 1);
assert_eq!(messages[1].role, "toolResult");
assert_eq!(messages[1].tool_call_id.as_deref(), Some("call_1"));
let text = messages[1].text();
assert!(text.contains("elided by /shake"), "got: {text}");
assert!(text.contains("bash"), "占位符应点名工具: {text}");
assert!(
messages[1]
.content
.iter()
.any(|b| matches!(b, ContentBlock::Image { .. })),
"图片应保留"
);
}
#[test]
fn elide_protects_recent_tokens() {
let mut messages = vec![
AgentMessage::user_text("one"),
tool_result("bash", "small"),
assistant("two", None),
AgentMessage::user_text("three"),
];
let report = elide(&mut messages);
assert_eq!(report.tool_results, 0, "预算内不应改动");
assert!(messages[1].text().contains("small"));
assert_eq!(protect_boundary(&messages, PROTECT_TOKENS), 0);
}
#[test]
fn elide_skips_small_blocks_and_shakes_big_ones() {
let big_body = filler("code", FENCE_MIN_TOKENS as usize / 4);
let small_body = "y".repeat(16);
let text = format!("intro\n```rust\n{big_body}\n```\nmiddle\n```\n{small_body}\n```\nend");
let (rebuilt, count) = elide_large_blocks(&text);
assert_eq!(count, 1, "只有大块被替换: {rebuilt}");
assert!(
rebuilt.contains("/shake elided a") && rebuilt.contains("code block"),
"got: {rebuilt}"
);
assert!(rebuilt.contains(&small_body), "小块应原样保留");
assert!(rebuilt.contains("intro") && rebuilt.contains("end"));
}
#[test]
fn elide_handles_xml_blocks() {
let body = "z".repeat(FENCE_MIN_TOKENS as usize * 4);
let text = format!("<file path=\"a.rs\">\n{body}\n</file>");
let (rebuilt, count) = elide_large_blocks(&text);
assert_eq!(count, 1, "got: {rebuilt}");
assert!(rebuilt.contains("3-line xml block"), "got: {rebuilt}");
assert!(!rebuilt.contains("a.rs"), "整块被换掉: {rebuilt}");
}
#[test]
fn elide_leaves_unterminated_blocks_alone() {
let body = "z".repeat(FENCE_MIN_TOKENS as usize * 4);
let text = format!("```\n{body}\n");
let (rebuilt, count) = elide_large_blocks(&text);
assert_eq!(count, 0, "没有闭合围栏不算块");
assert_eq!(rebuilt, text);
}
#[test]
fn drop_images_counts_and_keeps_message_non_empty() {
let mut message = AgentMessage::user_text("");
message.content = vec![ContentBlock::Image {
data: "AAA".into(),
mime_type: "image/png".into(),
}];
let mut messages = vec![AgentMessage::user_text("look"), message];
let report = drop_images(&mut messages);
assert_eq!(report.images, 1);
assert!(
messages[1]
.content
.iter()
.all(|b| !matches!(b, ContentBlock::Image { .. })),
"图片应被移除"
);
assert!(
messages[1].text().contains("image removed by /shake"),
"空消息需补占位文本: {:?}",
messages[1].text()
);
assert!(
report
.summary()
.contains("Dropped 1 image from this session.")
);
}
#[test]
fn drop_thinking_keeps_last_assistant() {
let mut messages = vec![
assistant("a1", Some("t1")),
AgentMessage::user_text("u"),
assistant("a2", Some("t2")),
];
let report = drop_thinking(&mut messages);
assert_eq!(report.thinking, 1, "只掉非末尾 assistant 的思考");
assert!(messages[0].text().contains("a1"));
assert_eq!(messages[2].content.len(), 2, "末尾 assistant 不动");
}
#[test]
fn summaries_cover_empty_and_hit_cases() {
assert_eq!(
ShakeReport::new(ShakeMode::Elide).summary(),
"Nothing to shake."
);
assert_eq!(
ShakeReport::new(ShakeMode::Images).summary(),
"No images found in this session."
);
assert_eq!(
ShakeReport::new(ShakeMode::Thinking).summary(),
"No thinking blocks found in this session."
);
let report = ShakeReport {
tool_results: 2,
blocks: 1,
tokens_freed: 1200,
..ShakeReport::new(ShakeMode::Elide)
};
assert_eq!(
report.summary(),
"Shook 2 tool results + 1 block (~1.2K tokens freed)."
);
let report = ShakeReport {
thinking: 1,
tokens_freed: 900,
..ShakeReport::new(ShakeMode::Thinking)
};
assert_eq!(
report.summary(),
"Dropped 1 thinking block from this session (~900 tokens freed)."
);
}
#[test]
fn command_queues_mode_and_rejects_unknown() {
clear_pending();
let mut st = App::new();
assert!(!command_shake(&mut st, "shake"));
assert_eq!(take_pending(), Some(ShakeMode::Elide));
assert!(!command_shake(&mut st, "shake IMAGES"));
assert_eq!(take_pending(), Some(ShakeMode::Images));
assert!(!command_shake(&mut st, "shake everything"));
assert_eq!(take_pending(), None);
}
}