use crate::agentic::compress::redact_secrets;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
pub const TOOL_RESULT_SPILL_CAP: usize = 16_000;
const HEAD_CHARS: usize = 800;
const TAIL_CHARS: usize = 800;
pub trait SpillStore: Send + Sync {
fn store(&self, redacted: String) -> String;
fn fetch(&self, id: &str) -> Option<String>;
fn spills(&self) -> u64;
fn offloaded_chars(&self) -> u64;
}
#[derive(Default)]
pub struct SessionSpillStore {
map: Mutex<HashMap<String, String>>,
counter: AtomicU64,
offloaded_chars: AtomicU64,
}
impl SpillStore for SessionSpillStore {
fn store(&self, redacted: String) -> String {
let n = self.counter.fetch_add(1, Ordering::Relaxed);
let id = format!("s{n}");
self.offloaded_chars
.fetch_add(redacted.chars().count() as u64, Ordering::Relaxed);
self.map.lock().unwrap().insert(id.clone(), redacted);
id
}
fn fetch(&self, id: &str) -> Option<String> {
self.map.lock().unwrap().get(id).cloned()
}
fn spills(&self) -> u64 {
self.counter.load(Ordering::Relaxed)
}
fn offloaded_chars(&self) -> u64 {
self.offloaded_chars.load(Ordering::Relaxed)
}
}
fn head_tail_excerpt(redacted: &str, id: &str) -> String {
let chars: Vec<char> = redacted.chars().collect();
let total = chars.len();
let head: String = chars.iter().take(HEAD_CHARS).collect();
let tail: String = chars
.iter()
.skip(total.saturating_sub(TAIL_CHARS))
.collect();
format!(
"{head}\n\n[… tool output truncated: {total} chars offloaded. Use \
memory_fetch(\"spill:{id}\") to read the full (secret-redacted) payload …]\n\n{tail}"
)
}
pub fn maybe_offload(result: String, tool_offload: bool, spill: Option<&dyn SpillStore>) -> String {
let Some(store) = spill else {
return result;
};
if !tool_offload || result.chars().count() <= TOOL_RESULT_SPILL_CAP {
return result;
}
let redacted = redact_secrets(&result);
let id = store.store(redacted.clone());
head_tail_excerpt(&redacted, &id)
}
pub fn store_redacted_full(result: &str, spill: &dyn SpillStore) -> (String, String) {
let redacted = redact_secrets(result);
let id = spill.store(redacted.clone());
(id, redacted)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_store_round_trips_with_monotonic_ids() {
let s = SessionSpillStore::default();
let id0 = s.store("alpha".to_string());
let id1 = s.store("beta".to_string());
assert_eq!(id0, "s0");
assert_eq!(id1, "s1");
assert_eq!(s.fetch("s0").as_deref(), Some("alpha"));
assert_eq!(s.fetch("s1").as_deref(), Some("beta"));
assert_eq!(s.fetch("s99"), None, "unknown id → None, no panic");
assert_eq!(s.spills(), 2);
assert_eq!(s.offloaded_chars(), 9); }
#[test]
fn maybe_offload_truth_table() {
let big = "x".repeat(TOOL_RESULT_SPILL_CAP + 1);
let s = SessionSpillStore::default();
assert_eq!(maybe_offload(big.clone(), false, Some(&s)), big);
assert_eq!(s.spills(), 0);
assert_eq!(maybe_offload(big.clone(), true, None), big);
let s = SessionSpillStore::default();
let small = "x".repeat(TOOL_RESULT_SPILL_CAP); assert_eq!(maybe_offload(small.clone(), true, Some(&s)), small);
assert_eq!(s.spills(), 0);
let s = SessionSpillStore::default();
let out = maybe_offload(big.clone(), true, Some(&s));
assert!(
out.contains("spill:s0"),
"teaser carries the handle: {out:.80}"
);
assert!(out.contains("memory_fetch"), "teaser coaches re-read");
assert!(
out.chars().count() < big.chars().count(),
"teaser is shorter"
);
assert_eq!(s.spills(), 1);
assert_eq!(s.fetch("s0").as_deref(), Some(big.as_str()));
}
#[test]
fn offload_redacts_before_store_and_in_teaser() {
let secret = "sk-ABCDEFGHIJKLMNOPQRST0123";
let payload = format!(
"{}\n{secret}\n{}",
"head ".repeat(2_000),
"tail ".repeat(2_000)
);
assert!(payload.chars().count() > TOOL_RESULT_SPILL_CAP);
let s = SessionSpillStore::default();
let teaser = maybe_offload(payload, true, Some(&s));
let stored = s.fetch("s0").expect("payload was stored");
assert!(stored.contains("[REDACTED]"), "stored payload is redacted");
assert!(
!stored.contains(secret),
"raw secret NOT retained in the store"
);
assert!(
!teaser.contains(secret),
"raw secret NOT shown in the teaser"
);
}
}