use std::borrow::Cow;
use std::path::PathBuf;
use caliban_provider::{ContentBlock, TextBlock};
pub trait AssistantPostProcessor: Send + Sync {
fn process<'a>(&self, text: &'a str) -> Cow<'a, str>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoopPostProcessor;
impl AssistantPostProcessor for NoopPostProcessor {
fn process<'a>(&self, text: &'a str) -> Cow<'a, str> {
Cow::Borrowed(text)
}
}
const HEAD_TAIL_CHARS: usize = 2048;
pub struct ToolResultCap {
pub max_chars: usize,
pub overflow_dir: PathBuf,
pub session_id: String,
}
impl ToolResultCap {
pub async fn cap(&self, blocks: &mut [ContentBlock]) -> std::io::Result<usize> {
if self.max_chars == 0 {
return Ok(0);
}
let session_dir = self.overflow_dir.join(&self.session_id);
let mut overflows = 0;
for block in blocks.iter_mut() {
let ContentBlock::ToolResult(tr) = block else {
continue;
};
if let Some(ContentBlock::Text(t)) = tr.content.first()
&& (t.text.starts_with("[truncated:") || t.text.starts_with("[superseded:"))
{
continue;
}
let full: String = tr
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let full_chars = full.chars().count();
if full_chars <= self.max_chars {
continue;
}
tokio::fs::create_dir_all(&session_dir).await?;
let path = session_dir.join(format!("{}.txt", tr.tool_use_id));
tokio::fs::write(&path, &full).await?;
let each = HEAD_TAIL_CHARS.min(self.max_chars / 2);
let head: String = full.chars().take(each).collect();
let tail_start = full_chars.saturating_sub(each);
let tail: String = full.chars().skip(tail_start).collect();
let head_chars = head.chars().count();
let tail_chars = tail.chars().count();
let placeholder = format!(
"[truncated: {} chars, full content at {}]\n\n--- head {} chars ---\n{}\n--- tail {} chars ---\n{}",
full_chars,
path.display(),
head_chars,
head,
tail_chars,
tail,
);
tr.content = vec![ContentBlock::Text(TextBlock {
text: placeholder,
cache_control: None,
})];
overflows += 1;
}
Ok(overflows)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn noop_returns_input_unchanged() {
let p = NoopPostProcessor;
let out = p.process("hello world");
assert_eq!(out, "hello world");
assert!(matches!(out, Cow::Borrowed(_)));
}
fn tool_result(text: &str) -> ContentBlock {
ContentBlock::ToolResult(caliban_provider::ToolResultBlock {
tool_use_id: "tid".into(),
content: vec![ContentBlock::Text(TextBlock {
text: text.into(),
cache_control: None,
})],
is_error: false,
})
}
fn placeholder_text(block: &ContentBlock) -> String {
let ContentBlock::ToolResult(tr) = block else {
panic!("expected tool result")
};
let Some(ContentBlock::Text(t)) = tr.content.first() else {
panic!("expected text block")
};
t.text.clone()
}
#[tokio::test]
async fn small_cap_does_not_overlap_or_enlarge() {
let body = format!("{}MIDDLE_MARKER{}", "A".repeat(1400), "B".repeat(1587));
assert_eq!(body.chars().count(), 3000);
let dir = tempfile::tempdir().unwrap();
let cap = ToolResultCap {
max_chars: 2500,
overflow_dir: dir.path().to_path_buf(),
session_id: "s".into(),
};
let mut blocks = vec![tool_result(&body)];
let n = cap.cap(&mut blocks).await.unwrap();
assert_eq!(n, 1, "the oversized result should overflow");
let placeholder = placeholder_text(&blocks[0]);
assert!(
placeholder.chars().count() < 3000,
"placeholder ({} chars) must be smaller than the 3000-char original",
placeholder.chars().count()
);
assert!(
!placeholder.contains("MIDDLE_MARKER"),
"the dropped middle must not appear (no head/tail overlap)"
);
}
}