diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe246fd5..8362d29c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.clawd.bot - BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell. - Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky. - Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla. +- Compaction: harden oversized summarization handling and gate UI compaction status by verbose level. (#1466) Thanks @dlauer. - Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer. - Agents: surface concrete API error details instead of generic AI service errors. - Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj. diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index a6a66637a..3cbce0061 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -15,6 +15,15 @@ const TURN_PREFIX_INSTRUCTIONS = const MAX_TOOL_FAILURES = 8; const MAX_TOOL_FAILURE_CHARS = 240; +function isAbortError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const name = "name" in err ? String(err.name) : ""; + if (name === "AbortError") return true; + const message = + "message" in err && typeof err.message === "string" ? err.message.toLowerCase() : ""; + return message.includes("aborted"); +} + type ToolFailure = { toolCallId: string; toolName: string; @@ -251,6 +260,9 @@ async function summarizeWithFallback(params: { try { return await summarizeChunks(params); } catch (fullError) { + if (params.signal.aborted || isAbortError(fullError)) { + throw fullError; + } console.warn( `Full summarization failed, trying partial: ${ fullError instanceof Error ? fullError.message : String(fullError) @@ -283,6 +295,9 @@ async function summarizeWithFallback(params: { const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : ""; return partialSummary + notes; } catch (partialError) { + if (params.signal.aborted || isAbortError(partialError)) { + throw partialError; + } console.warn( `Partial summarization also failed: ${ partialError instanceof Error ? partialError.message : String(partialError) diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 9ef62e688..0604d6e44 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -173,7 +173,7 @@ export function createAgentEventHandler({ nodeSendToSession(sessionKey, "chat", payload); }; - const shouldEmitToolEvents = (runId: string, sessionKey?: string) => { + const shouldEmitVerboseEvents = (runId: string, sessionKey?: string) => { const runContext = getAgentRunContext(runId); const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel); if (runVerbose) return runVerbose === "on"; @@ -198,7 +198,10 @@ export function createAgentEventHandler({ // Include sessionKey so Control UI can filter tool streams per session. const agentPayload = sessionKey ? { ...evt, sessionKey } : evt; const last = agentRunSeq.get(evt.runId) ?? 0; - if (evt.stream === "tool" && !shouldEmitToolEvents(evt.runId, sessionKey)) { + if ( + (evt.stream === "tool" || evt.stream === "compaction") && + !shouldEmitVerboseEvents(evt.runId, sessionKey) + ) { agentRunSeq.set(evt.runId, evt.seq); return; } diff --git a/src/gateway/server.agent.gateway-server-agent.test.ts b/src/gateway/server.agent.gateway-server-agent.test.ts index d5cdc5b48..2017ae1fb 100644 --- a/src/gateway/server.agent.gateway-server-agent.test.ts +++ b/src/gateway/server.agent.gateway-server-agent.test.ts @@ -77,6 +77,22 @@ describe("gateway server agent", () => { { registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" }); + const compactionEvtP = onceMessage( + ws, + (o) => + o.type === "event" && + o.event === "agent" && + o.payload?.runId === "run-tool-off" && + o.payload?.stream === "compaction", + 1000, + ); + + emitAgentEvent({ + runId: "run-tool-off", + stream: "compaction", + data: { phase: "start" }, + }); + emitAgentEvent({ runId: "run-tool-off", stream: "tool", @@ -98,6 +114,8 @@ describe("gateway server agent", () => { ? (evt.payload as Record) : {}; expect(payload.stream).toBe("assistant"); + + await expect(compactionEvtP).rejects.toThrow("timeout"); } { diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/ui/app-tool-stream.ts index 5c83c3a79..4898b2779 100644 --- a/ui/src/ui/app-tool-stream.ts +++ b/ui/src/ui/app-tool-stream.ts @@ -142,6 +142,7 @@ export type CompactionStatus = { active: boolean; startedAt: number | null; completedAt: number | null; + retryingAt: number | null; }; type CompactionHost = ToolStreamHost & { @@ -154,6 +155,7 @@ const COMPACTION_TOAST_DURATION_MS = 5000; export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) { const data = payload.data ?? {}; const phase = typeof data.phase === "string" ? data.phase : ""; + const willRetry = Boolean(data.willRetry); // Clear any existing timer if (host.compactionClearTimer != null) { @@ -166,12 +168,14 @@ export function handleCompactionEvent(host: CompactionHost, payload: AgentEventP active: true, startedAt: Date.now(), completedAt: null, + retryingAt: null, }; } else if (phase === "end") { host.compactionStatus = { - active: false, + active: willRetry, startedAt: host.compactionStatus?.startedAt ?? null, - completedAt: Date.now(), + completedAt: willRetry ? null : Date.now(), + retryingAt: willRetry ? Date.now() : null, }; // Auto-clear the toast after duration host.compactionClearTimer = window.setTimeout(() => { @@ -183,13 +187,19 @@ export function handleCompactionEvent(host: CompactionHost, payload: AgentEventP export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) { if (!payload) return; - + // Handle compaction events if (payload.stream === "compaction") { + const sessionKey = + typeof payload.sessionKey === "string" ? payload.sessionKey : undefined; + if (sessionKey && sessionKey !== host.sessionKey) return; + if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) return; + if (host.chatRunId && payload.runId !== host.chatRunId) return; + if (!host.chatRunId) return; handleCompactionEvent(host as CompactionHost, payload); return; } - + if (payload.stream !== "tool") return; const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 97ce9d4ec..70c95f6df 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -20,6 +20,7 @@ export type CompactionIndicatorStatus = { active: boolean; startedAt: number | null; completedAt: number | null; + retryingAt: number | null; }; export type ChatProps = { @@ -80,6 +81,17 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un `; } + if (status.retryingAt) { + const elapsed = Date.now() - status.retryingAt; + if (elapsed < COMPACTION_TOAST_DURATION_MS) { + return html` +
+ 🧹 Retrying compaction... +
+ `; + } + } + // Show "compaction complete" briefly after completion if (status.completedAt) { const elapsed = Date.now() - status.completedAt;