mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 22:10: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:
@@ -146,6 +146,7 @@ Docs: https://docs.openclaw.ai
|
||||
- TUI: skip the generic CLI respawn wrapper for interactive launches, exit cleanly on terminal loss, and refuse to restore heartbeat sessions as the remembered chat session, preventing stale heartbeat history and orphaned `openclaw-tui` processes on first boot. Thanks @vincentkoc.
|
||||
- Doctor/sessions: move heartbeat-poisoned default main session store entries to recovery keys and clear stale TUI restore pointers, so `doctor --fix` can repair instances already stuck on `agent:main:main` heartbeat history. Thanks @vincentkoc.
|
||||
- Agents/context engines: keep hidden OpenClaw runtime-context custom messages out of context-engine assemble, afterTurn, and ingest hooks so transcript reconstruction plugins only see conversation messages. Thanks @vincentkoc.
|
||||
- Agents/compaction: treat visible custom-message, bash, and branch-summary entries as real conversation anchors so safeguard mode does not write empty fallback summaries for cron and split-turn sessions with substantive tool work. Fixes #78300. Thanks @amknight.
|
||||
- Network/runtime: avoid importing Undici's package dispatcher during no-proxy timeout bootstrap so external channel plugin fetch requests with explicit Content-Length keep working. Fixes #78007. Thanks @shakkernerd.
|
||||
- Gateway/shutdown: cancel delayed post-ready maintenance during close and suppress maintenance/cron startup after quick restarts, preventing orphaned background timers. Thanks @vincentkoc.
|
||||
- Agents/TTS: send media-bearing block replies directly when block streaming is off, so agent `tts` tool audio attached to a final text reply is delivered instead of being consumed before final Telegram/media delivery. Thanks @Conan-Scott.
|
||||
|
||||
@@ -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