Skip to content

Commit a9a8212

Browse files
committed
fix: prevent UTF-8 multibyte character boundary panic on string truncation
Fixes: A3S-Lab#23 - core/agent.rs: truncate_utf8() instead of byte slice for compact summary - core/text.rs: add Chinese/multibyte regression tests for truncate_utf8 - sdk/python: fix __repr__ truncation in progress, idle, task_types, lib.rs - Bump version to 1.8.4
1 parent 4b024ed commit a9a8212

11 files changed

Lines changed: 77 additions & 21 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-code-core"
3-
version = "1.8.3"
3+
version = "1.8.4"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"

core/src/agent.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use crate::planning::{AgentGoal, ExecutionPlan, TaskStatus};
2222
use crate::prompts::{AgentStyle, DetectionConfidence, PlanningMode, SystemPromptSlots};
2323
use crate::queue::SessionCommand;
2424
use crate::session_lane_queue::SessionLaneQueue;
25+
use crate::text::truncate_utf8;
2526
use crate::tool_search::ToolIndex;
2627
use crate::tools::{ToolContext, ToolExecutor, ToolStreamEvent};
2728
use anyhow::{Context, Result};
@@ -722,7 +723,7 @@ impl AgentLoop {
722723
};
723724
let compact = raw.split_whitespace().collect::<Vec<_>>().join(" ");
724725
if compact.len() > 180 {
725-
format!("{}...", &compact[..180])
726+
format!("{}...", truncate_utf8(&compact, 180))
726727
} else {
727728
compact
728729
}

core/src/text.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,35 @@ mod tests {
2727
assert_eq!(truncated, "执");
2828
assert!(truncated.is_char_boundary(truncated.len()));
2929
}
30+
31+
#[test]
32+
fn truncate_utf8_issue_23_real_payload() {
33+
// Exact scenario from GitHub issue #23: byte 180 falls inside '费' (bytes 178..181)
34+
let raw = "# Issue Summary\n\n# Issue Source\n- issue_id: 297936\n- org_id: 848\n- create_time: 2025-10-29 03:16:16\n- item_id: 11089\n\n## issue_name\n用户请求处理视频分析任务,涉及计费(费用:100元)\n";
35+
let compact = raw.split_whitespace().collect::<Vec<_>>().join(" ");
36+
// After joining with spaces, byte 180 splits a Chinese char
37+
assert!(
38+
!compact.is_char_boundary(180),
39+
"compact len={}",
40+
compact.len()
41+
);
42+
// The original code did: &compact[..180] — this panicked
43+
let truncated = truncate_utf8(&compact, 180);
44+
assert!(truncated.is_char_boundary(truncated.len()));
45+
}
46+
47+
#[test]
48+
fn truncate_utf8_chinese_only() {
49+
let s = "用户请求处理视频分析任务,涉及计费";
50+
let truncated = truncate_utf8(s, 15);
51+
assert!(truncated.is_char_boundary(truncated.len()));
52+
// Should not split: 费 is 3 bytes (bytes 12-14), so 15 should stop before it
53+
}
54+
55+
#[test]
56+
fn truncate_utf8_mixed_ascii_chinese() {
57+
let s = "hello你好world世界test";
58+
let truncated = truncate_utf8(s, 10);
59+
assert!(truncated.is_char_boundary(truncated.len()));
60+
}
3061
}

sdk/node/package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@a3s-lab/code",
3-
"version": "1.8.3",
3+
"version": "1.8.4",
44
"description": "A3S Code - Native AI coding agent library for Node.js",
55
"main": "index.js",
66
"types": "index.d.ts",
@@ -44,11 +44,11 @@
4444
"test:loop": "tsx --tsconfig examples/tsconfig.json examples/test_loop_commands.ts"
4545
},
4646
"optionalDependencies": {
47-
"@a3s-lab/code-darwin-arm64": "1.8.3",
48-
"@a3s-lab/code-linux-x64-gnu": "1.8.3",
49-
"@a3s-lab/code-linux-x64-musl": "1.8.3",
50-
"@a3s-lab/code-linux-arm64-gnu": "1.8.3",
51-
"@a3s-lab/code-linux-arm64-musl": "1.8.3",
52-
"@a3s-lab/code-win32-x64-msvc": "1.8.3"
47+
"@a3s-lab/code-darwin-arm64": "1.8.4",
48+
"@a3s-lab/code-linux-x64-gnu": "1.8.4",
49+
"@a3s-lab/code-linux-x64-musl": "1.8.4",
50+
"@a3s-lab/code-linux-arm64-gnu": "1.8.4",
51+
"@a3s-lab/code-linux-arm64-musl": "1.8.4",
52+
"@a3s-lab/code-win32-x64-msvc": "1.8.4"
5353
}
5454
}

sdk/python-bootstrap/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "a3s-code"
7-
version = "1.8.3"
7+
version = "1.8.4"
88
description = "A3S Code Python bootstrap package that fetches platform-native binaries from GitHub Releases"
99
readme = "../python/README.md"
1010
license = { text = "MIT" }

sdk/python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "maturin"
44

55
[project]
66
name = "a3s-code"
7-
version = "1.8.3"
7+
version = "1.8.4"
88
description = "A3S Code - Native Python bindings for the AI coding agent"
99
readme = "README.md"
1010
license = {text = "MIT"}

sdk/python/src/idle.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ use a3s_code_core::task::idle::{
99
};
1010
use pyo3::prelude::*;
1111

12+
use crate::truncate_utf8;
13+
1214
// ============================================================================
1315
// IdlePhase
1416
// ============================================================================
@@ -110,7 +112,7 @@ impl PyIdleTurn {
110112
format!(
111113
"IdleTurn(text={:?}, tool_calls={}, files={})",
112114
if self.text.len() > 30 {
113-
format!("{}...", &self.text[..30])
115+
format!("{}...", truncate_utf8(&self.text, 30))
114116
} else {
115117
self.text.clone()
116118
},
@@ -204,7 +206,7 @@ impl PyEpisodicEntry {
204206
format!(
205207
"EpisodicEntry(description={:?}, importance={})",
206208
if self.description.len() > 40 {
207-
format!("{}...", &self.description[..40])
209+
format!("{}...", truncate_utf8(&self.description, 40))
208210
} else {
209211
self.description.clone()
210212
},
@@ -258,7 +260,7 @@ impl PyIdleTask {
258260
self.id,
259261
self.phase.phase,
260262
if self.reason.len() > 30 {
261-
format!("{}...", &self.reason[..30])
263+
format!("{}...", truncate_utf8(&self.reason, 30))
262264
} else {
263265
self.reason.clone()
264266
}

sdk/python/src/lib.rs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@ use std::sync::{
6868
use tokio::runtime::Runtime;
6969
use tokio::sync::Mutex;
7070

71+
// ============================================================================
72+
// Utilities
73+
// ============================================================================
74+
75+
/// Truncate a UTF-8 string to at most `max_bytes` bytes, without splitting
76+
/// a multibyte character. Falls back to the full string if it's already
77+
/// within the limit.
78+
fn truncate_utf8(s: &str, max_bytes: usize) -> &str {
79+
if s.len() <= max_bytes {
80+
return s;
81+
}
82+
let mut end = max_bytes;
83+
while end > 0 && !s.is_char_boundary(end) {
84+
end -= 1;
85+
}
86+
&s[..end]
87+
}
88+
7189
// ============================================================================
7290
// Task Module Bindings
7391
// ============================================================================
@@ -149,7 +167,7 @@ impl PyAgentResult {
149167
format!(
150168
"AgentResult(text={:?}, tool_calls={}, tokens={})",
151169
if self.text.len() > 80 {
152-
format!("{}...", &self.text[..80])
170+
format!("{}...", truncate_utf8(&self.text, 80))
153171
} else {
154172
self.text.clone()
155173
},
@@ -204,7 +222,7 @@ impl PyBtwResult {
204222
"BtwResult(question={:?}, answer={:?}, tokens={})",
205223
self.question,
206224
if self.answer.len() > 60 {
207-
format!("{}...", &self.answer[..60])
225+
format!("{}...", truncate_utf8(&self.answer, 60))
208226
} else {
209227
self.answer.clone()
210228
},
@@ -4224,7 +4242,7 @@ impl PySkillInfo {
42244242
self.name,
42254243
self.kind,
42264244
if self.description.len() > 60 {
4227-
format!("{}...", &self.description[..60])
4245+
format!("{}...", truncate_utf8(&self.description, 60))
42284246
} else {
42294247
self.description.clone()
42304248
}
@@ -4324,7 +4342,7 @@ impl PyTeamTask {
43244342
self.id,
43254343
self.status,
43264344
if self.description.len() > 60 {
4327-
format!("{}...", &self.description[..60])
4345+
format!("{}...", truncate_utf8(&self.description, 60))
43284346
} else {
43294347
self.description.clone()
43304348
}

sdk/python/src/progress.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ use a3s_code_core::task::progress::{
99
use pyo3::prelude::*;
1010
use std::collections::HashMap;
1111

12+
use crate::truncate_utf8;
13+
1214
// ============================================================================
1315
// TaskTokenUsage
1416
// ============================================================================
@@ -99,7 +101,7 @@ impl PyToolActivity {
99101
self.tool_name,
100102
self.success,
101103
if self.args_summary.len() > 40 {
102-
format!("{}...", &self.args_summary[..40])
104+
format!("{}...", truncate_utf8(&self.args_summary, 40))
103105
} else {
104106
self.args_summary.clone()
105107
}

0 commit comments

Comments
 (0)