diff --git a/crates/arc-agent/src/cli.rs b/crates/arc-agent/src/cli.rs index 9e0b77799..5a75235e2 100644 --- a/crates/arc-agent/src/cli.rs +++ b/crates/arc-agent/src/cli.rs @@ -205,7 +205,7 @@ fn format_tool_args(args: &serde_json::Value, cwd: &str) -> String { serde_json::Value::String(s) => { let s = s.strip_prefix(&cwd_prefix).unwrap_or(s); let display = if s.len() > 80 { - format!("{}...", &s[..77]) + format!("{}...", &s[..crate::truncation::floor_char_boundary(s, 77)]) } else { s.to_string() }; @@ -506,7 +506,7 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { .. } => { let short_id = &agent_id[..8.min(agent_id.len())]; - let task_preview = if task.len() > 60 { &task[..60] } else { task }; + let task_preview = if task.len() > 60 { &task[..crate::truncation::floor_char_boundary(task, 60)] } else { task }; eprintln!( " {}", s.dim.apply_to(format!( diff --git a/crates/arc-agent/src/compaction.rs b/crates/arc-agent/src/compaction.rs index 2a476ef65..9e69ec9ee 100644 --- a/crates/arc-agent/src/compaction.rs +++ b/crates/arc-agent/src/compaction.rs @@ -197,7 +197,7 @@ pub fn render_turns_for_summary(turns: &[Turn]) -> String { for tc in tool_calls { let args_str = tc.arguments.to_string(); let truncated = if args_str.len() > 500 { - format!("{}...", &args_str[..500]) + format!("{}...", &args_str[..crate::truncation::floor_char_boundary(&args_str, 500)]) } else { args_str }; @@ -208,7 +208,7 @@ pub fn render_turns_for_summary(turns: &[Turn]) -> String { for r in results { let content_str = r.content.to_string(); let truncated = if content_str.len() > 500 { - format!("{}...", &content_str[..500]) + format!("{}...", &content_str[..crate::truncation::floor_char_boundary(&content_str, 500)]) } else { content_str }; diff --git a/crates/arc-agent/src/lib.rs b/crates/arc-agent/src/lib.rs index 3b1b2014d..e6d5f5cdf 100644 --- a/crates/arc-agent/src/lib.rs +++ b/crates/arc-agent/src/lib.rs @@ -51,7 +51,9 @@ pub use tools::{ make_edit_file_tool, make_glob_tool, make_grep_tool, make_read_file_tool, make_shell_tool, make_shell_tool_with_config, make_write_file_tool, register_core_tools, WebFetchSummarizer, }; -pub use truncation::{truncate_lines, truncate_output, truncate_tool_output, TruncationMode}; +pub use truncation::{ + floor_char_boundary, truncate_lines, truncate_output, truncate_tool_output, TruncationMode, +}; pub use types::{AgentEvent, SessionEvent, SessionState, Turn}; #[cfg(test)] diff --git a/crates/arc-agent/src/truncation.rs b/crates/arc-agent/src/truncation.rs index 06d7d7c23..f0e1ec67c 100644 --- a/crates/arc-agent/src/truncation.rs +++ b/crates/arc-agent/src/truncation.rs @@ -1,5 +1,19 @@ use crate::config::SessionConfig; +/// Round a byte index down to the nearest UTF-8 char boundary. +/// Stable equivalent of `str::floor_char_boundary` (nightly-only). +#[must_use] +pub fn floor_char_boundary(s: &str, index: usize) -> usize { + if index >= s.len() { + return s.len(); + } + let mut i = index; + while i > 0 && !s.is_char_boundary(i) { + i -= 1; + } + i +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TruncationMode { HeadTail, @@ -47,8 +61,10 @@ pub fn truncate_output(output: &str, max_chars: usize, mode: TruncationMode) -> match mode { TruncationMode::HeadTail => { let half = max_chars / 2; - let head = &output[..half]; - let tail = &output[output.len() - half..]; + let head_end = floor_char_boundary(output, half); + let tail_start = floor_char_boundary(output, output.len() - half); + let head = &output[..head_end]; + let tail = &output[tail_start..]; format!( "{head}\n\n[WARNING: Tool output was truncated. {removed} characters were removed from the middle. \ The full output is available in the event stream. \ @@ -56,7 +72,8 @@ pub fn truncate_output(output: &str, max_chars: usize, mode: TruncationMode) -> ) } TruncationMode::Tail => { - let tail = &output[output.len() - max_chars..]; + let tail_start = floor_char_boundary(output, output.len() - max_chars); + let tail = &output[tail_start..]; format!( "[WARNING: Tool output was truncated. First {removed} characters were removed. \ The full output is available in the event stream.]\n\n{tail}" @@ -235,4 +252,28 @@ mod tests { let result = truncate_lines(&output, 10); assert_eq!(result, output); } + + #[test] + fn floor_char_boundary_ascii() { + assert_eq!(floor_char_boundary("hello", 3), 3); + assert_eq!(floor_char_boundary("hello", 10), 5); + assert_eq!(floor_char_boundary("hello", 0), 0); + } + + #[test] + fn floor_char_boundary_multibyte() { + // ✅ is 3 bytes (E2 9C 85) + let s = "a✅b"; + assert_eq!(floor_char_boundary(s, 1), 1); // just past 'a' + assert_eq!(floor_char_boundary(s, 2), 1); // inside ✅, rounds down to 'a' + assert_eq!(floor_char_boundary(s, 3), 1); // still inside ✅ + assert_eq!(floor_char_boundary(s, 4), 4); // at 'b' + } + + #[test] + fn truncate_output_multibyte_no_panic() { + let output = "✅".repeat(100); // 300 bytes + let result = truncate_output(&output, 10, TruncationMode::HeadTail); + assert!(result.contains("Tool output was truncated")); + } } diff --git a/crates/arc-workflows/src/cli/mod.rs b/crates/arc-workflows/src/cli/mod.rs index 441d3437c..51c67d233 100644 --- a/crates/arc-workflows/src/cli/mod.rs +++ b/crates/arc-workflows/src/cli/mod.rs @@ -365,7 +365,7 @@ pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String format!("[LOOP_RESTART] from={from_node} to={to_node}") } WorkflowRunEvent::Prompt { stage, text } => { - let truncated = if text.len() > 80 { &text[..80] } else { text }; + let truncated = if text.len() > 80 { &text[..arc_agent::floor_char_boundary(text, 80)] } else { text }; format!("[PROMPT] stage={stage} text=\"{truncated}\"") } WorkflowRunEvent::Agent { stage, event } => match event { @@ -436,7 +436,7 @@ pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String task, } => { let short_id = &agent_id[..8.min(agent_id.len())]; - let task_preview = if task.len() > 60 { &task[..60] } else { task }; + let task_preview = if task.len() > 60 { &task[..arc_agent::floor_char_boundary(task, 60)] } else { task }; format!("[SUBAGENT_SPAWNED] stage={stage} agent_id={short_id} depth={depth} task=\"{task_preview}\"") } AgentEvent::SubAgentCompleted { @@ -538,7 +538,7 @@ pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String stderr, } => { let truncated = if stderr.len() > 80 { - &stderr[..80] + &stderr[..arc_agent::floor_char_boundary(stderr, 80)] } else { stderr }; diff --git a/crates/arc-workflows/src/handler/codergen.rs b/crates/arc-workflows/src/handler/codergen.rs index 65f054fee..e8b8d697a 100644 --- a/crates/arc-workflows/src/handler/codergen.rs +++ b/crates/arc-workflows/src/handler/codergen.rs @@ -157,12 +157,12 @@ fn extract_status_fields(text: &str, outcome: &mut Outcome) { } } -/// Truncate a string to at most `max_chars` characters. +/// Truncate a string to at most `max_chars` characters (char-boundary safe). fn truncate(s: &str, max_chars: usize) -> &str { if s.len() <= max_chars { s } else { - &s[..max_chars] + &s[..arc_agent::floor_char_boundary(s, max_chars)] } }