use zeph_common::memory::TokenCounting;
use zeph_config::AconConfig;
const TRUNCATION_MARKER: &str = " [...truncated]";
#[derive(Debug, Clone)]
pub struct ToolResultCompressionConfig {
pub passthrough_threshold: usize,
pub summarize_threshold: usize,
pub total_budget: usize,
}
impl From<&AconConfig> for ToolResultCompressionConfig {
fn from(cfg: &AconConfig) -> Self {
Self {
passthrough_threshold: cfg.passthrough_threshold,
summarize_threshold: cfg.summarize_threshold,
total_budget: cfg.total_budget,
}
}
}
pub struct ToolResultEntry<'a> {
pub tool_name: &'a str,
pub text: &'a str,
pub index: usize,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompressionMethod {
PassThrough,
Truncated,
BatchTrimmed,
}
#[derive(Debug, Clone)]
pub struct CompressedToolResult {
pub text: String,
pub original_tokens: usize,
pub compressed_tokens: usize,
pub method: CompressionMethod,
}
pub struct ToolResultCompressor;
impl ToolResultCompressor {
#[must_use]
pub fn compress_single(
text: &str,
tc: &dyn TokenCounting,
config: &ToolResultCompressionConfig,
) -> CompressedToolResult {
let original_tokens = tc.count_tokens(text);
if original_tokens <= config.passthrough_threshold {
return CompressedToolResult {
text: text.to_owned(),
original_tokens,
compressed_tokens: original_tokens,
method: CompressionMethod::PassThrough,
};
}
let byte_limit = config
.passthrough_threshold
.saturating_mul(4)
.saturating_sub(TRUNCATION_MARKER.len());
let cut = text.floor_char_boundary(byte_limit.min(text.len()));
let truncated = format!("{}{}", &text[..cut], TRUNCATION_MARKER);
let compressed_tokens = tc.count_tokens(&truncated);
CompressedToolResult {
text: truncated,
original_tokens,
compressed_tokens,
method: CompressionMethod::Truncated,
}
}
#[must_use]
pub fn compress_batch(
entries: &[ToolResultEntry<'_>],
tc: &dyn TokenCounting,
config: &ToolResultCompressionConfig,
) -> Vec<CompressedToolResult> {
if entries.is_empty() {
return Vec::new();
}
let mut results: Vec<CompressedToolResult> = entries
.iter()
.map(|e| Self::compress_single(e.text, tc, config))
.collect();
let total_tokens: usize = results.iter().map(|r| r.compressed_tokens).sum();
if total_tokens <= config.total_budget {
return results;
}
let mut order: Vec<usize> = (0..results.len()).collect();
order.sort_unstable_by(|&a, &b| {
let ta = results[a].compressed_tokens;
let tb = results[b].compressed_tokens;
tb.cmp(&ta)
.then_with(|| entries[a].index.cmp(&entries[b].index))
});
let mut remaining = total_tokens;
for &idx in &order {
if remaining <= config.total_budget {
break;
}
let current = results[idx].compressed_tokens;
let excess = remaining.saturating_sub(config.total_budget);
let target_tokens = current.saturating_sub(excess.min(current));
let byte_limit = target_tokens.max(1).saturating_mul(4);
let cut = results[idx]
.text
.floor_char_boundary(byte_limit.min(results[idx].text.len()));
let trimmed = format!("{} [...truncated]", &results[idx].text[..cut]);
let new_tokens = tc.count_tokens(&trimmed);
remaining = remaining.saturating_sub(current).saturating_add(new_tokens);
results[idx].compressed_tokens = new_tokens;
results[idx].text = trimmed;
results[idx].method = CompressionMethod::BatchTrimmed;
}
results
}
}
#[cfg(test)]
mod tests {
use super::*;
struct WordCounter;
impl TokenCounting for WordCounter {
fn count_tokens(&self, text: &str) -> usize {
text.split_whitespace().count()
}
fn count_tool_schema_tokens(&self, _schema: &serde_json::Value) -> usize {
0
}
}
fn cfg(passthrough: usize, summarize: usize, budget: usize) -> ToolResultCompressionConfig {
ToolResultCompressionConfig {
passthrough_threshold: passthrough,
summarize_threshold: summarize,
total_budget: budget,
}
}
#[test]
fn compress_single_passthrough_below_threshold() {
let tc = WordCounter;
let config = cfg(10, 20, 40);
let r = ToolResultCompressor::compress_single("one two three", &tc, &config);
assert_eq!(r.method, CompressionMethod::PassThrough);
assert_eq!(r.text, "one two three");
assert_eq!(r.original_tokens, r.compressed_tokens);
}
#[test]
fn compress_single_passthrough_at_exact_threshold() {
let tc = WordCounter;
let config = cfg(3, 10, 20);
let r = ToolResultCompressor::compress_single("a b c", &tc, &config);
assert_eq!(r.method, CompressionMethod::PassThrough);
}
#[test]
fn compress_single_truncated_above_threshold() {
let tc = WordCounter;
let config = cfg(2, 10, 20);
let text = "one two three four five";
let r = ToolResultCompressor::compress_single(text, &tc, &config);
assert_eq!(r.method, CompressionMethod::Truncated);
assert!(r.text.ends_with("[...truncated]"));
assert!(r.compressed_tokens <= r.original_tokens);
}
#[test]
fn compress_single_empty_text_passthrough() {
let tc = WordCounter;
let config = cfg(5, 10, 20);
let r = ToolResultCompressor::compress_single("", &tc, &config);
assert_eq!(r.method, CompressionMethod::PassThrough);
assert_eq!(r.text, "");
}
#[test]
fn compress_batch_empty_input() {
let tc = WordCounter;
let config = cfg(5, 10, 20);
let results = ToolResultCompressor::compress_batch(&[], &tc, &config);
assert!(results.is_empty());
}
#[test]
fn compress_batch_within_budget_no_batch_trim() {
let tc = WordCounter;
let config = cfg(100, 200, 1000);
let entries = vec![
ToolResultEntry {
tool_name: "a",
text: "one two",
index: 0,
},
ToolResultEntry {
tool_name: "b",
text: "three four",
index: 1,
},
];
let results = ToolResultCompressor::compress_batch(&entries, &tc, &config);
assert!(
results
.iter()
.all(|r| r.method != CompressionMethod::BatchTrimmed),
"no batch trimming expected within budget"
);
}
#[test]
fn compress_batch_exceeds_budget_trims_largest_first() {
let tc = WordCounter;
let config = cfg(100, 200, 3);
let entries = vec![
ToolResultEntry {
tool_name: "a",
text: "one two three",
index: 0,
}, ToolResultEntry {
tool_name: "b",
text: "four five six",
index: 1,
}, ];
let results = ToolResultCompressor::compress_batch(&entries, &tc, &config);
assert_eq!(results.len(), 2);
assert!(
results
.iter()
.any(|r| r.method == CompressionMethod::BatchTrimmed)
);
let total: usize = results.iter().map(|r| r.compressed_tokens).sum();
assert!(
total <= config.total_budget + 3,
"total {total} should be near budget {}",
config.total_budget
);
}
#[test]
fn compress_batch_tiebreaker_lower_index_trimmed_first() {
let tc = WordCounter;
let config = cfg(100, 200, 3);
let entries = vec![
ToolResultEntry {
tool_name: "a",
text: "one two three",
index: 0,
},
ToolResultEntry {
tool_name: "b",
text: "four five six",
index: 1,
},
];
let results = ToolResultCompressor::compress_batch(&entries, &tc, &config);
assert_eq!(
results[0].method,
CompressionMethod::BatchTrimmed,
"lower index must be trimmed first on equal token counts"
);
}
#[test]
fn acon_config_default_into_compression_config() {
let acon = AconConfig::default();
let cfg = ToolResultCompressionConfig::from(&acon);
assert_eq!(cfg.passthrough_threshold, 2000);
assert_eq!(cfg.summarize_threshold, 4000);
assert_eq!(cfg.total_budget, 8000);
}
}