From ecb351b9011b4e3ce092676d123235797327138e Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 27 Jan 2026 16:50:03 -0800 Subject: [PATCH] feat(memoryFlush): add hard threshold for auto-execute backup command Adds two new config options to memoryFlush: - hardThresholdTokens: token count that triggers auto-execution - hardThresholdCommand: shell command to run (e.g., kernle checkpoint save) Flow: 1. Soft threshold (existing): prompts agent to save with context 2. Hard threshold (new): auto-executes command without agent involvement This provides a safety net - agents get a chance to save with context at soft threshold, but if they miss it, the hard threshold ensures state is preserved before compaction. Example config: ```json { "memoryFlush": { "softThresholdTokens": 100000, "prompt": "Save your state to Kernle...", "hardThresholdTokens": 120000, "hardThresholdCommand": "kernle -a agent checkpoint save 'auto-backup'" } } ``` --- src/auto-reply/reply/agent-runner-memory.ts | 100 ++++++++++++++++++++ src/auto-reply/reply/memory-flush.ts | 38 ++++++++ src/config/types.agent-defaults.ts | 4 + src/config/zod-schema.agent-defaults.ts | 2 + 4 files changed, 144 insertions(+) diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index cbecefccf..8747e9037 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { spawn } from "node:child_process"; import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { isCliProvider } from "../../agents/model-selection.js"; @@ -20,6 +21,7 @@ import { resolveMemoryFlushContextWindowTokens, resolveMemoryFlushSettings, shouldRunMemoryFlush, + shouldRunHardThresholdCommand, } from "./memory-flush.js"; import type { FollowupRun } from "./queue.js"; import { incrementCompactionCount } from "./session-updates.js"; @@ -71,6 +73,63 @@ export async function runMemoryFlushIfNeeded(params: { if (!shouldFlushMemory) return params.sessionEntry; + // Check if hard threshold is reached - auto-execute command without agent prompt + const contextWindowTokens = resolveMemoryFlushContextWindowTokens({ + modelId: params.followupRun.run.model ?? params.defaultModel, + agentCfgContextTokens: params.agentCfgContextTokens, + }); + + const shouldRunHardCommand = + memoryFlushSettings.hardThresholdCommand && + memoryFlushSettings.hardThresholdTokens && + shouldRunHardThresholdCommand({ + entry: + params.sessionEntry ?? + (params.sessionKey ? params.sessionStore?.[params.sessionKey] : undefined), + contextWindowTokens, + reserveTokensFloor: memoryFlushSettings.reserveTokensFloor, + hardThresholdTokens: memoryFlushSettings.hardThresholdTokens, + }); + + if (shouldRunHardCommand && memoryFlushSettings.hardThresholdCommand) { + logVerbose( + `hard threshold reached, auto-executing: ${memoryFlushSettings.hardThresholdCommand}`, + ); + try { + await runHardThresholdCommand( + memoryFlushSettings.hardThresholdCommand, + params.followupRun.run.workspaceDir, + ); + logVerbose("hard threshold command completed"); + // Update session metadata to mark flush completed + if (params.storePath && params.sessionKey) { + const compactionCount = + params.sessionEntry?.compactionCount ?? + (params.sessionKey ? params.sessionStore?.[params.sessionKey]?.compactionCount : 0) ?? + 0; + try { + const updatedEntry = await updateSessionStoreEntry({ + storePath: params.storePath, + sessionKey: params.sessionKey, + update: async () => ({ + memoryFlushAt: Date.now(), + memoryFlushCompactionCount: compactionCount, + }), + }); + if (updatedEntry) { + return updatedEntry; + } + } catch (err) { + logVerbose(`failed to persist hard threshold flush metadata: ${String(err)}`); + } + } + return params.sessionEntry; + } catch (err) { + logVerbose(`hard threshold command failed: ${String(err)}`); + // Fall through to soft threshold agent prompt + } + } + let activeSessionEntry = params.sessionEntry; const activeSessionStore = params.sessionStore; const flushRunId = crypto.randomUUID(); @@ -191,3 +250,44 @@ export async function runMemoryFlushIfNeeded(params: { return activeSessionEntry; } + +/** + * Execute the hard threshold command directly (no agent involvement). + * Runs the command in a shell with the workspace as cwd. + */ +async function runHardThresholdCommand(command: string, workspaceDir?: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, [], { + shell: true, + cwd: workspaceDir || process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + timeout: 30000, // 30 second timeout + }); + + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("close", (code) => { + if (code === 0) { + if (stdout.trim()) { + logVerbose(`hard threshold command output: ${stdout.trim()}`); + } + resolve(); + } else { + reject(new Error(`Command exited with code ${code}: ${stderr || stdout}`)); + } + }); + + child.on("error", (err) => { + reject(err); + }); + }); +} diff --git a/src/auto-reply/reply/memory-flush.ts b/src/auto-reply/reply/memory-flush.ts index 9ad14b0ba..9d1e3a33d 100644 --- a/src/auto-reply/reply/memory-flush.ts +++ b/src/auto-reply/reply/memory-flush.ts @@ -25,6 +25,8 @@ export type MemoryFlushSettings = { prompt: string; systemPrompt: string; reserveTokensFloor: number; + hardThresholdTokens: number | null; + hardThresholdCommand: string | null; }; const normalizeNonNegativeInt = (value: unknown): number | null => { @@ -44,6 +46,8 @@ export function resolveMemoryFlushSettings(cfg?: MoltbotConfig): MemoryFlushSett const reserveTokensFloor = normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ?? DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR; + const hardThresholdTokens = normalizeNonNegativeInt(defaults?.hardThresholdTokens); + const hardThresholdCommand = defaults?.hardThresholdCommand?.trim() || null; return { enabled, @@ -51,6 +55,8 @@ export function resolveMemoryFlushSettings(cfg?: MoltbotConfig): MemoryFlushSett prompt: ensureNoReplyHint(prompt), systemPrompt: ensureNoReplyHint(systemPrompt), reserveTokensFloor, + hardThresholdTokens, + hardThresholdCommand, }; } @@ -91,3 +97,35 @@ export function shouldRunMemoryFlush(params: { return true; } + +/** + * Check if the hard threshold has been reached and the command should auto-execute. + * Returns true if totalTokens exceeds the hard threshold. + */ +export function shouldRunHardThresholdCommand(params: { + entry?: Pick; + contextWindowTokens: number; + reserveTokensFloor: number; + hardThresholdTokens: number | null; +}): boolean { + if (!params.hardThresholdTokens) return false; + const totalTokens = params.entry?.totalTokens; + if (!totalTokens || totalTokens <= 0) return false; + + const contextWindow = Math.max(1, Math.floor(params.contextWindowTokens)); + const reserveTokens = Math.max(0, Math.floor(params.reserveTokensFloor)); + const hardThreshold = Math.max(0, Math.floor(params.hardThresholdTokens)); + const threshold = Math.max(0, contextWindow - reserveTokens - hardThreshold); + + if (threshold <= 0) return false; + if (totalTokens < threshold) return false; + + // Check if we already ran the hard flush this compaction cycle + const compactionCount = params.entry?.compactionCount ?? 0; + const lastFlushAt = params.entry?.memoryFlushCompactionCount; + if (typeof lastFlushAt === "number" && lastFlushAt === compactionCount) { + return false; + } + + return true; +} diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 9c6ce0211..5718a68a7 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -259,4 +259,8 @@ export type AgentCompactionMemoryFlushConfig = { prompt?: string; /** System prompt appended for the memory flush turn. */ systemPrompt?: string; + /** Auto-execute hardThresholdCommand when context exceeds this many tokens (no agent prompt). */ + hardThresholdTokens?: number; + /** Shell command to auto-execute when hard threshold is reached (e.g., "kernle -a agent checkpoint save 'auto-backup'"). */ + hardThresholdCommand?: string; }; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index a849078ed..3c1690ea3 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -97,6 +97,8 @@ export const AgentDefaultsSchema = z softThresholdTokens: z.number().int().nonnegative().optional(), prompt: z.string().optional(), systemPrompt: z.string().optional(), + hardThresholdTokens: z.number().int().nonnegative().optional(), + hardThresholdCommand: z.string().optional(), }) .strict() .optional(),