refactor: move memory flush ownership into memory plugin

This commit is contained in:
Peter Steinberger
2026-03-26 21:30:39 +00:00
parent 48a65f7749
commit e0dfc776bb
18 changed files with 480 additions and 339 deletions

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import { emitDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js";
import { registerMemoryFlushPlanResolver, resolveMemoryFlushPlan } from "../memory/flush-plan.js";
import { buildMemoryPromptSection, registerMemoryPromptSection } from "../memory/prompt-section.js";
import { withEnv } from "../test-utils/env.js";
import { clearPluginCommands, getPluginCommandSpecs } from "./command-registry-state.js";
@@ -1048,9 +1049,17 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
expect(scoped.providers.map((entry) => entry.provider.id)).toEqual(["deepseek"]);
});
it("does not replace the active memory prompt section during non-activating loads", () => {
it("does not replace active memory plugin registries during non-activating loads", () => {
useNoBundledPlugins();
registerMemoryPromptSection(() => ["active memory section"]);
registerMemoryFlushPlanResolver(() => ({
softThresholdTokens: 1,
forceFlushTranscriptBytes: 2,
reserveTokensFloor: 3,
prompt: "active",
systemPrompt: "active",
relativePath: "memory/active.md",
}));
const plugin = writePlugin({
id: "snapshot-memory",
filename: "snapshot-memory.cjs",
@@ -1059,6 +1068,14 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
kind: "memory",
register(api) {
api.registerMemoryPromptSection(() => ["snapshot memory section"]);
api.registerMemoryFlushPlan(() => ({
softThresholdTokens: 10,
forceFlushTranscriptBytes: 20,
reserveTokensFloor: 30,
prompt: "snapshot",
systemPrompt: "snapshot",
relativePath: "memory/snapshot.md",
}));
},
};`,
});
@@ -1081,9 +1098,10 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([
"active memory section",
]);
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/active.md");
});
it("clears a newly-registered memory prompt section when plugin register fails", () => {
it("clears newly-registered memory plugin registries when plugin register fails", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "failing-memory",
@@ -1093,6 +1111,14 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
kind: "memory",
register(api) {
api.registerMemoryPromptSection(() => ["stale failure section"]);
api.registerMemoryFlushPlan(() => ({
softThresholdTokens: 10,
forceFlushTranscriptBytes: 20,
reserveTokensFloor: 30,
prompt: "failed",
systemPrompt: "failed",
relativePath: "memory/failed.md",
}));
throw new Error("memory register failed");
},
};`,
@@ -1113,6 +1139,7 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip
expect(registry.plugins.find((entry) => entry.id === "failing-memory")?.status).toBe("error");
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([]);
expect(resolveMemoryFlushPlan({})).toBeNull();
});
it("throws when activate:false is used without cache:false", () => {
@@ -3374,14 +3401,24 @@ export const runtimeValue = helperValue;`,
});
describe("clearPluginLoaderCache", () => {
it("resets the registered memory prompt section builder", () => {
it("resets registered memory plugin registries", () => {
registerMemoryPromptSection(() => ["stale memory section"]);
registerMemoryFlushPlanResolver(() => ({
softThresholdTokens: 1,
forceFlushTranscriptBytes: 2,
reserveTokensFloor: 3,
prompt: "stale",
systemPrompt: "stale",
relativePath: "memory/stale.md",
}));
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([
"stale memory section",
]);
expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/stale.md");
clearPluginLoaderCache();
expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([]);
expect(resolveMemoryFlushPlan({})).toBeNull();
});
});

View File

@@ -8,6 +8,11 @@ import type { PluginInstallRecord } from "../config/types.plugins.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
clearMemoryFlushPlanResolver,
getMemoryFlushPlanResolver,
restoreMemoryFlushPlanResolver,
} from "../memory/flush-plan.js";
import {
clearMemoryPromptSection,
getMemoryPromptSectionBuilder,
@@ -97,6 +102,7 @@ export class PluginLoadFailureError extends Error {
type CachedPluginState = {
registry: PluginRegistry;
memoryFlushPlanResolver: ReturnType<typeof getMemoryFlushPlanResolver>;
memoryPromptBuilder: ReturnType<typeof getMemoryPromptSectionBuilder>;
};
@@ -124,6 +130,7 @@ const LAZY_RUNTIME_REFLECTION_KEYS = [
export function clearPluginLoaderCache(): void {
registryCache.clear();
openAllowlistWarningCache.clear();
clearMemoryFlushPlanResolver();
clearMemoryPromptSection();
}
@@ -709,6 +716,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const cached = getCachedPluginRegistry(cacheKey);
if (cached) {
restoreMemoryPromptSection(cached.memoryPromptBuilder);
restoreMemoryFlushPlanResolver(cached.memoryFlushPlanResolver);
if (shouldActivate) {
activatePluginRegistry(cached.registry, cacheKey);
}
@@ -721,6 +729,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (shouldActivate) {
clearPluginCommands();
clearPluginInteractiveHandlers();
clearMemoryFlushPlanResolver();
clearMemoryPromptSection();
}
@@ -1219,6 +1228,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
hookPolicy: entry?.hooks,
registrationMode,
});
const previousMemoryFlushPlanResolver = getMemoryFlushPlanResolver();
const previousMemoryPromptBuilder = getMemoryPromptSectionBuilder();
try {
@@ -1234,11 +1244,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
// Snapshot loads should not replace process-global runtime prompt state.
if (!shouldActivate) {
restoreMemoryPromptSection(previousMemoryPromptBuilder);
restoreMemoryFlushPlanResolver(previousMemoryFlushPlanResolver);
}
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
} catch (err) {
restoreMemoryPromptSection(previousMemoryPromptBuilder);
restoreMemoryFlushPlanResolver(previousMemoryFlushPlanResolver);
recordPluginError({
logger,
registry,
@@ -1274,6 +1286,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (cacheEnabled) {
setCachedPluginRegistry(cacheKey, {
registry,
memoryFlushPlanResolver: getMemoryFlushPlanResolver(),
memoryPromptBuilder: getMemoryPromptSectionBuilder(),
});
}

View File

@@ -8,6 +8,7 @@ import type {
} from "../gateway/server-methods/types.js";
import { registerInternalHook } from "../hooks/internal-hooks.js";
import type { HookEntry } from "../hooks/types.js";
import { registerMemoryFlushPlanResolver } from "../memory/flush-plan.js";
import { registerMemoryPromptSection } from "../memory/prompt-section.js";
import { resolveUserPath } from "../utils.js";
import { registerPluginCommand, validatePluginCommandDefinition } from "./command-registration.js";
@@ -1042,6 +1043,21 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}
registerMemoryPromptSection(builder);
},
registerMemoryFlushPlan: (resolver) => {
if (registrationMode !== "full") {
return;
}
if (record.kind !== "memory") {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "only memory plugins can register a memory flush plan",
});
return;
}
registerMemoryFlushPlanResolver(resolver);
},
resolvePath: (input: string) => resolveUserPath(input),
on: (hookName, handler, opts) =>
registrationMode === "full"

View File

@@ -1397,6 +1397,10 @@ export type OpenClawPluginApi = {
registerMemoryPromptSection: (
builder: import("../memory/prompt-section.js").MemoryPromptSectionBuilder,
) => void;
/** Register the pre-compaction flush plan resolver for this memory plugin (exclusive slot). */
registerMemoryFlushPlan: (
resolver: import("../memory/flush-plan.js").MemoryFlushPlanResolver,
) => void;
resolvePath: (input: string) => string;
/** Register a lifecycle hook handler */
on: <K extends PluginHookName>(