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}