Skip to main content

agent_engine/runtime/
tool_spill.rs

1//! Tool output virtualisation.
2//!
3//! Once a tool result is in model history it rides every later request. A
4//! text result bigger than `tool_output_spill_bytes` is therefore stored
5//! whole in a private spill file (`<base>/spill/<session>/<tool_use_id>.txt`,
6//! layout and retention in [`agent_core::core::tool_spill`]), and history
7//! gets a short preview instead: the first `tool_output_spill_head_bytes`
8//! and the last `tool_output_spill_tail_bytes` of the result, cut at line
9//! boundaries where possible, and a note naming the file and the omitted
10//! line range so the model can `read` it with offset/limit or `grep` it.
11//! The legacy cut (`max_tool_output`, head only) loses the tail, which is
12//! usually where the errors are; this loses nothing.
13//!
14//! Rules:
15//!
16//! - **Off is legacy.** A `0` threshold ([`ToolSpillPolicy::OFF`]) makes
17//!   [`ToolSpill::virtualise`] return `None` before touching anything, so
18//!   every caller falls through to the unchanged legacy path.
19//! - **Deterministic.** The preview is a pure function of the result, the
20//!   two budgets and the file path (session dir + tool_use_id): no
21//!   timestamps, no random ids. History bytes, and the prompt cache over
22//!   them, stay stable.
23//! - **Fail-soft.** A spill that cannot be written (unwritable base dir,
24//!   session cap reached) returns `None`: the caller uses the legacy cut,
25//!   never a pointer to a file that does not exist.
26//! - **`read` is never copied.** The model can already read that file by
27//!   range, so its preview points back at the file itself with the
28//!   offset/limit of the omitted lines. A `read` with an explicit `limit`
29//!   is a deliberate range request and is left alone.
30//! - **Rich results are untouched.** Callers only pass plain-text content
31//!   (no image/document blocks).
32
33use std::io::Write as _;
34use std::path::{Path, PathBuf};
35use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
36use std::sync::Arc;
37
38use serde_json::Value;
39
40/// A spill file keeps at most this many bytes of a result (then a note).
41pub const SPILL_FILE_CAP_BYTES: usize = 16 * 1024 * 1024;
42
43/// One session (runtime) writes at most this many spill bytes; past it,
44/// results fall back to the legacy cut. A safety net against a loop that
45/// re-runs a huge command, not a budget anyone should meet.
46pub const SPILL_SESSION_CAP_BYTES: u64 = 256 * 1024 * 1024;
47
48/// Builtin tools whose results are output dumps (command output, search
49/// hits, listings, worker reports) and may be spilled. Everything else
50/// builtin (`edit`, `write`, `load_skill`, memory and forum tools, …)
51/// returns content the model asked for in full, and keeps the legacy cut.
52const SPILL_BUILTINS: &[&str] = &[
53    "bash",
54    "powershell",
55    "shell_start",
56    "shell_send",
57    "shell_end",
58    "grep",
59    "find",
60    "ls",
61    "subagent",
62    "subagent_collect",
63    "subagent_resume",
64    "subagent_status",
65];
66
67/// Thresholds from `tool_output_spill_*`.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct ToolSpillPolicy {
70    /// Results strictly bigger than this are virtualised. `0` = off.
71    pub threshold_bytes: usize,
72    /// Bytes of the result's start kept in the preview.
73    pub head_bytes: usize,
74    /// Bytes of the result's end kept in the preview.
75    pub tail_bytes: usize,
76}
77
78impl ToolSpillPolicy {
79    /// Virtualisation off: every result takes the legacy path.
80    pub const OFF: Self = Self {
81        threshold_bytes: 0,
82        head_bytes: agent_core::config::DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES,
83        tail_bytes: agent_core::config::DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES,
84    };
85
86    pub fn from_config(config: &agent_core::config::SynapsConfig) -> Self {
87        Self {
88            threshold_bytes: config.tool_output_spill_bytes,
89            head_bytes: config.tool_output_spill_head_bytes,
90            tail_bytes: config.tool_output_spill_tail_bytes,
91        }
92    }
93
94    pub fn is_enabled(&self) -> bool {
95        self.threshold_bytes > 0
96    }
97}
98
99impl Default for ToolSpillPolicy {
100    fn default() -> Self {
101        Self::OFF
102    }
103}
104
105/// Session-cumulative counters, shared by a runtime and its clones.
106/// Metadata only.
107#[derive(Debug, Default)]
108pub struct ToolSpillStats {
109    virtualised: AtomicU64,
110    original_bytes: AtomicU64,
111    preview_bytes: AtomicU64,
112    file_bytes: AtomicU64,
113    failure_warned: AtomicBool,
114}
115
116/// Point-in-time copy of [`ToolSpillStats`].
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
118pub struct ToolSpillTotals {
119    /// Results whose history entry is a preview.
120    pub virtualised: u64,
121    /// Their full size.
122    pub original_bytes: u64,
123    /// Their preview size in history.
124    pub preview_bytes: u64,
125    /// Bytes written to spill files (`read` previews write none).
126    pub file_bytes: u64,
127}
128
129impl ToolSpillTotals {
130    /// Bytes kept out of history: `original - preview`.
131    pub fn kept_out_bytes(&self) -> u64 {
132        self.original_bytes.saturating_sub(self.preview_bytes)
133    }
134}
135
136impl ToolSpillStats {
137    pub fn totals(&self) -> ToolSpillTotals {
138        ToolSpillTotals {
139            virtualised: self.virtualised.load(Ordering::Relaxed),
140            original_bytes: self.original_bytes.load(Ordering::Relaxed),
141            preview_bytes: self.preview_bytes.load(Ordering::Relaxed),
142            file_bytes: self.file_bytes.load(Ordering::Relaxed),
143        }
144    }
145}
146
147/// How one call's result may be virtualised, decided from the tool and its
148/// (post-`before_tool_call`) input before it runs.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum SpillTarget {
151    /// Keep the legacy cut.
152    Never,
153    /// Store the whole result in a spill file.
154    Spill,
155    /// `read` without an explicit `limit`: point back at this file.
156    ReadFile(PathBuf),
157}
158
159impl SpillTarget {
160    /// `runtime_name` is the tool's runtime (not API-safe) name. Extension,
161    /// MCP and plugin tools spill; builtins only from [`SPILL_BUILTINS`];
162    /// tools of unknown origin keep the legacy cut.
163    pub fn classify(
164        runtime_name: &str,
165        origin: &crate::tools::ToolOrigin,
166        input: &Value,
167        cwd: Option<&Path>,
168    ) -> Self {
169        use crate::tools::ToolOrigin;
170        match origin {
171            ToolOrigin::Builtin if runtime_name == "read" => {
172                let ranged = input.get("limit").is_some_and(|limit| !limit.is_null());
173                match input.get("path").and_then(Value::as_str) {
174                    Some(path) if !ranged => {
175                        SpillTarget::ReadFile(crate::tools::resolve_path_in(path, cwd))
176                    }
177                    _ => SpillTarget::Never,
178                }
179            }
180            ToolOrigin::Builtin if SPILL_BUILTINS.contains(&runtime_name) => SpillTarget::Spill,
181            ToolOrigin::Builtin | ToolOrigin::Unknown => SpillTarget::Never,
182            ToolOrigin::Extension { .. } | ToolOrigin::Mcp { .. } | ToolOrigin::Plugin { .. } => {
183                SpillTarget::Spill
184            }
185        }
186    }
187}
188
189/// A virtualised result: what history carries instead of the full text.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct Virtualised {
192    pub preview: String,
193    pub original_bytes: usize,
194    /// The spill file, `None` for a `read` preview (it points at the file).
195    pub spill_path: Option<PathBuf>,
196}
197
198/// One session's virtualiser: policy, spill dir, shared counters. Cheap to
199/// clone; built per stream from the runtime's bound spill root.
200#[derive(Debug, Clone)]
201pub struct ToolSpill {
202    policy: ToolSpillPolicy,
203    /// `<root>/<session>`; `None` when the session key is not a safe path
204    /// component (spills then fall back; `read` previews still work).
205    dir: Option<PathBuf>,
206    stats: Arc<ToolSpillStats>,
207    file_cap: usize,
208    session_cap: u64,
209}
210
211impl Default for ToolSpill {
212    fn default() -> Self {
213        Self::off()
214    }
215}
216
217impl ToolSpill {
218    /// Virtualisation off.
219    pub fn off() -> Self {
220        Self {
221            policy: ToolSpillPolicy::OFF,
222            dir: None,
223            stats: Arc::default(),
224            file_cap: SPILL_FILE_CAP_BYTES,
225            session_cap: SPILL_SESSION_CAP_BYTES,
226        }
227    }
228
229    /// Spill into `<root>/<session_key>/`.
230    pub fn new(
231        policy: ToolSpillPolicy,
232        root: &Path,
233        session_key: &str,
234        stats: Arc<ToolSpillStats>,
235    ) -> Self {
236        let dir = agent_core::core::tool_spill::session_component(session_key)
237            .map(|component| root.join(component));
238        Self {
239            policy,
240            dir,
241            stats,
242            file_cap: SPILL_FILE_CAP_BYTES,
243            session_cap: SPILL_SESSION_CAP_BYTES,
244        }
245    }
246
247    #[cfg(test)]
248    pub(crate) fn with_caps(mut self, file_cap: usize, session_cap: u64) -> Self {
249        self.file_cap = file_cap;
250        self.session_cap = session_cap;
251        self
252    }
253
254    pub fn is_enabled(&self) -> bool {
255        self.policy.is_enabled()
256    }
257
258    pub fn policy(&self) -> ToolSpillPolicy {
259        self.policy
260    }
261
262    /// This session's spill dir, when it has a valid one.
263    pub fn dir(&self) -> Option<&Path> {
264        self.dir.as_deref()
265    }
266
267    pub fn totals(&self) -> ToolSpillTotals {
268        self.stats.totals()
269    }
270
271    /// The history text for a finished call's plain-text result, or `None`
272    /// to keep the legacy path: off, not a virtualised tool, not bigger than
273    /// the threshold, nothing to omit, or the spill could not be written.
274    pub fn virtualise(
275        &self,
276        tool_use_id: &str,
277        target: &SpillTarget,
278        text: &str,
279    ) -> Option<Virtualised> {
280        if !self.policy.is_enabled() || text.len() <= self.policy.threshold_bytes {
281            return None;
282        }
283        let cut = Cut::new(text, self.policy.head_bytes, self.policy.tail_bytes)?;
284        let virtualised = match target {
285            SpillTarget::Never => return None,
286            SpillTarget::ReadFile(path) => Virtualised {
287                preview: read_preview(text, &cut, path),
288                original_bytes: text.len(),
289                spill_path: None,
290            },
291            SpillTarget::Spill => {
292                let (path, capped) = match self.write_spill(tool_use_id, text) {
293                    Ok(written) => written,
294                    Err(error) => {
295                        if !self.stats.failure_warned.swap(true, Ordering::Relaxed) {
296                            // Metadata only: our own dir and the error.
297                            tracing::warn!(
298                                dir = ?self.dir,
299                                %error,
300                                "tool output spill failed; big results keep the legacy cut",
301                            );
302                        }
303                        return None;
304                    }
305                };
306                Virtualised {
307                    preview: spill_preview(text, &cut, &path, capped.then_some(self.file_cap)),
308                    original_bytes: text.len(),
309                    spill_path: Some(path),
310                }
311            }
312        };
313        self.stats.virtualised.fetch_add(1, Ordering::Relaxed);
314        self.stats
315            .original_bytes
316            .fetch_add(text.len() as u64, Ordering::Relaxed);
317        self.stats
318            .preview_bytes
319            .fetch_add(virtualised.preview.len() as u64, Ordering::Relaxed);
320        tracing::debug!(
321            tool_use_id,
322            original_bytes = text.len(),
323            preview_bytes = virtualised.preview.len(),
324            spilled = virtualised.spill_path.is_some(),
325            "tool output virtualised",
326        );
327        Some(virtualised)
328    }
329
330    /// Write `text` (capped) to a new private file in the session dir.
331    /// Returns its path and whether the cap cut it.
332    fn write_spill(&self, tool_use_id: &str, text: &str) -> std::io::Result<(PathBuf, bool)> {
333        let dir = self.dir.as_deref().ok_or_else(|| {
334            std::io::Error::new(std::io::ErrorKind::InvalidInput, "no valid spill session dir")
335        })?;
336        let capped = text.len() > self.file_cap;
337        let body = if capped {
338            agent_core::truncate_str(text, self.file_cap)
339        } else {
340            text
341        };
342        let note = capped.then(|| {
343            format!(
344                "\n[spill file cut at {} of {} bytes]\n",
345                body.len(),
346                text.len()
347            )
348        });
349        let size = (body.len() + note.as_ref().map_or(0, String::len)) as u64;
350        let before = self.stats.file_bytes.fetch_add(size, Ordering::Relaxed);
351        if before.saturating_add(size) > self.session_cap {
352            self.stats.file_bytes.fetch_sub(size, Ordering::Relaxed);
353            return Err(std::io::Error::other(format!(
354                "session spill cap of {} bytes reached",
355                self.session_cap
356            )));
357        }
358        let written = (|| {
359            if let Some(root) = dir.parent() {
360                agent_core::core::private_fs::ensure_private_dir(root)?;
361            }
362            agent_core::core::private_fs::ensure_private_dir(dir)?;
363            let (path, mut file) = create_spill_file(dir, &file_stem(tool_use_id))?;
364            file.write_all(body.as_bytes())?;
365            if let Some(note) = &note {
366                file.write_all(note.as_bytes())?;
367            }
368            Ok::<_, std::io::Error>(path)
369        })();
370        match written {
371            Ok(path) => Ok((path, capped)),
372            Err(error) => {
373                self.stats.file_bytes.fetch_sub(size, Ordering::Relaxed);
374                Err(error)
375            }
376        }
377    }
378}
379
380/// A tool_use_id as a file stem: `[A-Za-z0-9_-]` kept, anything else `_`,
381/// at most 96 bytes, never empty.
382fn file_stem(tool_use_id: &str) -> String {
383    let stem: String = tool_use_id
384        .chars()
385        .take(96)
386        .map(|c| {
387            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
388                c
389            } else {
390                '_'
391            }
392        })
393        .collect();
394    if stem.is_empty() {
395        "tool".to_string()
396    } else {
397        stem
398    }
399}
400
401/// Create `<stem>.txt` (or `<stem>-N.txt` when taken: providers that reuse
402/// ids like `call_0`, or two ids that sanitize alike) as a NEW `0600`
403/// file, never following a symlink. An existing file is never reused, so
404/// an earlier preview's pointer keeps pointing at its own output.
405fn create_spill_file(dir: &Path, stem: &str) -> std::io::Result<(PathBuf, std::fs::File)> {
406    let mut last = None;
407    for n in 0..16 {
408        let name = if n == 0 {
409            format!("{stem}.txt")
410        } else {
411            format!("{stem}-{n}.txt")
412        };
413        let path = dir.join(name);
414        match create_new_private(&path) {
415            Ok(file) => return Ok((path, file)),
416            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = Some(e),
417            Err(e) => return Err(e),
418        }
419    }
420    Err(last.unwrap_or_else(|| std::io::Error::other("no free spill file name")))
421}
422
423fn create_new_private(path: &Path) -> std::io::Result<std::fs::File> {
424    let mut options = std::fs::OpenOptions::new();
425    options.write(true).create_new(true);
426    #[cfg(unix)]
427    {
428        use std::os::unix::fs::OpenOptionsExt;
429        options
430            .mode(agent_core::core::private_fs::FILE_MODE)
431            .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
432    }
433    options.open(path)
434}
435
436/// Where a result is cut: `text[..head_end]` and `text[tail_start..]` are
437/// kept, with line numbers for the note. Both cuts are on UTF-8 char
438/// boundaries; each moves to a line boundary when that keeps at least half
439/// its budget (one huge line is cut mid-line instead of dropped).
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441struct Cut {
442    head_end: usize,
443    tail_start: usize,
444    /// Lines in the whole text (a final line without `\n` counts).
445    total_lines: usize,
446    /// First and last line (1-based) touched by the omitted bytes.
447    omitted_first: usize,
448    omitted_last: usize,
449}
450
451impl Cut {
452    /// `None` when head + tail already cover the text (nothing to omit).
453    fn new(text: &str, head: usize, tail: usize) -> Option<Self> {
454        let len = text.len();
455        if head.saturating_add(tail) >= len {
456            return None;
457        }
458        let bytes = text.as_bytes();
459
460        let mut head_end = floor_char_boundary(text, head);
461        if let Some(nl) = bytes[..head_end].iter().rposition(|&b| b == b'\n') {
462            if 2 * (nl + 1) >= head_end {
463                head_end = nl + 1;
464            }
465        }
466
467        let mut tail_start = ceil_char_boundary(text, len - tail);
468        if tail_start < len && bytes[tail_start - 1] != b'\n' {
469            if let Some(off) = bytes[tail_start..].iter().position(|&b| b == b'\n') {
470                let line_start = tail_start + off + 1;
471                if 2 * (len - line_start) >= len - tail_start {
472                    tail_start = line_start;
473                }
474            }
475        }
476        debug_assert!(head_end < tail_start, "head < len - tail <= tail_start");
477
478        let newlines = |range: std::ops::Range<usize>| {
479            bytes[range].iter().filter(|&&b| b == b'\n').count()
480        };
481        let before_omitted = newlines(0..head_end);
482        let within_omitted = newlines(head_end..tail_start - 1);
483        let after = newlines(tail_start - 1..len);
484        let total_lines =
485            before_omitted + within_omitted + after + usize::from(!text.ends_with('\n'));
486        Some(Self {
487            head_end,
488            tail_start,
489            total_lines,
490            omitted_first: before_omitted + 1,
491            omitted_last: before_omitted + within_omitted + 1,
492        })
493    }
494
495    fn head<'a>(&self, text: &'a str) -> &'a str {
496        &text[..self.head_end]
497    }
498
499    fn tail<'a>(&self, text: &'a str) -> &'a str {
500        &text[self.tail_start..]
501    }
502
503    fn omitted_lines(&self) -> usize {
504        self.omitted_last - self.omitted_first + 1
505    }
506}
507
508fn floor_char_boundary(s: &str, mut i: usize) -> usize {
509    i = i.min(s.len());
510    while !s.is_char_boundary(i) {
511        i -= 1;
512    }
513    i
514}
515
516fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
517    while i < s.len() && !s.is_char_boundary(i) {
518        i += 1;
519    }
520    i
521}
522
523/// `head`, the gap marker, `tail`, then the note.
524fn assemble(text: &str, cut: &Cut, marker: &str, note: &str) -> String {
525    let (head, tail) = (cut.head(text), cut.tail(text));
526    let mut out = String::with_capacity(head.len() + tail.len() + marker.len() + note.len() + 8);
527    out.push_str(head);
528    if !head.is_empty() && !head.ends_with('\n') {
529        out.push('\n');
530    }
531    out.push_str(marker);
532    out.push('\n');
533    out.push_str(tail);
534    if !tail.ends_with('\n') {
535        out.push('\n');
536    }
537    out.push('\n');
538    out.push_str(note);
539    out
540}
541
542fn spill_preview(text: &str, cut: &Cut, path: &Path, capped_at: Option<usize>) -> String {
543    let (first, last) = (cut.omitted_first, cut.omitted_last);
544    let marker = format!("[... lines {first}-{last} omitted ...]");
545    let capped = capped_at
546        .map(|cap| format!(" The file keeps the first {cap} bytes."))
547        .unwrap_or_default();
548    let note = format!(
549        "[Output virtualised: {} bytes, {} lines; lines {first}-{last} are omitted above. \
550         Full output: {} (read it with offset={} limit={}, or grep it).{capped}]",
551        text.len(),
552        cut.total_lines,
553        path.display(),
554        first - 1,
555        cut.omitted_lines(),
556    );
557    assemble(text, cut, &marker, &note)
558}
559
560/// `read` output lines are `<file line>\t<content>`: map the omitted
561/// output lines back to file lines so the hint is an exact offset/limit.
562fn read_preview(text: &str, cut: &Cut, path: &Path) -> String {
563    let file_line_at = |pos: usize| -> Option<usize> {
564        let start = text[..pos].rfind('\n').map_or(0, |nl| nl + 1);
565        let digits = text[start..].split('\t').next()?;
566        digits.parse::<usize>().ok().filter(|n| *n > 0)
567    };
568    let file_range = file_line_at(cut.head_end)
569        .zip(file_line_at(cut.tail_start - 1))
570        .filter(|(first, last)| first <= last);
571    match file_range {
572        Some((first, last)) => {
573            let marker = format!("[... file lines {first}-{last} omitted ...]");
574            let note = format!(
575                "[Output virtualised: {} bytes; file lines {first}-{last} are omitted above. \
576                 Read {} with offset={} limit={} to see them.]",
577                text.len(),
578                path.display(),
579                first - 1,
580                last - first + 1,
581            );
582            assemble(text, cut, &marker, &note)
583        }
584        None => {
585            let (first, last) = (cut.omitted_first, cut.omitted_last);
586            let marker = format!("[... lines {first}-{last} omitted ...]");
587            let note = format!(
588                "[Output virtualised: {} bytes, {} lines; lines {first}-{last} of this output \
589                 are omitted above. Read {} with offset/limit to see the rest.]",
590                text.len(),
591                cut.total_lines,
592                path.display(),
593            );
594            assemble(text, cut, &marker, &note)
595        }
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use crate::tools::ToolOrigin;
603    use serde_json::json;
604
605    fn policy(threshold: usize, head: usize, tail: usize) -> ToolSpillPolicy {
606        ToolSpillPolicy {
607            threshold_bytes: threshold,
608            head_bytes: head,
609            tail_bytes: tail,
610        }
611    }
612
613    fn spill_in(root: &Path, policy: ToolSpillPolicy) -> ToolSpill {
614        ToolSpill::new(policy, root, "sess-1", Arc::default())
615    }
616
617    /// `n` numbered lines of `width` bytes each (including `\n`).
618    fn lines(n: usize, width: usize) -> String {
619        (1..=n)
620            .map(|i| {
621                let label = format!("line {i:05} ");
622                format!("{label}{}\n", "x".repeat(width - label.len() - 1))
623            })
624            .collect()
625    }
626
627    #[test]
628    fn threshold_is_exclusive_at_exactly_and_virtualises_one_over() {
629        let tmp = tempfile::tempdir().unwrap();
630        let spill = spill_in(tmp.path(), policy(1000, 100, 100));
631        let exact = "a".repeat(1000);
632        assert_eq!(spill.virtualise("t1", &SpillTarget::Spill, &exact), None);
633        let over = "a".repeat(1001);
634        let v = spill.virtualise("t2", &SpillTarget::Spill, &over).expect("one over spills");
635        assert_eq!(v.original_bytes, 1001);
636        assert_eq!(std::fs::read_to_string(v.spill_path.unwrap()).unwrap(), over);
637    }
638
639    #[test]
640    fn nothing_to_omit_keeps_the_legacy_path() {
641        let tmp = tempfile::tempdir().unwrap();
642        // Over the threshold, but head + tail already cover all of it.
643        let spill = spill_in(tmp.path(), policy(10, 600, 600));
644        assert_eq!(spill.virtualise("t", &SpillTarget::Spill, &"z".repeat(1200)), None);
645        assert!(!tmp.path().join("sess-1").exists(), "no file written");
646    }
647
648    #[test]
649    fn off_and_never_touch_nothing() {
650        let tmp = tempfile::tempdir().unwrap();
651        let big = lines(500, 80);
652        let off = ToolSpill::new(ToolSpillPolicy::OFF, tmp.path(), "sess-1", Arc::default());
653        assert_eq!(off.virtualise("t", &SpillTarget::Spill, &big), None);
654        assert_eq!(ToolSpill::off().virtualise("t", &SpillTarget::Spill, &big), None);
655        let on = spill_in(tmp.path(), policy(1024, 256, 256));
656        assert_eq!(on.virtualise("t", &SpillTarget::Never, &big), None);
657        assert!(std::fs::read_dir(tmp.path()).unwrap().next().is_none(), "nothing written");
658        assert_eq!(on.totals(), ToolSpillTotals::default());
659    }
660
661    #[test]
662    fn cuts_land_on_line_boundaries_and_name_the_omitted_lines() {
663        let tmp = tempfile::tempdir().unwrap();
664        let text = lines(200, 50); // 10_000 bytes, 200 lines
665        let spill = spill_in(tmp.path(), policy(4096, 1030, 520));
666        let v = spill.virtualise("toolu_x", &SpillTarget::Spill, &text).unwrap();
667        // 1030 bytes of head = 20 whole lines (1000 bytes); 520 of tail =
668        // 10 whole lines (500 bytes): the partial lines are not kept.
669        let head = lines(20, 50);
670        let tail: String = text[text.len() - 500..].to_string();
671        let path = tmp.path().join("sess-1").join("toolu_x.txt");
672        let expected = format!(
673            "{head}[... lines 21-190 omitted ...]\n{tail}\n\
674             [Output virtualised: 10000 bytes, 200 lines; lines 21-190 are omitted above. \
675             Full output: {} (read it with offset=20 limit=170, or grep it).]",
676            path.display()
677        );
678        assert_eq!(v.preview, expected);
679        assert!(tail.starts_with("line 00191 "));
680        // The hint is exact: the spill file's lines 21..=190 are the gap.
681        let file = std::fs::read_to_string(&path).unwrap();
682        let gap: Vec<&str> = file.lines().skip(20).take(170).collect();
683        assert!(gap[0].starts_with("line 00021 ") && gap[169].starts_with("line 00190 "));
684    }
685
686    #[test]
687    fn one_huge_line_is_cut_mid_line_on_char_boundaries() {
688        let tmp = tempfile::tempdir().unwrap();
689        // Multibyte everywhere: 'é' is 2 bytes, '🦀' 4; no newline at all.
690        let text = "é🦀".repeat(5000); // 30_000 bytes
691        let spill = spill_in(tmp.path(), policy(1000, 1001, 999));
692        let v = spill.virtualise("t", &SpillTarget::Spill, &text).unwrap();
693        let cut = Cut::new(&text, 1001, 999).unwrap();
694        assert!(text.is_char_boundary(cut.head_end) && text.is_char_boundary(cut.tail_start));
695        assert!(cut.head_end <= 1001 && cut.head_end > 990, "{}", cut.head_end);
696        assert!(text.len() - cut.tail_start <= 999 && text.len() - cut.tail_start > 990);
697        assert!(v.preview.starts_with(cut.head(&text)));
698        assert!(v.preview.contains(&format!("\n[... lines 1-1 omitted ...]\n{}\n", cut.tail(&text))));
699        assert_eq!(cut.total_lines, 1);
700    }
701
702    #[test]
703    fn a_short_first_line_does_not_waste_the_head_budget() {
704        // The only newline in the head budget is near its start: cutting
705        // there would keep 4 of 1000 bytes, so the head is cut mid-line.
706        let text = format!("abc\n{}\n{}", "y".repeat(5000), lines(10, 40));
707        let cut = Cut::new(&text, 1000, 200).unwrap();
708        assert_eq!(cut.head_end, 1000);
709        assert_eq!(cut.omitted_first, 2, "line 2 is partly shown and partly omitted");
710        assert!(cut.tail(&text).starts_with("line 00006 "), "tail cut moved to a line start");
711    }
712
713    #[test]
714    fn preview_is_deterministic() {
715        let tmp = tempfile::tempdir().unwrap();
716        let text = lines(400, 64);
717        let spill = spill_in(tmp.path(), policy(12_288, 4096, 3072));
718        let first = spill.virtualise("toolu_same", &SpillTarget::Spill, &text).unwrap();
719        // Same output, same tool_use_id, same session dir: identical bytes.
720        std::fs::remove_file(first.spill_path.as_ref().unwrap()).unwrap();
721        let again = spill_in(tmp.path(), policy(12_288, 4096, 3072));
722        let second = again.virtualise("toolu_same", &SpillTarget::Spill, &text).unwrap();
723        assert_eq!(first.preview.as_bytes(), second.preview.as_bytes());
724        let cut = Cut::new(&text, 4096, 3072).unwrap();
725        let path = tmp.path().join("p.txt");
726        assert_eq!(spill_preview(&text, &cut, &path, None), spill_preview(&text, &cut, &path, None));
727    }
728
729    #[test]
730    fn default_budgets_shrink_a_25kb_result_to_about_7kb() {
731        let tmp = tempfile::tempdir().unwrap();
732        let config = agent_core::config::SynapsConfig::default();
733        let spill = spill_in(tmp.path(), ToolSpillPolicy::from_config(&config));
734        let text = lines(320, 80); // 25_600 bytes
735        let v = spill.virtualise("toolu_big", &SpillTarget::Spill, &text).unwrap();
736        assert!(v.preview.len() < 7 * 1024 + 400, "{}", v.preview.len());
737        assert!(v.preview.len() > 6 * 1024, "{}", v.preview.len());
738        let totals = spill.totals();
739        assert_eq!(totals.virtualised, 1);
740        assert_eq!(totals.original_bytes, 25_600);
741        assert_eq!(totals.preview_bytes, v.preview.len() as u64);
742        assert_eq!(totals.file_bytes, 25_600);
743        assert_eq!(totals.kept_out_bytes(), 25_600 - v.preview.len() as u64);
744    }
745
746    #[cfg(unix)]
747    #[test]
748    fn spill_dirs_are_0700_and_files_0600() {
749        use std::os::unix::fs::PermissionsExt;
750        let tmp = tempfile::tempdir().unwrap();
751        let root = tmp.path().join("spill");
752        let spill = ToolSpill::new(policy(100, 10, 10), &root, "sess-1", Arc::default());
753        let v = spill.virtualise("t", &SpillTarget::Spill, &"q".repeat(500)).unwrap();
754        let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
755        assert_eq!(mode(&root), 0o700);
756        assert_eq!(mode(&root.join("sess-1")), 0o700);
757        assert_eq!(mode(v.spill_path.as_ref().unwrap()), 0o600);
758    }
759
760    #[test]
761    fn per_file_cap_cuts_the_file_with_a_note_and_says_so() {
762        let tmp = tempfile::tempdir().unwrap();
763        let spill = spill_in(tmp.path(), policy(100, 20, 60)).with_caps(1000, u64::MAX);
764        let text = lines(100, 50); // 5_000 bytes
765        let v = spill.virtualise("t", &SpillTarget::Spill, &text).unwrap();
766        let file = std::fs::read_to_string(v.spill_path.as_ref().unwrap()).unwrap();
767        assert!(file.starts_with(&text[..1000]));
768        assert!(file.ends_with("\n[spill file cut at 1000 of 5000 bytes]\n"), "{file:?}");
769        assert!(v.preview.ends_with(" The file keeps the first 1000 bytes.]"));
770        // The preview still carries the real tail.
771        assert!(v.preview.contains("line 00100 "));
772    }
773
774    #[test]
775    fn session_cap_falls_back_to_the_legacy_cut() {
776        let tmp = tempfile::tempdir().unwrap();
777        let spill = spill_in(tmp.path(), policy(100, 20, 20)).with_caps(usize::MAX, 1500);
778        let text = "w".repeat(1000);
779        assert!(spill.virtualise("a", &SpillTarget::Spill, &text).is_some());
780        assert_eq!(spill.virtualise("b", &SpillTarget::Spill, &text), None, "over the cap");
781        assert_eq!(spill.totals().file_bytes, 1000);
782        assert!(!tmp.path().join("sess-1/b.txt").exists());
783    }
784
785    #[test]
786    fn unwritable_or_invalid_dirs_fall_back_instead_of_pointing_nowhere() {
787        let tmp = tempfile::tempdir().unwrap();
788        // The "root" is a regular file: no dir can be made under it.
789        let blocker = tmp.path().join("blocker");
790        std::fs::write(&blocker, "x").unwrap();
791        let spill = ToolSpill::new(policy(100, 10, 10), &blocker, "sess-1", Arc::default());
792        assert_eq!(spill.virtualise("t", &SpillTarget::Spill, &"q".repeat(500)), None);
793        assert_eq!(spill.totals(), ToolSpillTotals::default());
794        // A session key that is not one path component never becomes a dir.
795        let spill = ToolSpill::new(policy(100, 10, 10), tmp.path(), "../escape", Arc::default());
796        assert_eq!(spill.dir(), None);
797        assert_eq!(spill.virtualise("t", &SpillTarget::Spill, &"q".repeat(500)), None);
798    }
799
800    #[test]
801    fn reused_tool_use_ids_never_overwrite_an_earlier_spill() {
802        let tmp = tempfile::tempdir().unwrap();
803        let spill = spill_in(tmp.path(), policy(100, 10, 10));
804        let a = spill.virtualise("call_0", &SpillTarget::Spill, &"a".repeat(300)).unwrap();
805        let b = spill.virtualise("call_0", &SpillTarget::Spill, &"b".repeat(300)).unwrap();
806        let c = spill.virtualise("call/0", &SpillTarget::Spill, &"c".repeat(300)).unwrap();
807        let name = |v: &Virtualised| v.spill_path.as_ref().unwrap().file_name().unwrap().to_owned();
808        assert_eq!(name(&a), "call_0.txt");
809        assert_eq!(name(&b), "call_0-1.txt");
810        assert_eq!(name(&c), "call_0-2.txt", "sanitized alike: still a new file");
811        assert_eq!(std::fs::read_to_string(a.spill_path.unwrap()).unwrap(), "a".repeat(300));
812        assert_eq!(file_stem(""), "tool");
813        assert_eq!(file_stem("toolu_01AbC-9"), "toolu_01AbC-9");
814    }
815
816    #[test]
817    fn read_points_back_at_the_file_with_an_exact_offset_and_limit() {
818        let tmp = tempfile::tempdir().unwrap();
819        let file = tmp.path().join("big.rs");
820        let body: String = (1..=500).map(|i| format!("fn line_{i:03}() {{ /* {} */ }}\n", "r".repeat(40))).collect();
821        std::fs::write(&file, &body).unwrap();
822        // What `read` returns: `<n>\t<line>` (1-based).
823        let text: String = body.lines().enumerate().map(|(i, l)| format!("{}\t{l}\n", i + 1)).collect();
824        let spill = spill_in(tmp.path(), policy(12_288, 4096, 3072));
825        let target = SpillTarget::ReadFile(file.clone());
826        let v = spill.virtualise("toolu_r", &target, &text).unwrap();
827        assert_eq!(v.spill_path, None, "read is never copied");
828        assert!(!tmp.path().join("sess-1").exists(), "no spill dir either");
829        let cut = Cut::new(&text, 4096, 3072).unwrap();
830        let first: usize = text.lines().nth(cut.omitted_first - 1).unwrap().split('\t').next().unwrap().parse().unwrap();
831        let last: usize = text.lines().nth(cut.omitted_last - 1).unwrap().split('\t').next().unwrap().parse().unwrap();
832        assert!(v.preview.contains(&format!("[... file lines {first}-{last} omitted ...]")));
833        assert!(v.preview.ends_with(&format!(
834            "file lines {first}-{last} are omitted above. Read {} with offset={} limit={} to see them.]",
835            file.display(),
836            first - 1,
837            last - first + 1
838        )));
839        // That offset/limit names exactly the omitted file lines.
840        let omitted: Vec<&str> = body.lines().skip(first - 1).take(last - first + 1).collect();
841        assert!(omitted[0].starts_with(&format!("fn line_{first:03}")));
842        assert!(!v.preview.contains(omitted[0]));
843        assert_eq!(spill.totals().file_bytes, 0);
844    }
845
846    #[test]
847    fn read_output_without_line_numbers_still_points_at_the_file() {
848        let tmp = tempfile::tempdir().unwrap();
849        let spill = spill_in(tmp.path(), policy(1000, 100, 100));
850        let v = spill
851            .virtualise("t", &SpillTarget::ReadFile(PathBuf::from("/p/x.log")), &lines(100, 40))
852            .unwrap();
853        assert!(v.preview.ends_with(
854            "of this output are omitted above. Read /p/x.log with offset/limit to see the rest.]"
855        ));
856    }
857
858    #[test]
859    fn classify_spills_output_dumps_and_extension_mcp_tools_only() {
860        let builtin = ToolOrigin::Builtin;
861        let none = json!({});
862        for name in ["bash", "grep", "find", "ls", "shell_send", "subagent_collect"] {
863            assert_eq!(SpillTarget::classify(name, &builtin, &none, None), SpillTarget::Spill, "{name}");
864        }
865        // Mutation results (multi_edit's included: at most 64 short lines,
866        // each saying what changed) stay whole in history, never a preview.
867        for name in ["edit", "write", "multi_edit", "load_skill", "memory_fetch", "respond"] {
868            assert_eq!(SpillTarget::classify(name, &builtin, &none, None), SpillTarget::Never, "{name}");
869        }
870        let mcp = ToolOrigin::Mcp {
871            server_id: "s".into(),
872            server_tool_name: "t".into(),
873        };
874        let ext = ToolOrigin::Extension {
875            extension_id: "e".into(),
876        };
877        let plugin = ToolOrigin::Plugin {
878            plugin_id: "p".into(),
879            tool_name: "t".into(),
880        };
881        for origin in [&mcp, &ext, &plugin] {
882            assert_eq!(SpillTarget::classify("read", origin, &none, None), SpillTarget::Spill);
883        }
884        assert_eq!(SpillTarget::classify("bash", &ToolOrigin::Unknown, &none, None), SpillTarget::Never);
885        // read: the original path (cwd-relative), unless a limit was asked for.
886        let cwd = Path::new("/work");
887        assert_eq!(
888            SpillTarget::classify("read", &builtin, &json!({"path": "src/a.rs", "offset": 10}), Some(cwd)),
889            SpillTarget::ReadFile(PathBuf::from("/work/src/a.rs"))
890        );
891        assert_eq!(
892            SpillTarget::classify("read", &builtin, &json!({"path": "a.rs", "limit": 900}), Some(cwd)),
893            SpillTarget::Never
894        );
895        assert_eq!(
896            SpillTarget::classify("read", &builtin, &json!({"path": "a.rs", "limit": null}), Some(cwd)),
897            SpillTarget::ReadFile(PathBuf::from("/work/a.rs"))
898        );
899        assert_eq!(SpillTarget::classify("read", &builtin, &none, None), SpillTarget::Never);
900    }
901}