fix(compact): 压缩后重注入按字节下标切片 → UTF-8 边界 panic
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 72/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- rust
- Domain
- ai-infra-agents
Research direction
Start with the two byte-index slices in src/compact/reinject.rs: FileReinjector around lines 79-85 and SkillReinjector around lines 288-293. Add a helper truncate_on_char_boundary(s, max) that walks back with is_char_boundary, then add the four tests listed in the spec, including a CJK string over budget. Done when both reinjectors stay panic-free on multibyte content and the existing truncation marker still appears.
Written by the indexing model from the issue text.
Description
优先级 P1 · 依赖:无 · 本单属 #186「上下文与压缩契约」簇(tracking 单,见该单的落地顺序与簇级验收)
基线 HEAD = 14bac15a。
Summary
src/compact/reinject.rs:79-85(FileReinjector):
let max_chars = self.per_file_budget * 4;
let truncated = if content.len() > max_chars {
format!("{}...\n[... truncated for compaction; use Read on the path for full text]", &content[..max_chars])
content 是 String,&content[..max_chars] 是字节下标切片;max_chars = 5_000 * 4 = 20_000(DEFAULT_PER_FILE_BUDGET=5_000,src/preset.rs:69/565)。
- 技能注入同型:
reinject.rs:288-293的&body[..max_chars]。 - 全目录无保护:
grep -rn "char_boundary" --include=*.rs src/compact零命中。 - 内容来自 Read 工具每次完整读入的
ReadFileState(src/tools/execution/fs.rs:356);调用点在src/runtime.rs:900与:915(跨轮压缩内),panic 发生在AgentRuntime::run内、turn 已落盘之后,运行时路径没有catch_unwind。 - Rust 语义保证:非字符边界的字节切片必 panic(
byte index 20000 is not a char boundary)。
影响
默认 standard 预设(开启 reinject_recent_files)下,agent 读过任意一个 >20KB 且含非 ASCII 的文件(本仓中文文档即是:一个汉字 3 字节,20_000 落在字符中间的概率约 2/3),此后一次跨轮压缩即 panic,终结该 turn 的任务(HTTP 请求任务 / CLI 进程)。技能形态同理(SKILL.md 正文 >20KB 且含中文)。
建议
- 两处都改为按字符边界截断:
content.floor_char_boundary(max_chars)(或自行用is_char_boundary回退),再切片。 - 顺带把
max_chars更名/改成与语义一致(当前是按「预算 token × 4」当字节用)。 - 验收:新增测试——构造一个多字节字符跨在 20_000 字节上的字符串,调用两个 reinjector,断言不 panic 且输出是合法 UTF-8 前缀。
边界
不覆盖压缩摘要/前缀注入侧的其他问题(见相邻单)。
实施规格(补充 · 2026-10-10)
目标:两处按字节下标的切片改为按字符边界,消除跨轮压缩路径上的确定性 panic。
改动清单
| 文件 | 改动 |
|---|---|
src/compact/reinject.rs:79-85(FileReinjector) |
&content[..max_chars] → 边界安全截断 |
src/compact/reinject.rs:288-293(SkillReinjector) |
&body[..max_chars] 同上 |
| 同文件顶部 | 新增 pub(crate) fn truncate_on_char_boundary(s: &str, max_bytes: usize) -> &str |
设计
/// Truncate `s` to at most `max_bytes` bytes without splitting a char.
/// MSRV 1.75:`str::floor_char_boundary` 仍是 nightly,故手写。
pub(crate) fn truncate_on_char_boundary(s: &str, max: usize) -> &str {
if max >= s.len() { return s; }
let mut end = max;
while end > 0 && !s.is_char_boundary(end) { end -= 1; }
&s[..end]
}
调用点保持既有格式串不变(省略号 + [... truncated for compaction; use Read on the path for full text])。
测试
| 用例 | 断言 |
|---|---|
truncate_stops_at_char_boundary |
"中".repeat(10) 截 5 字节 → 返回 "中"(3 字节),不 panic |
truncate_returns_full_string_when_short |
max >= len 时原样返回 |
file_reinjector_survives_cjk_over_budget |
7000 个汉字(21000 字节)经 FileReinjector::reinject → 不 panic,输出含截断标记 |
skill_reinjector_survives_cjk_over_budget |
同型,走 SkillReinjector |
陷阱
max_chars这个名字本身就是误导(它按"预算 token × 4"算,实际当字节用)。建议同 PR 更名max_bytes,并保留一句注释说明"预算 token ≈ 字节 / 4"。- 截断后的
att_len_chars(reinject.rs内用于预算累加)现阶段按truncated.len() / 4 + 1计算,保持原样即可,不要在本单改动预算语义。
不做:不改 per_file_budget / token_budget 的默认值;不改注入内容的排版。
- Dominant language
- Rust
- Stars
- 4
- Forks
- 0
- Avg merge
- 5h 32m
- Merged PRs (30d)
- 7
Getting set up
- Ships a Dockerfile or Docker Compose file
- No pull request template
- No contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from jeffkit/recursive
-
Difficulty 3/5 1-2 days Newbie friendliness 68/100
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 53/100
jeffkit/recursive#195 · 4 comments ·
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 15/100
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 15/100
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 8/100
Maintainers usually reply within 1 day
All issues in jeffkit/recursive
Similar issues
-
Difficulty 1/5 Under an hour Newbie friendliness 75/100
RustCrypto/SSH#596 ·
-
proxy: SQL-over-HTTP turns empty arrays into [""] or [false], or HTTP 500 for int and float arraysOpen
Difficulty 2/5 1-3 hours Newbie friendliness 83/100
neondatabase/neon#12965 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
goauthentik/authentik#26867 ·
Maintainers usually reply within 1 day
-
api bug macos maintainer-needed p2 triaged
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
Maintainers usually reply within 1 day
-
area::tern difficulty::easy good first issue kind::cleanup triage::ready
Difficulty 1/5 1-3 hours Newbie friendliness 88/100