fix: cover compaction safeguard trim restore flow

This commit is contained in:
Josh Lehman 2026-03-12 19:44:00 -07:00
parent c87b42fe76
commit ffd7f20b26
No known key found for this signature in database
GPG Key ID: D141B425AC7F876B
3 changed files with 133 additions and 0 deletions

View File

@ -275,6 +275,7 @@ Docs: https://docs.openclaw.ai
- Agents/Anthropic replay: drop replayed assistant thinking blocks for native Anthropic and Bedrock Claude providers so persisted follow-up turns no longer fail on stored thinking blocks. (#44843) Thanks @jmcte.
- Docs/Brave pricing: escape literal dollar signs in Brave Search cost text so the docs render the free credit and per-request pricing correctly. (#44989) Thanks @keelanfh.
- Feishu/file uploads: preserve literal UTF-8 filenames in `im.file.create` so Chinese and other non-ASCII filenames no longer appear percent-encoded in chat. (#34262) Thanks @fabiaodemianyang and @KangShuaiFu.
- Agents/compaction safeguard: trim large kept `toolResult` payloads consistently for budgeting, pruning, and identifier seeding, then restore preserved payloads after prune so oversized safeguard summaries stay stable. (#44133) thanks @SayrWolfridge.
## 2026.3.11

View File

@ -28,6 +28,8 @@ const mockSummarizeInStages = vi.mocked(compactionModule.summarizeInStages);
const {
collectToolFailures,
formatToolFailuresSection,
trimToolResultsForSummarization,
restoreOriginalToolResultsForKeptMessages,
splitPreservedRecentTurns,
formatPreservedTurnsSection,
buildCompactionStructureInstructions,
@ -45,6 +47,26 @@ const {
SAFETY_MARGIN,
} = __testing;
function readTextBlocks(message: AgentMessage): string {
const content = (message as { content?: unknown }).content;
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return content
.map((block) => {
if (!block || typeof block !== "object") {
return "";
}
const text = (block as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.filter(Boolean)
.join("\n");
}
function stubSessionManager(): ExtensionContext["sessionManager"] {
const stub: ExtensionContext["sessionManager"] = {
getCwd: () => "/stub",
@ -234,6 +256,114 @@ describe("compaction-safeguard tool failures", () => {
});
});
describe("compaction-safeguard toolResult trimming", () => {
it("truncates oversized tool results and compacts older entries to stay within budget", () => {
const messages: AgentMessage[] = Array.from({ length: 7 }, (_unused, index) => ({
role: "toolResult",
toolCallId: `call-${index}`,
toolName: "read",
content: [
{
type: "text",
text: `head-${index}\n${"x".repeat(25_000)}\ntail-${index}`,
},
],
timestamp: index + 1,
})) as AgentMessage[];
const trimmed = trimToolResultsForSummarization(messages);
expect(trimmed.stats.truncatedCount).toBe(7);
expect(trimmed.stats.compactedCount).toBe(1);
expect(readTextBlocks(trimmed.messages[0])).toBe("");
expect(trimmed.stats.afterChars).toBeLessThan(trimmed.stats.beforeChars);
expect(readTextBlocks(trimmed.messages[6])).toContain("head-6");
expect(readTextBlocks(trimmed.messages[6])).toContain("[truncated for compaction stability]");
expect(readTextBlocks(trimmed.messages[6])).toContain("tail-6");
});
it("restores kept tool results after prune for both toolCallId and toolUseId", () => {
const originalMessages: AgentMessage[] = [
{ role: "user", content: "keep these tool results", timestamp: 1 },
{
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "original call payload" }],
timestamp: 2,
} as AgentMessage,
{
role: "toolResult",
toolUseId: "use-1",
toolName: "exec",
content: [{ type: "text", text: "original use payload" }],
timestamp: 3,
} as AgentMessage,
];
const prunedMessages: AgentMessage[] = [
originalMessages[0],
{
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "trimmed call payload" }],
timestamp: 2,
} as AgentMessage,
{
role: "toolResult",
toolUseId: "use-1",
toolName: "exec",
content: [{ type: "text", text: "trimmed use payload" }],
timestamp: 3,
} as AgentMessage,
];
const restored = restoreOriginalToolResultsForKeptMessages({
prunedMessages,
originalMessages,
});
expect(readTextBlocks(restored[1])).toBe("original call payload");
expect(readTextBlocks(restored[2])).toBe("original use payload");
});
it("extracts identifiers from the trimmed kept payloads after prune restore", () => {
const hiddenIdentifier = "DEADBEEF12345678";
const restored = restoreOriginalToolResultsForKeptMessages({
prunedMessages: [
{ role: "user", content: "recent ask", timestamp: 1 },
{
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "placeholder" }],
timestamp: 2,
} as AgentMessage,
],
originalMessages: [
{ role: "user", content: "recent ask", timestamp: 1 },
{
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [
{
type: "text",
text: `visible head ${"a".repeat(16_000)}${hiddenIdentifier}${"b".repeat(16_000)} visible tail`,
},
],
timestamp: 2,
} as AgentMessage,
],
});
const trimmed = trimToolResultsForSummarization(restored).messages;
const identifierSeedText = trimmed.map((message) => readTextBlocks(message)).join("\n");
expect(extractOpaqueIdentifiers(identifierSeedText)).not.toContain(hiddenIdentifier);
});
});
describe("computeAdaptiveChunkRatio", () => {
const CONTEXT_WINDOW = 200_000;

View File

@ -1218,6 +1218,8 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
export const __testing = {
collectToolFailures,
formatToolFailuresSection,
trimToolResultsForSummarization,
restoreOriginalToolResultsForKeptMessages,
splitPreservedRecentTurns,
formatPreservedTurnsSection,
buildCompactionStructureInstructions,