From fcc3756634163f98e10ff9d5e15b5bdc3cecda43 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Tue, 25 Aug 2026 15:47:34 -0600 Subject: [PATCH] fix(function): floor tool-output truncation cut to a UTF-8 char boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When max_chars landed inside a multi-byte UTF-8 character of the serialized output, s.get(..max_chars) returned None and the code fell back to the FULL untruncated string while still prepending the truncation marker — the "truncated" output actually grew. The cut is now floored to the previous char boundary so the prefix is always a valid, genuinely truncated slice. --- src/function/mod.rs | 53 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/src/function/mod.rs b/src/function/mod.rs index 9d7d3d6..579c430 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -405,7 +405,11 @@ impl ToolResult { pub fn truncate_if_needed(mut self, max_chars: usize) -> Self { let s = self.output.to_string(); if s.len() > max_chars { - let prefix = s.get(..max_chars).unwrap_or(s.as_str()); + let mut cut = max_chars; + while !s.is_char_boundary(cut) { + cut -= 1; + } + let prefix = &s[..cut]; self.output = json!(format!( "[truncated: tool output exceeded {max_chars} chars]\n{prefix}" )); @@ -4179,23 +4183,54 @@ mod tests { assert!(!results[0].output.to_string().contains("[truncated")); } - /// Pins current behavior: when the char cap lands inside a multi-byte - /// UTF-8 character of the serialized output, no prefix can be taken, so - /// the truncation marker is prepended to the FULL original output and the - /// "truncated" result is longer than the input. + /// When the char cap lands inside a multi-byte UTF-8 character of the + /// serialized output, the cut is floored to the previous char boundary + /// so the output actually shrinks. #[test] - fn truncate_if_needed_utf8_boundary_returns_full_output_with_marker() { + fn truncate_if_needed_floors_cut_to_char_boundary() { let serialized = json!("aé").to_string(); let result = ToolResult::new(call("t", Some("id-1")), json!("aé")); let truncated = result.truncate_if_needed(3); let out = truncated.output.as_str().unwrap(); + assert_eq!(out, "[truncated: tool output exceeded 3 chars]\n\"a"); + assert!(out.len() < "[truncated: tool output exceeded 3 chars]\n".len() + serialized.len()); + } + + #[test] + fn truncate_if_needed_cap_on_char_boundary_truncates_normally() { + let result = ToolResult::new(call("t", Some("id-1")), json!("aé")); + + let truncated = result.truncate_if_needed(2); + assert_eq!( - out, - format!("[truncated: tool output exceeded 3 chars]\n{serialized}") + truncated.output.as_str().unwrap(), + "[truncated: tool output exceeded 2 chars]\n\"a" ); - assert!(out.len() > serialized.len()); + } + + #[test] + fn truncate_if_needed_cap_zero_yields_marker_and_empty_prefix() { + let result = ToolResult::new(call("t", Some("id-1")), json!("aé")); + + let truncated = result.truncate_if_needed(0); + + assert_eq!( + truncated.output.as_str().unwrap(), + "[truncated: tool output exceeded 0 chars]\n" + ); + } + + #[test] + fn truncate_if_needed_cap_at_or_above_length_leaves_output_unchanged() { + for cap in [5, 100] { + let result = ToolResult::new(call("t", Some("id-1")), json!("aé")); + + let truncated = result.truncate_if_needed(cap); + + assert_eq!(truncated.output, json!("aé")); + } } #[test]