mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 04:00:43 +00:00
fix: recognize custom compaction conversation (#78390)
* fix: recognize custom compaction conversation * fix: use branch fallback for compaction safeguard --------- Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
This commit is contained in:
@@ -27,7 +27,32 @@ function hasMeaningfulText(text: string): boolean {
|
||||
}
|
||||
|
||||
export function hasMeaningfulConversationContent(message: AgentMessage): boolean {
|
||||
if ((message as { role?: unknown }).role === "custom") {
|
||||
const custom = message as { content?: unknown; display?: unknown };
|
||||
return custom.display !== false && hasMeaningfulMessageContent(custom.content);
|
||||
}
|
||||
if ((message as { role?: unknown }).role === "bashExecution") {
|
||||
const bash = message as {
|
||||
command?: unknown;
|
||||
output?: unknown;
|
||||
excludeFromContext?: unknown;
|
||||
};
|
||||
if (bash.excludeFromContext === true) {
|
||||
return false;
|
||||
}
|
||||
const command = typeof bash.command === "string" ? bash.command : "";
|
||||
const output = typeof bash.output === "string" ? bash.output : "";
|
||||
return hasMeaningfulText(`${command}\n${output}`);
|
||||
}
|
||||
if ((message as { role?: unknown }).role === "branchSummary") {
|
||||
const summary = (message as { summary?: unknown }).summary;
|
||||
return typeof summary === "string" && hasMeaningfulText(summary);
|
||||
}
|
||||
const content = (message as { content?: unknown }).content;
|
||||
return hasMeaningfulMessageContent(content);
|
||||
}
|
||||
|
||||
function hasMeaningfulMessageContent(content: unknown): boolean {
|
||||
if (typeof content === "string") {
|
||||
return hasMeaningfulText(content);
|
||||
}
|
||||
@@ -60,12 +85,29 @@ export function hasMeaningfulConversationContent(message: AgentMessage): boolean
|
||||
return sawMeaningfulNonTextBlock;
|
||||
}
|
||||
|
||||
function isToolResultConversationAnchor(message: AgentMessage): boolean {
|
||||
const role = (message as { role?: unknown }).role;
|
||||
return (
|
||||
(role === "user" ||
|
||||
role === "custom" ||
|
||||
role === "bashExecution" ||
|
||||
role === "branchSummary") &&
|
||||
hasMeaningfulConversationContent(message)
|
||||
);
|
||||
}
|
||||
|
||||
export function isRealConversationMessage(
|
||||
message: AgentMessage,
|
||||
messages: AgentMessage[],
|
||||
index: number,
|
||||
): boolean {
|
||||
if (message.role === "user" || message.role === "assistant") {
|
||||
if (
|
||||
message.role === "user" ||
|
||||
message.role === "assistant" ||
|
||||
message.role === "custom" ||
|
||||
message.role === "bashExecution" ||
|
||||
message.role === "branchSummary"
|
||||
) {
|
||||
return hasMeaningfulConversationContent(message);
|
||||
}
|
||||
if (message.role !== "toolResult") {
|
||||
@@ -74,10 +116,10 @@ export function isRealConversationMessage(
|
||||
const start = Math.max(0, index - TOOL_RESULT_REAL_CONVERSATION_LOOKBACK);
|
||||
for (let i = index - 1; i >= start; i -= 1) {
|
||||
const candidate = messages[i];
|
||||
if (!candidate || candidate.role !== "user") {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
if (hasMeaningfulConversationContent(candidate)) {
|
||||
if (isToolResultConversationAnchor(candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,6 +989,30 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("counts visible custom prompts as real conversation anchors for tool output", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "custom",
|
||||
customType: "cron-request",
|
||||
content: "prepare the daily report",
|
||||
display: true,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "report source data" }],
|
||||
},
|
||||
] as AgentMessage[];
|
||||
|
||||
expect(compactTesting.hasRealConversationContent(messages[0], messages, 0)).toBe(true);
|
||||
expect(compactTesting.hasRealConversationContent(messages[2], messages, 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("registers the Ollama api provider before compaction", async () => {
|
||||
const streamFn = vi.fn();
|
||||
registerProviderStreamForModelMock.mockReturnValue(streamFn);
|
||||
|
||||
@@ -2037,6 +2037,138 @@ describe("compaction-safeguard double-compaction guard", () => {
|
||||
expect(result).toEqual({ cancel: true });
|
||||
});
|
||||
|
||||
it("does not write boundary when visible custom turn-prefix content is real conversation", async () => {
|
||||
const sessionManager = stubSessionManager();
|
||||
const model = createAnthropicModelFixture();
|
||||
setCompactionSafeguardRuntime(sessionManager, { model });
|
||||
|
||||
const mockEvent = {
|
||||
preparation: {
|
||||
messagesToSummarize: [] as AgentMessage[],
|
||||
turnPrefixMessages: [
|
||||
{
|
||||
role: "custom" as const,
|
||||
customType: "cron-request",
|
||||
content: "prepare the daily report",
|
||||
display: true,
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: {} }],
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
role: "toolResult" as const,
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "report source data" }],
|
||||
timestamp: 3,
|
||||
},
|
||||
] as AgentMessage[],
|
||||
firstKeptEntryId: "entry-5",
|
||||
tokensBefore: 38085,
|
||||
fileOps: { read: [], edited: [], written: [] },
|
||||
isSplitTurn: true,
|
||||
},
|
||||
customInstructions: "",
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({
|
||||
sessionManager,
|
||||
event: mockEvent,
|
||||
apiKey: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ cancel: true });
|
||||
expect(getApiKeyAndHeadersMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to visible custom session branch entries before writing an empty boundary", async () => {
|
||||
mockSummarizeInStages.mockReset();
|
||||
mockSummarizeInStages.mockResolvedValue("branch summary");
|
||||
|
||||
const now = Date.now();
|
||||
const sessionManager = {
|
||||
...stubSessionManager(),
|
||||
getBranch: () => [
|
||||
{
|
||||
type: "custom_message",
|
||||
id: "custom-1",
|
||||
parentId: null,
|
||||
timestamp: new Date(now).toISOString(),
|
||||
customType: "cron-request",
|
||||
content: "prepare the daily report",
|
||||
display: true,
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "assistant-1",
|
||||
parentId: "custom-1",
|
||||
timestamp: new Date(now + 1).toISOString(),
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: {} }],
|
||||
timestamp: now + 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "tool-1",
|
||||
parentId: "assistant-1",
|
||||
timestamp: new Date(now + 2).toISOString(),
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "report source data" }],
|
||||
timestamp: now + 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as ExtensionContext["sessionManager"];
|
||||
const model = createAnthropicModelFixture();
|
||||
setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 });
|
||||
|
||||
const mockEvent = {
|
||||
preparation: {
|
||||
messagesToSummarize: [] as AgentMessage[],
|
||||
turnPrefixMessages: [] as AgentMessage[],
|
||||
firstKeptEntryId: "entry-5",
|
||||
tokensBefore: 38085,
|
||||
fileOps: { read: [], edited: [], written: [] },
|
||||
settings: { reserveTokens: 4000 },
|
||||
isSplitTurn: true,
|
||||
},
|
||||
customInstructions: "",
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
const { result } = await runCompactionScenario({
|
||||
sessionManager,
|
||||
event: mockEvent,
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
const compaction = expectCompactionResult(result);
|
||||
expect(compaction.summary).toContain("branch summary");
|
||||
expect(compaction.summary).not.toContain("No prior history.");
|
||||
expect(mockSummarizeInStages).toHaveBeenCalled();
|
||||
const summaryCall = mockSummarizeInStages.mock.calls[0]?.[0];
|
||||
expect(summaryCall?.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "custom",
|
||||
customType: "cron-request",
|
||||
content: "prepare the daily report",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "toolResult",
|
||||
toolName: "read",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("continues when messages include real conversation content", async () => {
|
||||
const sessionManager = stubSessionManager();
|
||||
const model = createAnthropicModelFixture();
|
||||
|
||||
@@ -99,6 +99,85 @@ function prependPreviousSummaryForRedistill(params: {
|
||||
return [buildPreviousSummaryMessage(previousSummary), ...params.messages];
|
||||
}
|
||||
|
||||
type SessionBranchEntry = {
|
||||
type?: unknown;
|
||||
message?: unknown;
|
||||
customType?: unknown;
|
||||
content?: unknown;
|
||||
display?: unknown;
|
||||
details?: unknown;
|
||||
timestamp?: unknown;
|
||||
summary?: unknown;
|
||||
fromId?: unknown;
|
||||
};
|
||||
|
||||
function coerceTimestamp(value: unknown): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function sessionBranchEntryToMessage(entry: SessionBranchEntry): AgentMessage | undefined {
|
||||
if (entry.type === "message" && entry.message && typeof entry.message === "object") {
|
||||
return entry.message as AgentMessage;
|
||||
}
|
||||
if (entry.type === "custom_message") {
|
||||
return {
|
||||
role: "custom",
|
||||
customType: typeof entry.customType === "string" ? entry.customType : "custom",
|
||||
content: entry.content,
|
||||
display: entry.display !== false,
|
||||
details: entry.details,
|
||||
timestamp: coerceTimestamp(entry.timestamp),
|
||||
} as AgentMessage;
|
||||
}
|
||||
if (entry.type === "branch_summary") {
|
||||
return {
|
||||
role: "branchSummary",
|
||||
summary: typeof entry.summary === "string" ? entry.summary : "",
|
||||
fromId: typeof entry.fromId === "string" ? entry.fromId : "root",
|
||||
timestamp: coerceTimestamp(entry.timestamp),
|
||||
} as AgentMessage;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectSessionBranchMessages(sessionManager: unknown): AgentMessage[] {
|
||||
const getBranch = (sessionManager as { getBranch?: unknown })?.getBranch;
|
||||
if (typeof getBranch !== "function") {
|
||||
return [];
|
||||
}
|
||||
let entries: unknown;
|
||||
try {
|
||||
entries = getBranch.call(sessionManager);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(entries)) {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.map((entry) =>
|
||||
entry && typeof entry === "object"
|
||||
? sessionBranchEntryToMessage(entry as SessionBranchEntry)
|
||||
: undefined,
|
||||
)
|
||||
.filter((message): message is AgentMessage => Boolean(message));
|
||||
}
|
||||
|
||||
function containsRealConversation(messages: AgentMessage[]): boolean {
|
||||
return messages.some((message, index, allMessages) =>
|
||||
isRealConversationMessage(message, allMessages, index),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt provider-based summarization. Returns the summary string on success,
|
||||
* or `undefined` when the caller should fall back to built-in LLM summarization.
|
||||
@@ -778,16 +857,26 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
api.on("session_before_compact", async (event, ctx) => {
|
||||
const { preparation, customInstructions: eventInstructions, signal } = event;
|
||||
const rawTurnPrefixMessages = preparation.turnPrefixMessages ?? [];
|
||||
const baseMessagesToSummarize = stripRuntimeContextCustomMessages(
|
||||
let baseMessagesToSummarize = stripRuntimeContextCustomMessages(
|
||||
preparation.messagesToSummarize,
|
||||
);
|
||||
const baseTurnPrefixMessages = stripRuntimeContextCustomMessages(rawTurnPrefixMessages);
|
||||
const hasRealSummarizable = baseMessagesToSummarize.some((message, index, messages) =>
|
||||
isRealConversationMessage(message, messages, index),
|
||||
);
|
||||
const hasRealTurnPrefix = baseTurnPrefixMessages.some((message, index, messages) =>
|
||||
isRealConversationMessage(message, messages, index),
|
||||
);
|
||||
let baseTurnPrefixMessages = stripRuntimeContextCustomMessages(rawTurnPrefixMessages);
|
||||
let hasRealSummarizable = containsRealConversation(baseMessagesToSummarize);
|
||||
let hasRealTurnPrefix = containsRealConversation(baseTurnPrefixMessages);
|
||||
if (!hasRealSummarizable && !hasRealTurnPrefix) {
|
||||
const branchMessages = stripRuntimeContextCustomMessages(
|
||||
collectSessionBranchMessages(ctx.sessionManager),
|
||||
);
|
||||
if (containsRealConversation(branchMessages)) {
|
||||
log.info(
|
||||
"Compaction safeguard: using session branch messages after compaction preparation omitted real conversation content.",
|
||||
);
|
||||
baseMessagesToSummarize = branchMessages;
|
||||
baseTurnPrefixMessages = [];
|
||||
hasRealSummarizable = true;
|
||||
hasRealTurnPrefix = false;
|
||||
}
|
||||
}
|
||||
setCompactionSafeguardCancelReason(ctx.sessionManager, undefined);
|
||||
if (!hasRealSummarizable && !hasRealTurnPrefix) {
|
||||
// When there are no summarizable messages AND no real turn-prefix content,
|
||||
|
||||
Reference in New Issue
Block a user