Skip to main content

llm_tool/types/truncation/
chars.rs

1//! Character-boundary truncation helpers, token-adaptive budget sizing, and canonical JSON ordering.
2//!
3//! Rust string slicing (`&s[..n]`) indexes by *byte* and panics when `n` splits a multi-byte
4//! UTF-8 sequence. These helpers centralize character-counted head, tail, and middle truncation
5//! alongside token-adaptive byte budget sizing for [`TruncationPolicy`].
6
7use alloc::{
8    collections::BTreeMap,
9    format,
10    string::{String, ToString},
11    vec::Vec,
12};
13
14use super::policy::TruncationPolicy;
15
16/// Heuristic UTF-8 byte-per-token multiplier (`4` bytes/token).
17pub const DEFAULT_BYTES_PER_TOKEN: usize = 4;
18
19/// Default minimum byte budget (`4 KiB`) for token-adaptive tool output truncation.
20pub const MIN_ADAPTIVE_TOOL_OUTPUT_BYTES: usize = 4 * 1024;
21
22/// Default maximum byte budget (`64 KiB`) for token-adaptive tool output truncation.
23pub const MAX_ADAPTIVE_TOOL_OUTPUT_BYTES: usize = 64 * 1024;
24
25/// Default numerator (`3`) of the remaining context token fraction (`3/10 = 30%`) assigned to a single tool output.
26pub const DEFAULT_TOOL_OUTPUT_CONTEXT_NUMERATOR: usize = 3;
27
28/// Default denominator (`10`) of the remaining context token fraction (`3/10 = 30%`) assigned to a single tool output.
29pub const DEFAULT_TOOL_OUTPUT_CONTEXT_DENOMINATOR: usize = 10;
30
31/// Compute a token-adaptive byte budget from `remaining_context_tokens`, allocating `30%`
32/// (`3/10`) of remaining tokens at `4` bytes/token and clamping to `[min_bytes, max_bytes]`.
33///
34/// ```
35/// use llm_tool::{
36///     MAX_ADAPTIVE_TOOL_OUTPUT_BYTES, MIN_ADAPTIVE_TOOL_OUTPUT_BYTES,
37///     compute_token_adaptive_budget,
38/// };
39///
40/// // Small remaining window clamps to MIN_ADAPTIVE_TOOL_OUTPUT_BYTES (4 KiB).
41/// assert_eq!(
42///     compute_token_adaptive_budget(
43///         500,
44///         MIN_ADAPTIVE_TOOL_OUTPUT_BYTES,
45///         MAX_ADAPTIVE_TOOL_OUTPUT_BYTES
46///     ),
47///     MIN_ADAPTIVE_TOOL_OUTPUT_BYTES
48/// );
49/// // 20,000 remaining tokens -> 30% = 6,000 tokens -> 24,000 bytes.
50/// assert_eq!(
51///     compute_token_adaptive_budget(
52///         20_000,
53///         MIN_ADAPTIVE_TOOL_OUTPUT_BYTES,
54///         MAX_ADAPTIVE_TOOL_OUTPUT_BYTES
55///     ),
56///     24_000
57/// );
58/// // Huge remaining window clamps to MAX_ADAPTIVE_TOOL_OUTPUT_BYTES (64 KiB).
59/// assert_eq!(
60///     compute_token_adaptive_budget(
61///         200_000,
62///         MIN_ADAPTIVE_TOOL_OUTPUT_BYTES,
63///         MAX_ADAPTIVE_TOOL_OUTPUT_BYTES
64///     ),
65///     MAX_ADAPTIVE_TOOL_OUTPUT_BYTES
66/// );
67/// ```
68#[must_use]
69pub const fn compute_token_adaptive_budget(
70    remaining_context_tokens: usize,
71    min_bytes: usize,
72    max_bytes: usize,
73) -> usize {
74    let ceiling = if max_bytes < min_bytes {
75        min_bytes
76    } else {
77        max_bytes
78    };
79    let allocated_tokens = remaining_context_tokens
80        .saturating_mul(DEFAULT_TOOL_OUTPUT_CONTEXT_NUMERATOR)
81        / DEFAULT_TOOL_OUTPUT_CONTEXT_DENOMINATOR;
82    let raw_bytes = allocated_tokens.saturating_mul(DEFAULT_BYTES_PER_TOKEN);
83    if raw_bytes < min_bytes {
84        min_bytes
85    } else if raw_bytes > ceiling {
86        ceiling
87    } else {
88        raw_bytes
89    }
90}
91
92impl TruncationPolicy {
93    /// Construct a [`TruncationPolicy`] whose `max_bytes` scales with `remaining_context_tokens`
94    /// (`30%` of remaining tokens at `4` bytes/token, clamped between [`MIN_ADAPTIVE_TOOL_OUTPUT_BYTES`]
95    /// and [`MAX_ADAPTIVE_TOOL_OUTPUT_BYTES`]).
96    #[must_use]
97    pub const fn for_context_window(remaining_context_tokens: usize) -> Self {
98        Self::new(compute_token_adaptive_budget(
99            remaining_context_tokens,
100            MIN_ADAPTIVE_TOOL_OUTPUT_BYTES,
101            MAX_ADAPTIVE_TOOL_OUTPUT_BYTES,
102        ))
103    }
104
105    /// Construct a [`TruncationPolicy`] scaled to `remaining_context_tokens` with custom
106    /// `[min_bytes, max_bytes]` bounds.
107    #[must_use]
108    pub const fn for_context_window_clamped(
109        remaining_context_tokens: usize,
110        min_bytes: usize,
111        max_bytes: usize,
112    ) -> Self {
113        Self::new(compute_token_adaptive_budget(
114            remaining_context_tokens,
115            min_bytes,
116            max_bytes,
117        ))
118    }
119}
120
121/// Truncate `text` to at most `max_chars` Unicode scalar values, keeping the **start** and
122/// appending `marker` when truncation occurs.
123///
124/// The returned string (including `marker`) never exceeds `max_chars` characters when
125/// `marker.chars().count() <= max_chars`. Truncation always lands on a UTF-8 character boundary.
126#[must_use]
127pub fn truncate_head_chars(text: &str, max_chars: usize, marker: &str) -> String {
128    if text.chars().count() <= max_chars {
129        return text.to_string();
130    }
131    let keep = max_chars.saturating_sub(marker.chars().count());
132    let end = text
133        .char_indices()
134        .nth(keep)
135        .map_or(text.len(), |(idx, _)| idx);
136    format!("{}{marker}", &text[..end])
137}
138
139/// Truncate `text` to at most `max_chars` Unicode scalar values, keeping the **end** and
140/// prepending `prefix` when truncation occurs.
141///
142/// Mirrors [`truncate_head_chars`] for tail-retaining truncation (e.g. showing only the most
143/// recent portion of a log or thread). Always lands on a UTF-8 character boundary.
144#[must_use]
145pub fn truncate_tail_chars(text: &str, max_chars: usize, prefix: &str) -> String {
146    let total = text.chars().count();
147    if total <= max_chars {
148        return text.to_string();
149    }
150    let keep = max_chars.saturating_sub(prefix.chars().count());
151    let skip = total - keep;
152    let start = text
153        .char_indices()
154        .nth(skip)
155        .map_or(text.len(), |(idx, _)| idx);
156    format!("{prefix}{}", &text[start..])
157}
158
159/// Truncate `text` by keeping its **head and tail** Unicode scalar counts (`head_chars` and
160/// `tail_chars`), eliding the middle with `marker` when `text.chars().count() > head_chars + tail_chars`.
161///
162/// Both cut points land on UTF-8 character boundaries. Returns `text` unchanged when it fits.
163#[must_use]
164pub fn truncate_middle_chars(
165    text: &str,
166    head_chars: usize,
167    tail_chars: usize,
168    marker: &str,
169) -> String {
170    let total = text.chars().count();
171    if total <= head_chars.saturating_add(tail_chars) {
172        return text.to_string();
173    }
174    let head_end = text
175        .char_indices()
176        .nth(head_chars)
177        .map_or(text.len(), |(idx, _)| idx);
178    let tail_start = text
179        .char_indices()
180        .nth(total - tail_chars)
181        .map_or(text.len(), |(idx, _)| idx);
182    format!("{}{marker}{}", &text[..head_end], &text[tail_start..])
183}
184
185/// Recursively sorts JSON object keys lexicographically and canonicalizes nested values.
186///
187/// Useful for deterministic tool-call argument hashing, loop detection, and cache keys.
188#[must_use]
189pub fn canonical_json(value: &serde_json::Value) -> serde_json::Value {
190    match value {
191        serde_json::Value::Object(map) => {
192            let mut btree = BTreeMap::new();
193            for (k, v) in map {
194                btree.insert(k.clone(), canonical_json(v));
195            }
196            let mut sorted_map = serde_json::Map::with_capacity(btree.len());
197            for (k, v) in btree {
198                sorted_map.insert(k, v);
199            }
200            serde_json::Value::Object(sorted_map)
201        }
202        serde_json::Value::Array(arr) => {
203            let canonical_arr: Vec<serde_json::Value> = arr.iter().map(canonical_json).collect();
204            serde_json::Value::Array(canonical_arr)
205        }
206        _ => value.clone(),
207    }
208}