fix(codex): keep dynamic tool output UTF-16 safe (#102622)

* fix(codex-dynamic-tools): use truncateUtf16Safe for notice text truncation

* fix(codex): keep dynamic tool text UTF-16 safe

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
lzyyzznl 2026-07-09 19:44:34 +08:00 committed by GitHub
parent 18fd6ee9e6
commit e99b5deb7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 42 additions and 4 deletions

View file

@ -733,6 +733,41 @@ describe("createCodexDynamicToolBridge", () => {
expect(text).toContain("rerun with narrower args");
});
it("keeps a whole code point when dynamic tool text crosses the configured boundary", async () => {
const maxChars = 180;
const totalChars = 400;
const noticeText = `...(OpenClaw truncated dynamic tool result: original ${totalChars} chars, showing ${maxChars}; rerun with narrower args.)`;
const textBudget = maxChars - noticeText.length - 1;
const prefix = "a".repeat(textBudget - 1);
const longText = `${prefix}😀${"z".repeat(totalChars - prefix.length - 2)}`;
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
name: "large_lookup",
execute: vi.fn(async () => textToolResult(longText)),
}),
],
signal: new AbortController().signal,
hookContext: {
agentId: "main",
config: {
agents: { defaults: { contextLimits: { toolResultMaxChars: maxChars } } },
} as never,
},
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "large_lookup",
arguments: {},
});
expect(result.contentItems).toEqual([{ type: "inputText", text: `${prefix}\n${noticeText}` }]);
});
it("honors normalized per-agent dynamic tool result caps", async () => {
const bridge = createCodexDynamicToolBridge({
tools: [

View file

@ -47,6 +47,7 @@ import {
asOptionalRecord as readRecord,
isRecord,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { CodexDynamicToolsLoading } from "./config.js";
import { invalidInlineImageText, sanitizeInlineImageDataUrl } from "./image-payload-sanitizer.js";
import type {
@ -1359,16 +1360,18 @@ function convertToolContents(
continue;
}
if (notice.length >= maxChars) {
output.push({ type: "inputText", text: noticeText.slice(0, maxChars) });
output.push({ type: "inputText", text: truncateUtf16Safe(noticeText, maxChars) });
appendedNotice = true;
continue;
}
const sliceLength = Math.min(item.text.length, remainingTextBudget);
remainingTextBudget -= sliceLength;
const shouldAppendNotice = remainingTextBudget <= 0;
const text = item.text.slice(0, sliceLength);
const text = truncateUtf16Safe(item.text, sliceLength);
if (shouldAppendNotice) {
output.push({ type: "inputText", text: `${text.trimEnd()}${notice}`.slice(0, maxChars) });
// The notice budget is reserved before slicing text, so the combined
// result is already bounded without another boundary-sensitive cut.
output.push({ type: "inputText", text: `${text.trimEnd()}${notice}` });
appendedNotice = true;
} else if (text.length > 0) {
output.push({ type: "inputText", text });
@ -1376,7 +1379,7 @@ function convertToolContents(
}
if (!appendedNotice) {
output.push({ type: "inputText", text: noticeText.slice(0, maxChars) });
output.push({ type: "inputText", text: truncateUtf16Safe(noticeText, maxChars) });
}
return output;
}