Skip to content

Commit 9e75ce9

Browse files
chengzhichao-xydtclaudesteipete
authored
fix(agents): keep tool-result truncation UTF-16 safe (#102087)
* fix(agents): keep tool-result truncation UTF-16 safe Replace raw .slice() calls in tool-result truncation with UTF-16-safe helpers (sliceUtf16Safe/truncateUtf16Safe) so surrogate pairs are not split when capping tool output, error-tail detection, or aggregate elision markers. - hasImportantTail: sliceUtf16Safe(text, -2000) - appendBoundedTruncationSuffix: truncateUtf16Safe + sliceUtf16Safe - truncateToolResultText head/tail/default paths: sliceUtf16Safe - formatAggregateElisionText fallback: truncateUtf16Safe Added regression tests for emoji at char and head+tail boundaries. Co-Authored-By: Claude Opus 4.8 <[email protected]> * test(agents): cover exact tool-result UTF-16 cuts --------- Co-authored-by: chengzhichao-xydt <[email protected]> Co-authored-by: Claude Opus 4.8 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 57db713 commit 9e75ce9

2 files changed

Lines changed: 36 additions & 7 deletions

File tree

src/agents/embedded-agent-runner/tool-result-truncation.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,32 @@ describe("truncateToolResultText", () => {
166166
expect(result).toContain("[custom-truncated]");
167167
expect(result.length).toBeGreaterThan(250);
168168
});
169+
170+
it("keeps direct and suffix-only cuts on complete code points", () => {
171+
expect(
172+
truncateToolResultText("aaa😀z", 5, {
173+
suffix: "!",
174+
minKeepChars: 0,
175+
}),
176+
).toBe("aaa!");
177+
expect(
178+
truncateToolResultText("abcdef", 1, {
179+
suffix: "😀",
180+
minKeepChars: 0,
181+
}),
182+
).toBe("");
183+
});
184+
185+
it("keeps both head and tail cuts on complete code points", () => {
186+
const marker = "\n\n⚠️ [... middle content omitted — showing head and tail ...]\n\n";
187+
const text = `${"a".repeat(6)}😀${"m".repeat(100)}😀${"x".repeat(22)} Error`;
188+
expect(
189+
truncateToolResultText(text, 100, {
190+
suffix: "!",
191+
minKeepChars: 1,
192+
}),
193+
).toBe(`${"a".repeat(6)}${marker}${"x".repeat(22)} Error!`);
194+
});
169195
});
170196

171197
describe("getToolResultTextLength", () => {

src/agents/embedded-agent-runner/tool-result-truncation.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import { existsSync } from "node:fs";
55
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
6+
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
67
import type { OpenClawConfig } from "../../config/types.openclaw.js";
78
import { formatErrorMessage } from "../../infra/errors.js";
89
import type { TextContent } from "../../llm/types.js";
@@ -139,11 +140,12 @@ function appendBoundedTruncationSuffix(params: {
139140
return finalText;
140141
}
141142
if (keptText.length === 0) {
142-
return finalText.slice(0, params.maxChars);
143+
return truncateUtf16Safe(finalText, params.maxChars);
143144
}
144145
const overflow = finalText.length - params.maxChars;
145-
const nextKeptText = keptText.slice(0, Math.max(0, keptText.length - overflow));
146-
keptText = nextKeptText.length < keptText.length ? nextKeptText : keptText.slice(0, -1);
146+
const nextKeptText = sliceUtf16Safe(keptText, 0, Math.max(0, keptText.length - overflow));
147+
keptText =
148+
nextKeptText.length < keptText.length ? nextKeptText : sliceUtf16Safe(keptText, 0, -1);
147149
}
148150
}
149151

@@ -158,8 +160,8 @@ const MIDDLE_OMISSION_MARKER =
158160
* which should be preserved during truncation.
159161
*/
160162
function hasImportantTail(text: string): boolean {
161-
// Check last ~2000 chars for error-like patterns
162-
const tail = normalizeLowercaseStringOrEmpty(text.slice(-2000));
163+
// Check last ~2000 chars for error-like patterns without splitting a surrogate pair.
164+
const tail = normalizeLowercaseStringOrEmpty(sliceUtf16Safe(text, -2000));
163165
return (
164166
/\b(error|exception|failed|fatal|traceback|panic|stack trace|errno|exit code)\b/.test(tail) ||
165167
// JSON closing — if the output is JSON, the tail has closing structure
@@ -213,7 +215,8 @@ export function truncateToolResultText(
213215
tailStart = tailNewline + 1;
214216
}
215217

216-
const keptText = text.slice(0, headCut) + MIDDLE_OMISSION_MARKER + text.slice(tailStart);
218+
const keptText =
219+
sliceUtf16Safe(text, 0, headCut) + MIDDLE_OMISSION_MARKER + sliceUtf16Safe(text, tailStart);
217220
return appendBoundedTruncationSuffix({
218221
keptText,
219222
originalTextLength: text.length,
@@ -229,7 +232,7 @@ export function truncateToolResultText(
229232
if (lastNewline > budget * 0.8) {
230233
cutPoint = lastNewline;
231234
}
232-
const keptText = text.slice(0, cutPoint);
235+
const keptText = sliceUtf16Safe(text, 0, cutPoint);
233236
return appendBoundedTruncationSuffix({
234237
keptText,
235238
originalTextLength: text.length,

0 commit comments

Comments
 (0)