fix(compaction): surface safeguard cancel reasons and clarify /compact skips (#51072)

Merged via squash.

Prepared head SHA: f1dbef0443
Co-authored-by: afurm <6375192+afurm@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Andrii Furmanets
2026-03-25 20:03:22 +02:00
committed by GitHub
parent 7847e67f8a
commit 89c4c674d1
10 changed files with 347 additions and 62 deletions

View File

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

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

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

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

View File

@@ -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"],