From 89c4c674d17855df79f3824b09d31e7658822f82 Mon Sep 17 00:00:00 2001 From: Andrii Furmanets Date: Wed, 25 Mar 2026 20:03:22 +0200 Subject: [PATCH] fix(compaction): surface safeguard cancel reasons and clarify /compact skips (#51072) Merged via squash. Prepared head SHA: f1dbef044384fbd79ca5ef3616ad37fb03b05fae Co-authored-by: afurm <6375192+afurm@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 4 + .../compact-reasons.test.ts | 40 +++++++ .../pi-embedded-runner/compact-reasons.ts | 59 ++++++++++ src/agents/pi-embedded-runner/compact.ts | 62 +++-------- .../compaction-safeguard-runtime.ts | 43 ++++++++ .../compaction-safeguard.test.ts | 20 ++++ .../pi-extensions/compaction-safeguard.ts | 23 +++- src/auto-reply/reply/commands-compact.ts | 53 +++++++-- src/auto-reply/reply/commands.test.ts | 104 ++++++++++++++++++ src/cli/program/preaction.test.ts | 1 - 10 files changed, 347 insertions(+), 62 deletions(-) create mode 100644 src/agents/pi-embedded-runner/compact-reasons.test.ts create mode 100644 src/agents/pi-embedded-runner/compact-reasons.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f953d9298a4..622beb3e3c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - MiniMax: add image generation provider for `image-01` model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97. - MiniMax: trim model catalog to M2.7 only, removing legacy M2, M2.1, M2.5, and VL-01 models. (#54487) Thanks @liyuan97. - Plugins/runtime: expose `runHeartbeatOnce` in the plugin runtime `system` namespace so plugins can trigger a single heartbeat cycle with an explicit delivery target override (e.g. `heartbeat: { target: "last" }`). (#40299) Thanks @loveyana. +- Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual `/compact` no-op cases as skipped instead of failed. (#51072) Thanks @afurm. ### Fixes @@ -494,6 +495,9 @@ Docs: https://docs.openclaw.ai - Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys. - Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras. - Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman. +- Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan. +- CLI/update status: explicitly say `up to date` when the local version already matches npm latest, while keeping the availability logic unchanged. (#51409) Thanks @dongzhenye. +- Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob. - Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscoob. - Agents/compaction safeguard: preserve split-turn context and preserved recent turns when capped retry fallback reuses the last successful summary. (#27727) thanks @Pandadadadazxf. - Agents/memory flush: keep transcript-hash dedup active across memory-flush fallback retries so a write-then-throw flush attempt cannot append duplicate `MEMORY.md` entries before the fallback cycle completes. (#34222) Thanks @lml2468. diff --git a/src/agents/pi-embedded-runner/compact-reasons.test.ts b/src/agents/pi-embedded-runner/compact-reasons.test.ts new file mode 100644 index 00000000000..85f8101cb58 --- /dev/null +++ b/src/agents/pi-embedded-runner/compact-reasons.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { classifyCompactionReason, resolveCompactionFailureReason } from "./compact-reasons.js"; + +describe("resolveCompactionFailureReason", () => { + it("replaces generic compaction cancellation with the safeguard reason", () => { + expect( + resolveCompactionFailureReason({ + reason: "Compaction cancelled", + safeguardCancelReason: + "Compaction safeguard could not resolve an API key for anthropic/claude-opus-4-6.", + }), + ).toBe("Compaction safeguard could not resolve an API key for anthropic/claude-opus-4-6."); + }); + + it("preserves non-generic compaction failures", () => { + expect( + resolveCompactionFailureReason({ + reason: "Compaction timed out", + safeguardCancelReason: + "Compaction safeguard could not resolve an API key for anthropic/claude-opus-4-6.", + }), + ).toBe("Compaction timed out"); + }); +}); + +describe("classifyCompactionReason", () => { + it('classifies "nothing to compact" as a skip-like reason', () => { + expect(classifyCompactionReason("Nothing to compact (session too small)")).toBe( + "no_compactable_entries", + ); + }); + + it("classifies safeguard messages as guard-blocked", () => { + expect( + classifyCompactionReason( + "Compaction safeguard could not resolve an API key for anthropic/claude-opus-4-6.", + ), + ).toBe("guard_blocked"); + }); +}); diff --git a/src/agents/pi-embedded-runner/compact-reasons.ts b/src/agents/pi-embedded-runner/compact-reasons.ts new file mode 100644 index 00000000000..1772faeaaa2 --- /dev/null +++ b/src/agents/pi-embedded-runner/compact-reasons.ts @@ -0,0 +1,59 @@ +function isGenericCompactionCancelledReason(reason: string): boolean { + const normalized = reason.trim().toLowerCase(); + return normalized === "compaction cancelled" || normalized === "error: compaction cancelled"; +} + +export function resolveCompactionFailureReason(params: { + reason: string; + safeguardCancelReason?: string | null; +}): string { + if (isGenericCompactionCancelledReason(params.reason) && params.safeguardCancelReason) { + return params.safeguardCancelReason; + } + return params.reason; +} + +export function classifyCompactionReason(reason?: string): string { + const text = (reason ?? "").trim().toLowerCase(); + if (!text) { + return "unknown"; + } + if (text.includes("nothing to compact")) { + return "no_compactable_entries"; + } + if (text.includes("below threshold")) { + return "below_threshold"; + } + if (text.includes("already compacted")) { + return "already_compacted_recently"; + } + if (text.includes("still exceeds target")) { + return "live_context_still_exceeds_target"; + } + if (text.includes("guard")) { + return "guard_blocked"; + } + if (text.includes("summary")) { + return "summary_failed"; + } + if (text.includes("timed out") || text.includes("timeout")) { + return "timeout"; + } + if ( + text.includes("400") || + text.includes("401") || + text.includes("403") || + text.includes("429") + ) { + return "provider_error_4xx"; + } + if ( + text.includes("500") || + text.includes("502") || + text.includes("503") || + text.includes("504") + ) { + return "provider_error_5xx"; + } + return "unknown"; +} diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 7641afbffd7..13cc4089703 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -64,6 +64,10 @@ import { validateAnthropicTurns, validateGeminiTurns, } from "../pi-embedded-helpers.js"; +import { + consumeCompactionSafeguardCancelReason, + setCompactionSafeguardCancelReason, +} from "../pi-extensions/compaction-safeguard-runtime.js"; import { createPreparedEmbeddedPiSettingsManager } from "../pi-project-settings.js"; import { createOpenClawCodingTools } from "../pi-tools.js"; import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; @@ -83,6 +87,7 @@ import { type SkillSnapshot, } from "../skills.js"; import { resolveTranscriptPolicy } from "../transcript-policy.js"; +import { classifyCompactionReason, resolveCompactionFailureReason } from "./compact-reasons.js"; import { compactWithSafetyTimeout, resolveCompactionTimeoutMs, @@ -253,51 +258,6 @@ function summarizeCompactionMessages(messages: AgentMessage[]): CompactionMessag }; } -function classifyCompactionReason(reason?: string): string { - const text = (reason ?? "").trim().toLowerCase(); - if (!text) { - return "unknown"; - } - if (text.includes("nothing to compact")) { - return "no_compactable_entries"; - } - if (text.includes("below threshold")) { - return "below_threshold"; - } - if (text.includes("already compacted")) { - return "already_compacted_recently"; - } - if (text.includes("still exceeds target")) { - return "live_context_still_exceeds_target"; - } - if (text.includes("guard")) { - return "guard_blocked"; - } - if (text.includes("summary")) { - return "summary_failed"; - } - if (text.includes("timed out") || text.includes("timeout")) { - return "timeout"; - } - if ( - text.includes("400") || - text.includes("401") || - text.includes("403") || - text.includes("429") - ) { - return "provider_error_4xx"; - } - if ( - text.includes("500") || - text.includes("502") || - text.includes("503") || - text.includes("504") - ) { - return "provider_error_5xx"; - } - return "unknown"; -} - function resolvePostCompactionIndexSyncMode(config?: OpenClawConfig): "off" | "async" | "await" { const mode = config?.agents?.defaults?.compaction?.postIndexSync; if (mode === "off" || mode === "async" || mode === "await") { @@ -741,6 +701,7 @@ export async function compactEmbeddedPiSessionDirect( }); let restoreSkillEnv: (() => void) | undefined; + let compactionSessionManager: unknown = null; try { const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({ workspaceDir: effectiveWorkspace, @@ -1004,6 +965,7 @@ export async function compactEmbeddedPiSessionDirect( allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults, allowedToolNames, }); + compactionSessionManager = sessionManager; trackSessionManagerAccess(params.sessionFile); const settingsManager = createPreparedEmbeddedPiSettingsManager({ cwd: effectiveWorkspace, @@ -1162,7 +1124,10 @@ export async function compactEmbeddedPiSessionDirect( // the sanity check below becomes a no-op instead of crashing compaction. } const result = await compactWithSafetyTimeout( - () => session.compact(params.customInstructions), + () => { + setCompactionSafeguardCancelReason(compactionSessionManager, undefined); + return session.compact(params.customInstructions); + }, compactionTimeoutMs, { abortSignal: params.abortSignal, @@ -1260,7 +1225,10 @@ export async function compactEmbeddedPiSessionDirect( await sessionLock.release(); } } catch (err) { - const reason = describeUnknownError(err); + const reason = resolveCompactionFailureReason({ + reason: describeUnknownError(err), + safeguardCancelReason: consumeCompactionSafeguardCancelReason(compactionSessionManager), + }); return fail(reason); } finally { restoreSkillEnv?.(); diff --git a/src/agents/pi-extensions/compaction-safeguard-runtime.ts b/src/agents/pi-extensions/compaction-safeguard-runtime.ts index 42ccb90aa49..902cc9d3df3 100644 --- a/src/agents/pi-extensions/compaction-safeguard-runtime.ts +++ b/src/agents/pi-extensions/compaction-safeguard-runtime.ts @@ -17,6 +17,12 @@ export type CompactionSafeguardRuntimeValue = { recentTurnsPreserve?: number; qualityGuardEnabled?: boolean; qualityGuardMaxRetries?: number; + /** + * Pending human-readable cancel reason from the current safeguard compaction + * attempt. OpenClaw consumes this to replace the upstream generic + * "Compaction cancelled" message. + */ + cancelReason?: string; }; const registry = createSessionManagerRuntimeRegistry(); @@ -24,3 +30,40 @@ const registry = createSessionManagerRuntimeRegistry 0 ? next : null); + return reason; +} diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index e7032183ce9..2240d86d03f 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -10,7 +10,9 @@ import * as compactionModule from "../compaction.js"; import { buildEmbeddedExtensionFactories } from "../pi-embedded-runner/extensions.js"; import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js"; import { + consumeCompactionSafeguardCancelReason, getCompactionSafeguardRuntime, + setCompactionSafeguardCancelReason, setCompactionSafeguardRuntime, } from "./compaction-safeguard-runtime.js"; import compactionSafeguardExtension, { __testing } from "./compaction-safeguard.js"; @@ -539,6 +541,24 @@ describe("compaction-safeguard runtime registry", () => { }); }); + it("consumes cancel reasons without dropping other runtime fields", () => { + const sm = {}; + setCompactionSafeguardRuntime(sm, { maxHistoryShare: 0.6 }); + setCompactionSafeguardCancelReason(sm, "no API key"); + + expect(consumeCompactionSafeguardCancelReason(sm)).toBe("no API key"); + expect(consumeCompactionSafeguardCancelReason(sm)).toBeNull(); + expect(getCompactionSafeguardRuntime(sm)).toEqual({ maxHistoryShare: 0.6 }); + }); + + it("clears cancel reason when set to undefined", () => { + const sm = {}; + setCompactionSafeguardCancelReason(sm, "temporary reason"); + expect(consumeCompactionSafeguardCancelReason(sm)).toBe("temporary reason"); + setCompactionSafeguardCancelReason(sm, undefined); + expect(consumeCompactionSafeguardCancelReason(sm)).toBeNull(); + }); + it("wires oversized safeguard runtime values when config validation is bypassed", () => { const sessionManager = {} as unknown as Parameters< typeof buildEmbeddedExtensionFactories diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index a9b25a72e6a..85052728a9c 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -31,7 +31,10 @@ import { composeSplitTurnInstructions, resolveCompactionInstructions, } from "./compaction-instructions.js"; -import { getCompactionSafeguardRuntime } from "./compaction-safeguard-runtime.js"; +import { + getCompactionSafeguardRuntime, + setCompactionSafeguardCancelReason, +} from "./compaction-safeguard-runtime.js"; const log = createSubsystemLogger("compaction-safeguard"); @@ -783,6 +786,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { const hasRealTurnPrefix = preparation.turnPrefixMessages.some((message, index, messages) => isRealConversationMessage(message, messages, index), ); + setCompactionSafeguardCancelReason(ctx.sessionManager, undefined); if (!hasRealSummarizable && !hasRealTurnPrefix) { // When there are no summarizable messages AND no real turn-prefix content, // cancelling compaction leaves context unchanged but the SDK re-triggers @@ -840,6 +844,10 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { "was not called and model was not passed through runtime registry.", ); } + setCompactionSafeguardCancelReason( + ctx.sessionManager, + "Compaction safeguard could not resolve a summarization model.", + ); return { cancel: true }; } @@ -848,6 +856,10 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { log.warn( "Compaction safeguard: no API key available; cancelling compaction to preserve history.", ); + setCompactionSafeguardCancelReason( + ctx.sessionManager, + `Compaction safeguard could not resolve an API key for ${model.provider}/${model.id}.`, + ); return { cancel: true }; } @@ -1103,10 +1115,13 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { }, }; } catch (error) { + const message = error instanceof Error ? error.message : String(error); log.warn( - `Compaction summarization failed; cancelling compaction to preserve history: ${ - error instanceof Error ? error.message : String(error) - }`, + `Compaction summarization failed; cancelling compaction to preserve history: ${message}`, + ); + setCompactionSafeguardCancelReason( + ctx.sessionManager, + `Compaction safeguard could not summarize the session: ${message}`, ); return { cancel: true }; } diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index 9c3c9f28c29..fa4ff3b313e 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -44,6 +44,38 @@ function extractCompactInstructions(params: { return rest.length ? rest : undefined; } +function isCompactionSkipReason(reason?: string): boolean { + const text = reason?.trim().toLowerCase() ?? ""; + return ( + text.includes("nothing to compact") || + text.includes("below threshold") || + text.includes("already compacted") || + text.includes("no real conversation messages") + ); +} + +function formatCompactionReason(reason?: string): string | undefined { + const text = reason?.trim(); + if (!text) { + return undefined; + } + + const lower = text.toLowerCase(); + if (lower.includes("nothing to compact")) { + return "nothing compactable in this session yet"; + } + if (lower.includes("below threshold")) { + return "context is below the compaction threshold"; + } + if (lower.includes("already compacted")) { + return "session was already compacted recently"; + } + if (lower.includes("no real conversation messages")) { + return "no real conversation messages yet"; + } + return text; +} + export const handleCompactCommand: CommandHandler = async (params) => { const compactRequested = params.command.commandBodyNormalized === "/compact" || @@ -110,15 +142,16 @@ export const handleCompactCommand: CommandHandler = async (params) => { ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined, }); - const compactLabel = result.ok - ? result.compacted - ? result.result?.tokensBefore != null && result.result?.tokensAfter != null - ? `Compacted (${formatTokenCount(result.result.tokensBefore)} → ${formatTokenCount(result.result.tokensAfter)})` - : result.result?.tokensBefore - ? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)` - : "Compacted" - : "Compaction skipped" - : "Compaction failed"; + const compactLabel = + result.ok || isCompactionSkipReason(result.reason) + ? result.compacted + ? result.result?.tokensBefore != null && result.result?.tokensAfter != null + ? `Compacted (${formatTokenCount(result.result.tokensBefore)} → ${formatTokenCount(result.result.tokensAfter)})` + : result.result?.tokensBefore + ? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)` + : "Compacted" + : "Compaction skipped" + : "Compaction failed"; if (result.ok && result.compacted) { await incrementCompactionCount({ sessionEntry: params.sessionEntry, @@ -136,7 +169,7 @@ export const handleCompactCommand: CommandHandler = async (params) => { typeof totalTokens === "number" && totalTokens > 0 ? totalTokens : null, params.contextTokens ?? params.sessionEntry.contextTokens ?? null, ); - const reason = result.reason?.trim(); + const reason = formatCompactionReason(result.reason); const line = reason ? `${compactLabel}: ${reason} • ${contextSummary}` : `${compactLabel} • ${contextSummary}`; diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 6e50d387e63..b65708cefaf 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -120,6 +120,7 @@ const { clearPluginCommands, registerPluginCommand } = await import("../../plugi const { abortEmbeddedPiRun, compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); const { __testing: subagentControlTesting } = await import("../../agents/subagent-control.js"); +const { enqueueSystemEvent } = await import("../../infra/system-events.js"); const { resetBashChatCommandForTests } = await import("./bash-command.js"); const { handleCompactCommand } = await import("./commands-compact.js"); const { buildCommandsPaginationKeyboard } = await import("./commands-info.js"); @@ -625,6 +626,109 @@ describe("/compact command", () => { }), ); }); + + it("labels nothing-to-compact results as skipped without calling them below-threshold", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/compact", cfg); + vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + ok: false, + compacted: false, + reason: "Nothing to compact (session too small)", + }); + + const result = await handleCompactCommand( + { + ...params, + sessionEntry: { + sessionId: "session-1", + updatedAt: Date.now(), + totalTokens: 31_000, + contextTokens: 200_000, + }, + }, + true, + ); + + expect(result).toEqual({ + shouldContinue: false, + reply: { + text: "⚙️ Compaction skipped: nothing compactable in this session yet • Context 31k/?", + }, + }); + expect(vi.mocked(enqueueSystemEvent)).toHaveBeenCalledWith( + "Compaction skipped: nothing compactable in this session yet • Context 31k/?", + { sessionKey: params.sessionKey }, + ); + }); + + it("formats below-threshold skip reasons with friendly copy", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/compact", cfg); + vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + ok: false, + compacted: false, + reason: "Compaction skipped: below threshold for manual compaction", + }); + + const result = await handleCompactCommand( + { + ...params, + sessionEntry: { + sessionId: "session-1", + updatedAt: Date.now(), + totalTokens: 31_000, + contextTokens: 200_000, + }, + }, + true, + ); + + expect(result).toEqual({ + shouldContinue: false, + reply: { + text: "⚙️ Compaction skipped: context is below the compaction threshold • Context 31k/?", + }, + }); + }); + + it("keeps true compaction errors labeled as failures", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig; + const params = buildParams("/compact", cfg); + vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ + ok: false, + compacted: false, + reason: "Compaction safeguard could not resolve an API key for anthropic/claude-opus-4-6.", + }); + + const result = await handleCompactCommand( + { + ...params, + sessionEntry: { + sessionId: "session-1", + updatedAt: Date.now(), + totalTokens: 109_000, + contextTokens: 200_000, + }, + }, + true, + ); + + expect(result).toEqual({ + shouldContinue: false, + reply: { + text: "⚙️ Compaction failed: Compaction safeguard could not resolve an API key for anthropic/claude-opus-4-6. • Context 109k/?", + }, + }); + }); }); describe("abort trigger command", () => { diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index a9a62ebbf75..dfb1a78bb3c 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -203,7 +203,6 @@ describe("registerPreActionHooks", () => { it("handles debug mode and plugin-required command preaction", async () => { const processTitleSetSpy = vi.spyOn(process, "title", "set"); - await runPreAction({ parseArgv: ["status"], processArgv: ["node", "openclaw", "status", "--debug"],