mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 02:41:07 +00:00
fix(compaction): surface safeguard cancel reasons
This commit is contained in:
committed by
Josh Lehman
parent
7847e67f8a
commit
2a99e99a2a
40
src/agents/pi-embedded-runner/compact-reasons.test.ts
Normal file
40
src/agents/pi-embedded-runner/compact-reasons.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
59
src/agents/pi-embedded-runner/compact-reasons.ts
Normal file
59
src/agents/pi-embedded-runner/compact-reasons.ts
Normal 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";
|
||||
}
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user