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'"
}
}
```
This commit is contained in:
parent
afd57c7e23
commit
ecb351b901
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<SessionEntry, "totalTokens" | "compactionCount" | "memoryFlushCompactionCount">;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user