feat(tool-truncation): use head+tail strategy to preserve errors during truncation (#20076)

Merged via squash.

Prepared head SHA: 6edebf22b1
Co-authored-by: jlwestsr <52389+jlwestsr@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Jason L. West, Sr.
2026-03-03 10:11:14 -06:00
committed by GitHub
parent d89e1e40f9
commit 606cd0d591
3 changed files with 80 additions and 6 deletions

View File

@@ -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

View File

@@ -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");
});
});

View File

@@ -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;