diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index cbecefccf..0bdfe8f05 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -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; + sessionKey?: string; + storePath?: string; + isHeartbeat: boolean; + memoryCompactionCompleted: boolean; +}): Promise { + 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; } diff --git a/src/auto-reply/reply/post-compaction.test.ts b/src/auto-reply/reply/post-compaction.test.ts new file mode 100644 index 000000000..f64b25386 --- /dev/null +++ b/src/auto-reply/reply/post-compaction.test.ts @@ -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"); + }); +}); diff --git a/src/auto-reply/reply/post-compaction.ts b/src/auto-reply/reply/post-compaction.ts new file mode 100644 index 000000000..56a9ece29 --- /dev/null +++ b/src/auto-reply/reply/post-compaction.ts @@ -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; + 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; +} diff --git a/src/config/schema.ts b/src/config/schema.ts index b4ec8723b..6ded10c56 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -546,6 +546,30 @@ const FIELD_HELP: Record = { "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.1–0.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.", diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 48ce428c1..55bdb8a1e 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -77,6 +77,8 @@ export type SessionEntry = { compactionCount?: number; memoryFlushAt?: number; memoryFlushCompactionCount?: number; + postCompactionAt?: number; + postCompactionCompactionCount?: number; cliSessionIds?: Record; claudeCliSessionId?: string; label?: string; diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 9c6ce0211..84cf6ac7b 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -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; +}; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index a849078ed..bcfd69271 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -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(),