mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 03:51:25 +00:00
* 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)
273 lines
8.1 KiB
TypeScript
273 lines
8.1 KiB
TypeScript
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
|
import { describe, expect, it } from "vitest";
|
|
import { installSessionToolResultGuard } from "./session-tool-result-guard.js";
|
|
|
|
type AppendMessage = Parameters<SessionManager["appendMessage"]>[0];
|
|
|
|
const asAppendMessage = (message: unknown) => message as AppendMessage;
|
|
|
|
const toolCallMessage = asAppendMessage({
|
|
role: "assistant",
|
|
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
|
|
});
|
|
|
|
describe("installSessionToolResultGuard", () => {
|
|
it("inserts synthetic toolResult before non-tool message when pending", () => {
|
|
const sm = SessionManager.inMemory();
|
|
installSessionToolResultGuard(sm);
|
|
|
|
sm.appendMessage(toolCallMessage);
|
|
sm.appendMessage(
|
|
asAppendMessage({
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "error" }],
|
|
stopReason: "error",
|
|
}),
|
|
);
|
|
|
|
const entries = sm
|
|
.getEntries()
|
|
.filter((e) => e.type === "message")
|
|
.map((e) => (e as { message: AgentMessage }).message);
|
|
|
|
expect(entries.map((m) => m.role)).toEqual(["assistant", "toolResult", "assistant"]);
|
|
const synthetic = entries[1] as {
|
|
toolCallId?: string;
|
|
isError?: boolean;
|
|
content?: Array<{ type?: string; text?: string }>;
|
|
};
|
|
expect(synthetic.toolCallId).toBe("call_1");
|
|
expect(synthetic.isError).toBe(true);
|
|
expect(synthetic.content?.[0]?.text).toContain("missing tool result");
|
|
});
|
|
|
|
it("flushes pending tool calls when asked explicitly", () => {
|
|
const sm = SessionManager.inMemory();
|
|
const guard = installSessionToolResultGuard(sm);
|
|
|
|
sm.appendMessage(toolCallMessage);
|
|
guard.flushPendingToolResults();
|
|
|
|
const messages = sm
|
|
.getEntries()
|
|
.filter((e) => e.type === "message")
|
|
.map((e) => (e as { message: AgentMessage }).message);
|
|
|
|
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
|
|
});
|
|
|
|
it("does not add synthetic toolResult when a matching one exists", () => {
|
|
const sm = SessionManager.inMemory();
|
|
installSessionToolResultGuard(sm);
|
|
|
|
sm.appendMessage(toolCallMessage);
|
|
sm.appendMessage(
|
|
asAppendMessage({
|
|
role: "toolResult",
|
|
toolCallId: "call_1",
|
|
content: [{ type: "text", text: "ok" }],
|
|
isError: false,
|
|
}),
|
|
);
|
|
|
|
const messages = sm
|
|
.getEntries()
|
|
.filter((e) => e.type === "message")
|
|
.map((e) => (e as { message: AgentMessage }).message);
|
|
|
|
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
|
|
});
|
|
|
|
it("preserves ordering with multiple tool calls and partial results", () => {
|
|
const sm = SessionManager.inMemory();
|
|
const guard = installSessionToolResultGuard(sm);
|
|
|
|
sm.appendMessage(
|
|
asAppendMessage({
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "toolCall", id: "call_a", name: "one", arguments: {} },
|
|
{ type: "toolUse", id: "call_b", name: "two", arguments: {} },
|
|
],
|
|
}),
|
|
);
|
|
sm.appendMessage(
|
|
asAppendMessage({
|
|
role: "toolResult",
|
|
toolUseId: "call_a",
|
|
content: [{ type: "text", text: "a" }],
|
|
isError: false,
|
|
}),
|
|
);
|
|
sm.appendMessage(
|
|
asAppendMessage({
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "after tools" }],
|
|
}),
|
|
);
|
|
|
|
const messages = sm
|
|
.getEntries()
|
|
.filter((e) => e.type === "message")
|
|
.map((e) => (e as { message: AgentMessage }).message);
|
|
|
|
expect(messages.map((m) => m.role)).toEqual([
|
|
"assistant", // tool calls
|
|
"toolResult", // call_a real
|
|
"toolResult", // synthetic for call_b
|
|
"assistant", // text
|
|
]);
|
|
expect((messages[2] as { toolCallId?: string }).toolCallId).toBe("call_b");
|
|
expect(guard.getPendingIds()).toEqual([]);
|
|
});
|
|
|
|
it("flushes pending on guard when no toolResult arrived", () => {
|
|
const sm = SessionManager.inMemory();
|
|
const guard = installSessionToolResultGuard(sm);
|
|
|
|
sm.appendMessage(toolCallMessage);
|
|
sm.appendMessage(
|
|
asAppendMessage({
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "hard error" }],
|
|
stopReason: "error",
|
|
}),
|
|
);
|
|
expect(guard.getPendingIds()).toEqual([]);
|
|
});
|
|
|
|
it("handles toolUseId on toolResult", () => {
|
|
const sm = SessionManager.inMemory();
|
|
installSessionToolResultGuard(sm);
|
|
|
|
sm.appendMessage(
|
|
asAppendMessage({
|
|
role: "assistant",
|
|
content: [{ type: "toolUse", id: "use_1", name: "f", arguments: {} }],
|
|
}),
|
|
);
|
|
sm.appendMessage(
|
|
asAppendMessage({
|
|
role: "toolResult",
|
|
toolUseId: "use_1",
|
|
content: [{ type: "text", text: "ok" }],
|
|
}),
|
|
);
|
|
|
|
const messages = sm
|
|
.getEntries()
|
|
.filter((e) => e.type === "message")
|
|
.map((e) => (e as { message: AgentMessage }).message);
|
|
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
|
|
});
|
|
|
|
it("drops malformed tool calls missing input before persistence", () => {
|
|
const sm = SessionManager.inMemory();
|
|
installSessionToolResultGuard(sm);
|
|
|
|
sm.appendMessage(
|
|
asAppendMessage({
|
|
role: "assistant",
|
|
content: [{ type: "toolCall", id: "call_1", name: "read" }],
|
|
}),
|
|
);
|
|
|
|
const messages = sm
|
|
.getEntries()
|
|
.filter((e) => e.type === "message")
|
|
.map((e) => (e as { message: AgentMessage }).message);
|
|
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it("flushes pending tool results when a sanitized assistant message is dropped", () => {
|
|
const sm = SessionManager.inMemory();
|
|
installSessionToolResultGuard(sm);
|
|
|
|
sm.appendMessage(
|
|
asAppendMessage({
|
|
role: "assistant",
|
|
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
|
|
}),
|
|
);
|
|
|
|
sm.appendMessage(
|
|
asAppendMessage({
|
|
role: "assistant",
|
|
content: [{ type: "toolCall", id: "call_2", name: "read" }],
|
|
}),
|
|
);
|
|
|
|
const messages = sm
|
|
.getEntries()
|
|
.filter((e) => e.type === "message")
|
|
.map((e) => (e as { message: AgentMessage }).message);
|
|
|
|
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
|
|
});
|
|
|
|
it("caps oversized tool result text during persistence", () => {
|
|
const sm = SessionManager.inMemory();
|
|
installSessionToolResultGuard(sm);
|
|
|
|
sm.appendMessage(toolCallMessage);
|
|
sm.appendMessage(
|
|
asAppendMessage({
|
|
role: "toolResult",
|
|
toolCallId: "call_1",
|
|
toolName: "read",
|
|
content: [{ type: "text", text: "x".repeat(500_000) }],
|
|
isError: false,
|
|
timestamp: Date.now(),
|
|
}),
|
|
);
|
|
|
|
const entries = sm
|
|
.getEntries()
|
|
.filter((e) => e.type === "message")
|
|
.map((e) => (e as { message: AgentMessage }).message);
|
|
|
|
const toolResult = entries.find((m) => m.role === "toolResult") as {
|
|
content: Array<{ type: string; text: string }>;
|
|
};
|
|
expect(toolResult).toBeDefined();
|
|
const textBlock = toolResult.content.find((b: { type: string }) => b.type === "text") as {
|
|
text: string;
|
|
};
|
|
expect(textBlock.text.length).toBeLessThan(500_000);
|
|
expect(textBlock.text).toContain("truncated");
|
|
});
|
|
|
|
it("does not truncate tool results under the limit", () => {
|
|
const sm = SessionManager.inMemory();
|
|
installSessionToolResultGuard(sm);
|
|
|
|
const originalText = "small tool result";
|
|
sm.appendMessage(toolCallMessage);
|
|
sm.appendMessage(
|
|
asAppendMessage({
|
|
role: "toolResult",
|
|
toolCallId: "call_1",
|
|
toolName: "read",
|
|
content: [{ type: "text", text: originalText }],
|
|
isError: false,
|
|
timestamp: Date.now(),
|
|
}),
|
|
);
|
|
|
|
const entries = sm
|
|
.getEntries()
|
|
.filter((e) => e.type === "message")
|
|
.map((e) => (e as { message: AgentMessage }).message);
|
|
|
|
const toolResult = entries.find((m) => m.role === "toolResult") as {
|
|
content: Array<{ type: string; text: string }>;
|
|
};
|
|
const textBlock = toolResult.content.find((b: { type: string }) => b.type === "text") as {
|
|
text: string;
|
|
};
|
|
expect(textBlock.text).toBe(originalText);
|
|
});
|
|
});
|