mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 07:22:44 +00:00
fix(agents): harden compaction and reset safety
Co-authored-by: jaden-clovervnd <91520439+jaden-clovervnd@users.noreply.github.com> Co-authored-by: Sid <201593046+Sid-Qin@users.noreply.github.com> Co-authored-by: Marcus Widing <245375637+widingmarcus-cyber@users.noreply.github.com>
This commit is contained in:
@@ -268,6 +268,110 @@ describe("sanitizeSessionHistory", () => {
|
||||
expect(assistants[1]?.usage).toBeDefined();
|
||||
});
|
||||
|
||||
it("drops stale usage when compaction summary appears before kept assistant messages", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
|
||||
const messages = [
|
||||
{
|
||||
role: "compactionSummary",
|
||||
summary: "compressed",
|
||||
tokensBefore: 191_919,
|
||||
timestamp: new Date(compactionTs).toISOString(),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "kept pre-compaction answer" }],
|
||||
stopReason: "stop",
|
||||
timestamp: compactionTs - 1_000,
|
||||
usage: {
|
||||
input: 191_919,
|
||||
output: 2_000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 193_919,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
});
|
||||
|
||||
const assistant = result.find((message) => message.role === "assistant") as
|
||||
| (AgentMessage & { usage?: unknown })
|
||||
| undefined;
|
||||
expect(assistant?.usage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps fresh usage after compaction timestamp in summary-first ordering", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
|
||||
const messages = [
|
||||
{
|
||||
role: "compactionSummary",
|
||||
summary: "compressed",
|
||||
tokensBefore: 123_000,
|
||||
timestamp: new Date(compactionTs).toISOString(),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "kept pre-compaction answer" }],
|
||||
stopReason: "stop",
|
||||
timestamp: compactionTs - 2_000,
|
||||
usage: {
|
||||
input: 120_000,
|
||||
output: 3_000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 123_000,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
{ role: "user", content: "new question", timestamp: compactionTs + 1_000 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "fresh answer" }],
|
||||
stopReason: "stop",
|
||||
timestamp: compactionTs + 2_000,
|
||||
usage: {
|
||||
input: 1_000,
|
||||
output: 250,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 1_250,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
},
|
||||
] as unknown as AgentMessage[];
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: TEST_SESSION_ID,
|
||||
});
|
||||
|
||||
const assistants = result.filter((message) => message.role === "assistant") as Array<
|
||||
AgentMessage & { usage?: unknown; content?: unknown }
|
||||
>;
|
||||
const keptAssistant = assistants.find((message) =>
|
||||
JSON.stringify(message.content).includes("kept pre-compaction answer"),
|
||||
);
|
||||
const freshAssistant = assistants.find((message) =>
|
||||
JSON.stringify(message.content).includes("fresh answer"),
|
||||
);
|
||||
expect(keptAssistant?.usage).toBeUndefined();
|
||||
expect(freshAssistant?.usage).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps reasoning-only assistant messages for openai-responses", async () => {
|
||||
setNonGoogleModelApi();
|
||||
|
||||
|
||||
@@ -133,27 +133,59 @@ function annotateInterSessionUserMessages(messages: AgentMessage[]): AgentMessag
|
||||
return touched ? out : messages;
|
||||
}
|
||||
|
||||
function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[]): AgentMessage[] {
|
||||
let latestCompactionSummaryIndex = -1;
|
||||
for (let i = 0; i < messages.length; i += 1) {
|
||||
if (messages[i]?.role === "compactionSummary") {
|
||||
latestCompactionSummaryIndex = i;
|
||||
function parseMessageTimestamp(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (latestCompactionSummaryIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[]): AgentMessage[] {
|
||||
let latestCompactionSummaryIndex = -1;
|
||||
let latestCompactionTimestamp: number | null = null;
|
||||
for (let i = 0; i < messages.length; i += 1) {
|
||||
const entry = messages[i];
|
||||
if (entry?.role !== "compactionSummary") {
|
||||
continue;
|
||||
}
|
||||
latestCompactionSummaryIndex = i;
|
||||
latestCompactionTimestamp = parseMessageTimestamp(
|
||||
(entry as { timestamp?: unknown }).timestamp ?? null,
|
||||
);
|
||||
}
|
||||
if (latestCompactionSummaryIndex === -1) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const out = [...messages];
|
||||
let touched = false;
|
||||
for (let i = 0; i < latestCompactionSummaryIndex; i += 1) {
|
||||
const candidate = out[i] as (AgentMessage & { usage?: unknown }) | undefined;
|
||||
for (let i = 0; i < out.length; i += 1) {
|
||||
const candidate = out[i] as
|
||||
| (AgentMessage & { usage?: unknown; timestamp?: unknown })
|
||||
| undefined;
|
||||
if (!candidate || candidate.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
if (!candidate.usage || typeof candidate.usage !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const messageTimestamp = parseMessageTimestamp(candidate.timestamp);
|
||||
const staleByTimestamp =
|
||||
latestCompactionTimestamp !== null &&
|
||||
messageTimestamp !== null &&
|
||||
messageTimestamp <= latestCompactionTimestamp;
|
||||
const staleByLegacyOrdering = i < latestCompactionSummaryIndex;
|
||||
if (!staleByTimestamp && !staleByLegacyOrdering) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateRecord = candidate as unknown as Record<string, unknown>;
|
||||
const { usage: _droppedUsage, ...rest } = candidateRecord;
|
||||
out[i] = rest as unknown as AgentMessage;
|
||||
|
||||
@@ -1162,13 +1162,15 @@ export async function runEmbeddedAttempt(
|
||||
}
|
||||
}
|
||||
|
||||
const compactionOccurredThisAttempt = getCompactionCount() > 0;
|
||||
|
||||
// Append cache-TTL timestamp AFTER prompt + compaction retry completes.
|
||||
// Previously this was before the prompt, which caused a custom entry to be
|
||||
// inserted between compaction and the next prompt — breaking the
|
||||
// prepareCompaction() guard that checks the last entry type, leading to
|
||||
// double-compaction. See: https://github.com/openclaw/openclaw/issues/9282
|
||||
// Skip when timed out during compaction — session state may be inconsistent.
|
||||
if (!timedOutDuringCompaction) {
|
||||
if (!timedOutDuringCompaction && !compactionOccurredThisAttempt) {
|
||||
const shouldTrackCacheTtl =
|
||||
params.config?.agents?.defaults?.contextPruning?.mode === "cache-ttl" &&
|
||||
isCacheTtlEligibleProvider(params.provider, params.modelId);
|
||||
@@ -1200,7 +1202,7 @@ export async function runEmbeddedAttempt(
|
||||
messagesSnapshot = snapshotSelection.messagesSnapshot;
|
||||
sessionIdUsed = snapshotSelection.sessionIdUsed;
|
||||
|
||||
if (promptError && promptErrorSource === "prompt") {
|
||||
if (promptError && promptErrorSource === "prompt" && !compactionOccurredThisAttempt) {
|
||||
try {
|
||||
sessionManager.appendCustomEntry("openclaw:prompt-error", {
|
||||
timestamp: Date.now(),
|
||||
|
||||
@@ -52,6 +52,7 @@ export function handleAutoCompactionEnd(
|
||||
ctx.log.debug(`embedded run compaction retry: runId=${ctx.params.runId}`);
|
||||
} else {
|
||||
ctx.maybeResolveCompactionWait();
|
||||
clearStaleAssistantUsageOnSessionMessages(ctx);
|
||||
}
|
||||
emitAgentEvent({
|
||||
runId: ctx.params.runId,
|
||||
@@ -81,3 +82,23 @@ export function handleAutoCompactionEnd(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearStaleAssistantUsageOnSessionMessages(ctx: EmbeddedPiSubscribeContext): void {
|
||||
const messages = ctx.params.session.messages;
|
||||
if (!Array.isArray(messages)) {
|
||||
return;
|
||||
}
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== "object") {
|
||||
continue;
|
||||
}
|
||||
const candidate = message as { role?: unknown; usage?: unknown };
|
||||
if (candidate.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
if (!("usage" in candidate)) {
|
||||
continue;
|
||||
}
|
||||
delete (candidate as { usage?: unknown }).usage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,3 +428,59 @@ describe("compaction-safeguard extension model fallback", () => {
|
||||
expect(getApiKeyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("compaction-safeguard double-compaction guard", () => {
|
||||
it("cancels compaction when there are no real messages to summarize", async () => {
|
||||
const sessionManager = stubSessionManager();
|
||||
const model = createAnthropicModelFixture();
|
||||
setCompactionSafeguardRuntime(sessionManager, { model });
|
||||
|
||||
const compactionHandler = createCompactionHandler();
|
||||
const mockEvent = {
|
||||
preparation: {
|
||||
messagesToSummarize: [] as AgentMessage[],
|
||||
turnPrefixMessages: [] as AgentMessage[],
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 1500,
|
||||
fileOps: { read: [], edited: [], written: [] },
|
||||
},
|
||||
customInstructions: "",
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
|
||||
const getApiKeyMock = vi.fn().mockResolvedValue("sk-test");
|
||||
const mockContext = createCompactionContext({
|
||||
sessionManager,
|
||||
getApiKeyMock,
|
||||
});
|
||||
|
||||
const result = (await compactionHandler(mockEvent, mockContext)) as {
|
||||
cancel?: boolean;
|
||||
};
|
||||
expect(result).toEqual({ cancel: true });
|
||||
expect(getApiKeyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues when messages include real conversation content", async () => {
|
||||
const sessionManager = stubSessionManager();
|
||||
const model = createAnthropicModelFixture();
|
||||
setCompactionSafeguardRuntime(sessionManager, { model });
|
||||
|
||||
const compactionHandler = createCompactionHandler();
|
||||
const mockEvent = createCompactionEvent({
|
||||
messageText: "real message",
|
||||
tokensBefore: 1500,
|
||||
});
|
||||
const getApiKeyMock = vi.fn().mockResolvedValue(null);
|
||||
const mockContext = createCompactionContext({
|
||||
sessionManager,
|
||||
getApiKeyMock,
|
||||
});
|
||||
|
||||
const result = (await compactionHandler(mockEvent, mockContext)) as {
|
||||
cancel?: boolean;
|
||||
};
|
||||
expect(result).toEqual({ cancel: true });
|
||||
expect(getApiKeyMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,6 +130,10 @@ function formatToolFailuresSection(failures: ToolFailure[]): string {
|
||||
return `\n\n## Tool Failures\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
function isRealConversationMessage(message: AgentMessage): boolean {
|
||||
return message.role === "user" || message.role === "assistant" || message.role === "toolResult";
|
||||
}
|
||||
|
||||
function computeFileLists(fileOps: FileOperations): {
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
@@ -191,6 +195,12 @@ async function readWorkspaceContextForSummary(): Promise<string> {
|
||||
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
api.on("session_before_compact", async (event, ctx) => {
|
||||
const { preparation, customInstructions, signal } = event;
|
||||
if (!preparation.messagesToSummarize.some(isRealConversationMessage)) {
|
||||
log.warn(
|
||||
"Compaction safeguard: cancelling compaction with no real conversation messages to summarize.",
|
||||
);
|
||||
return { cancel: true };
|
||||
}
|
||||
const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps);
|
||||
const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles);
|
||||
const toolFailures = collectToolFailures([
|
||||
|
||||
@@ -103,6 +103,24 @@ describe("ensureAgentWorkspace", () => {
|
||||
expect(state.bootstrapSeededAt).toBeUndefined();
|
||||
expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("treats memory-backed workspaces as existing even when template files are missing", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-workspace-");
|
||||
await fs.mkdir(path.join(tempDir, "memory"), { recursive: true });
|
||||
await fs.writeFile(path.join(tempDir, "memory", "2026-02-25.md"), "# Daily log\nSome notes");
|
||||
await fs.writeFile(path.join(tempDir, "MEMORY.md"), "# Long-term memory\nImportant stuff");
|
||||
|
||||
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
|
||||
|
||||
await expect(fs.access(path.join(tempDir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined();
|
||||
await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
const state = await readOnboardingState(tempDir);
|
||||
expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
const memoryContent = await fs.readFile(path.join(tempDir, "MEMORY.md"), "utf-8");
|
||||
expect(memoryContent).toBe("# Long-term memory\nImportant stuff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadWorkspaceBootstrapFiles", () => {
|
||||
|
||||
@@ -349,7 +349,13 @@ export async function ensureAgentWorkspace(params?: {
|
||||
const statePath = resolveWorkspaceStatePath(dir);
|
||||
|
||||
const isBrandNewWorkspace = await (async () => {
|
||||
const paths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath];
|
||||
const templatePaths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath];
|
||||
const userContentPaths = [
|
||||
path.join(dir, "memory"),
|
||||
path.join(dir, DEFAULT_MEMORY_FILENAME),
|
||||
path.join(dir, ".git"),
|
||||
];
|
||||
const paths = [...templatePaths, ...userContentPaths];
|
||||
const existing = await Promise.all(
|
||||
paths.map(async (p) => {
|
||||
try {
|
||||
@@ -394,14 +400,27 @@ export async function ensureAgentWorkspace(params?: {
|
||||
}
|
||||
|
||||
if (!state.bootstrapSeededAt && !state.onboardingCompletedAt && !bootstrapExists) {
|
||||
// Legacy migration path: if USER/IDENTITY diverged from templates, treat onboarding as complete
|
||||
// and avoid recreating BOOTSTRAP for already-onboarded workspaces.
|
||||
// Legacy migration path: if USER/IDENTITY diverged from templates, or if user-content
|
||||
// indicators exist, treat onboarding as complete and avoid recreating BOOTSTRAP for
|
||||
// already-onboarded workspaces.
|
||||
const [identityContent, userContent] = await Promise.all([
|
||||
fs.readFile(identityPath, "utf-8"),
|
||||
fs.readFile(userPath, "utf-8"),
|
||||
]);
|
||||
const hasUserContent = await (async () => {
|
||||
const indicators = [path.join(dir, "memory"), path.join(dir, DEFAULT_MEMORY_FILENAME)];
|
||||
for (const indicator of indicators) {
|
||||
try {
|
||||
await fs.access(indicator);
|
||||
return true;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
const legacyOnboardingCompleted =
|
||||
identityContent !== identityTemplate || userContent !== userTemplate;
|
||||
identityContent !== identityTemplate || userContent !== userTemplate || hasUserContent;
|
||||
if (legacyOnboardingCompleted) {
|
||||
markState({ onboardingCompletedAt: nowIso() });
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user