From 871086537bf30f9448d760fe927ff7a3ef18db7c Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 19:06:28 +0000 Subject: [PATCH] Plugins: extract slot arbitration --- src/extension-host/context-engine-runtime.ts | 4 +- src/extension-host/slot-arbitration.test.ts | 44 +++++++ src/extension-host/slot-arbitration.ts | 112 ++++++++++++++++++ src/plugins/slots.ts | 117 ++----------------- 4 files changed, 165 insertions(+), 112 deletions(-) create mode 100644 src/extension-host/slot-arbitration.test.ts create mode 100644 src/extension-host/slot-arbitration.ts diff --git a/src/extension-host/context-engine-runtime.ts b/src/extension-host/context-engine-runtime.ts index f580cdf2a34..3f14edcbe49 100644 --- a/src/extension-host/context-engine-runtime.ts +++ b/src/extension-host/context-engine-runtime.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ContextEngine } from "../context-engine/types.js"; -import { defaultSlotIdForKey } from "../plugins/slots.js"; +import { getExtensionHostDefaultSlotId } from "./slot-arbitration.js"; export type ExtensionHostContextEngineFactory = () => ContextEngine | Promise; @@ -46,7 +46,7 @@ export async function resolveExtensionHostContextEngine( const engineId = typeof slotValue === "string" && slotValue.trim() ? slotValue.trim() - : defaultSlotIdForKey("contextEngine"); + : getExtensionHostDefaultSlotId("contextEngine"); const factory = getExtensionHostContextEngineRuntimeState().engines.get(engineId); if (!factory) { diff --git a/src/extension-host/slot-arbitration.test.ts b/src/extension-host/slot-arbitration.test.ts new file mode 100644 index 00000000000..9e9e6c88832 --- /dev/null +++ b/src/extension-host/slot-arbitration.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { applyExtensionHostExclusiveSlotSelection } from "./slot-arbitration.js"; + +describe("extension host slot arbitration", () => { + const createMemoryConfig = (plugins?: OpenClawConfig["plugins"]): OpenClawConfig => ({ + plugins: { + ...plugins, + entries: { + ...plugins?.entries, + memory: { + enabled: true, + ...plugins?.entries?.memory, + }, + }, + }, + }); + + it("selects the slot and disables competing plugins of the same kind", () => { + const config = createMemoryConfig({ + slots: { memory: "memory-core" }, + entries: { "memory-core": { enabled: true } }, + }); + const result = applyExtensionHostExclusiveSlotSelection({ + config, + selectedId: "memory", + selectedKind: "memory", + registry: { + plugins: [ + { id: "memory-core", kind: "memory" }, + { id: "memory", kind: "memory" }, + ], + }, + }); + + expect(result.changed).toBe(true); + expect(result.config.plugins?.slots?.memory).toBe("memory"); + expect(result.config.plugins?.entries?.["memory-core"]?.enabled).toBe(false); + expect(result.warnings).toContain( + 'Exclusive slot "memory" switched from "memory-core" to "memory".', + ); + expect(result.warnings).toContain('Disabled other "memory" slot plugins: memory-core.'); + }); +}); diff --git a/src/extension-host/slot-arbitration.ts b/src/extension-host/slot-arbitration.ts new file mode 100644 index 00000000000..05ea8e1bc95 --- /dev/null +++ b/src/extension-host/slot-arbitration.ts @@ -0,0 +1,112 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { PluginSlotsConfig } from "../config/types.plugins.js"; +import type { PluginKind } from "../plugins/types.js"; + +export type ExtensionHostPluginSlotKey = keyof PluginSlotsConfig; + +type SlotPluginRecord = { + id: string; + kind?: PluginKind; +}; + +const SLOT_BY_KIND: Record = { + memory: "memory", + "context-engine": "contextEngine", +}; + +const DEFAULT_SLOT_BY_KEY: Record = { + memory: "memory-core", + contextEngine: "legacy", +}; + +export function extensionHostSlotKeyForPluginKind( + kind?: PluginKind, +): ExtensionHostPluginSlotKey | null { + if (!kind) { + return null; + } + return SLOT_BY_KIND[kind] ?? null; +} + +export function getExtensionHostDefaultSlotId(slotKey: ExtensionHostPluginSlotKey): string { + return DEFAULT_SLOT_BY_KEY[slotKey]; +} + +export type ExtensionHostSlotSelectionResult = { + config: OpenClawConfig; + warnings: string[]; + changed: boolean; +}; + +export function applyExtensionHostExclusiveSlotSelection(params: { + config: OpenClawConfig; + selectedId: string; + selectedKind?: PluginKind; + registry?: { plugins: SlotPluginRecord[] }; +}): ExtensionHostSlotSelectionResult { + const slotKey = extensionHostSlotKeyForPluginKind(params.selectedKind); + if (!slotKey) { + return { config: params.config, warnings: [], changed: false }; + } + + const warnings: string[] = []; + const pluginsConfig = params.config.plugins ?? {}; + const prevSlot = pluginsConfig.slots?.[slotKey]; + const slots = { + ...pluginsConfig.slots, + [slotKey]: params.selectedId, + }; + + const inferredPrevSlot = prevSlot ?? getExtensionHostDefaultSlotId(slotKey); + if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) { + warnings.push( + `Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`, + ); + } + + const entries = { ...pluginsConfig.entries }; + const disabledIds: string[] = []; + if (params.registry) { + for (const plugin of params.registry.plugins) { + if (plugin.id === params.selectedId) { + continue; + } + if (plugin.kind !== params.selectedKind) { + continue; + } + const entry = entries[plugin.id]; + if (!entry || entry.enabled !== false) { + entries[plugin.id] = { + ...entry, + enabled: false, + }; + disabledIds.push(plugin.id); + } + } + } + + if (disabledIds.length > 0) { + warnings.push( + `Disabled other "${slotKey}" slot plugins: ${disabledIds.toSorted().join(", ")}.`, + ); + } + + const changed = prevSlot !== params.selectedId || disabledIds.length > 0; + + if (!changed) { + return { config: params.config, warnings: [], changed: false }; + } + + return { + config: { + ...params.config, + plugins: { + ...pluginsConfig, + slots, + entries, + }, + }, + warnings, + changed: true, + }; +} diff --git a/src/plugins/slots.ts b/src/plugins/slots.ts index bcbbdd44a03..b383ab09548 100644 --- a/src/plugins/slots.ts +++ b/src/plugins/slots.ts @@ -1,110 +1,7 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { PluginSlotsConfig } from "../config/types.plugins.js"; -import type { PluginKind } from "./types.js"; - -export type PluginSlotKey = keyof PluginSlotsConfig; - -type SlotPluginRecord = { - id: string; - kind?: PluginKind; -}; - -const SLOT_BY_KIND: Record = { - memory: "memory", - "context-engine": "contextEngine", -}; - -const DEFAULT_SLOT_BY_KEY: Record = { - memory: "memory-core", - contextEngine: "legacy", -}; - -export function slotKeyForPluginKind(kind?: PluginKind): PluginSlotKey | null { - if (!kind) { - return null; - } - return SLOT_BY_KIND[kind] ?? null; -} - -export function defaultSlotIdForKey(slotKey: PluginSlotKey): string { - return DEFAULT_SLOT_BY_KEY[slotKey]; -} - -export type SlotSelectionResult = { - config: OpenClawConfig; - warnings: string[]; - changed: boolean; -}; - -export function applyExclusiveSlotSelection(params: { - config: OpenClawConfig; - selectedId: string; - selectedKind?: PluginKind; - registry?: { plugins: SlotPluginRecord[] }; -}): SlotSelectionResult { - const slotKey = slotKeyForPluginKind(params.selectedKind); - if (!slotKey) { - return { config: params.config, warnings: [], changed: false }; - } - - const warnings: string[] = []; - const pluginsConfig = params.config.plugins ?? {}; - const prevSlot = pluginsConfig.slots?.[slotKey]; - const slots = { - ...pluginsConfig.slots, - [slotKey]: params.selectedId, - }; - - const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey); - if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) { - warnings.push( - `Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`, - ); - } - - const entries = { ...pluginsConfig.entries }; - const disabledIds: string[] = []; - if (params.registry) { - for (const plugin of params.registry.plugins) { - if (plugin.id === params.selectedId) { - continue; - } - if (plugin.kind !== params.selectedKind) { - continue; - } - const entry = entries[plugin.id]; - if (!entry || entry.enabled !== false) { - entries[plugin.id] = { - ...entry, - enabled: false, - }; - disabledIds.push(plugin.id); - } - } - } - - if (disabledIds.length > 0) { - warnings.push( - `Disabled other "${slotKey}" slot plugins: ${disabledIds.toSorted().join(", ")}.`, - ); - } - - const changed = prevSlot !== params.selectedId || disabledIds.length > 0; - - if (!changed) { - return { config: params.config, warnings: [], changed: false }; - } - - return { - config: { - ...params.config, - plugins: { - ...pluginsConfig, - slots, - entries, - }, - }, - warnings, - changed: true, - }; -} +export { + applyExtensionHostExclusiveSlotSelection as applyExclusiveSlotSelection, + getExtensionHostDefaultSlotId as defaultSlotIdForKey, + extensionHostSlotKeyForPluginKind as slotKeyForPluginKind, + type ExtensionHostPluginSlotKey as PluginSlotKey, + type ExtensionHostSlotSelectionResult as SlotSelectionResult, +} from "../extension-host/slot-arbitration.js";