1use 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
40pub const SPILL_FILE_CAP_BYTES: usize = 16 * 1024 * 1024;
42
43pub const SPILL_SESSION_CAP_BYTES: u64 = 256 * 1024 * 1024;
47
48const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct ToolSpillPolicy {
70 pub threshold_bytes: usize,
72 pub head_bytes: usize,
74 pub tail_bytes: usize,
76}
77
78impl ToolSpillPolicy {
79 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#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
118pub struct ToolSpillTotals {
119 pub virtualised: u64,
121 pub original_bytes: u64,
123 pub preview_bytes: u64,
125 pub file_bytes: u64,
127}
128
129impl ToolSpillTotals {
130 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#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum SpillTarget {
151 Never,
153 Spill,
155 ReadFile(PathBuf),
157}
158
159impl SpillTarget {
160 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#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct Virtualised {
192 pub preview: String,
193 pub original_bytes: usize,
194 pub spill_path: Option<PathBuf>,
196}
197
198#[derive(Debug, Clone)]
201pub struct ToolSpill {
202 policy: ToolSpillPolicy,
203 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 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 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 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 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 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 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) = ¬e {
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
380fn 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
401fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441struct Cut {
442 head_end: usize,
443 tail_start: usize,
444 total_lines: usize,
446 omitted_first: usize,
448 omitted_last: usize,
449}
450
451impl Cut {
452 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
523fn 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, ¬e)
558}
559
560fn 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, ¬e)
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, ¬e)
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 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 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); let spill = spill_in(tmp.path(), policy(4096, 1030, 520));
666 let v = spill.virtualise("toolu_x", &SpillTarget::Spill, &text).unwrap();
667 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 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 let text = "é🦀".repeat(5000); 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 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 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); 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); 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 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 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 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 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 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 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 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}