diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc07ffb059..d206e228cae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Docs/Web search: remove outdated Brave free-tier wording and replace prescriptive AI ToS guidance with neutral compliance language in Brave setup docs. (#26860) Thanks @HenryLoenwind. - Tools/Diffs guidance loading: move diffs usage guidance from unconditional prompt-hook injection to the plugin companion skill path, reducing unrelated-turn prompt noise while keeping diffs tool behavior unchanged. (#32630) thanks @sircrumpet. +- Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr. ### Fixes diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts index a606d977ba1..8b4fbb628c6 100644 --- a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts +++ b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts @@ -289,3 +289,25 @@ describe("sessionLikelyHasOversizedToolResults", () => { ).toBe(false); }); }); + +describe("truncateToolResultText head+tail strategy", () => { + it("preserves error content at the tail when present", () => { + const head = "Line 1\n".repeat(500); + const middle = "data data data\n".repeat(500); + const tail = "\nError: something failed\nStack trace: at foo.ts:42\n"; + const text = head + middle + tail; + const result = truncateToolResultText(text, 5000); + // Should contain both the beginning and the error at the end + expect(result).toContain("Line 1"); + expect(result).toContain("Error: something failed"); + expect(result).toContain("middle content omitted"); + }); + + it("uses simple head truncation when tail has no important content", () => { + const text = "normal line\n".repeat(1000); + const result = truncateToolResultText(text, 5000); + expect(result).toContain("normal line"); + expect(result).not.toContain("middle content omitted"); + expect(result).toContain("truncated"); + }); +}); diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.ts b/src/agents/pi-embedded-runner/tool-result-truncation.ts index 05bce138868..c8cbd1124bb 100644 --- a/src/agents/pi-embedded-runner/tool-result-truncation.ts +++ b/src/agents/pi-embedded-runner/tool-result-truncation.ts @@ -39,7 +39,34 @@ type ToolResultTruncationOptions = { }; /** - * Truncate a single text string to fit within maxChars, preserving the beginning. + * Marker inserted between head and tail when using head+tail truncation. + */ +const MIDDLE_OMISSION_MARKER = + "\n\n⚠️ [... middle content omitted — showing head and tail ...]\n\n"; + +/** + * Detect whether text likely contains error/diagnostic content near the end, + * which should be preserved during truncation. + */ +function hasImportantTail(text: string): boolean { + // Check last ~2000 chars for error-like patterns + const tail = text.slice(-2000).toLowerCase(); + return ( + /\b(error|exception|failed|fatal|traceback|panic|stack trace|errno|exit code)\b/.test(tail) || + // JSON closing — if the output is JSON, the tail has closing structure + /\}\s*$/.test(tail.trim()) || + // Summary/result lines often appear at the end + /\b(total|summary|result|complete|finished|done)\b/.test(tail) + ); +} + +/** + * Truncate a single text string to fit within maxChars. + * + * Uses a head+tail strategy when the tail contains important content + * (errors, results, JSON structure), otherwise preserves the beginning. + * This ensures error messages and summaries at the end of tool output + * aren't lost during truncation. */ export function truncateToolResultText( text: string, @@ -51,11 +78,35 @@ export function truncateToolResultText( if (text.length <= maxChars) { return text; } - const keepChars = Math.max(minKeepChars, maxChars - suffix.length); - // Try to break at a newline boundary to avoid cutting mid-line - let cutPoint = keepChars; - const lastNewline = text.lastIndexOf("\n", keepChars); - if (lastNewline > keepChars * 0.8) { + const budget = Math.max(minKeepChars, maxChars - suffix.length); + + // If tail looks important, split budget between head and tail + if (hasImportantTail(text) && budget > minKeepChars * 2) { + const tailBudget = Math.min(Math.floor(budget * 0.3), 4_000); + const headBudget = budget - tailBudget - MIDDLE_OMISSION_MARKER.length; + + if (headBudget > minKeepChars) { + // Find clean cut points at newline boundaries + let headCut = headBudget; + const headNewline = text.lastIndexOf("\n", headBudget); + if (headNewline > headBudget * 0.8) { + headCut = headNewline; + } + + let tailStart = text.length - tailBudget; + const tailNewline = text.indexOf("\n", tailStart); + if (tailNewline !== -1 && tailNewline < tailStart + tailBudget * 0.2) { + tailStart = tailNewline + 1; + } + + return text.slice(0, headCut) + MIDDLE_OMISSION_MARKER + text.slice(tailStart) + suffix; + } + } + + // Default: keep the beginning + let cutPoint = budget; + const lastNewline = text.lastIndexOf("\n", budget); + if (lastNewline > budget * 0.8) { cutPoint = lastNewline; } return text.slice(0, cutPoint) + suffix;