Skip to main content

newt_core/agentic/
spill.rs

1//! Tool-output offloading — the `tool_offload` context feature (Step 26.3, #584).
2//!
3//! When a single tool result exceeds [`TOOL_RESULT_SPILL_CAP`] **and** the
4//! feature is on, the FULL payload is redacted via [`redact_secrets`] and stored
5//! in a session [`SpillStore`] keyed by a short id; a head+tail excerpt plus a
6//! `spill:<id>` handle is injected into context in its place. The model re-reads
7//! the full (redacted) payload via `memory_fetch("spill:<id>")`.
8//!
9//! **Redact-on-store (the security contract):** the raw result is redacted
10//! BEFORE anything is stored or shown, and the un-redacted string is dropped
11//! immediately after — only the redacted copy is ever retained or displayed, so
12//! no raw secret reaches disk-or-context. `redact_secrets` is a closed,
13//! high-precision pattern table (it won't catch novel secret shapes — the same
14//! accepted limitation as the summarizer path).
15
16use crate::agentic::compress::redact_secrets;
17use std::collections::HashMap;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::Mutex;
20
21/// Offload trigger: a tool result longer than this many chars spills. ~4k tokens
22/// at the codebase's chars/4 heuristic (cf. `SUMMARY_INPUT_MSG_CAP` = 2_000).
23pub const TOOL_RESULT_SPILL_CAP: usize = 16_000;
24
25/// Chars kept from the head / tail of an offloaded payload in the teaser. Kept
26/// well under [`TOOL_RESULT_SPILL_CAP`] so the teaser can never re-overflow.
27const HEAD_CHARS: usize = 800;
28const TAIL_CHARS: usize = 800;
29
30/// A session store for offloaded (already-redacted) tool payloads (Step 26.3).
31///
32/// Methods take `&self` (interior mutability) so a single shared
33/// `&dyn SpillStore` serves BOTH the loop's write path and the `memory_fetch`
34/// read path without the `&mut dyn _` reborrow/invariance dance.
35pub trait SpillStore: Send + Sync {
36    /// Store an already-redacted payload; returns its `spill:` id.
37    fn store(&self, redacted: String) -> String;
38    /// Fetch a stored payload by id (`None` if unknown / expired).
39    fn fetch(&self, id: &str) -> Option<String>;
40    /// Number of payloads offloaded this session (for `/context stats`).
41    fn spills(&self) -> u64;
42    /// Total chars elided from context by offloading (for `/context stats`).
43    fn offloaded_chars(&self) -> u64;
44}
45
46/// In-memory, session-scoped [`SpillStore`] — pure (no filesystem), discarded at
47/// session end / `/new`. Ids are monotonic (`s0`, `s1`, …) so injected handles
48/// are deterministic and unit-testable (no uuid, no clock).
49#[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
79/// The teaser injected in place of an offloaded payload: head + a re-read marker
80/// + tail. Already-redacted input; kept short so it cannot re-overflow.
81fn 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
95/// Offload an oversized tool result (Step 26.3). Returns `result` UNCHANGED when
96/// the feature is off, no spill store is provided, or the result is under the
97/// cap (the bit-for-bit OFF path). Otherwise redacts → stores → returns a
98/// head+tail teaser carrying the `spill:<id>` handle. The raw `result` is
99/// consumed and dropped; only its redacted form is retained or shown.
100pub 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
112/// Redact and store a full payload, returning `(id, redacted_payload)` so a
113/// caller can build its own model-facing teaser from the exact bytes that were
114/// stored. Used by `run_command` before its model-facing cap, so the spill store
115/// sees the true tail instead of an already-truncated result.
116pub 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); // "alpha"(5) + "beta"(4)
138    }
139
140    #[test]
141    fn maybe_offload_truth_table() {
142        let big = "x".repeat(TOOL_RESULT_SPILL_CAP + 1);
143
144        // (a) feature OFF + over-cap → unchanged, store untouched
145        let s = SessionSpillStore::default();
146        assert_eq!(maybe_offload(big.clone(), false, Some(&s)), big);
147        assert_eq!(s.spills(), 0);
148
149        // (b) no store + over-cap → unchanged, no panic
150        assert_eq!(maybe_offload(big.clone(), true, None), big);
151
152        // (c) ON + Some + UNDER cap → unchanged, store untouched
153        let s = SessionSpillStore::default();
154        let small = "x".repeat(TOOL_RESULT_SPILL_CAP); // == cap is NOT over
155        assert_eq!(maybe_offload(small.clone(), true, Some(&s)), small);
156        assert_eq!(s.spills(), 0);
157
158        // (d) ON + Some + OVER cap → teaser with spill: handle, shorter, stored
159        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        // the STORED value is the redacted full payload (here no secret → == big)
172        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        // A planted secret in an over-cap payload must never survive raw.
178        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}