diff --git a/src/cortex-app-server/src/tools/filesystem.rs b/src/cortex-app-server/src/tools/filesystem.rs index 052a40b52..a88500b93 100644 --- a/src/cortex-app-server/src/tools/filesystem.rs +++ b/src/cortex-app-server/src/tools/filesystem.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; +use super::preview::truncate_at_char_boundary; use super::types::ToolResult; /// Read file contents. @@ -119,7 +120,7 @@ pub async fn write_file(cwd: &Path, args: Value) -> ToolResult { "filename": std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(""), "extension": extension, "size": content.len(), - "content_preview": if content.len() > 500 { &content[..500] } else { content } + "content_preview": truncate_at_char_boundary(content, 500) })), }, Err(e) => ToolResult::error(format!("Failed to write file: {e}")), diff --git a/src/cortex-app-server/src/tools/mod.rs b/src/cortex-app-server/src/tools/mod.rs index d28d47e94..7f46937f0 100644 --- a/src/cortex-app-server/src/tools/mod.rs +++ b/src/cortex-app-server/src/tools/mod.rs @@ -15,6 +15,7 @@ mod definitions; mod executor; mod filesystem; mod planning; +mod preview; mod search; mod security; mod shell; diff --git a/src/cortex-app-server/src/tools/preview.rs b/src/cortex-app-server/src/tools/preview.rs new file mode 100644 index 000000000..ccc00660b --- /dev/null +++ b/src/cortex-app-server/src/tools/preview.rs @@ -0,0 +1,38 @@ +//! Helpers for building tool output previews. + +/// Truncate a string to at most `max_bytes` bytes without splitting a UTF-8 character. +pub(super) fn truncate_at_char_boundary(input: &str, max_bytes: usize) -> &str { + if input.len() <= max_bytes { + return input; + } + + let mut end = max_bytes; + while end > 0 && !input.is_char_boundary(end) { + end -= 1; + } + + &input[..end] +} + +#[cfg(test)] +mod tests { + use super::truncate_at_char_boundary; + + #[test] + fn keeps_ascii_at_requested_byte_limit() { + assert_eq!(truncate_at_char_boundary("abcdef", 3), "abc"); + } + + #[test] + fn backs_up_to_utf8_boundary() { + let input = format!("{}{}", "A".repeat(499), "\u{4E2D}"); + + assert_eq!(input.len(), 502); + assert_eq!(truncate_at_char_boundary(&input, 500), "A".repeat(499)); + } + + #[test] + fn returns_full_input_when_under_limit() { + assert_eq!(truncate_at_char_boundary("hello", 500), "hello"); + } +} diff --git a/src/cortex-app-server/src/tools/web.rs b/src/cortex-app-server/src/tools/web.rs index 764d4face..6fe605158 100644 --- a/src/cortex-app-server/src/tools/web.rs +++ b/src/cortex-app-server/src/tools/web.rs @@ -3,6 +3,7 @@ use serde_json::Value; use tokio::process::Command; +use super::preview::truncate_at_char_boundary; use super::types::ToolResult; /// Fetch content from a URL. @@ -79,9 +80,10 @@ pub async fn fetch_url(args: Value) -> ToolResult { // Truncate for display if too long let truncated = if content.len() > 100_000 { + let preview = truncate_at_char_boundary(&content, 100_000); format!( - "{}...\n[Truncated at 100000 chars, full size: {} chars]", - &content[..100_000], + "{}...\n[Truncated at 100000 bytes, full size: {} bytes]", + preview, content.len() ) } else { @@ -124,7 +126,7 @@ pub async fn web_search(args: Value) -> ToolResult { let html = String::from_utf8_lossy(&output.stdout); // Simple extraction of text let truncated = if html.len() > 10_000 { - &html[..10_000] + truncate_at_char_boundary(&html, 10_000) } else { &html };