mirror of https://github.com/openclaw/openclaw.git
fix: support multi-kind plugins for dual slot ownership (#57507) (thanks @fuller-stack-dev)
* feat(plugins): support multi-kind plugins for dual slot ownership * fix: address review feedback on multi-kind plugin support - Use sorted normalizeKinds() for kind-mismatch comparison in loader.ts (fixes order-sensitive JSON.stringify for arrays) - Derive slot-to-kind reverse mapping from SLOT_BY_KIND in slots.ts (removes hardcoded ternary that would break for future slot types) - Use shared hasKind() helper in config-state.ts instead of inline logic * fix: don't disable dual-kind plugin that still owns another slot When a new plugin takes over one slot, a dual-kind plugin that still owns the other slot must not be disabled — otherwise context engine resolution fails at runtime. * fix: exempt dual-kind plugins from memory slot disablement A plugin with kind: ["memory", "context-engine"] must stay enabled even when it loses the memory slot, so its context engine role can still load. * fix: address remaining review feedback - Pass manifest kind (not hardcoded "memory") in early memory gating - Extract kindsEqual() helper for DRY kind comparison in loader.ts - Narrow slotKeyForPluginKind back to single PluginKind with JSDoc - Reject empty array in parsePluginKind - Add kindsEqual tests * fix: use toSorted() instead of sort() per lint rules * plugins: include default slot ownership in disable checks and gate dual-kind memory registration
This commit is contained in:
parent
10ac6ead6b
commit
235908c30e
|
|
@ -8,6 +8,7 @@ import {
|
|||
resolveMemorySlotDecision,
|
||||
} from "../../plugins/config-state.js";
|
||||
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
|
||||
import { hasKind } from "../../plugins/slots.js";
|
||||
import { isPathInsideWithRealpath } from "../../security/scan-paths.js";
|
||||
|
||||
const log = createSubsystemLogger("skills");
|
||||
|
|
@ -60,7 +61,7 @@ export function resolvePluginSkillDirs(params: {
|
|||
if (!memoryDecision.enabled) {
|
||||
continue;
|
||||
}
|
||||
if (memoryDecision.selected && record.kind === "memory") {
|
||||
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
|
||||
selectedMemoryPluginId = record.id;
|
||||
}
|
||||
for (const raw of record.skills) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from "../plugins/config-state.js";
|
||||
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
|
||||
import { hasKind } from "../plugins/slots.js";
|
||||
import {
|
||||
hasAvatarUriScheme,
|
||||
isAvatarDataUrl,
|
||||
|
|
@ -847,7 +848,7 @@ function validateConfigObjectWithPluginsBase(
|
|||
enabled = false;
|
||||
reason = memoryDecision.reason;
|
||||
}
|
||||
if (memoryDecision.selected && record.kind === "memory") {
|
||||
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
|
||||
selectedMemoryPluginId = pluginId;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
resolveMemorySlotDecision,
|
||||
} from "../plugins/config-state.js";
|
||||
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import { hasKind } from "../plugins/slots.js";
|
||||
import { isPathInsideWithRealpath } from "../security/scan-paths.js";
|
||||
|
||||
const log = createSubsystemLogger("hooks");
|
||||
|
|
@ -64,7 +65,7 @@ export function resolvePluginHookDirs(params: {
|
|||
if (!memoryDecision.enabled) {
|
||||
continue;
|
||||
}
|
||||
if (memoryDecision.selected && record.kind === "memory") {
|
||||
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
|
||||
selectedMemoryPluginId = record.id;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
normalizePluginsConfig,
|
||||
resolveEffectiveEnableState,
|
||||
resolveEnableState,
|
||||
resolveMemorySlotDecision,
|
||||
} from "./config-state.js";
|
||||
|
||||
function normalizeVoiceCallEntry(entry: Record<string, unknown>) {
|
||||
|
|
@ -267,3 +268,57 @@ describe("resolveEnableState", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMemorySlotDecision", () => {
|
||||
it("disables a memory-only plugin when slot points elsewhere", () => {
|
||||
const result = resolveMemorySlotDecision({
|
||||
id: "old-memory",
|
||||
kind: "memory",
|
||||
slot: "new-memory",
|
||||
selectedId: null,
|
||||
});
|
||||
expect(result.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a dual-kind plugin enabled when memory slot points elsewhere", () => {
|
||||
const result = resolveMemorySlotDecision({
|
||||
id: "dual-plugin",
|
||||
kind: ["memory", "context-engine"],
|
||||
slot: "new-memory",
|
||||
selectedId: null,
|
||||
});
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.selected).toBeUndefined();
|
||||
});
|
||||
|
||||
it("selects a dual-kind plugin when it owns the memory slot", () => {
|
||||
const result = resolveMemorySlotDecision({
|
||||
id: "dual-plugin",
|
||||
kind: ["memory", "context-engine"],
|
||||
slot: "dual-plugin",
|
||||
selectedId: null,
|
||||
});
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.selected).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a dual-kind plugin enabled when memory slot is null", () => {
|
||||
const result = resolveMemorySlotDecision({
|
||||
id: "dual-plugin",
|
||||
kind: ["memory", "context-engine"],
|
||||
slot: null,
|
||||
selectedId: null,
|
||||
});
|
||||
expect(result.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables a memory-only plugin when memory slot is null", () => {
|
||||
const result = resolveMemorySlotDecision({
|
||||
id: "old-memory",
|
||||
kind: "memory",
|
||||
slot: null,
|
||||
selectedId: null,
|
||||
});
|
||||
expect(result.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import {
|
|||
BUNDLED_PROVIDER_PLUGIN_ID_ALIASES,
|
||||
} from "./bundled-capability-metadata.js";
|
||||
import type { PluginRecord } from "./registry.js";
|
||||
import { defaultSlotIdForKey } from "./slots.js";
|
||||
import { defaultSlotIdForKey, hasKind } from "./slots.js";
|
||||
import type { PluginKind } from "./types.js";
|
||||
|
||||
export type NormalizedPluginsConfig = {
|
||||
enabled: boolean;
|
||||
|
|
@ -312,30 +313,33 @@ export function resolveEffectiveEnableState(params: {
|
|||
|
||||
export function resolveMemorySlotDecision(params: {
|
||||
id: string;
|
||||
kind?: string;
|
||||
kind?: string | string[];
|
||||
slot: string | null | undefined;
|
||||
selectedId: string | null;
|
||||
}): { enabled: boolean; reason?: string; selected?: boolean } {
|
||||
if (params.kind !== "memory") {
|
||||
if (!hasKind(params.kind as PluginKind | PluginKind[] | undefined, "memory")) {
|
||||
return { enabled: true };
|
||||
}
|
||||
// A dual-kind plugin (e.g. ["memory", "context-engine"]) that lost the
|
||||
// memory slot must stay enabled so its other slot role can still load.
|
||||
const isMultiKind = Array.isArray(params.kind) && params.kind.length > 1;
|
||||
if (params.slot === null) {
|
||||
return { enabled: false, reason: "memory slot disabled" };
|
||||
return isMultiKind
|
||||
? { enabled: true }
|
||||
: { enabled: false, reason: "memory slot disabled" };
|
||||
}
|
||||
if (typeof params.slot === "string") {
|
||||
if (params.slot === params.id) {
|
||||
return { enabled: true, selected: true };
|
||||
}
|
||||
return {
|
||||
enabled: false,
|
||||
reason: `memory slot set to "${params.slot}"`,
|
||||
};
|
||||
return isMultiKind
|
||||
? { enabled: true }
|
||||
: { enabled: false, reason: `memory slot set to "${params.slot}"` };
|
||||
}
|
||||
if (params.selectedId && params.selectedId !== params.id) {
|
||||
return {
|
||||
enabled: false,
|
||||
reason: `memory slot already filled by "${params.selectedId}"`,
|
||||
};
|
||||
return isMultiKind
|
||||
? { enabled: true }
|
||||
: { enabled: false, reason: `memory slot already filled by "${params.selectedId}"` };
|
||||
}
|
||||
return { enabled: true, selected: true };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import {
|
|||
resolvePluginSdkScopedAliasMap,
|
||||
shouldPreferNativeJiti,
|
||||
} from "./sdk-alias.js";
|
||||
import { hasKind, kindsEqual } from "./slots.js";
|
||||
import type {
|
||||
OpenClawPluginDefinition,
|
||||
OpenClawPluginModule,
|
||||
|
|
@ -1162,11 +1163,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
if (
|
||||
registrationMode === "full" &&
|
||||
candidate.origin === "bundled" &&
|
||||
manifestRecord.kind === "memory"
|
||||
hasKind(manifestRecord.kind, "memory")
|
||||
) {
|
||||
const earlyMemoryDecision = resolveMemorySlotDecision({
|
||||
id: record.id,
|
||||
kind: "memory",
|
||||
kind: manifestRecord.kind,
|
||||
slot: memorySlot,
|
||||
selectedId: selectedMemoryPluginId,
|
||||
});
|
||||
|
|
@ -1262,19 +1263,19 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
record.name = definition?.name ?? record.name;
|
||||
record.description = definition?.description ?? record.description;
|
||||
record.version = definition?.version ?? record.version;
|
||||
const manifestKind = record.kind as string | undefined;
|
||||
const exportKind = definition?.kind as string | undefined;
|
||||
if (manifestKind && exportKind && exportKind !== manifestKind) {
|
||||
const manifestKind = record.kind;
|
||||
const exportKind = definition?.kind;
|
||||
if (manifestKind && exportKind && !kindsEqual(manifestKind, exportKind)) {
|
||||
registry.diagnostics.push({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
|
||||
message: `plugin kind mismatch (manifest uses "${String(manifestKind)}", export uses "${String(exportKind)}")`,
|
||||
});
|
||||
}
|
||||
record.kind = definition?.kind ?? record.kind;
|
||||
|
||||
if (record.kind === "memory" && memorySlot === record.id) {
|
||||
if (hasKind(record.kind, "memory") && memorySlot === record.id) {
|
||||
memorySlotMatched = true;
|
||||
}
|
||||
|
||||
|
|
@ -1295,8 +1296,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
continue;
|
||||
}
|
||||
|
||||
if (memoryDecision.selected && record.kind === "memory") {
|
||||
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
|
||||
selectedMemoryPluginId = record.id;
|
||||
record.memorySlotSelected = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1626,14 +1628,14 @@ export async function loadOpenClawPluginCliRegistry(
|
|||
record.name = definition?.name ?? record.name;
|
||||
record.description = definition?.description ?? record.description;
|
||||
record.version = definition?.version ?? record.version;
|
||||
const manifestKind = record.kind as string | undefined;
|
||||
const exportKind = definition?.kind as string | undefined;
|
||||
if (manifestKind && exportKind && exportKind !== manifestKind) {
|
||||
const manifestKind = record.kind;
|
||||
const exportKind = definition?.kind;
|
||||
if (manifestKind && exportKind && !kindsEqual(manifestKind, exportKind)) {
|
||||
registry.diagnostics.push({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
|
||||
message: `plugin kind mismatch (manifest uses "${String(manifestKind)}", export uses "${String(exportKind)}")`,
|
||||
});
|
||||
}
|
||||
record.kind = definition?.kind ?? record.kind;
|
||||
|
|
@ -1652,8 +1654,9 @@ export async function loadOpenClawPluginCliRegistry(
|
|||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
}
|
||||
if (memoryDecision.selected && record.kind === "memory") {
|
||||
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
|
||||
selectedMemoryPluginId = record.id;
|
||||
record.memorySlotSelected = true;
|
||||
}
|
||||
|
||||
if (typeof register !== "function") {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export type PluginManifestRecord = {
|
|||
format?: PluginFormat;
|
||||
bundleFormat?: PluginBundleFormat;
|
||||
bundleCapabilities?: string[];
|
||||
kind?: PluginKind;
|
||||
kind?: PluginKind | PluginKind[];
|
||||
channels: string[];
|
||||
providers: string[];
|
||||
cliBackends: string[];
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export type PluginManifest = {
|
|||
legacyPluginIds?: string[];
|
||||
/** Provider ids that should auto-enable this plugin when referenced in auth/config/models. */
|
||||
autoEnableWhenConfiguredProviders?: string[];
|
||||
kind?: PluginKind;
|
||||
kind?: PluginKind | PluginKind[];
|
||||
channels?: string[];
|
||||
providers?: string[];
|
||||
/** Cheap startup activation lookup for plugin-owned CLI inference backends. */
|
||||
|
|
@ -233,6 +233,16 @@ export function resolvePluginManifestPath(rootDir: string): string {
|
|||
return path.join(rootDir, PLUGIN_MANIFEST_FILENAME);
|
||||
}
|
||||
|
||||
function parsePluginKind(raw: unknown): PluginKind | PluginKind[] | undefined {
|
||||
if (typeof raw === "string") {
|
||||
return raw as PluginKind;
|
||||
}
|
||||
if (Array.isArray(raw) && raw.length > 0 && raw.every((k) => typeof k === "string")) {
|
||||
return raw.length === 1 ? (raw[0] as PluginKind) : (raw as PluginKind[]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function loadPluginManifest(
|
||||
rootDir: string,
|
||||
rejectHardlinks = true,
|
||||
|
|
@ -282,7 +292,7 @@ export function loadPluginManifest(
|
|||
return { ok: false, error: "plugin manifest requires configSchema", manifestPath };
|
||||
}
|
||||
|
||||
const kind = typeof raw.kind === "string" ? (raw.kind as PluginKind) : undefined;
|
||||
const kind = parsePluginKind(raw.kind);
|
||||
const enabledByDefault = raw.enabledByDefault === true;
|
||||
const legacyPluginIds = normalizeStringList(raw.legacyPluginIds);
|
||||
const autoEnableWhenConfiguredProviders = normalizeStringList(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPluginRegistryFixture,
|
||||
registerTestPlugin,
|
||||
registerVirtualTestPlugin,
|
||||
} from "./contracts/testkit.js";
|
||||
import { clearMemoryEmbeddingProviders } from "./memory-embedding-providers.js";
|
||||
import { _resetMemoryPluginState, getMemoryRuntime } from "./memory-state.js";
|
||||
import { createPluginRecord } from "./status.test-helpers.js";
|
||||
|
||||
afterEach(() => {
|
||||
_resetMemoryPluginState();
|
||||
clearMemoryEmbeddingProviders();
|
||||
});
|
||||
|
||||
function createStubMemoryRuntime() {
|
||||
return {
|
||||
async getMemorySearchManager() {
|
||||
return { manager: null, error: "missing" } as const;
|
||||
},
|
||||
resolveMemoryBackendConfig() {
|
||||
return { backend: "builtin" as const };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("dual-kind memory registration gate", () => {
|
||||
it("blocks memory runtime registration for dual-kind plugins not selected for memory slot", () => {
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
|
||||
registerVirtualTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
id: "dual-plugin",
|
||||
name: "Dual Plugin",
|
||||
kind: ["memory", "context-engine"],
|
||||
register(api) {
|
||||
api.registerMemoryRuntime(createStubMemoryRuntime());
|
||||
},
|
||||
});
|
||||
|
||||
expect(getMemoryRuntime()).toBeUndefined();
|
||||
expect(registry.registry.diagnostics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginId: "dual-plugin",
|
||||
level: "warn",
|
||||
message: expect.stringContaining("dual-kind plugin not selected for memory slot"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows memory runtime registration for dual-kind plugins selected for memory slot", () => {
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
|
||||
registerTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
record: createPluginRecord({
|
||||
id: "dual-plugin",
|
||||
name: "Dual Plugin",
|
||||
kind: ["memory", "context-engine"],
|
||||
memorySlotSelected: true,
|
||||
}),
|
||||
register(api) {
|
||||
api.registerMemoryRuntime(createStubMemoryRuntime());
|
||||
},
|
||||
});
|
||||
|
||||
expect(getMemoryRuntime()).toBeDefined();
|
||||
expect(
|
||||
registry.registry.diagnostics.filter(
|
||||
(d) => d.pluginId === "dual-plugin" && d.level === "warn",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("allows memory runtime registration for single-kind memory plugins without memorySlotSelected", () => {
|
||||
const { config, registry } = createPluginRegistryFixture();
|
||||
|
||||
registerVirtualTestPlugin({
|
||||
registry,
|
||||
config,
|
||||
id: "memory-only",
|
||||
name: "Memory Only",
|
||||
kind: "memory",
|
||||
register(api) {
|
||||
api.registerMemoryRuntime(createStubMemoryRuntime());
|
||||
},
|
||||
});
|
||||
|
||||
expect(getMemoryRuntime()).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -28,7 +28,7 @@ import { normalizeRegisteredProvider } from "./provider-validation.js";
|
|||
import { createEmptyPluginRegistry } from "./registry-empty.js";
|
||||
import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
import { defaultSlotIdForKey } from "./slots.js";
|
||||
import { defaultSlotIdForKey, hasKind } from "./slots.js";
|
||||
import {
|
||||
isPluginHookName,
|
||||
isPromptInjectionHookName,
|
||||
|
|
@ -188,7 +188,7 @@ export type PluginRecord = {
|
|||
format?: PluginFormat;
|
||||
bundleFormat?: PluginBundleFormat;
|
||||
bundleCapabilities?: string[];
|
||||
kind?: PluginKind;
|
||||
kind?: PluginKind | PluginKind[];
|
||||
source: string;
|
||||
rootDir?: string;
|
||||
origin: PluginOrigin;
|
||||
|
|
@ -214,6 +214,7 @@ export type PluginRecord = {
|
|||
configSchema: boolean;
|
||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||
configJsonSchema?: Record<string, unknown>;
|
||||
memorySlotSelected?: boolean;
|
||||
};
|
||||
|
||||
export type PluginRegistry = {
|
||||
|
|
@ -1034,7 +1035,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
}
|
||||
},
|
||||
registerMemoryPromptSection: (builder) => {
|
||||
if (record.kind !== "memory") {
|
||||
if (!hasKind(record.kind, "memory")) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
|
|
@ -1043,10 +1044,24 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
Array.isArray(record.kind) &&
|
||||
record.kind.length > 1 &&
|
||||
!record.memorySlotSelected
|
||||
) {
|
||||
pushDiagnostic({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message:
|
||||
"dual-kind plugin not selected for memory slot; skipping memory prompt section registration",
|
||||
});
|
||||
return;
|
||||
}
|
||||
registerMemoryPromptSection(builder);
|
||||
},
|
||||
registerMemoryFlushPlan: (resolver) => {
|
||||
if (record.kind !== "memory") {
|
||||
if (!hasKind(record.kind, "memory")) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
|
|
@ -1055,10 +1070,24 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
Array.isArray(record.kind) &&
|
||||
record.kind.length > 1 &&
|
||||
!record.memorySlotSelected
|
||||
) {
|
||||
pushDiagnostic({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message:
|
||||
"dual-kind plugin not selected for memory slot; skipping memory flush plan registration",
|
||||
});
|
||||
return;
|
||||
}
|
||||
registerMemoryFlushPlanResolver(resolver);
|
||||
},
|
||||
registerMemoryRuntime: (runtime) => {
|
||||
if (record.kind !== "memory") {
|
||||
if (!hasKind(record.kind, "memory")) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
|
|
@ -1067,10 +1096,24 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
Array.isArray(record.kind) &&
|
||||
record.kind.length > 1 &&
|
||||
!record.memorySlotSelected
|
||||
) {
|
||||
pushDiagnostic({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message:
|
||||
"dual-kind plugin not selected for memory slot; skipping memory runtime registration",
|
||||
});
|
||||
return;
|
||||
}
|
||||
registerMemoryRuntime(runtime);
|
||||
},
|
||||
registerMemoryEmbeddingProvider: (adapter) => {
|
||||
if (record.kind !== "memory") {
|
||||
if (!hasKind(record.kind, "memory")) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
|
|
@ -1079,6 +1122,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
Array.isArray(record.kind) &&
|
||||
record.kind.length > 1 &&
|
||||
!record.memorySlotSelected
|
||||
) {
|
||||
pushDiagnostic({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message:
|
||||
"dual-kind plugin not selected for memory slot; skipping memory embedding provider registration",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const existing = getRegisteredMemoryEmbeddingProvider(adapter.id);
|
||||
if (existing) {
|
||||
const ownerDetail = existing.ownerPluginId
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { applyExclusiveSlotSelection } from "./slots.js";
|
||||
import {
|
||||
applyExclusiveSlotSelection,
|
||||
hasKind,
|
||||
kindsEqual,
|
||||
normalizeKinds,
|
||||
slotKeysForPluginKind,
|
||||
} from "./slots.js";
|
||||
import type { PluginKind } from "./types.js";
|
||||
|
||||
describe("applyExclusiveSlotSelection", () => {
|
||||
|
|
@ -69,7 +75,9 @@ describe("applyExclusiveSlotSelection", () => {
|
|||
expect(result.warnings).toHaveLength(0);
|
||||
}
|
||||
|
||||
function buildSelectionRegistry(plugins: ReadonlyArray<{ id: string; kind?: PluginKind }>) {
|
||||
function buildSelectionRegistry(
|
||||
plugins: ReadonlyArray<{ id: string; kind?: PluginKind | PluginKind[] }>,
|
||||
) {
|
||||
return {
|
||||
plugins: [...plugins],
|
||||
};
|
||||
|
|
@ -78,8 +86,8 @@ describe("applyExclusiveSlotSelection", () => {
|
|||
function expectUnchangedSelectionCase(params: {
|
||||
config: OpenClawConfig;
|
||||
selectedId: string;
|
||||
selectedKind?: PluginKind;
|
||||
registry?: { plugins: ReadonlyArray<{ id: string; kind?: PluginKind }> };
|
||||
selectedKind?: PluginKind | PluginKind[];
|
||||
registry?: { plugins: ReadonlyArray<{ id: string; kind?: PluginKind | PluginKind[] }> };
|
||||
}) {
|
||||
const result = applyExclusiveSlotSelection({
|
||||
config: params.config,
|
||||
|
|
@ -183,4 +191,153 @@ describe("applyExclusiveSlotSelection", () => {
|
|||
...(registry ? { registry: buildSelectionRegistry(registry.plugins) } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
it("applies slot selection for each kind in a multi-kind array", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
slots: { memory: "memory-core", contextEngine: "legacy" },
|
||||
entries: {
|
||||
"memory-core": { enabled: true },
|
||||
legacy: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = applyExclusiveSlotSelection({
|
||||
config,
|
||||
selectedId: "dual-plugin",
|
||||
selectedKind: ["memory", "context-engine"],
|
||||
registry: buildSelectionRegistry([
|
||||
{ id: "memory-core", kind: "memory" },
|
||||
{ id: "legacy", kind: "context-engine" },
|
||||
{ id: "dual-plugin", kind: ["memory", "context-engine"] },
|
||||
]),
|
||||
});
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.config.plugins?.slots?.memory).toBe("dual-plugin");
|
||||
expect(result.config.plugins?.slots?.contextEngine).toBe("dual-plugin");
|
||||
expect(result.config.plugins?.entries?.["memory-core"]?.enabled).toBe(false);
|
||||
expect(result.config.plugins?.entries?.legacy?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("does not disable a dual-kind plugin that still owns another slot", () => {
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
slots: { memory: "dual-plugin", contextEngine: "dual-plugin" },
|
||||
entries: {
|
||||
"dual-plugin": { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = applyExclusiveSlotSelection({
|
||||
config,
|
||||
selectedId: "new-memory",
|
||||
selectedKind: "memory",
|
||||
registry: buildSelectionRegistry([
|
||||
{ id: "dual-plugin", kind: ["memory", "context-engine"] },
|
||||
{ id: "new-memory", kind: "memory" },
|
||||
]),
|
||||
});
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.config.plugins?.slots?.memory).toBe("new-memory");
|
||||
// dual-plugin still owns contextEngine — must NOT be disabled
|
||||
expect(result.config.plugins?.entries?.["dual-plugin"]?.enabled).not.toBe(false);
|
||||
});
|
||||
|
||||
it("does not disable a dual-kind plugin that owns another slot via default", () => {
|
||||
// contextEngine is NOT explicitly set — defaults to "legacy"
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
slots: { memory: "legacy" },
|
||||
entries: {
|
||||
legacy: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = applyExclusiveSlotSelection({
|
||||
config,
|
||||
selectedId: "new-memory",
|
||||
selectedKind: "memory",
|
||||
registry: buildSelectionRegistry([
|
||||
{ id: "legacy", kind: ["memory", "context-engine"] },
|
||||
{ id: "new-memory", kind: "memory" },
|
||||
]),
|
||||
});
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.config.plugins?.slots?.memory).toBe("new-memory");
|
||||
// legacy still owns contextEngine via default — must NOT be disabled
|
||||
expect(result.config.plugins?.entries?.legacy?.enabled).not.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeKinds", () => {
|
||||
it("returns empty array for undefined", () => {
|
||||
expect(normalizeKinds(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("wraps a single kind in an array", () => {
|
||||
expect(normalizeKinds("memory")).toEqual(["memory"]);
|
||||
});
|
||||
|
||||
it("returns an array kind as-is", () => {
|
||||
expect(normalizeKinds(["memory", "context-engine"])).toEqual(["memory", "context-engine"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasKind", () => {
|
||||
it("returns false for undefined kind", () => {
|
||||
expect(hasKind(undefined, "memory")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches a single kind string", () => {
|
||||
expect(hasKind("memory", "memory")).toBe(true);
|
||||
expect(hasKind("memory", "context-engine")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches within a kind array", () => {
|
||||
expect(hasKind(["memory", "context-engine"], "memory")).toBe(true);
|
||||
expect(hasKind(["memory", "context-engine"], "context-engine")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slotKeysForPluginKind", () => {
|
||||
it("returns empty for undefined", () => {
|
||||
expect(slotKeysForPluginKind(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns single slot key for single kind", () => {
|
||||
expect(slotKeysForPluginKind("memory")).toEqual(["memory"]);
|
||||
});
|
||||
|
||||
it("returns multiple slot keys for multi-kind", () => {
|
||||
expect(slotKeysForPluginKind(["memory", "context-engine"])).toEqual([
|
||||
"memory",
|
||||
"contextEngine",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("kindsEqual", () => {
|
||||
it("treats undefined as equal to undefined", () => {
|
||||
expect(kindsEqual(undefined, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches identical strings", () => {
|
||||
expect(kindsEqual("memory", "memory")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects different strings", () => {
|
||||
expect(kindsEqual("memory", "context-engine")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches arrays in different order", () => {
|
||||
expect(kindsEqual(["memory", "context-engine"], ["context-engine", "memory"])).toBe(true);
|
||||
});
|
||||
|
||||
it("matches string against single-element array", () => {
|
||||
expect(kindsEqual("memory", ["memory"])).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects mismatched lengths", () => {
|
||||
expect(kindsEqual("memory", ["memory", "context-engine"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export type PluginSlotKey = keyof PluginSlotsConfig;
|
|||
|
||||
type SlotPluginRecord = {
|
||||
id: string;
|
||||
kind?: PluginKind;
|
||||
kind?: PluginKind | PluginKind[];
|
||||
};
|
||||
|
||||
const SLOT_BY_KIND: Record<PluginKind, PluginSlotKey> = {
|
||||
|
|
@ -19,6 +19,26 @@ const DEFAULT_SLOT_BY_KEY: Record<PluginSlotKey, string> = {
|
|||
contextEngine: "legacy",
|
||||
};
|
||||
|
||||
/** Normalize a kind field to an array for uniform iteration. */
|
||||
export function normalizeKinds(kind?: PluginKind | PluginKind[]): PluginKind[] {
|
||||
if (!kind) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(kind) ? kind : [kind];
|
||||
}
|
||||
|
||||
/** Check whether a plugin's kind field includes a specific kind. */
|
||||
export function hasKind(kind: PluginKind | PluginKind[] | undefined, target: PluginKind): boolean {
|
||||
if (!kind) {
|
||||
return false;
|
||||
}
|
||||
return Array.isArray(kind) ? kind.includes(target) : kind === target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the slot key for a single-kind plugin.
|
||||
* For multi-kind plugins use `slotKeysForPluginKind` instead.
|
||||
*/
|
||||
export function slotKeyForPluginKind(kind?: PluginKind): PluginSlotKey | null {
|
||||
if (!kind) {
|
||||
return null;
|
||||
|
|
@ -26,6 +46,23 @@ export function slotKeyForPluginKind(kind?: PluginKind): PluginSlotKey | null {
|
|||
return SLOT_BY_KIND[kind] ?? null;
|
||||
}
|
||||
|
||||
/** Order-insensitive equality check for two kind values (string or array). */
|
||||
export function kindsEqual(
|
||||
a: PluginKind | PluginKind[] | undefined,
|
||||
b: PluginKind | PluginKind[] | undefined,
|
||||
): boolean {
|
||||
const aN = normalizeKinds(a).toSorted();
|
||||
const bN = normalizeKinds(b).toSorted();
|
||||
return aN.length === bN.length && aN.every((k, i) => k === bN[i]);
|
||||
}
|
||||
|
||||
/** Return all slot keys that a plugin's kind field maps to. */
|
||||
export function slotKeysForPluginKind(kind?: PluginKind | PluginKind[]): PluginSlotKey[] {
|
||||
return normalizeKinds(kind)
|
||||
.map((k) => SLOT_BY_KIND[k])
|
||||
.filter((k): k is PluginSlotKey => k != null);
|
||||
}
|
||||
|
||||
export function defaultSlotIdForKey(slotKey: PluginSlotKey): string {
|
||||
return DEFAULT_SLOT_BY_KEY[slotKey];
|
||||
}
|
||||
|
|
@ -39,59 +76,74 @@ export type SlotSelectionResult = {
|
|||
export function applyExclusiveSlotSelection(params: {
|
||||
config: OpenClawConfig;
|
||||
selectedId: string;
|
||||
selectedKind?: PluginKind;
|
||||
selectedKind?: PluginKind | PluginKind[];
|
||||
registry?: { plugins: SlotPluginRecord[] };
|
||||
}): SlotSelectionResult {
|
||||
const slotKey = slotKeyForPluginKind(params.selectedKind);
|
||||
if (!slotKey) {
|
||||
const slotKeys = slotKeysForPluginKind(params.selectedKind);
|
||||
if (slotKeys.length === 0) {
|
||||
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,
|
||||
};
|
||||
let pluginsConfig = params.config.plugins ?? {};
|
||||
let anyChanged = false;
|
||||
let entries = { ...pluginsConfig.entries };
|
||||
let slots = { ...pluginsConfig.slots };
|
||||
|
||||
const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey);
|
||||
if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) {
|
||||
warnings.push(
|
||||
`Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`,
|
||||
);
|
||||
}
|
||||
for (const slotKey of slotKeys) {
|
||||
const prevSlot = slots[slotKey];
|
||||
slots = { ...slots, [slotKey]: 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);
|
||||
const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey);
|
||||
if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) {
|
||||
warnings.push(
|
||||
`Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const disabledIds: string[] = [];
|
||||
if (params.registry) {
|
||||
for (const plugin of params.registry.plugins) {
|
||||
if (plugin.id === params.selectedId) {
|
||||
continue;
|
||||
}
|
||||
const kindForSlot = (Object.keys(SLOT_BY_KIND) as PluginKind[]).find(
|
||||
(k) => SLOT_BY_KIND[k] === slotKey,
|
||||
);
|
||||
if (!kindForSlot || !hasKind(plugin.kind, kindForSlot)) {
|
||||
continue;
|
||||
}
|
||||
// Don't disable a plugin that still owns another slot (explicit or default).
|
||||
const stillOwnsOtherSlot = (Object.keys(SLOT_BY_KIND) as PluginKind[])
|
||||
.map((k) => SLOT_BY_KIND[k])
|
||||
.filter((sk) => sk !== slotKey)
|
||||
.some((sk) => (slots[sk] ?? defaultSlotIdForKey(sk)) === plugin.id);
|
||||
if (stillOwnsOtherSlot) {
|
||||
continue;
|
||||
}
|
||||
const entry = entries[plugin.id];
|
||||
if (!entry || entry.enabled !== false) {
|
||||
entries = {
|
||||
...entries,
|
||||
[plugin.id]: { ...entry, enabled: false },
|
||||
};
|
||||
disabledIds.push(plugin.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (disabledIds.length > 0) {
|
||||
warnings.push(
|
||||
`Disabled other "${slotKey}" slot plugins: ${disabledIds.toSorted().join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (prevSlot !== params.selectedId || disabledIds.length > 0) {
|
||||
anyChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (disabledIds.length > 0) {
|
||||
warnings.push(
|
||||
`Disabled other "${slotKey}" slot plugins: ${disabledIds.toSorted().join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const changed = prevSlot !== params.selectedId || disabledIds.length > 0;
|
||||
|
||||
if (!changed) {
|
||||
if (!anyChanged) {
|
||||
return { config: params.config, warnings: [], changed: false };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1664,7 +1664,7 @@ export type OpenClawPluginDefinition = {
|
|||
name?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
kind?: PluginKind;
|
||||
kind?: PluginKind | PluginKind[];
|
||||
configSchema?: OpenClawPluginConfigSchema;
|
||||
register?: (api: OpenClawPluginApi) => void | Promise<void>;
|
||||
activate?: (api: OpenClawPluginApi) => void | Promise<void>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue