fix(plugins): include selected context-engine slot plugin in gateway startup (#76576)

External context-engine plugins (e.g. lossless-claw) register via
api.registerContextEngine at load time but ship without
activation.onStartup in their manifest. The gateway startup planner
only considered memory plugins and explicit sidecar plugins, so a
selected non-legacy context engine was omitted from the startup load
plan and never loaded before agent turns resolved the active engine,
producing the "Context engine X is not registered; falling back to
default engine legacy" warning.

Fix: add resolveContextEngineSlotStartupPluginId mirroring the memory
slot pattern; pass contextEngineSlotStartupPluginId into
shouldConsiderForGatewayStartup so the selected context-engine plugin
is included in pluginIds regardless of its manifest activation shape.

Tests: added four regression cases covering include, exclude, legacy
bypass, and id normalization. 82 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HCL
2026-05-03 19:13:16 +08:00
committed by Peter Steinberger
parent d00bcf555b
commit deb7b821c1
3 changed files with 98 additions and 8 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
- CLI/config: keep JSON dry-run patches validating touched channel configuration against bundled channel schemas even when the patch only contains SecretRef objects.
- Plugins/tools: keep disabled bundled tool plugins out of explicit runtime allowlist ownership and fall back from loaded-but-empty channel registries to tool-bearing plugin registries, so Active Memory can use bundled `memory-core` search/get tools even when `memory-lancedb` is disabled. Fixes #76603. Thanks @jwong-art.
- Plugins/install: run `npm install` from the managed npm-root manifest so installing one `@openclaw/*` plugin preserves already installed sibling plugins instead of pruning them. Fixes #76571. (#76602) Thanks @byungskers and @crpol.
- Plugins/context-engine: include the selected `plugins.slots.contextEngine` plugin in the gateway startup load plan so external context-engine plugins without `activation.onStartup` in their manifest are loaded before any agent turn resolves the active engine; prevents the "Context engine X is not registered; falling back to default engine legacy" warning after gateway startup. Fixes #76576. Thanks @hclsys.
- Plugins/tools: restore on-demand registry load for path-based plugins (origin "config") so tool factories registered via `plugins.load.paths` are resolved at agent request time when no pre-warmed channel registry is present; prevents "unknown method" errors after gateway startup. Fixes #76598. Thanks @hclsys.
- Channels/QQ Bot: resolve structured `clientSecret` SecretRefs before QQ token exchange, expose the QQ Bot secret contract to secrets tooling, and reject legacy `secretref:/...` marker strings. (#74772) Thanks @xialonglee.
- Agents: keep active streamed provider replies alive by refreshing guarded fetch timeouts on raw body chunks and surface true prompt stream timeouts as explicit errors instead of partial assistant fragments. Fixes #76307. (#76633) Thanks @MkDev11.

View File

@@ -301,6 +301,18 @@ function createManifestRegistryFixture(): PluginManifestRegistry {
providers: [],
cliBackends: [],
},
{
id: "lossless-claw",
kind: "context-engine",
channels: [],
// No activation.onStartup — this is the bug scenario (#76576):
// external context-engine plugins do not set onStartup but must be
// included in gateway startup when selected via plugins.slots.contextEngine.
origin: "installed",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
},
].map(withManifestLoadPaths) as PluginManifestRecord[],
diagnostics: [],
};
@@ -436,7 +448,13 @@ function createStartupConfig(params: {
allowPluginIds?: string[];
noConfiguredChannels?: boolean;
memorySlot?: string;
contextEngine?: string;
}) {
const slotsConfig = {
...(params.memorySlot ? { memory: params.memorySlot } : {}),
...(params.contextEngine ? { contextEngine: params.contextEngine } : {}),
};
const hasSlots = Object.keys(slotsConfig).length > 0;
return {
...(params.noConfiguredChannels
? {
@@ -453,7 +471,7 @@ function createStartupConfig(params: {
? {
plugins: {
...(params.allowPluginIds?.length ? { allow: params.allowPluginIds } : {}),
...(params.memorySlot ? { slots: { memory: params.memorySlot } } : {}),
...(hasSlots ? { slots: slotsConfig } : {}),
entries: Object.fromEntries(
params.enabledPluginIds.map((pluginId) => [pluginId, { enabled: true }]),
),
@@ -465,12 +483,10 @@ function createStartupConfig(params: {
allow: params.allowPluginIds,
},
}
: params.memorySlot
: hasSlots
? {
plugins: {
slots: {
memory: params.memorySlot,
},
slots: slotsConfig,
},
}
: {}),
@@ -1095,6 +1111,44 @@ describe("resolveGatewayStartupPluginIds", () => {
});
});
it("includes the selected context-engine slot plugin in startup scope even without activation.onStartup (#76576)", () => {
expectStartupPluginIdsCase({
config: createStartupConfig({
enabledPluginIds: ["lossless-claw"],
contextEngine: "lossless-claw",
}),
expected: ["demo-channel", "browser", "memory-core", "lossless-claw"],
});
});
it("does not include context-engine plugins not selected via the slot", () => {
expectStartupPluginIdsCase({
config: createStartupConfig({
enabledPluginIds: ["lossless-claw"],
}),
expected: ["demo-channel", "browser", "memory-core"],
});
});
it("does not include the context-engine slot plugin when it is the built-in legacy engine", () => {
expectStartupPluginIdsCase({
config: createStartupConfig({
contextEngine: "legacy",
}),
expected: ["demo-channel", "browser", "memory-core"],
});
});
it("normalizes the context-engine slot id before startup filtering", () => {
expectStartupPluginIdsCase({
config: createStartupConfig({
enabledPluginIds: ["lossless-claw"],
contextEngine: "Lossless-Claw",
}),
expected: ["demo-channel", "browser", "memory-core", "lossless-claw"],
});
});
it("includes required agent harness owner plugins when the default runtime is forced", () => {
expectStartupPluginIdsCase({
config: createStartupConfig({

View File

@@ -99,15 +99,43 @@ function resolveMemorySlotStartupPluginId(params: {
return normalizePluginId(configuredSlot);
}
function resolveContextEngineSlotStartupPluginId(params: {
activationSourceConfig: OpenClawConfig;
activationSourcePlugins: ReturnType<typeof normalizePluginsConfigWithRegistry>;
normalizePluginId: (pluginId: string) => string;
}): string | undefined {
const { activationSourceConfig, activationSourcePlugins, normalizePluginId } = params;
const configuredSlot = activationSourceConfig.plugins?.slots?.contextEngine?.trim();
if (!configuredSlot) {
return undefined;
}
const normalized = normalizePluginId(configuredSlot);
// "legacy" is the built-in default engine — no plugin startup needed.
if (normalized === "legacy") {
return undefined;
}
if (activationSourcePlugins.deny.includes(normalized)) {
return undefined;
}
if (activationSourcePlugins.entries[normalized]?.enabled === false) {
return undefined;
}
return normalized;
}
function shouldConsiderForGatewayStartup(params: {
plugin: InstalledPluginIndexRecord;
manifest: PluginManifestRecord | undefined;
startupDreamingPluginIds: ReadonlySet<string>;
memorySlotStartupPluginId?: string;
contextEngineSlotStartupPluginId?: string;
}): boolean {
if (params.manifest?.activation?.onStartup === true) {
return true;
}
if (params.contextEngineSlotStartupPluginId === params.plugin.pluginId) {
return true;
}
if (!isGatewayStartupMemoryPlugin(params.plugin)) {
return false;
}
@@ -404,12 +432,18 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: {
const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config);
const manifestLookup = createManifestRegistryLookup(params.manifestRegistry);
const configuredSpeechProviderIds = collectConfiguredSpeechProviderIds(activationSourceConfig);
const normalizePluginId = createPluginRegistryIdNormalizer(params.index, {
manifestRegistry: params.manifestRegistry,
});
const memorySlotStartupPluginId = resolveMemorySlotStartupPluginId({
activationSourceConfig,
activationSourcePlugins,
normalizePluginId: createPluginRegistryIdNormalizer(params.index, {
manifestRegistry: params.manifestRegistry,
}),
normalizePluginId,
});
const contextEngineSlotStartupPluginId = resolveContextEngineSlotStartupPluginId({
activationSourceConfig,
activationSourcePlugins,
normalizePluginId,
});
const pluginIds = params.index.plugins
.filter((plugin) => {
@@ -471,6 +505,7 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: {
manifest,
startupDreamingPluginIds,
memorySlotStartupPluginId,
contextEngineSlotStartupPluginId,
})
) {
return false;