diff --git a/CHANGELOG.md b/CHANGELOG.md index b904914942f..4a1d9a7bbdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/compaction-real-conversation.ts b/src/agents/compaction-real-conversation.ts index 969c2262042..09e2453aafc 100644 --- a/src/agents/compaction-real-conversation.ts +++ b/src/agents/compaction-real-conversation.ts @@ -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; } } diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 88b3e981453..2644f9d2787 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -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); diff --git a/src/agents/pi-hooks/compaction-safeguard.test.ts b/src/agents/pi-hooks/compaction-safeguard.test.ts index 85b9495ffe1..a2beff5cfd9 100644 --- a/src/agents/pi-hooks/compaction-safeguard.test.ts +++ b/src/agents/pi-hooks/compaction-safeguard.test.ts @@ -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(); diff --git a/src/agents/pi-hooks/compaction-safeguard.ts b/src/agents/pi-hooks/compaction-safeguard.ts index 0f316563107..dc7bf43f3d6 100644 --- a/src/agents/pi-hooks/compaction-safeguard.ts +++ b/src/agents/pi-hooks/compaction-safeguard.ts @@ -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,