feat: add pluggable compaction provider registry (#56224)

Merged via squash.

Prepared head SHA: 0cc9cf3f30
Co-authored-by: DhruvBhatia0 <69252327+DhruvBhatia0@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
DhruvBhatia0
2026-04-07 13:55:34 -04:00
committed by GitHub
parent 14ec1ac50f
commit 12331f0463
23 changed files with 790 additions and 82 deletions

View File

@@ -45,6 +45,7 @@ export type BuildPluginApiParams = {
| "onConversationBindingResolved"
| "registerCommand"
| "registerContextEngine"
| "registerCompactionProvider"
| "registerMemoryCapability"
| "registerMemoryPromptSection"
| "registerMemoryPromptSupplement"
@@ -92,6 +93,7 @@ const noopOnConversationBindingResolved: OpenClawPluginApi["onConversationBindin
() => {};
const noopRegisterCommand: OpenClawPluginApi["registerCommand"] = () => {};
const noopRegisterContextEngine: OpenClawPluginApi["registerContextEngine"] = () => {};
const noopRegisterCompactionProvider: OpenClawPluginApi["registerCompactionProvider"] = () => {};
const noopRegisterMemoryCapability: OpenClawPluginApi["registerMemoryCapability"] = () => {};
const noopRegisterMemoryPromptSection: OpenClawPluginApi["registerMemoryPromptSection"] = () => {};
const noopRegisterMemoryPromptSupplement: OpenClawPluginApi["registerMemoryPromptSupplement"] =
@@ -154,6 +156,8 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
handlers.onConversationBindingResolved ?? noopOnConversationBindingResolved,
registerCommand: handlers.registerCommand ?? noopRegisterCommand,
registerContextEngine: handlers.registerContextEngine ?? noopRegisterContextEngine,
registerCompactionProvider:
handlers.registerCompactionProvider ?? noopRegisterCompactionProvider,
registerMemoryCapability: handlers.registerMemoryCapability ?? noopRegisterMemoryCapability,
registerMemoryPromptSection:
handlers.registerMemoryPromptSection ?? noopRegisterMemoryPromptSection,

View File

@@ -0,0 +1,145 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearCompactionProviders,
getCompactionProvider,
getRegisteredCompactionProvider,
listCompactionProviderIds,
listRegisteredCompactionProviders,
registerCompactionProvider,
restoreRegisteredCompactionProviders,
type CompactionProvider,
} from "./compaction-provider.js";
const REGISTRY_KEY = Symbol.for("openclaw.compactionProviderRegistryState");
/** Reset the process-global registry between tests. */
afterEach(() => {
const g = globalThis as Record<symbol, unknown>;
delete g[REGISTRY_KEY];
});
function makeProvider(id: string, label?: string): CompactionProvider {
return {
id,
label: label ?? id,
async summarize() {
return `summary-from-${id}`;
},
};
}
describe("compaction provider registry", () => {
it("starts empty", () => {
expect(listCompactionProviderIds()).toEqual([]);
expect(listRegisteredCompactionProviders()).toEqual([]);
});
it("returns undefined for an unknown id", () => {
expect(getCompactionProvider("nonexistent")).toBeUndefined();
expect(getRegisteredCompactionProvider("nonexistent")).toBeUndefined();
});
it("registers and retrieves a provider", () => {
const p = makeProvider("test-compactor");
registerCompactionProvider(p);
expect(getCompactionProvider("test-compactor")).toBe(p);
});
it("tracks ownerPluginId", () => {
const p = makeProvider("owned");
registerCompactionProvider(p, { ownerPluginId: "my-plugin" });
const entry = getRegisteredCompactionProvider("owned");
expect(entry?.provider).toBe(p);
expect(entry?.ownerPluginId).toBe("my-plugin");
});
it("lists registered provider ids", () => {
registerCompactionProvider(makeProvider("alpha"));
registerCompactionProvider(makeProvider("beta"));
expect(listCompactionProviderIds()).toEqual(["alpha", "beta"]);
});
it("lists registered entries with owner metadata", () => {
registerCompactionProvider(makeProvider("a"), { ownerPluginId: "plugin-a" });
registerCompactionProvider(makeProvider("b"));
const entries = listRegisteredCompactionProviders();
expect(entries).toHaveLength(2);
expect(entries[0]?.provider.id).toBe("a");
expect(entries[0]?.ownerPluginId).toBe("plugin-a");
expect(entries[1]?.provider.id).toBe("b");
expect(entries[1]?.ownerPluginId).toBeUndefined();
});
it("supports multiple providers", () => {
registerCompactionProvider(makeProvider("a"));
registerCompactionProvider(makeProvider("b"));
registerCompactionProvider(makeProvider("c"));
expect(getCompactionProvider("a")?.id).toBe("a");
expect(getCompactionProvider("b")?.id).toBe("b");
expect(getCompactionProvider("c")?.id).toBe("c");
expect(listCompactionProviderIds()).toHaveLength(3);
});
it("calls summarize and returns expected result", async () => {
registerCompactionProvider(makeProvider("my-compactor"));
const provider = getCompactionProvider("my-compactor");
const result = await provider!.summarize({ messages: [] });
expect(result).toBe("summary-from-my-compactor");
});
it("overwrites when re-registering the same id", () => {
const first = makeProvider("dup", "first-label");
const second = makeProvider("dup", "second-label");
registerCompactionProvider(first);
registerCompactionProvider(second);
expect(getCompactionProvider("dup")).toBe(second);
expect(getCompactionProvider("dup")?.label).toBe("second-label");
expect(listCompactionProviderIds()).toEqual(["dup"]);
});
describe("lifecycle (clear / restore)", () => {
it("clear removes all providers", () => {
registerCompactionProvider(makeProvider("a"));
registerCompactionProvider(makeProvider("b"));
expect(listCompactionProviderIds()).toHaveLength(2);
clearCompactionProviders();
expect(listCompactionProviderIds()).toEqual([]);
expect(getCompactionProvider("a")).toBeUndefined();
});
it("restore replaces current entries with snapshot", () => {
const provA = makeProvider("a");
const provB = makeProvider("b");
registerCompactionProvider(provA, { ownerPluginId: "p-a" });
registerCompactionProvider(provB, { ownerPluginId: "p-b" });
const snapshot = listRegisteredCompactionProviders();
// Register a third provider to change state
registerCompactionProvider(makeProvider("c"));
expect(listCompactionProviderIds()).toHaveLength(3);
// Restore from snapshot — should have only a and b
restoreRegisteredCompactionProviders(snapshot);
expect(listCompactionProviderIds()).toEqual(["a", "b"]);
expect(getCompactionProvider("c")).toBeUndefined();
expect(getRegisteredCompactionProvider("a")?.ownerPluginId).toBe("p-a");
});
it("restore with empty array clears everything", () => {
registerCompactionProvider(makeProvider("x"));
restoreRegisteredCompactionProviders([]);
expect(listCompactionProviderIds()).toEqual([]);
});
});
});

View File

@@ -0,0 +1,132 @@
/**
* Compaction provider registry — process-global singleton.
*
* Plugins implement the CompactionProvider interface and register via
* `registerCompactionProvider()`. The compaction safeguard checks this
* registry before falling back to the built-in `summarizeInStages()`.
*/
// ---------------------------------------------------------------------------
// Provider interface
// ---------------------------------------------------------------------------
/**
* A pluggable compaction provider that can replace the built-in
* summarizeInStages pipeline.
*/
export type CompactionProviderSummarizationInstructions = {
identifierPolicy?: "strict" | "off" | "custom";
identifierInstructions?: string;
};
export interface CompactionProvider {
id: string;
label: string;
summarize(params: {
messages: unknown[];
signal?: AbortSignal;
compressionRatio?: number;
customInstructions?: string;
summarizationInstructions?: CompactionProviderSummarizationInstructions;
/** Summary from a prior compaction round, if re-compacting. */
previousSummary?: string;
}): Promise<string>;
}
// ---------------------------------------------------------------------------
// Registered entry (mirrors RegisteredMemoryEmbeddingProvider pattern)
// ---------------------------------------------------------------------------
/** A compaction provider with its owning plugin id for lifecycle tracking. */
export type RegisteredCompactionProvider = {
provider: CompactionProvider;
ownerPluginId?: string;
};
// ---------------------------------------------------------------------------
// Registry (process-global singleton)
// ---------------------------------------------------------------------------
const COMPACTION_PROVIDER_REGISTRY_STATE = Symbol.for("openclaw.compactionProviderRegistryState");
type CompactionProviderRegistryState = {
providers: Map<string, RegisteredCompactionProvider>;
};
// Keep compaction-provider registrations process-global so duplicated dist
// chunks still share one registry map at runtime.
function getCompactionProviderRegistryState(): CompactionProviderRegistryState {
const globalState = globalThis as typeof globalThis & {
[COMPACTION_PROVIDER_REGISTRY_STATE]?: CompactionProviderRegistryState;
};
if (!globalState[COMPACTION_PROVIDER_REGISTRY_STATE]) {
globalState[COMPACTION_PROVIDER_REGISTRY_STATE] = {
providers: new Map<string, RegisteredCompactionProvider>(),
};
}
return globalState[COMPACTION_PROVIDER_REGISTRY_STATE];
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
/**
* Register a compaction provider implementation.
* Pass `ownerPluginId` so the loader can snapshot/restore correctly.
*/
export function registerCompactionProvider(
provider: CompactionProvider,
options?: { ownerPluginId?: string },
): void {
getCompactionProviderRegistryState().providers.set(provider.id, {
provider,
ownerPluginId: options?.ownerPluginId,
});
}
// ---------------------------------------------------------------------------
// Lookup
// ---------------------------------------------------------------------------
/** Return the provider for the given id, or undefined. */
export function getCompactionProvider(id: string): CompactionProvider | undefined {
return getCompactionProviderRegistryState().providers.get(id)?.provider;
}
/** Return the registered entry (provider + owner) for the given id. */
export function getRegisteredCompactionProvider(
id: string,
): RegisteredCompactionProvider | undefined {
return getCompactionProviderRegistryState().providers.get(id);
}
/** List all registered compaction provider ids. */
export function listCompactionProviderIds(): string[] {
return [...getCompactionProviderRegistryState().providers.keys()];
}
/** List all registered entries with owner metadata (for snapshot/restore). */
export function listRegisteredCompactionProviders(): RegisteredCompactionProvider[] {
return Array.from(getCompactionProviderRegistryState().providers.values());
}
// ---------------------------------------------------------------------------
// Lifecycle (clear / restore) — mirrors memory-embedding-providers.ts
// ---------------------------------------------------------------------------
/** Clear all compaction providers. Used by clearPluginLoaderCache() and reload. */
export function clearCompactionProviders(): void {
getCompactionProviderRegistryState().providers.clear();
}
/** Restore from a snapshot, replacing all current entries. */
export function restoreRegisteredCompactionProviders(
entries: RegisteredCompactionProvider[],
): void {
const map = getCompactionProviderRegistryState().providers;
map.clear();
for (const entry of entries) {
map.set(entry.provider.id, entry);
}
}

View File

@@ -13,6 +13,11 @@ import { resolveUserPath } from "../utils.js";
import { buildPluginApi } from "./api-builder.js";
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
import { clearPluginCommands } from "./command-registry-state.js";
import {
clearCompactionProviders,
listRegisteredCompactionProviders,
restoreRegisteredCompactionProviders,
} from "./compaction-provider.js";
import {
applyTestPluginDefaults,
createPluginActivationSource,
@@ -143,6 +148,7 @@ export class PluginLoadReentryError extends Error {
type CachedPluginState = {
registry: PluginRegistry;
memoryCorpusSupplements: ReturnType<typeof listMemoryCorpusSupplements>;
compactionProviders: ReturnType<typeof listRegisteredCompactionProviders>;
memoryEmbeddingProviders: ReturnType<typeof listRegisteredMemoryEmbeddingProviders>;
memoryFlushPlanResolver: ReturnType<typeof getMemoryFlushPlanResolver>;
memoryPromptBuilder: ReturnType<typeof getMemoryPromptSectionBuilder>;
@@ -175,6 +181,7 @@ export function clearPluginLoaderCache(): void {
registryCache.clear();
inFlightPluginRegistryLoads.clear();
openAllowlistWarningCache.clear();
clearCompactionProviders();
clearMemoryEmbeddingProviders();
clearMemoryPluginState();
}
@@ -1074,6 +1081,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (cacheEnabled) {
const cached = getCachedPluginRegistry(cacheKey);
if (cached) {
restoreRegisteredCompactionProviders(cached.compactionProviders);
restoreRegisteredMemoryEmbeddingProviders(cached.memoryEmbeddingProviders);
restoreMemoryPluginState({
corpusSupplements: cached.memoryCorpusSupplements,
@@ -1667,6 +1675,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
hookPolicy: entry?.hooks,
registrationMode,
});
const previousCompactionProviders = listRegisteredCompactionProviders();
const previousMemoryEmbeddingProviders = listRegisteredMemoryEmbeddingProviders();
const previousMemoryFlushPlanResolver = getMemoryFlushPlanResolver();
const previousMemoryPromptBuilder = getMemoryPromptSectionBuilder();
@@ -1686,6 +1695,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
// Snapshot loads should not replace process-global runtime prompt state.
if (!shouldActivate) {
restoreRegisteredCompactionProviders(previousCompactionProviders);
restoreRegisteredMemoryEmbeddingProviders(previousMemoryEmbeddingProviders);
restoreMemoryPluginState({
corpusSupplements: previousMemoryCorpusSupplements,
@@ -1698,6 +1708,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
} catch (err) {
restoreRegisteredCompactionProviders(previousCompactionProviders);
restoreRegisteredMemoryEmbeddingProviders(previousMemoryEmbeddingProviders);
restoreMemoryPluginState({
corpusSupplements: previousMemoryCorpusSupplements,
@@ -1756,6 +1767,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
setCachedPluginRegistry(cacheKey, {
memoryCorpusSupplements: listMemoryCorpusSupplements(),
registry,
compactionProviders: listRegisteredCompactionProviders(),
memoryEmbeddingProviders: listRegisteredMemoryEmbeddingProviders(),
memoryFlushPlanResolver: getMemoryFlushPlanResolver(),
memoryPromptBuilder: getMemoryPromptSectionBuilder(),

View File

@@ -19,6 +19,10 @@ import { normalizeOptionalString } from "../shared/string-coerce.js";
import { resolveUserPath } from "../utils.js";
import { buildPluginApi } from "./api-builder.js";
import { registerPluginCommand, validatePluginCommandDefinition } from "./command-registration.js";
import {
getRegisteredCompactionProvider,
registerCompactionProvider,
} from "./compaction-provider.js";
import type { PluginActivationSource } from "./config-state.js";
import { normalizePluginHttpPath } from "./http-path.js";
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
@@ -1297,6 +1301,24 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
}
},
registerCompactionProvider: (
provider: Parameters<OpenClawPluginApi["registerCompactionProvider"]>[0],
) => {
const existing = getRegisteredCompactionProvider(provider.id);
if (existing) {
const ownerDetail = existing.ownerPluginId
? ` (owner: ${existing.ownerPluginId})`
: "";
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `compaction provider already registered: ${provider.id}${ownerDetail}`,
});
return;
}
registerCompactionProvider(provider, { ownerPluginId: record.id });
},
registerMemoryCapability: (capability) => {
if (!hasKind(record.kind, "memory")) {
pushDiagnostic({

View File

@@ -2216,6 +2216,10 @@ export type OpenClawPluginApi = {
id: string,
factory: import("../context-engine/registry.js").ContextEngineFactory,
) => void;
/** Register a compaction provider (pluggable summarization backend). */
registerCompactionProvider: (
provider: import("./compaction-provider.js").CompactionProvider,
) => void;
/** Register the active memory capability for this memory plugin (exclusive slot). */
registerMemoryCapability: (
capability: import("./memory-state.js").MemoryPluginCapability,