Files
openclaw/src/agents/session-tool-result-guard.ts
Tyler Yust 0deb8b0da1 fix: recover from context overflow caused by oversized tool results (#11579)
* fix: gracefully handle oversized tool results causing context overflow

When a subagent reads a very large file or gets a huge tool result (e.g.,
gh pr diff on a massive PR), it can exceed the model's context window in
a single prompt. Auto-compaction can't help because there's no older
history to compact — just one giant tool result.

This adds two layers of defense:

1. Pre-emptive: Hard cap on tool result size (400K chars ≈ 100K tokens)
   applied in the session tool result guard before persistence. This
   prevents extremely large tool results from being stored in full,
   regardless of model context window size.

2. Recovery: When context overflow is detected and compaction fails,
   scan session messages for oversized tool results relative to the
   model's actual context window (30% max share). If found, truncate
   them in the session via branching (creating a new branch with
   truncated content) and retry the prompt.

The truncation preserves the beginning of the content (most useful for
understanding what was read) and appends a notice explaining the
truncation and suggesting offset/limit parameters for targeted reads.

Includes comprehensive tests for:
- Text truncation with newline-boundary awareness
- Context-window-proportional size calculation
- In-memory message truncation
- Oversized detection heuristics
- Guard-level size capping during persistence

* fix: prep fixes for tool result truncation PR (#11579) (thanks @tyler6204)
2026-02-07 17:40:51 -08:00

241 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { TextContent } from "@mariozechner/pi-ai";
import type { SessionManager } from "@mariozechner/pi-coding-agent";
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import { HARD_MAX_TOOL_RESULT_CHARS } from "./pi-embedded-runner/tool-result-truncation.js";
import { makeMissingToolResult, sanitizeToolCallInputs } from "./session-transcript-repair.js";
const GUARD_TRUNCATION_SUFFIX =
"\n\n⚠ [Content truncated during persistence — original exceeded size limit. " +
"Use offset/limit parameters or request specific sections for large content.]";
/**
* Truncate oversized text content blocks in a tool result message.
* Returns the original message if under the limit, or a new message with
* truncated text blocks otherwise.
*/
function capToolResultSize(msg: AgentMessage): AgentMessage {
const role = (msg as { role?: string }).role;
if (role !== "toolResult") {
return msg;
}
const content = (msg as { content?: unknown }).content;
if (!Array.isArray(content)) {
return msg;
}
// Calculate total text size
let totalTextChars = 0;
for (const block of content) {
if (block && typeof block === "object" && (block as { type?: string }).type === "text") {
const text = (block as TextContent).text;
if (typeof text === "string") {
totalTextChars += text.length;
}
}
}
if (totalTextChars <= HARD_MAX_TOOL_RESULT_CHARS) {
return msg;
}
// Truncate proportionally
const newContent = content.map((block: unknown) => {
if (!block || typeof block !== "object" || (block as { type?: string }).type !== "text") {
return block;
}
const textBlock = block as TextContent;
if (typeof textBlock.text !== "string") {
return block;
}
const blockShare = textBlock.text.length / totalTextChars;
const blockBudget = Math.max(
2_000,
Math.floor(HARD_MAX_TOOL_RESULT_CHARS * blockShare) - GUARD_TRUNCATION_SUFFIX.length,
);
if (textBlock.text.length <= blockBudget) {
return block;
}
// Try to cut at a newline boundary
let cutPoint = blockBudget;
const lastNewline = textBlock.text.lastIndexOf("\n", blockBudget);
if (lastNewline > blockBudget * 0.8) {
cutPoint = lastNewline;
}
return {
...textBlock,
text: textBlock.text.slice(0, cutPoint) + GUARD_TRUNCATION_SUFFIX,
};
});
return { ...msg, content: newContent } as AgentMessage;
}
type ToolCall = { id: string; name?: string };
function extractAssistantToolCalls(msg: Extract<AgentMessage, { role: "assistant" }>): ToolCall[] {
const content = msg.content;
if (!Array.isArray(content)) {
return [];
}
const toolCalls: ToolCall[] = [];
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const rec = block as { type?: unknown; id?: unknown; name?: unknown };
if (typeof rec.id !== "string" || !rec.id) {
continue;
}
if (rec.type === "toolCall" || rec.type === "toolUse" || rec.type === "functionCall") {
toolCalls.push({
id: rec.id,
name: typeof rec.name === "string" ? rec.name : undefined,
});
}
}
return toolCalls;
}
function extractToolResultId(msg: Extract<AgentMessage, { role: "toolResult" }>): string | null {
const toolCallId = (msg as { toolCallId?: unknown }).toolCallId;
if (typeof toolCallId === "string" && toolCallId) {
return toolCallId;
}
const toolUseId = (msg as { toolUseId?: unknown }).toolUseId;
if (typeof toolUseId === "string" && toolUseId) {
return toolUseId;
}
return null;
}
export function installSessionToolResultGuard(
sessionManager: SessionManager,
opts?: {
/**
* Optional, synchronous transform applied to toolResult messages *before* they are
* persisted to the session transcript.
*/
transformToolResultForPersistence?: (
message: AgentMessage,
meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean },
) => AgentMessage;
/**
* Whether to synthesize missing tool results to satisfy strict providers.
* Defaults to true.
*/
allowSyntheticToolResults?: boolean;
},
): {
flushPendingToolResults: () => void;
getPendingIds: () => string[];
} {
const originalAppend = sessionManager.appendMessage.bind(sessionManager);
const pending = new Map<string, string | undefined>();
const persistToolResult = (
message: AgentMessage,
meta: { toolCallId?: string; toolName?: string; isSynthetic?: boolean },
) => {
const transformer = opts?.transformToolResultForPersistence;
return transformer ? transformer(message, meta) : message;
};
const allowSyntheticToolResults = opts?.allowSyntheticToolResults ?? true;
const flushPendingToolResults = () => {
if (pending.size === 0) {
return;
}
if (allowSyntheticToolResults) {
for (const [id, name] of pending.entries()) {
const synthetic = makeMissingToolResult({ toolCallId: id, toolName: name });
originalAppend(
persistToolResult(synthetic, {
toolCallId: id,
toolName: name,
isSynthetic: true,
}) as never,
);
}
}
pending.clear();
};
const guardedAppend = (message: AgentMessage) => {
let nextMessage = message;
const role = (message as { role?: unknown }).role;
if (role === "assistant") {
const sanitized = sanitizeToolCallInputs([message]);
if (sanitized.length === 0) {
if (allowSyntheticToolResults && pending.size > 0) {
flushPendingToolResults();
}
return undefined;
}
nextMessage = sanitized[0];
}
const nextRole = (nextMessage as { role?: unknown }).role;
if (nextRole === "toolResult") {
const id = extractToolResultId(nextMessage as Extract<AgentMessage, { role: "toolResult" }>);
const toolName = id ? pending.get(id) : undefined;
if (id) {
pending.delete(id);
}
// Apply hard size cap before persistence to prevent oversized tool results
// from consuming the entire context window on subsequent LLM calls.
const capped = capToolResultSize(nextMessage);
return originalAppend(
persistToolResult(capped, {
toolCallId: id ?? undefined,
toolName,
isSynthetic: false,
}) as never,
);
}
const toolCalls =
nextRole === "assistant"
? extractAssistantToolCalls(nextMessage as Extract<AgentMessage, { role: "assistant" }>)
: [];
if (allowSyntheticToolResults) {
// If previous tool calls are still pending, flush before non-tool results.
if (pending.size > 0 && (toolCalls.length === 0 || nextRole !== "assistant")) {
flushPendingToolResults();
}
// If new tool calls arrive while older ones are pending, flush the old ones first.
if (pending.size > 0 && toolCalls.length > 0) {
flushPendingToolResults();
}
}
const result = originalAppend(nextMessage as never);
const sessionFile = (
sessionManager as { getSessionFile?: () => string | null }
).getSessionFile?.();
if (sessionFile) {
emitSessionTranscriptUpdate(sessionFile);
}
if (toolCalls.length > 0) {
for (const call of toolCalls) {
pending.set(call.id, call.name);
}
}
return result;
};
// Monkey-patch appendMessage with our guarded version.
sessionManager.appendMessage = guardedAppend as SessionManager["appendMessage"];
return {
flushPendingToolResults,
getPendingIds: () => Array.from(pending.keys()),
};
}