diff --git a/changelog.d/fixes/8169-lite-word-boundary-truncation.md b/changelog.d/fixes/8169-lite-word-boundary-truncation.md new file mode 100644 index 0000000000..4943d64cf0 --- /dev/null +++ b/changelog.d/fixes/8169-lite-word-boundary-truncation.md @@ -0,0 +1 @@ +- fix(backend): word-boundary-safe tool-result truncation in lite compression mode (#8169) diff --git a/open-sse/services/compression/lite.ts b/open-sse/services/compression/lite.ts index 3ed09beebf..6c795766fd 100644 --- a/open-sse/services/compression/lite.ts +++ b/open-sse/services/compression/lite.ts @@ -105,6 +105,47 @@ export function dedupSystemPrompt( return { body: { ...body, messages }, applied }; } +// Adjust a hard cut index to the nearest whitespace within a small lookback/lookahead +// window, so a truncated tool result never garbles the word it lands in the middle of +// (#8169). Prefers backing off to the end of the previous word (keeps the result at or +// under the limit); if no whitespace precedes the cut within the window (e.g. the tail +// end of a very long unbroken run), looks forward to complete the current word instead. +// Falls back to the original hard cut index when neither direction finds a boundary. +const TOOL_TRUNCATION_LOOKBACK = 80; + +function isWordChar(char: string | undefined): boolean { + return char !== undefined && /\S/.test(char); +} + +function findWhitespaceBackward(content: string, cutIndex: number): number { + const windowStart = Math.max(0, cutIndex - TOOL_TRUNCATION_LOOKBACK); + for (let i = cutIndex; i > windowStart; i--) { + if (!isWordChar(content[i - 1])) return i - 1; + } + return -1; +} + +function findWhitespaceForward(content: string, cutIndex: number): number { + const windowEnd = Math.min(content.length, cutIndex + TOOL_TRUNCATION_LOOKBACK); + for (let i = cutIndex; i < windowEnd; i++) { + if (!isWordChar(content[i])) return i; + } + return -1; +} + +function backOffToWordBoundary(content: string, cutIndex: number): number { + const onWordBoundary = !isWordChar(content[cutIndex - 1]) || !isWordChar(content[cutIndex]); + if (onWordBoundary) return cutIndex; + + const backward = findWhitespaceBackward(content, cutIndex); + if (backward !== -1) return backward; + + const forward = findWhitespaceForward(content, cutIndex); + if (forward !== -1) return forward; + + return cutIndex; +} + export function compressToolResults(body: ChatBody): { body: ChatBody; applied: boolean; @@ -116,9 +157,10 @@ export function compressToolResults(body: ChatBody): { if (msg.role !== "tool" || typeof msg.content !== "string") return msg; if (msg.content.length <= MAX_TOOL_LENGTH) return msg; applied = true; + const cutIndex = backOffToWordBoundary(msg.content, MAX_TOOL_LENGTH); return { ...msg, - content: msg.content.slice(0, MAX_TOOL_LENGTH) + "\n...[truncated]", + content: msg.content.slice(0, cutIndex) + "\n...[truncated]", }; }); return { body: { ...body, messages }, applied }; diff --git a/tests/unit/8169-lite-word-boundary-truncation.test.ts b/tests/unit/8169-lite-word-boundary-truncation.test.ts new file mode 100644 index 0000000000..e3e634c766 --- /dev/null +++ b/tests/unit/8169-lite-word-boundary-truncation.test.ts @@ -0,0 +1,63 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { compressToolResults } from "../../open-sse/services/compression/lite.ts"; + +interface TestMessage { + role: string; + content: string; +} + +interface TestChatBody { + messages: TestMessage[]; +} + +function toolBody(content: string): TestChatBody { + return { messages: [{ role: "tool", content }] }; +} + +function firstMessageContent(body: TestChatBody): string { + return body.messages[0].content; +} + +test("#8169: lite compressToolResults must not cut a word in half", () => { + const prefix = "x".repeat(1990); + const word = "authentication"; // straddles the 2000-char cut point + const content = prefix + word + " rest of the message continues here."; + const { body: out } = compressToolResults(toolBody(content)); + const resultContent = firstMessageContent(out as TestChatBody); + const cutPoint = resultContent.indexOf("\n...[truncated]"); + assert.notEqual(cutPoint, -1); + const lastChar = resultContent[cutPoint - 1]; + const charAfterWouldBe = content[cutPoint]; + const isMidWord = /[a-zA-Z0-9]/.test(lastChar) && /[a-zA-Z0-9]/.test(charAfterWouldBe); + assert.equal( + isMidWord, + false, + `mid-word cut: "...${resultContent.slice(cutPoint - 20, cutPoint)}" next="${charAfterWouldBe}"` + ); +}); + +test("#8169: compressToolResults still truncates content well over MAX_TOOL_LENGTH", () => { + const content = "word ".repeat(1000); // 5000 chars, plenty of whitespace boundaries + const { body: out, applied } = compressToolResults(toolBody(content)); + const resultContent = firstMessageContent(out as TestChatBody); + assert.equal(applied, true); + assert.ok(resultContent.length < content.length); + assert.ok(resultContent.endsWith("\n...[truncated]")); +}); + +test("#8169: compressToolResults falls back to hard cut when no whitespace found in lookback window", () => { + const content = "a".repeat(2100); // no whitespace anywhere + const { body: out, applied } = compressToolResults(toolBody(content)); + const resultContent = firstMessageContent(out as TestChatBody); + assert.equal(applied, true); + assert.ok(resultContent.endsWith("\n...[truncated]")); +}); + +test("#8169: compressToolResults leaves short tool content untouched", () => { + const content = "short content"; + const { body: out, applied } = compressToolResults(toolBody(content)); + const resultContent = firstMessageContent(out as TestChatBody); + assert.equal(applied, false); + assert.equal(resultContent, content); +});