fix(agents): keep tool-result context guard truncation UTF-16 safe (#102466)

* fix(agents): keep tool-result context guard truncation UTF-16 safe

Replace the raw text.slice(0, cutPoint) in truncateTextToBudget with
truncateUtf16Safe so oversized tool results do not emit lone surrogates
when an emoji falls on the truncation boundary.

Adds a regression test that places an emoji exactly at the legacy cut
point and asserts the truncated output contains no lone surrogates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(agents): report surrogate-safe omitted count

---------

Co-authored-by: chengzhichao-xydt <chengzhichao-xydt@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
chengzhichao-xydt
2026-07-09 14:59:08 +08:00
committed by GitHub
parent 00e323c990
commit bc6a4bdd94
2 changed files with 23 additions and 2 deletions
@@ -327,6 +327,26 @@ describe("installToolResultContextGuard", () => {
expectOpenClawTruncation(getToolResultText(transformed[0]));
});
it("truncates UTF-16 tool results without splitting surrogate pairs", async () => {
// With contextWindowTokens=1000, maxSingleToolResultChars=1024 and the
// text budget becomes 512. The legacy cut point falls inside the emoji
// at index 439, which used to emit a lone high surrogate.
const agent = makeGuardableAgent();
const text = "a".repeat(439) + "😀" + "b".repeat(1_000);
const contextForNextCall = [makeToolResult("call_utf16", text)];
const transformed = (await applyGuardToContext(
agent,
contextForNextCall,
1_000,
)) as AgentMessage[];
expect(getToolResultText(transformed[0])).toBe(
"a".repeat(439) + formatContextLimitTruncationNotice(1_002),
);
expect(getToolResultText(contextForNextCall[0])).toBe(text);
});
it("raises a structured mid-turn precheck signal after a new tool result overflows", async () => {
// The signal carries route metadata so the run loop can compact/truncate
// without guessing from a generic overflow error.
@@ -1,3 +1,4 @@
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
/**
* Installs context guards for oversized tool-result histories.
*/
@@ -164,8 +165,8 @@ function truncateTextToBudget(text: string, maxChars: number): string {
cutPoint = newline;
}
const omittedChars = text.length - cutPoint;
return text.slice(0, cutPoint) + formatContextLimitTruncationNotice(omittedChars);
const prefix = truncateUtf16Safe(text, cutPoint);
return prefix + formatContextLimitTruncationNotice(text.length - prefix.length);
}
function replaceToolResultText(msg: AgentMessage, text: string): AgentMessage {