feat(compaction): add post-compaction recovery turn

After auto-compaction completes, run an agentic turn to help the agent
recover context from memory files and continue any pending work.

Configuration:
- agents.defaults.compaction.postCompaction.enabled (default: true)
- agents.defaults.compaction.postCompaction.prompt
- agents.defaults.compaction.postCompaction.systemPrompt

Default prompt instructs agent to read memory/YYYY-MM-DD.md and reply
with NO_REPLY if nothing requires attention.

Implements #3452

Co-authored-by: Sebastian Almeida <18198367+sebasalmeidam@users.noreply.github.com>
This commit is contained in:
Jarvis Deploy 2026-01-28 12:13:32 -05:00
parent 109ac1c549
commit 3ea7a66c39
7 changed files with 414 additions and 0 deletions

View File

@ -21,6 +21,7 @@ import {
resolveMemoryFlushSettings,
shouldRunMemoryFlush,
} from "./memory-flush.js";
import { resolvePostCompactionSettings, shouldRunPostCompaction } from "./post-compaction.js";
import type { FollowupRun } from "./queue.js";
import { incrementCompactionCount } from "./session-updates.js";
@ -189,5 +190,171 @@ export async function runMemoryFlushIfNeeded(params: {
logVerbose(`memory flush run failed: ${String(err)}`);
}
// Run post-compaction recovery if needed
if (memoryCompactionCompleted) {
activeSessionEntry =
(await runPostCompactionRecoveryIfNeeded({
cfg: params.cfg,
followupRun: params.followupRun,
sessionCtx: params.sessionCtx,
opts: params.opts,
defaultModel: params.defaultModel,
agentCfgContextTokens: params.agentCfgContextTokens,
resolvedVerboseLevel: params.resolvedVerboseLevel,
sessionEntry: activeSessionEntry,
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
isHeartbeat: params.isHeartbeat,
memoryCompactionCompleted,
})) ?? activeSessionEntry;
}
return activeSessionEntry;
}
export async function runPostCompactionRecoveryIfNeeded(params: {
cfg: MoltbotConfig;
followupRun: FollowupRun;
sessionCtx: TemplateContext;
opts?: GetReplyOptions;
defaultModel: string;
agentCfgContextTokens?: number;
resolvedVerboseLevel: VerboseLevel;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
isHeartbeat: boolean;
memoryCompactionCompleted: boolean;
}): Promise<SessionEntry | undefined> {
const postCompactionSettings = resolvePostCompactionSettings(params.cfg);
if (!postCompactionSettings) return params.sessionEntry;
const postCompactionWritable = (() => {
if (!params.sessionKey) return true;
const runtime = resolveSandboxRuntimeStatus({
cfg: params.cfg,
sessionKey: params.sessionKey,
});
if (!runtime.sandboxed) return true;
const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, runtime.agentId);
return sandboxCfg.workspaceAccess === "rw";
})();
const shouldRunRecovery =
postCompactionSettings &&
postCompactionWritable &&
!params.isHeartbeat &&
!isCliProvider(params.followupRun.run.provider, params.cfg) &&
shouldRunPostCompaction({
entry:
params.sessionEntry ??
(params.sessionKey ? params.sessionStore?.[params.sessionKey] : undefined),
memoryCompactionCompleted: params.memoryCompactionCompleted,
});
if (!shouldRunRecovery) return params.sessionEntry;
let activeSessionEntry = params.sessionEntry;
const recoveryRunId = crypto.randomUUID();
if (params.sessionKey) {
registerAgentRunContext(recoveryRunId, {
sessionKey: params.sessionKey,
verboseLevel: params.resolvedVerboseLevel,
});
}
const recoverySystemPrompt = [
params.followupRun.run.extraSystemPrompt,
postCompactionSettings.systemPrompt,
]
.filter(Boolean)
.join("\n\n");
try {
await runWithModelFallback({
cfg: params.followupRun.run.config,
provider: params.followupRun.run.provider,
model: params.followupRun.run.model,
agentDir: params.followupRun.run.agentDir,
fallbacksOverride: resolveAgentModelFallbacksOverride(
params.followupRun.run.config,
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
),
run: (provider, model) => {
const authProfileId =
provider === params.followupRun.run.provider
? params.followupRun.run.authProfileId
: undefined;
return runEmbeddedPiAgent({
sessionId: params.followupRun.run.sessionId,
sessionKey: params.sessionKey,
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: params.sessionCtx.AccountId,
messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To,
messageThreadId: params.sessionCtx.MessageThreadId ?? undefined,
// Provider threading context for tool auto-injection
...buildThreadingToolContext({
sessionCtx: params.sessionCtx,
config: params.followupRun.run.config,
hasRepliedRef: params.opts?.hasRepliedRef,
}),
senderId: params.sessionCtx.SenderId?.trim() || undefined,
senderName: params.sessionCtx.SenderName?.trim() || undefined,
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
sessionFile: params.followupRun.run.sessionFile,
workspaceDir: params.followupRun.run.workspaceDir,
agentDir: params.followupRun.run.agentDir,
config: params.followupRun.run.config,
skillsSnapshot: params.followupRun.run.skillsSnapshot,
prompt: postCompactionSettings.prompt,
extraSystemPrompt: recoverySystemPrompt,
ownerNumbers: params.followupRun.run.ownerNumbers,
enforceFinalTag: resolveEnforceFinalTag(params.followupRun.run, provider),
provider,
model,
authProfileId,
authProfileIdSource: authProfileId
? params.followupRun.run.authProfileIdSource
: undefined,
thinkLevel: params.followupRun.run.thinkLevel,
verboseLevel: params.followupRun.run.verboseLevel,
reasoningLevel: params.followupRun.run.reasoningLevel,
execOverrides: params.followupRun.run.execOverrides,
bashElevated: params.followupRun.run.bashElevated,
timeoutMs: params.followupRun.run.timeoutMs,
runId: recoveryRunId,
});
},
});
const postCompactionCompactionCount =
activeSessionEntry?.compactionCount ??
(params.sessionKey ? params.sessionStore?.[params.sessionKey]?.compactionCount : 0) ??
0;
if (params.storePath && params.sessionKey) {
try {
const updatedEntry = await updateSessionStoreEntry({
storePath: params.storePath,
sessionKey: params.sessionKey,
update: async () => ({
postCompactionAt: Date.now(),
postCompactionCompactionCount,
}),
});
if (updatedEntry) {
activeSessionEntry = updatedEntry;
}
} catch (err) {
logVerbose(`failed to persist post-compaction recovery metadata: ${String(err)}`);
}
}
} catch (err) {
logVerbose(`post-compaction recovery run failed: ${String(err)}`);
}
return activeSessionEntry;
}

View File

@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_POST_COMPACTION_PROMPT,
DEFAULT_POST_COMPACTION_SYSTEM_PROMPT,
resolvePostCompactionSettings,
shouldRunPostCompaction,
} from "./post-compaction.js";
describe("post-compaction settings", () => {
it("defaults to enabled with fallback prompt and system prompt", () => {
const settings = resolvePostCompactionSettings();
expect(settings).not.toBeNull();
expect(settings?.enabled).toBe(true);
expect(settings?.prompt.length).toBeGreaterThan(0);
expect(settings?.systemPrompt.length).toBeGreaterThan(0);
});
it("respects disable flag", () => {
expect(
resolvePostCompactionSettings({
agents: {
defaults: { compaction: { postCompaction: { enabled: false } } },
},
}),
).toBeNull();
});
it("uses default prompts when not configured", () => {
const settings = resolvePostCompactionSettings();
expect(settings?.prompt).toContain("Compaction completed");
expect(settings?.systemPrompt).toContain("Post-compaction recovery turn");
});
it("appends NO_REPLY hint when missing", () => {
const settings = resolvePostCompactionSettings({
agents: {
defaults: {
compaction: {
postCompaction: {
prompt: "Recovery turn now.",
systemPrompt: "Check memory.",
},
},
},
},
});
expect(settings?.prompt).toContain("NO_REPLY");
expect(settings?.systemPrompt).toContain("NO_REPLY");
});
it("preserves NO_REPLY hint when already present", () => {
const settings = resolvePostCompactionSettings({
agents: {
defaults: {
compaction: {
postCompaction: {
prompt: "Recovery turn. Reply NO_REPLY if nothing needed.",
systemPrompt: "Check memory. Use NO_REPLY when appropriate.",
},
},
},
},
});
// Should not double-append the hint
const promptHintCount = (settings?.prompt.match(/NO_REPLY/g) || []).length;
const systemHintCount = (settings?.systemPrompt.match(/NO_REPLY/g) || []).length;
expect(promptHintCount).toBe(1);
expect(systemHintCount).toBe(1);
});
});
describe("shouldRunPostCompaction", () => {
it("requires memoryCompactionCompleted to be true", () => {
expect(
shouldRunPostCompaction({
entry: { compactionCount: 1 },
memoryCompactionCompleted: false,
}),
).toBe(false);
});
it("runs when compaction just completed", () => {
expect(
shouldRunPostCompaction({
entry: { compactionCount: 1 },
memoryCompactionCompleted: true,
}),
).toBe(true);
});
it("skips when entry is missing but compaction completed", () => {
expect(
shouldRunPostCompaction({
entry: undefined,
memoryCompactionCompleted: true,
}),
).toBe(true); // Should still run even without entry
});
it("skips when already ran for current compaction count", () => {
expect(
shouldRunPostCompaction({
entry: {
compactionCount: 2,
postCompactionCompactionCount: 2,
},
memoryCompactionCompleted: true,
}),
).toBe(false);
});
it("runs when compaction count increased since last post-compaction", () => {
expect(
shouldRunPostCompaction({
entry: {
compactionCount: 3,
postCompactionCompactionCount: 2,
},
memoryCompactionCompleted: true,
}),
).toBe(true);
});
it("runs on first compaction when postCompactionCompactionCount is undefined", () => {
expect(
shouldRunPostCompaction({
entry: {
compactionCount: 1,
},
memoryCompactionCompleted: true,
}),
).toBe(true);
});
});
describe("default prompts", () => {
it("DEFAULT_POST_COMPACTION_PROMPT includes memory file reference", () => {
expect(DEFAULT_POST_COMPACTION_PROMPT).toContain("memory/YYYY-MM-DD.md");
});
it("DEFAULT_POST_COMPACTION_SYSTEM_PROMPT mentions memory files", () => {
expect(DEFAULT_POST_COMPACTION_SYSTEM_PROMPT).toContain("memory files");
});
});

View File

@ -0,0 +1,57 @@
import type { MoltbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
export const DEFAULT_POST_COMPACTION_PROMPT = [
"Compaction completed.",
"Read memory/YYYY-MM-DD.md to recover context and continue any pending work.",
`Reply with ${SILENT_REPLY_TOKEN} if nothing requires attention.`,
].join(" ");
export const DEFAULT_POST_COMPACTION_SYSTEM_PROMPT = [
"Post-compaction recovery turn.",
"Check memory files for recent work context and verify if you were mid-task.",
].join(" ");
export type PostCompactionSettings = {
enabled: boolean;
prompt: string;
systemPrompt: string;
};
export function resolvePostCompactionSettings(cfg?: MoltbotConfig): PostCompactionSettings | null {
const defaults = cfg?.agents?.defaults?.compaction?.postCompaction;
const enabled = defaults?.enabled ?? true;
if (!enabled) return null;
const prompt = defaults?.prompt?.trim() || DEFAULT_POST_COMPACTION_PROMPT;
const systemPrompt = defaults?.systemPrompt?.trim() || DEFAULT_POST_COMPACTION_SYSTEM_PROMPT;
return {
enabled,
prompt: ensureNoReplyHint(prompt),
systemPrompt: ensureNoReplyHint(systemPrompt),
};
}
function ensureNoReplyHint(text: string): string {
if (text.includes(SILENT_REPLY_TOKEN)) return text;
return `${text}\n\nIf no user-visible reply is needed, start with ${SILENT_REPLY_TOKEN}.`;
}
export function shouldRunPostCompaction(params: {
entry?: Pick<SessionEntry, "compactionCount" | "postCompactionCompactionCount">;
memoryCompactionCompleted: boolean;
}): boolean {
if (!params.memoryCompactionCompleted) return false;
const compactionCount = params.entry?.compactionCount ?? 0;
const lastPostCompactionAt = params.entry?.postCompactionCompactionCount;
// Don't run if we've already run post-compaction for this compaction cycle
if (typeof lastPostCompactionAt === "number" && lastPostCompactionAt === compactionCount) {
return false;
}
return true;
}

View File

@ -546,6 +546,30 @@ const FIELD_HELP: Record<string, string> = {
"Minimum appended bytes before session transcripts trigger reindex (default: 100000).",
"agents.defaults.memorySearch.sync.sessions.deltaMessages":
"Minimum appended JSONL lines before session transcripts trigger reindex (default: 50).",
"agents.defaults.compaction": "Compaction tuning and pre/post-compaction agentic turns.",
"agents.defaults.compaction.mode": 'Compaction summarization mode ("default" or "safeguard").',
"agents.defaults.compaction.reserveTokensFloor":
"Minimum reserve tokens enforced for Pi compaction (0 disables the floor).",
"agents.defaults.compaction.maxHistoryShare":
"Max share of context window for history during safeguard pruning (0.10.9, default 0.5).",
"agents.defaults.compaction.memoryFlush":
"Pre-compaction memory flush (agentic turn). Default: enabled.",
"agents.defaults.compaction.memoryFlush.enabled":
"Enable the pre-compaction memory flush (default: true).",
"agents.defaults.compaction.memoryFlush.softThresholdTokens":
"Run the memory flush when context is within this many tokens of the compaction threshold.",
"agents.defaults.compaction.memoryFlush.prompt":
"User prompt used for the memory flush turn (NO_REPLY is enforced if missing).",
"agents.defaults.compaction.memoryFlush.systemPrompt":
"System prompt appended for the memory flush turn.",
"agents.defaults.compaction.postCompaction":
"Post-compaction recovery turn (agentic turn). Default: enabled.",
"agents.defaults.compaction.postCompaction.enabled":
"Enable the post-compaction recovery turn (default: true).",
"agents.defaults.compaction.postCompaction.prompt":
"User prompt used for the post-compaction recovery turn (NO_REPLY is enforced if missing).",
"agents.defaults.compaction.postCompaction.systemPrompt":
"System prompt appended for the post-compaction recovery turn.",
"plugins.enabled": "Enable plugin/extension loading (default: true).",
"plugins.allow": "Optional allowlist of plugin ids; when set, only listed plugins load.",
"plugins.deny": "Optional denylist of plugin ids; deny wins over allowlist.",

View File

@ -77,6 +77,8 @@ export type SessionEntry = {
compactionCount?: number;
memoryFlushAt?: number;
memoryFlushCompactionCount?: number;
postCompactionAt?: number;
postCompactionCompactionCount?: number;
cliSessionIds?: Record<string, string>;
claudeCliSessionId?: string;
label?: string;

View File

@ -248,6 +248,8 @@ export type AgentCompactionConfig = {
maxHistoryShare?: number;
/** Pre-compaction memory flush (agentic turn). Default: enabled. */
memoryFlush?: AgentCompactionMemoryFlushConfig;
/** Post-compaction recovery turn (agentic turn). Default: enabled. */
postCompaction?: AgentCompactionPostCompactionConfig;
};
export type AgentCompactionMemoryFlushConfig = {
@ -260,3 +262,12 @@ export type AgentCompactionMemoryFlushConfig = {
/** System prompt appended for the memory flush turn. */
systemPrompt?: string;
};
export type AgentCompactionPostCompactionConfig = {
/** Enable the post-compaction recovery turn (default: true). */
enabled?: boolean;
/** User prompt used for the post-compaction recovery turn (NO_REPLY is enforced if missing). */
prompt?: string;
/** System prompt appended for the post-compaction recovery turn. */
systemPrompt?: string;
};

View File

@ -100,6 +100,14 @@ export const AgentDefaultsSchema = z
})
.strict()
.optional(),
postCompaction: z
.object({
enabled: z.boolean().optional(),
prompt: z.string().optional(),
systemPrompt: z.string().optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),