Skip to main content

locode_tools/
truncate.rs

1//! The shared model-facing truncation post-process (ADR-0008 amendment
2//! 2026-07-18: applied centrally at the dispatch door — every `tool_result`
3//! passes through [`truncate_for_model`] in `Registry::dispatch`, so no tool
4//! can flood the model regardless of its own caps). Moved from `locode-host`
5//! (where nothing consumed it): the budget is a property of the model-facing
6//! boundary, not of OS access.
7
8/// Default model-facing byte budget. Matches Grok's shell output cap.
9pub const MODEL_OUTPUT_BUDGET: usize = 30_000;
10
11/// Bound `text` to roughly `budget` bytes for model consumption, keeping the **head and
12/// tail** and eliding the middle (middle-truncation, Codex-style) with a byte-count
13/// marker in the seam.
14///
15/// Returns `(text, was_truncated)`. Below budget the input is returned untouched with no
16/// marker (idempotent). UTF-8-safe: cuts fall on char boundaries.
17#[must_use]
18pub fn truncate_for_model(text: &str, budget: usize) -> (String, bool) {
19    if text.len() <= budget {
20        return (text.to_string(), false);
21    }
22
23    let head_len = budget / 2;
24    let tail_len = budget - head_len;
25    let head_end = floor_boundary(text, head_len);
26    let tail_start = ceil_boundary(text, text.len() - tail_len);
27    let elided = tail_start.saturating_sub(head_end);
28
29    let marker = format!("\n[... {elided} bytes truncated ...]\n");
30    let mut out = String::with_capacity(head_end + marker.len() + (text.len() - tail_start));
31    out.push_str(&text[..head_end]);
32    out.push_str(&marker);
33    out.push_str(&text[tail_start..]);
34    (out, true)
35}
36
37/// The largest char boundary `<= idx`.
38fn floor_boundary(text: &str, mut idx: usize) -> usize {
39    if idx >= text.len() {
40        return text.len();
41    }
42    while idx > 0 && !text.is_char_boundary(idx) {
43        idx -= 1;
44    }
45    idx
46}
47
48/// The smallest char boundary `>= idx`.
49fn ceil_boundary(text: &str, mut idx: usize) -> usize {
50    while idx < text.len() && !text.is_char_boundary(idx) {
51        idx += 1;
52    }
53    idx
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn below_budget_is_untouched() {
62        let (out, truncated) = truncate_for_model("short", 100);
63        assert_eq!(out, "short");
64        assert!(!truncated);
65    }
66
67    #[test]
68    fn over_budget_keeps_head_and_tail() {
69        let text: String = (0..1000).map(|_| 'x').collect::<String>() + "TAILMARK";
70        let head: String = std::iter::repeat_n('H', 500).collect();
71        let text = head.clone() + &text;
72        let (out, truncated) = truncate_for_model(&text, 200);
73        assert!(truncated);
74        assert!(out.starts_with("HHHH"), "keeps the head");
75        assert!(out.ends_with("TAILMARK"), "keeps the tail");
76        assert!(out.contains("truncated"), "has the marker");
77        assert!(out.len() < text.len());
78    }
79
80    #[test]
81    fn utf8_multibyte_seam_does_not_panic() {
82        // A string of multibyte chars; every cut must land on a boundary.
83        let text: String = std::iter::repeat_n('あ', 10_000).collect();
84        let (out, truncated) = truncate_for_model(&text, 1000);
85        assert!(truncated);
86        // Round-trips as valid UTF-8 (the pushes would have panicked otherwise).
87        assert!(out.starts_with('あ') && out.ends_with('あ'));
88    }
89}