fix(compaction): surface safeguard cancel reasons

This commit is contained in:
Andrii Furmanets
2026-03-20 15:58:05 +02:00
committed by Josh Lehman
parent 7847e67f8a
commit 2a99e99a2a
8 changed files with 304 additions and 61 deletions

View File

@@ -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");
});
});

View File

@@ -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";
}

View File

@@ -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?.();

View File

@@ -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<CompactionSafeguardRuntimeValue>();
@@ -24,3 +30,40 @@ const registry = createSessionManagerRuntimeRegistry<CompactionSafeguardRuntimeV
export const setCompactionSafeguardRuntime = registry.set;
export const getCompactionSafeguardRuntime = registry.get;
export function setCompactionSafeguardCancelReason(
sessionManager: unknown,
reason: string | undefined,
): void {
const current = getCompactionSafeguardRuntime(sessionManager);
const trimmed = reason?.trim();
if (!current) {
if (!trimmed) {
return;
}
setCompactionSafeguardRuntime(sessionManager, { cancelReason: trimmed });
return;
}
const next = { ...current };
if (trimmed) {
next.cancelReason = trimmed;
} else {
delete next.cancelReason;
}
setCompactionSafeguardRuntime(sessionManager, next);
}
export function consumeCompactionSafeguardCancelReason(sessionManager: unknown): string | null {
const current = getCompactionSafeguardRuntime(sessionManager);
const reason = current?.cancelReason?.trim();
if (!reason) {
return null;
}
const next = { ...current };
delete next.cancelReason;
setCompactionSafeguardRuntime(sessionManager, Object.keys(next).length > 0 ? next : null);
return reason;
}

View File

@@ -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

View File

@@ -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 };
}

View File

@@ -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}`;

View File

@@ -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", () => {