mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 08:30:30 +00:00
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:
@@ -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,
|
||||
|
||||
145
src/plugins/compaction-provider.test.ts
Normal file
145
src/plugins/compaction-provider.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
132
src/plugins/compaction-provider.ts
Normal file
132
src/plugins/compaction-provider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user