1use talon_core::protocol::ContainerOutput;
28
29pub const OUTPUT_CHANNEL_CAPACITY: usize = 64;
31pub const DEFAULT_MAX_RESULT_BYTES: usize = 64 * 1024;
33pub const DEFAULT_MAX_RUN_RESULT_BYTES: usize = 4 * 1024 * 1024;
35pub const DEFAULT_MAX_RUN_FRAMES: usize = 1024;
37pub const DEFAULT_MAX_ERROR_BYTES: usize = 8 * 1024;
39pub const MAX_SESSION_ID_BYTES: usize = 128;
43const BOUNDARY_SEARCH_BYTES: usize = 1024;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct OutputLimits {
51 pub max_result_bytes: usize,
54 pub max_run_result_bytes: usize,
56 pub max_run_frames: usize,
58 pub max_error_bytes: usize,
60}
61
62impl Default for OutputLimits {
63 fn default() -> Self {
64 Self {
65 max_result_bytes: DEFAULT_MAX_RESULT_BYTES,
66 max_run_result_bytes: DEFAULT_MAX_RUN_RESULT_BYTES,
67 max_run_frames: DEFAULT_MAX_RUN_FRAMES,
68 max_error_bytes: DEFAULT_MAX_ERROR_BYTES,
69 }
70 }
71}
72
73pub fn is_valid_session_id(id: &str) -> bool {
77 !id.is_empty()
78 && id.len() <= MAX_SESSION_ID_BYTES
79 && id
80 .bytes()
81 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
82}
83
84pub fn sanitize_session_id(id: Option<String>) -> Option<String> {
88 let id = id.filter(|id| !id.is_empty())?;
89 if is_valid_session_id(&id) {
90 Some(id)
91 } else {
92 tracing::warn!(
93 bytes = id.len(),
94 limit = MAX_SESSION_ID_BYTES,
95 "agent reported an invalid session id (oversized or not [A-Za-z0-9_-]); ignored"
96 );
97 None
98 }
99}
100
101fn cut_point(text: &str, max: usize) -> usize {
104 let mut cut = max.min(text.len());
105 while cut > 0 && !text.is_char_boundary(cut) {
106 cut -= 1;
107 }
108 let floor = cut.saturating_sub(BOUNDARY_SEARCH_BYTES);
109 match text[floor..cut].rfind(char::is_whitespace) {
110 Some(rel) => floor + rel,
111 None => cut,
112 }
113}
114
115pub fn cap_text(text: &str, max: usize) -> String {
119 if text.len() <= max {
120 return text.to_string();
121 }
122 let note = format!(
123 "\n\n[Talahon: output truncated — the result was {} bytes; only the first part \
124 (limit {max} bytes) is shown here]",
125 text.len()
126 );
127 let budget = max.saturating_sub(note.len());
128 if budget == 0 {
129 let cut = cut_point(text, max);
130 return text[..cut].to_string();
131 }
132 let cut = cut_point(text, budget);
133 let mut out = String::with_capacity(cut + note.len());
134 out.push_str(text[..cut].trim_end());
135 out.push_str(¬e);
136 out
137}
138
139#[derive(Debug, Clone, PartialEq)]
141pub enum Admission {
142 Deliver(ContainerOutput),
144 Abort(String),
146}
147
148#[derive(Debug, Clone)]
150pub struct OutputGuard {
151 limits: OutputLimits,
152 frames: usize,
153 result_bytes: usize,
154 truncated: usize,
155}
156
157impl OutputGuard {
158 pub fn new(limits: OutputLimits) -> Self {
160 Self {
161 limits,
162 frames: 0,
163 result_bytes: 0,
164 truncated: 0,
165 }
166 }
167
168 pub fn frames(&self) -> usize {
170 self.frames
171 }
172
173 pub fn result_bytes(&self) -> usize {
175 self.result_bytes
176 }
177
178 pub fn truncated(&self) -> usize {
180 self.truncated
181 }
182
183 pub fn admit(&mut self, mut output: ContainerOutput) -> Admission {
185 self.frames += 1;
186 if self.frames > self.limits.max_run_frames {
187 return Admission::Abort(format!(
188 "agent output exceeded the per-run limit of {} frames; run stopped",
189 self.limits.max_run_frames
190 ));
191 }
192 if let Some(result) = output.result.take() {
193 let capped = if result.len() > self.limits.max_result_bytes {
194 self.truncated += 1;
195 tracing::warn!(
196 bytes = result.len(),
197 limit = self.limits.max_result_bytes,
198 "agent result exceeds the per-message limit; truncated"
199 );
200 cap_text(&result, self.limits.max_result_bytes)
201 } else {
202 result
203 };
204 self.result_bytes = self.result_bytes.saturating_add(capped.len());
205 if self.result_bytes > self.limits.max_run_result_bytes {
206 return Admission::Abort(format!(
207 "agent output exceeded the per-run limit of {} bytes; run stopped",
208 self.limits.max_run_result_bytes
209 ));
210 }
211 output.result = Some(capped);
212 }
213 if let Some(error) = output.error.take() {
214 output.error = Some(cap_text(&error, self.limits.max_error_bytes));
215 }
216 output.new_session_id = sanitize_session_id(output.new_session_id.take());
217 Admission::Deliver(output)
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 fn limits(result: usize, run: usize, frames: usize) -> OutputLimits {
226 OutputLimits {
227 max_result_bytes: result,
228 max_run_result_bytes: run,
229 max_run_frames: frames,
230 max_error_bytes: 200,
231 }
232 }
233
234 #[test]
235 fn short_text_is_untouched() {
236 assert_eq!(cap_text("hello", 64), "hello");
237 assert_eq!(cap_text("", 0), "");
238 }
239
240 #[test]
241 fn long_text_is_truncated_with_a_note_within_the_limit() {
242 let text = "word ".repeat(100_000);
243 let capped = cap_text(&text, DEFAULT_MAX_RESULT_BYTES);
244 assert!(capped.len() <= DEFAULT_MAX_RESULT_BYTES, "{}", capped.len());
245 assert!(capped.contains("output truncated"));
246 assert!(capped.contains(&text.len().to_string()));
247 assert!(capped.starts_with("word word"));
248 }
249
250 #[test]
251 fn truncation_respects_char_boundaries_and_token_boundaries() {
252 let text = "é".repeat(50_000);
253 let capped = cap_text(&text, 1001);
254 assert!(capped.len() <= 1001);
255 let secret = "sk-SECRETSECRETSECRET";
257 let text = format!("{} {secret} {}", "a ".repeat(200), "b".repeat(10_000));
258 let capped = cap_text(&text, 600);
259 assert!(capped.contains(secret), "{capped}");
260 let start = text.find(secret).unwrap();
262 let cut = cut_point(&text, start + 5);
263 assert_eq!(cut, start - 1);
264 assert!(!text[..cut].contains("sk-"));
265 let solid = "z".repeat(5_000);
267 assert_eq!(cut_point(&solid, 4_000), 4_000);
268 assert!(cap_text(&"x".repeat(100), 5).len() <= 5);
270 }
271
272 #[test]
273 fn guard_truncates_frames_and_caps_errors_and_session_ids() {
274 let mut guard = OutputGuard::new(limits(1000, 1_000_000, 10));
275 let big = ContainerOutput::success(Some("x ".repeat(5_000)), Some("s".repeat(300)));
276 let Admission::Deliver(out) = guard.admit(big) else {
277 panic!("must deliver");
278 };
279 assert!(out.result.as_ref().unwrap().len() <= 1000);
280 assert_eq!(out.new_session_id, None);
281 assert_eq!(guard.truncated(), 1);
282
283 let Admission::Deliver(err) = guard.admit(ContainerOutput::error("e".repeat(10_000)))
284 else {
285 panic!("must deliver");
286 };
287 assert!(err.error.unwrap().len() <= 200);
288
289 let Admission::Deliver(ok) = guard.admit(ContainerOutput::success(
290 Some("hi".into()),
291 Some("sess-1".into()),
292 )) else {
293 panic!("must deliver");
294 };
295 assert_eq!(ok.result.as_deref(), Some("hi"));
296 assert_eq!(ok.new_session_id.as_deref(), Some("sess-1"));
297 assert_eq!(guard.frames(), 3);
298 }
299
300 #[test]
301 fn session_ids_are_validated() {
302 assert!(is_valid_session_id("3f2b8c1e-9a4d-4e6f-8b7a-0c1d2e3f4a5b"));
303 assert!(is_valid_session_id("sess_1-A"));
304 assert!(is_valid_session_id(&"a".repeat(MAX_SESSION_ID_BYTES)));
305 assert!(!is_valid_session_id(&"a".repeat(MAX_SESSION_ID_BYTES + 1)));
306 assert!(!is_valid_session_id(""));
307 for bad in [
308 "../../etc/passwd",
309 "a/b",
310 "a.b",
311 "a b",
312 "line\nbreak",
313 "tab\t",
314 "ümlaut",
315 "id;rm",
316 "\u{1b}[31m",
317 ] {
318 assert!(!is_valid_session_id(bad), "{bad:?} must be rejected");
319 }
320 assert_eq!(sanitize_session_id(None), None);
321 assert_eq!(sanitize_session_id(Some(String::new())), None);
322 assert_eq!(sanitize_session_id(Some("x/y".into())), None);
323 assert_eq!(
324 sanitize_session_id(Some("x".repeat(MAX_SESSION_ID_BYTES + 1))),
325 None
326 );
327 assert_eq!(
328 sanitize_session_id(Some("sess-1".into())).as_deref(),
329 Some("sess-1")
330 );
331 }
332
333 #[test]
334 fn guard_drops_invalid_session_ids() {
335 let mut guard = OutputGuard::new(limits(1000, 1_000_000, 10));
336 for bad in [
337 "s".repeat(MAX_SESSION_ID_BYTES + 1),
338 "../sessions/x".to_string(),
339 "id with spaces".to_string(),
340 "evil\nlog line".to_string(),
341 ] {
342 let Admission::Deliver(out) =
343 guard.admit(ContainerOutput::success(Some("ok".into()), Some(bad)))
344 else {
345 panic!("must deliver");
346 };
347 assert_eq!(out.new_session_id, None);
348 assert_eq!(out.result.as_deref(), Some("ok"));
350 }
351 let max = "s".repeat(MAX_SESSION_ID_BYTES);
352 let Admission::Deliver(out) =
353 guard.admit(ContainerOutput::success(None, Some(max.clone())))
354 else {
355 panic!("must deliver");
356 };
357 assert_eq!(out.new_session_id, Some(max));
358 }
359
360 #[test]
361 fn guard_aborts_when_the_run_exceeds_its_caps() {
362 let mut guard = OutputGuard::new(limits(1000, 2500, 100));
363 for _ in 0..2 {
364 assert!(matches!(
365 guard.admit(ContainerOutput::success(Some("y".repeat(1000)), None)),
366 Admission::Deliver(_)
367 ));
368 }
369 assert!(matches!(
370 guard.admit(ContainerOutput::success(Some("y".repeat(1000)), None)),
371 Admission::Abort(reason) if reason.contains("2500 bytes")
372 ));
373
374 let mut guard = OutputGuard::new(limits(1000, 1_000_000, 3));
375 for _ in 0..3 {
376 assert!(matches!(
377 guard.admit(ContainerOutput::success(None, None)),
378 Admission::Deliver(_)
379 ));
380 }
381 assert!(matches!(
382 guard.admit(ContainerOutput::success(None, None)),
383 Admission::Abort(reason) if reason.contains("3 frames")
384 ));
385 }
386}