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..219ae9bc028 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -64,6 +64,7 @@ import { validateAnthropicTurns, validateGeminiTurns, } from "../pi-embedded-helpers.js"; +import { consumeCompactionSafeguardCancelReason } 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 +84,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 +255,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 +698,7 @@ export async function compactEmbeddedPiSessionDirect( }); let restoreSkillEnv: (() => void) | undefined; + let compactionSessionManager: unknown = null; try { const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({ workspaceDir: effectiveWorkspace, @@ -1004,6 +962,7 @@ export async function compactEmbeddedPiSessionDirect( allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults, allowedToolNames, }); + compactionSessionManager = sessionManager; trackSessionManagerAccess(params.sessionFile); const settingsManager = createPreparedEmbeddedPiSettingsManager({ cwd: effectiveWorkspace, @@ -1162,7 +1121,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), + () => { + consumeCompactionSafeguardCancelReason(compactionSessionManager); + return session.compact(params.customInstructions); + }, compactionTimeoutMs, { abortSignal: params.abortSignal, @@ -1260,7 +1222,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..14ba1fe7a16 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -44,6 +44,35 @@ 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 "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 +139,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 +166,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..e1399b19888 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,76 @@ describe("/compact command", () => { }), ); }); + + it("labels benign no-op compaction results as skipped", 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: context is below the compaction threshold • Context 31k/?", + }, + }); + expect(vi.mocked(enqueueSystemEvent)).toHaveBeenCalledWith( + "Compaction skipped: context is below the compaction threshold • Context 31k/?", + { sessionKey: params.sessionKey }, + ); + }); + + 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", () => {