Fix UTF-8 string slicing panics with floor_char_boundary helper

Add a shared floor_char_boundary() utility that rounds byte indices down
to the nearest char boundary, preventing panics on multi-byte UTF-8
characters like emoji. Applied to all 9 unsafe slice sites across
arc-agent and arc-workflows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-04 02:36:56 -05:00
parent 53dac89d1b
commit 2a66e5fde5
6 changed files with 56 additions and 13 deletions

View file

@ -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!(

View file

@ -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
};

View file

@ -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)]

View file

@ -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"));
}
}

View file

@ -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
};

View file

@ -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)]
}
}