fix(agents): recover from invalid compaction summaries (#119137)

This commit is contained in:
Vincent Koc
2026-08-04 19:43:25 +08:00
committed by GitHub
parent 2a3a840aaf
commit 6d88ec5248
22 changed files with 1393 additions and 69 deletions
@@ -30,10 +30,13 @@ function createMessageEntry(message: AgentMessage, index: number): SessionTreeEn
};
}
function createResponse(model: Model): AssistantMessage {
function createResponse(
model: Model,
content: AssistantMessage["content"] = [{ type: "text", text: "Branch summary" }],
): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text: "Branch summary" }],
content,
api: model.api,
provider: model.provider,
model: model.id,
@@ -50,6 +53,13 @@ function createResponse(model: Model): AssistantMessage {
};
}
function createResponseStream(model: Model, content?: AssistantMessage["content"]) {
const stream = createAssistantMessageEventStream();
stream.push({ type: "done", reason: "stop", message: createResponse(model, content) });
stream.end();
return stream;
}
function createCapturingStream(model: Model) {
let prompt = "";
let systemPrompt = "";
@@ -65,10 +75,7 @@ function createCapturingStream(model: Model) {
: userMessage.content.map((block) => (block.type === "text" ? block.text : "")).join("");
systemPrompt = context.systemPrompt ?? "";
maxOutputTokens = options?.maxTokens;
const stream = createAssistantMessageEventStream();
stream.push({ type: "done", reason: "stop", message: createResponse(model) });
stream.end();
return stream;
return createResponseStream(model);
});
return {
streamFn,
@@ -86,6 +93,89 @@ function createLongBranchEntries(count: number): SessionTreeEntry[] {
}
describe("branch summarization", () => {
it.each([
["empty", []],
["whitespace-only", [{ type: "text" as const, text: " \n\t " }]],
["reasoning-only", [{ type: "thinking" as const, thinking: "internal reasoning" }]],
])("rejects %s model output before creating a summary", async (_name, content) => {
const model = createModel(128_000);
const streamFn = vi.fn<StreamFn>(() => createResponseStream(model, content));
const entries = [
createMessageEntry({ role: "user", content: "summarize this branch", timestamp: 1 }, 0),
];
const result = await generateBranchSummary(entries, {
model,
apiKey: "test-key",
signal: new AbortController().signal,
streamFn,
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected invalid branch summary output to fail");
}
expect(result.error).toMatchObject({
name: "BranchSummaryError",
code: "summarization_failed",
message: "Branch summary failed: model returned no summary text",
});
});
it("preserves valid summary whitespace, preamble, and file metadata", async () => {
const model = createModel(128_000);
const summaryText = " Branch summary body \ncontinues ";
const streamFn = vi.fn<StreamFn>(() =>
createResponseStream(model, [
{ type: "text", text: " Branch summary body " },
{ type: "text", text: "continues " },
]),
);
const entries: SessionTreeEntry[] = [
createMessageEntry({ role: "user", content: "inspect files", timestamp: 1 }, 0),
createMessageEntry(
createResponse(model, [
{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "src/read.ts" } },
{
type: "toolCall",
id: "write-1",
name: "write",
arguments: { path: "src/write.ts" },
},
]),
1,
),
];
const result = await generateBranchSummary(entries, {
model,
apiKey: "test-key",
signal: new AbortController().signal,
streamFn,
});
expect(result.ok).toBe(true);
if (!result.ok) {
throw result.error;
}
expect(result.value).toEqual({
summary: `The user explored a different conversation branch before returning here.
Summary of that exploration:
${summaryText}
<read-files>
src/read.ts
</read-files>
<modified-files>
src/write.ts
</modified-files>`,
readFiles: ["src/read.ts"],
modifiedFiles: ["src/write.ts"],
});
});
it("retains failed tool results when preparing a branch", () => {
const entries: SessionTreeEntry[] = [
createMessageEntry({ role: "user", content: "run deployment", timestamp: 1 }, 0),
@@ -19,6 +19,7 @@ import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
extractSummaryText,
type FileOperations,
formatFileOperations,
serializeConversation,
@@ -292,16 +293,22 @@ export async function generateBranchSummary(
);
}
let summary = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
summary = BRANCH_SUMMARY_PREAMBLE + summary;
const summaryText = extractSummaryText(response);
if (summaryText === undefined) {
return err(
new BranchSummaryError(
"summarization_failed",
"Branch summary failed: model returned no summary text",
),
);
}
let summary = BRANCH_SUMMARY_PREAMBLE + summaryText;
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
return ok({
summary: summary || "No summary generated",
summary,
readFiles,
modifiedFiles,
});
@@ -499,6 +499,67 @@ describe("generateSummary thinking options", () => {
expect(result).toEqual({ ok: true, value: "summary" });
expect(streamFn).toHaveBeenCalledOnce();
});
it.each([
["empty", []],
["whitespace-only", [{ type: "text" as const, text: " \n\t " }]],
["reasoning-only", [{ type: "thinking" as const, thinking: "internal summary reasoning" }]],
])("rejects %s compaction output", async (_name, content) => {
const model: Model = {
id: "summary-model",
name: "Summary Model",
api: "test-api",
provider: "test-provider",
baseUrl: "https://example.test",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 100_000,
maxTokens: 8_000,
};
const streamFn = vi.fn<StreamFn>(() => {
const stream = createAssistantMessageEventStream();
stream.push({
type: "done",
reason: "stop",
message: {
role: "assistant",
content,
api: model.api,
provider: model.provider,
model: model.id,
usage: createUsage(1),
stopReason: "stop",
timestamp: 1,
},
});
stream.end();
return stream;
});
const result = await generateSummary(
[{ role: "user", content: "hello", timestamp: 1 }],
model,
1_000,
undefined,
undefined,
undefined,
undefined,
undefined,
"low",
streamFn,
);
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected empty compaction output to fail");
}
expect(result.error).toMatchObject({
name: "CompactionError",
code: "summarization_failed",
message: "Summarization failed: model returned no summary text",
});
});
});
describe("split-turn compaction", () => {
@@ -31,6 +31,7 @@ import {
type CompactionEntry,
CompactionError,
err,
InvalidSummaryOutputError,
ok,
type Result,
type SessionTreeEntry,
@@ -39,6 +40,7 @@ import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
extractSummaryText,
type FileOperations,
formatFileOperations,
getCompactionContentBlockText,
@@ -646,12 +648,13 @@ async function runSummarizationCompletion(params: {
);
}
return ok(
response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n"),
);
const summary = extractSummaryText(response);
if (summary === undefined) {
return err(
new InvalidSummaryOutputError(`${params.errorLabel} failed: model returned no summary text`),
);
}
return ok(summary);
}
/** Generate or update a conversation summary for compaction. */
@@ -1,4 +1,4 @@
import type { Message } from "@openclaw/llm-core";
import type { AssistantMessage, Message } from "@openclaw/llm-core";
// Agent Core helper module supports utils behavior.
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { AgentMessage } from "../../types.js";
@@ -85,6 +85,15 @@ export function formatFileOperations(readFiles: string[], modifiedFiles: string[
return `\n\n${sections.join("\n\n")}`;
}
/** Extract visible summary text without normalizing valid model output. */
export function extractSummaryText(response: AssistantMessage): string | undefined {
const summary = response.content
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("\n");
return summary.trim() ? summary : undefined;
}
const TOOL_RESULT_MAX_CHARS = 2000;
const IMPORTANT_TOOL_RESULT_TAIL =
/(error|exception|failed|fatal|traceback|panic|stack trace|errno|exit code)/i;
+7
View File
@@ -16,6 +16,13 @@ export class CompactionError extends Error {
}
}
/** Internal typed signal for a completed summary response with no usable text. */
export class InvalidSummaryOutputError extends CompactionError {
constructor(message: string) {
super("summarization_failed", message);
}
}
type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session";
export class BranchSummaryError extends Error {