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

When tool results are truncated to fit the context window, important
content at the end (error messages, stack traces, result summaries)
was previously lost since only the beginning was kept.

Now detects when the tail contains error/diagnostic content and splits
the truncation budget: 70% head, 30% tail, with a middle omission
marker. This preserves both the initial context and the error output
that's typically most actionable.

Falls back to head-only truncation when the tail doesn't contain
important patterns (errors, JSON closing, summary lines).

# Conflicts:
#	src/agents/pi-embedded-runner/tool-result-truncation.ts
This commit is contained in:
Josh Lehman
2026-03-03 07:25:38 -08:00
parent d89e1e40f9
commit 6edebf22b1
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;