mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
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:
parent
53dac89d1b
commit
2a66e5fde5
6 changed files with 56 additions and 13 deletions
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue