mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 15:30:39 +00:00
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:
committed by
GitHub
parent
d89e1e40f9
commit
606cd0d591
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user