mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 11:41:24 +00:00
271 lines
9.1 KiB
TypeScript
271 lines
9.1 KiB
TypeScript
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
CONTEXT_LIMIT_TRUNCATION_NOTICE,
|
|
PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER,
|
|
installToolResultContextGuard,
|
|
} from "./tool-result-context-guard.js";
|
|
|
|
function makeUser(text: string): AgentMessage {
|
|
return {
|
|
role: "user",
|
|
content: text,
|
|
timestamp: Date.now(),
|
|
} as unknown as AgentMessage;
|
|
}
|
|
|
|
function makeToolResult(id: string, text: string): AgentMessage {
|
|
return {
|
|
role: "toolResult",
|
|
toolCallId: id,
|
|
toolName: "read",
|
|
content: [{ type: "text", text }],
|
|
isError: false,
|
|
timestamp: Date.now(),
|
|
} as unknown as AgentMessage;
|
|
}
|
|
|
|
function makeLegacyToolResult(id: string, text: string): AgentMessage {
|
|
return {
|
|
role: "tool",
|
|
tool_call_id: id,
|
|
tool_name: "read",
|
|
content: text,
|
|
} as unknown as AgentMessage;
|
|
}
|
|
|
|
function makeToolResultWithDetails(id: string, text: string, detailText: string): AgentMessage {
|
|
return {
|
|
role: "toolResult",
|
|
toolCallId: id,
|
|
toolName: "read",
|
|
content: [{ type: "text", text }],
|
|
details: {
|
|
truncation: {
|
|
truncated: true,
|
|
outputLines: 100,
|
|
content: detailText,
|
|
},
|
|
},
|
|
isError: false,
|
|
timestamp: Date.now(),
|
|
} as unknown as AgentMessage;
|
|
}
|
|
|
|
function getToolResultText(msg: AgentMessage): string {
|
|
const content = (msg as { content?: unknown }).content;
|
|
if (!Array.isArray(content)) {
|
|
return "";
|
|
}
|
|
const block = content.find(
|
|
(entry) => entry && typeof entry === "object" && (entry as { type?: string }).type === "text",
|
|
) as { text?: string } | undefined;
|
|
return typeof block?.text === "string" ? block.text : "";
|
|
}
|
|
|
|
function makeGuardableAgent(
|
|
transformContext?: (
|
|
messages: AgentMessage[],
|
|
signal: AbortSignal,
|
|
) => AgentMessage[] | Promise<AgentMessage[]>,
|
|
) {
|
|
return { transformContext };
|
|
}
|
|
|
|
function makeTwoToolResultOverflowContext(): AgentMessage[] {
|
|
return [
|
|
makeUser("u".repeat(2_000)),
|
|
makeToolResult("call_old", "x".repeat(1_000)),
|
|
makeToolResult("call_new", "y".repeat(1_000)),
|
|
];
|
|
}
|
|
|
|
async function applyGuardToContext(
|
|
agent: { transformContext?: (messages: AgentMessage[], signal: AbortSignal) => unknown },
|
|
contextForNextCall: AgentMessage[],
|
|
) {
|
|
installToolResultContextGuard({
|
|
agent,
|
|
contextWindowTokens: 1_000,
|
|
});
|
|
return await agent.transformContext?.(contextForNextCall, new AbortController().signal);
|
|
}
|
|
|
|
describe("installToolResultContextGuard", () => {
|
|
it("compacts oldest-first when total context overflows, even if each result fits individually", async () => {
|
|
const agent = makeGuardableAgent();
|
|
const contextForNextCall = makeTwoToolResultOverflowContext();
|
|
const transformed = await applyGuardToContext(agent, contextForNextCall);
|
|
|
|
expect(transformed).toBe(contextForNextCall);
|
|
const oldResultText = getToolResultText(contextForNextCall[1]);
|
|
const newResultText = getToolResultText(contextForNextCall[2]);
|
|
|
|
expect(oldResultText).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
|
expect(newResultText).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
|
expect(newResultText).not.toContain(CONTEXT_LIMIT_TRUNCATION_NOTICE);
|
|
});
|
|
|
|
it("keeps compacting oldest-first until context is back under budget", async () => {
|
|
const agent = makeGuardableAgent();
|
|
|
|
installToolResultContextGuard({
|
|
agent,
|
|
contextWindowTokens: 1_000,
|
|
});
|
|
|
|
const contextForNextCall = [
|
|
makeUser("u".repeat(2_200)),
|
|
makeToolResult("call_1", "a".repeat(800)),
|
|
makeToolResult("call_2", "b".repeat(800)),
|
|
makeToolResult("call_3", "c".repeat(800)),
|
|
];
|
|
|
|
await agent.transformContext?.(contextForNextCall, new AbortController().signal);
|
|
|
|
const first = getToolResultText(contextForNextCall[1]);
|
|
const second = getToolResultText(contextForNextCall[2]);
|
|
const third = getToolResultText(contextForNextCall[3]);
|
|
|
|
expect(first).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
|
expect(second).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
|
expect(third).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
|
});
|
|
|
|
it("survives repeated large tool results by compacting older outputs before later turns", async () => {
|
|
const agent = makeGuardableAgent();
|
|
|
|
installToolResultContextGuard({
|
|
agent,
|
|
contextWindowTokens: 100_000,
|
|
});
|
|
|
|
const contextForNextCall: AgentMessage[] = [makeUser("stress")];
|
|
for (let i = 1; i <= 4; i++) {
|
|
contextForNextCall.push(makeToolResult(`call_${i}`, String(i).repeat(95_000)));
|
|
await agent.transformContext?.(contextForNextCall, new AbortController().signal);
|
|
}
|
|
|
|
const toolResultTexts = contextForNextCall
|
|
.filter((msg) => msg.role === "toolResult")
|
|
.map((msg) => getToolResultText(msg as AgentMessage));
|
|
|
|
expect(toolResultTexts[0]).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
|
expect(toolResultTexts[3]?.length).toBe(95_000);
|
|
expect(toolResultTexts.join("\n")).not.toContain(CONTEXT_LIMIT_TRUNCATION_NOTICE);
|
|
});
|
|
|
|
it("truncates an individually oversized tool result with a context-limit notice", async () => {
|
|
const agent = makeGuardableAgent();
|
|
|
|
installToolResultContextGuard({
|
|
agent,
|
|
contextWindowTokens: 1_000,
|
|
});
|
|
|
|
const contextForNextCall = [makeToolResult("call_big", "z".repeat(5_000))];
|
|
|
|
await agent.transformContext?.(contextForNextCall, new AbortController().signal);
|
|
|
|
const newResultText = getToolResultText(contextForNextCall[0]);
|
|
expect(newResultText.length).toBeLessThan(5_000);
|
|
expect(newResultText).toContain(CONTEXT_LIMIT_TRUNCATION_NOTICE);
|
|
});
|
|
|
|
it("keeps compacting oldest-first until overflow clears, including the newest tool result when needed", async () => {
|
|
const agent = makeGuardableAgent();
|
|
|
|
installToolResultContextGuard({
|
|
agent,
|
|
contextWindowTokens: 1_000,
|
|
});
|
|
|
|
const contextForNextCall = [
|
|
makeUser("u".repeat(2_600)),
|
|
makeToolResult("call_old", "x".repeat(700)),
|
|
makeToolResult("call_new", "y".repeat(1_000)),
|
|
];
|
|
|
|
await agent.transformContext?.(contextForNextCall, new AbortController().signal);
|
|
|
|
const oldResultText = getToolResultText(contextForNextCall[1]);
|
|
const newResultText = getToolResultText(contextForNextCall[2]);
|
|
|
|
expect(oldResultText).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
|
expect(newResultText).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
|
expect(newResultText).not.toContain(CONTEXT_LIMIT_TRUNCATION_NOTICE);
|
|
});
|
|
|
|
it("wraps an existing transformContext and guards the transformed output", async () => {
|
|
const agent = makeGuardableAgent((messages) => {
|
|
return messages.map(
|
|
(msg) =>
|
|
({
|
|
...(msg as unknown as Record<string, unknown>),
|
|
}) as unknown as AgentMessage,
|
|
);
|
|
});
|
|
const contextForNextCall = makeTwoToolResultOverflowContext();
|
|
const transformed = await applyGuardToContext(agent, contextForNextCall);
|
|
|
|
expect(transformed).not.toBe(contextForNextCall);
|
|
const transformedMessages = transformed as AgentMessage[];
|
|
const oldResultText = getToolResultText(transformedMessages[1]);
|
|
expect(oldResultText).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
|
});
|
|
|
|
it("handles legacy role=tool string outputs when enforcing context budget", async () => {
|
|
const agent = makeGuardableAgent();
|
|
|
|
installToolResultContextGuard({
|
|
agent,
|
|
contextWindowTokens: 1_000,
|
|
});
|
|
|
|
const contextForNextCall = [
|
|
makeUser("u".repeat(2_000)),
|
|
makeLegacyToolResult("call_old", "x".repeat(1_000)),
|
|
makeLegacyToolResult("call_new", "y".repeat(1_000)),
|
|
];
|
|
|
|
await agent.transformContext?.(contextForNextCall, new AbortController().signal);
|
|
|
|
const oldResultText = (contextForNextCall[1] as { content?: unknown }).content;
|
|
const newResultText = (contextForNextCall[2] as { content?: unknown }).content;
|
|
|
|
expect(oldResultText).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
|
expect(newResultText).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
|
});
|
|
|
|
it("drops oversized read-tool details payloads when compacting tool results", async () => {
|
|
const agent = makeGuardableAgent();
|
|
|
|
installToolResultContextGuard({
|
|
agent,
|
|
contextWindowTokens: 1_000,
|
|
});
|
|
|
|
const contextForNextCall = [
|
|
makeUser("u".repeat(1_600)),
|
|
makeToolResultWithDetails("call_old", "x".repeat(900), "d".repeat(8_000)),
|
|
makeToolResultWithDetails("call_new", "y".repeat(900), "d".repeat(8_000)),
|
|
];
|
|
|
|
await agent.transformContext?.(contextForNextCall, new AbortController().signal);
|
|
|
|
const oldResult = contextForNextCall[1] as unknown as {
|
|
details?: unknown;
|
|
};
|
|
const newResult = contextForNextCall[2] as unknown as {
|
|
details?: unknown;
|
|
};
|
|
const oldResultText = getToolResultText(contextForNextCall[1]);
|
|
const newResultText = getToolResultText(contextForNextCall[2]);
|
|
|
|
expect(oldResultText).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
|
expect(newResultText).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
|
expect(oldResult.details).toBeUndefined();
|
|
expect(newResult.details).toBeUndefined();
|
|
});
|
|
});
|