Skip to content

Commit 2404871

Browse files
authored
fix(web-fetch): keep spill content truncation UTF-16 safe (#101312)
* Fix UTF-16-safe web fetch spill truncation * fix(web-fetch): report actual spill character count
1 parent 037412e commit 2404871

2 files changed

Lines changed: 37 additions & 3 deletions

File tree

src/agents/tools/web-fetch.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
normalizeOptionalLowercaseString,
1010
normalizeOptionalString,
1111
} from "@openclaw/normalization-core/string-coerce";
12+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
1213
import { Type } from "typebox";
1314
import { resolveWebProviderConfig } from "../../../packages/web-content-core/src/provider-runtime-shared.js";
1415
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -314,7 +315,8 @@ async function spillWebFetchContent(
314315
}
315316
// maxChars/maxCharsCap bound the model-visible return text. Recoverable spill
316317
// uses this fixed file cap so vanished pages can still be read after truncation.
317-
const content = value.slice(0, WEB_FETCH_SPILL_MAX_CHARS);
318+
const content = truncateUtf16Safe(value, WEB_FETCH_SPILL_MAX_CHARS);
319+
const spilledChars = content.length;
318320
const fullOutputPath = await writePrivateTempFile(
319321
"openclaw-web-fetch",
320322
wrapWebContent(content, "web_fetch"),
@@ -324,7 +326,7 @@ async function spillWebFetchContent(
324326
const spillNote = sourceTruncated
325327
? " Spilled available content from truncated response."
326328
: spillCapped
327-
? ` Spilled first ${WEB_FETCH_SPILL_MAX_CHARS} chars.`
329+
? ` Spilled first ${spilledChars} chars.`
328330
: "";
329331
const fullOutputFooter = formatFullOutputFooter(fullOutputPath);
330332
const footer = `\n\n[Showing truncated web_fetch content. ${fullOutputFooter}.${spillNote}]`;
@@ -344,7 +346,7 @@ async function spillWebFetchContent(
344346
text,
345347
wrappedLength: text.length,
346348
fullOutputPath,
347-
spilledChars: content.length,
349+
spilledChars,
348350
spillTruncated,
349351
};
350352
}

src/agents/tools/web-tools.fetch.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,38 @@ describe("web_fetch extraction fallbacks", () => {
503503
await rm(details.fullOutputPath, { force: true });
504504
});
505505

506+
it("does not split an emoji at the web_fetch spill file cap", async () => {
507+
const prefix = "x".repeat(WEB_FETCH_SPILL_MAX_CHARS - 1);
508+
const fullText = `${prefix}${String.fromCodePoint(0x1f600)}tail`;
509+
installPlainTextFetch(fullText);
510+
511+
const tool = createFetchTool({
512+
firecrawl: { enabled: false },
513+
maxChars: 500,
514+
maxResponseBytes: WEB_FETCH_SPILL_MAX_CHARS + 1_000,
515+
});
516+
517+
const result = await tool?.execute?.("call", { url: "https://example.com/spill-utf16" });
518+
const details = result?.details as {
519+
text?: string;
520+
fullOutputPath?: string;
521+
spilledChars?: number;
522+
spillTruncated?: boolean;
523+
};
524+
if (!details.fullOutputPath) {
525+
throw new Error("expected fullOutputPath");
526+
}
527+
528+
expect(details.spilledChars).toBe(WEB_FETCH_SPILL_MAX_CHARS - 1);
529+
expect(details.text).toContain(`Spilled first ${WEB_FETCH_SPILL_MAX_CHARS - 1} chars.`);
530+
expect(details.spillTruncated).toBe(true);
531+
const spilledText = await readFile(details.fullOutputPath, "utf8");
532+
expect(spilledText).toContain(prefix);
533+
expect(spilledText).not.toContain(String.fromCodePoint(0x1f600));
534+
expect(spilledText).not.toContain(String.fromCharCode(0xd83d));
535+
await rm(details.fullOutputPath, { force: true });
536+
});
537+
506538
it("marks byte-capped web_fetch spills as partial", async () => {
507539
const fullText = "z".repeat(40_000);
508540
installMockFetch((input: RequestInfo | URL) => {

0 commit comments

Comments
 (0)