mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(agents): recover from invalid compaction summaries (#119137)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user