newt_core/agentic/
spill.rs1use crate::agentic::compress::redact_secrets;
17use std::collections::HashMap;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::Mutex;
20
21pub const TOOL_RESULT_SPILL_CAP: usize = 16_000;
24
25const HEAD_CHARS: usize = 800;
28const TAIL_CHARS: usize = 800;
29
30pub trait SpillStore: Send + Sync {
36 fn store(&self, redacted: String) -> String;
38 fn fetch(&self, id: &str) -> Option<String>;
40 fn spills(&self) -> u64;
42 fn offloaded_chars(&self) -> u64;
44}
45
46#[derive(Default)]
50pub struct SessionSpillStore {
51 map: Mutex<HashMap<String, String>>,
52 counter: AtomicU64,
53 offloaded_chars: AtomicU64,
54}
55
56impl SpillStore for SessionSpillStore {
57 fn store(&self, redacted: String) -> String {
58 let n = self.counter.fetch_add(1, Ordering::Relaxed);
59 let id = format!("s{n}");
60 self.offloaded_chars
61 .fetch_add(redacted.chars().count() as u64, Ordering::Relaxed);
62 self.map.lock().unwrap().insert(id.clone(), redacted);
63 id
64 }
65
66 fn fetch(&self, id: &str) -> Option<String> {
67 self.map.lock().unwrap().get(id).cloned()
68 }
69
70 fn spills(&self) -> u64 {
71 self.counter.load(Ordering::Relaxed)
72 }
73
74 fn offloaded_chars(&self) -> u64 {
75 self.offloaded_chars.load(Ordering::Relaxed)
76 }
77}
78
79fn head_tail_excerpt(redacted: &str, id: &str) -> String {
82 let chars: Vec<char> = redacted.chars().collect();
83 let total = chars.len();
84 let head: String = chars.iter().take(HEAD_CHARS).collect();
85 let tail: String = chars
86 .iter()
87 .skip(total.saturating_sub(TAIL_CHARS))
88 .collect();
89 format!(
90 "{head}\n\n[… tool output truncated: {total} chars offloaded. Use \
91 memory_fetch(\"spill:{id}\") to read the full (secret-redacted) payload …]\n\n{tail}"
92 )
93}
94
95pub fn maybe_offload(result: String, tool_offload: bool, spill: Option<&dyn SpillStore>) -> String {
101 let Some(store) = spill else {
102 return result;
103 };
104 if !tool_offload || result.chars().count() <= TOOL_RESULT_SPILL_CAP {
105 return result;
106 }
107 let redacted = redact_secrets(&result);
108 let id = store.store(redacted.clone());
109 head_tail_excerpt(&redacted, &id)
110}
111
112pub fn store_redacted_full(result: &str, spill: &dyn SpillStore) -> (String, String) {
117 let redacted = redact_secrets(result);
118 let id = spill.store(redacted.clone());
119 (id, redacted)
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn session_store_round_trips_with_monotonic_ids() {
128 let s = SessionSpillStore::default();
129 let id0 = s.store("alpha".to_string());
130 let id1 = s.store("beta".to_string());
131 assert_eq!(id0, "s0");
132 assert_eq!(id1, "s1");
133 assert_eq!(s.fetch("s0").as_deref(), Some("alpha"));
134 assert_eq!(s.fetch("s1").as_deref(), Some("beta"));
135 assert_eq!(s.fetch("s99"), None, "unknown id → None, no panic");
136 assert_eq!(s.spills(), 2);
137 assert_eq!(s.offloaded_chars(), 9); }
139
140 #[test]
141 fn maybe_offload_truth_table() {
142 let big = "x".repeat(TOOL_RESULT_SPILL_CAP + 1);
143
144 let s = SessionSpillStore::default();
146 assert_eq!(maybe_offload(big.clone(), false, Some(&s)), big);
147 assert_eq!(s.spills(), 0);
148
149 assert_eq!(maybe_offload(big.clone(), true, None), big);
151
152 let s = SessionSpillStore::default();
154 let small = "x".repeat(TOOL_RESULT_SPILL_CAP); assert_eq!(maybe_offload(small.clone(), true, Some(&s)), small);
156 assert_eq!(s.spills(), 0);
157
158 let s = SessionSpillStore::default();
160 let out = maybe_offload(big.clone(), true, Some(&s));
161 assert!(
162 out.contains("spill:s0"),
163 "teaser carries the handle: {out:.80}"
164 );
165 assert!(out.contains("memory_fetch"), "teaser coaches re-read");
166 assert!(
167 out.chars().count() < big.chars().count(),
168 "teaser is shorter"
169 );
170 assert_eq!(s.spills(), 1);
171 assert_eq!(s.fetch("s0").as_deref(), Some(big.as_str()));
173 }
174
175 #[test]
176 fn offload_redacts_before_store_and_in_teaser() {
177 let secret = "sk-ABCDEFGHIJKLMNOPQRST0123";
179 let payload = format!(
180 "{}\n{secret}\n{}",
181 "head ".repeat(2_000),
182 "tail ".repeat(2_000)
183 );
184 assert!(payload.chars().count() > TOOL_RESULT_SPILL_CAP);
185 let s = SessionSpillStore::default();
186 let teaser = maybe_offload(payload, true, Some(&s));
187 let stored = s.fetch("s0").expect("payload was stored");
188 assert!(stored.contains("[REDACTED]"), "stored payload is redacted");
189 assert!(
190 !stored.contains(secret),
191 "raw secret NOT retained in the store"
192 );
193 assert!(
194 !teaser.contains(secret),
195 "raw secret NOT shown in the teaser"
196 );
197 }
198}