fix(plugins): cold-load partial tool registries

Fix plugin tool discovery when a selected wildcard plugin set is resolved against a partial active registry.\n\nRequire scoped registries to cover every requested plugin owner, force cold-load incomplete tool discovery registries without replacing active plugin runtime state, and add regression coverage for the partial-registry path.\n\nFixes #76780.\nThanks @lilesjtu.
This commit is contained in:
Edionwheels
2026-05-04 02:09:34 +08:00
committed by GitHub
parent ee6052a169
commit 66ffb29679
4 changed files with 208 additions and 26 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
- Discord/native commands: skip slash-command registration and cleanup REST calls when `channels.discord.commands.native=false`, letting low-power gateways start without waiting on disabled native-command lifecycle requests. Fixes #76202. Thanks @vincentkoc.
- Plugins/commands: normalize empty plugin command handler results and let Telegram native plugin commands send the empty-response fallback instead of throwing when a handler returns `undefined`. Fixes #74800. Thanks @vincentkoc.
- Plugins/tools: cold-load selected plugin tool registries when the active registry only has partial tool coverage, so wildcard-expanded allowlists no longer hide installed plugin tools from `tools.effective`. Fixes #76780. Thanks @lilesjtu.
- Plugins/OpenRouter: advertise DeepSeek V4 thinking levels, including `xhigh` and `max`, through the runtime and lightweight provider policy surfaces so `/think` validation no longer rejects OpenRouter-routed DeepSeek V4 models. Fixes #74788. Thanks @vincentkoc.
- Status/sessions: ignore malformed non-string persisted session provider/model metadata instead of throwing while rendering status summaries. Thanks @vincentkoc.
- CLI/config: remove only the targeted array element for `openclaw config unset array[index]` instead of replaying the unset during config write and deleting the shifted next element. Fixes #76290. Thanks @SymbolStar and @vincentkoc.

View File

@@ -50,23 +50,30 @@ function installStandaloneRegistry(
export function ensureStandaloneRuntimePluginRegistryLoaded(params: {
loadOptions: PluginLoadOptions;
forceLoad?: boolean;
installRegistry?: boolean;
requiredPluginIds?: readonly string[];
surface?: ActiveRuntimePluginRegistrySurface;
}): PluginRegistry | undefined {
const requiredPluginIds = params.requiredPluginIds ?? params.loadOptions.onlyPluginIds;
const surface = params.surface ?? "active";
const existing = getLoadedRuntimePluginRegistry({
env: params.loadOptions.env,
loadOptions: params.loadOptions,
workspaceDir: params.loadOptions.workspaceDir,
requiredPluginIds,
surface,
});
if (existing) {
return existing;
if (!params.forceLoad) {
const existing = getLoadedRuntimePluginRegistry({
env: params.loadOptions.env,
loadOptions: params.loadOptions,
workspaceDir: params.loadOptions.workspaceDir,
requiredPluginIds,
surface,
});
if (existing) {
return existing;
}
}
const registry = loadOpenClawPlugins(params.loadOptions);
const effectiveLoadOptions = params.forceLoad
? { ...params.loadOptions, cache: false }
: params.loadOptions;
const registry = loadOpenClawPlugins(effectiveLoadOptions);
if (params.loadOptions.activate !== false) {
switch (surface) {
case "active":
@@ -81,6 +88,10 @@ export function ensureStandaloneRuntimePluginRegistryLoaded(params: {
return registry;
}
if (params.installRegistry === false) {
return registry;
}
installStandaloneRegistry(registry, {
loadOptions: params.loadOptions,
surface,

View File

@@ -32,6 +32,7 @@ let resolvePluginTools: typeof import("./tools.js").resolvePluginTools;
let ensureStandalonePluginToolRegistryLoaded: typeof import("./tools.js").ensureStandalonePluginToolRegistryLoaded;
let buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetadataKey;
let resetPluginToolFactoryCache: typeof import("./tools.js").resetPluginToolFactoryCache;
let getActivePluginRegistry: typeof import("./runtime.js").getActivePluginRegistry;
let pinActivePluginChannelRegistry: typeof import("./runtime.js").pinActivePluginChannelRegistry;
let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest;
let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry;
@@ -394,8 +395,12 @@ describe("resolvePluginTools optional tools", () => {
resetPluginToolFactoryCache,
resolvePluginTools,
} = await import("./tools.js"));
({ pinActivePluginChannelRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry } =
await import("./runtime.js"));
({
getActivePluginRegistry,
pinActivePluginChannelRegistry,
resetPluginRuntimeStateForTest,
setActivePluginRegistry,
} = await import("./runtime.js"));
({ clearCurrentPluginMetadataSnapshot, setCurrentPluginMetadataSnapshot } =
await import("./current-plugin-metadata-snapshot.js"));
});
@@ -553,6 +558,73 @@ describe("resolvePluginTools optional tools", () => {
);
});
it("does not reuse a partial active registry for wildcard-selected plugin tools", () => {
const context = createContext();
const config = context.config;
const optionalEntry = createOptionalDemoEntry();
const multiEntry: MockRegistryToolEntry = {
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["other_tool"],
declaredNames: ["other_tool"],
factory: () => makeTool("other_tool"),
};
installToolManifestSnapshots({
config,
plugins: [
{
id: "multi",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["other_tool"],
},
},
{
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
],
});
const partialRegistry = createToolRegistry([multiEntry]);
partialRegistry.plugins.push({ id: "optional-demo", status: "loaded" });
const fullRegistry = createToolRegistry([multiEntry, optionalEntry]);
setActivePluginRegistry?.(
partialRegistry as never,
"partial-test-tool-registry",
"gateway-bindable",
"/tmp",
);
resolveRuntimePluginRegistryMock.mockReturnValue(partialRegistry);
loadOpenClawPluginsMock.mockReturnValue(fullRegistry);
const tools = resolvePluginTools(
createResolveToolsParams({
context,
toolAllowlist: ["*", "optional-demo"],
}),
);
expectResolvedToolNames(tools, ["other_tool", "optional_tool"]);
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
activate: false,
cache: false,
onlyPluginIds: ["multi", "optional-demo"],
toolDiscovery: true,
}),
);
});
it("warns when cold registry load still does not provide the selected plugin tools", () => {
const context = {
...createContext(),
@@ -597,6 +669,72 @@ describe("resolvePluginTools optional tools", () => {
);
});
it("uses the fresh cold-loaded registry for diagnostics when partial active registries remain incomplete", () => {
const context = createContext();
const config = context.config;
const multiEntry: MockRegistryToolEntry = {
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
names: ["other_tool"],
declaredNames: ["other_tool"],
factory: () => makeTool("other_tool"),
};
const optionalEntry = createOptionalDemoEntry();
installToolManifestSnapshots({
config,
plugins: [
{
id: "multi",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["other_tool"],
},
},
{
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
],
});
const staleRegistry = createToolRegistry([multiEntry]);
staleRegistry.plugins.push({ id: "optional-demo", status: "loaded" });
const freshRegistry = createToolRegistry([optionalEntry]);
freshRegistry.plugins.push({ id: "multi", status: "loaded" });
setActivePluginRegistry?.(
staleRegistry as never,
"partial-test-tool-registry",
"gateway-bindable",
"/tmp",
);
resolveRuntimePluginRegistryMock.mockReturnValue(staleRegistry);
loadOpenClawPluginsMock.mockReturnValue(freshRegistry);
const tools = resolvePluginTools(
createResolveToolsParams({
context,
toolAllowlist: ["*", "optional-demo"],
}),
);
expectResolvedToolNames(tools, ["optional_tool"]);
expect(getActivePluginRegistry?.()).toBe(staleRegistry);
expectSingleDiagnosticMessage(
freshRegistry.diagnostics,
"plugin tool registry did not include selected plugin tools after cold load (multi)",
);
expect(staleRegistry.diagnostics).toEqual([]);
});
it("does not reuse a pinned gateway registry for manifest-unavailable tools", () => {
const config = createContext().config;
installToolManifestSnapshot({
@@ -1504,26 +1642,54 @@ describe("resolvePluginTools optional tools", () => {
it("adds enabled non-startup tool plugins to the active tool runtime scope", () => {
const activeRegistry = createOptionalDemoActiveRegistry();
const context = createContext();
const config = {
...context.config,
plugins: {
...context.config.plugins,
allow: ["tavily"],
entries: {
tavily: { enabled: true },
},
},
};
installToolManifestSnapshots({
config,
plugins: [
{
id: "optional-demo",
origin: "bundled",
enabledByDefault: true,
channels: [],
providers: [],
contracts: {
tools: ["optional_tool"],
},
},
{
id: "tavily",
origin: "bundled",
enabledByDefault: false,
channels: [],
providers: [],
contracts: {
tools: ["tavily_search"],
},
},
],
});
setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable", "/tmp");
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
loadOpenClawPluginsMock.mockReturnValue(createToolRegistry([]));
resolvePluginTools({
context: {
...createContext(),
config: {
plugins: {
enabled: true,
allow: ["tavily"],
entries: {
tavily: { enabled: true },
},
},
},
...context,
config,
} as never,
toolAllowlist: ["optional_tool", "tavily"],
toolAllowlist: ["*", "tavily"],
allowGatewaySubagentBinding: true,
});
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: expect.arrayContaining(["tavily"]),

View File

@@ -645,15 +645,18 @@ function resolvePluginToolRegistry(params: {
return activeRegistry;
}
const forceStandaloneLoad = Boolean(channelRegistry || activeRegistry);
const standaloneRegistry = ensureStandaloneRuntimePluginRegistryLoaded({
surface: "active",
forceLoad: forceStandaloneLoad,
installRegistry: !forceStandaloneLoad,
requiredPluginIds: params.onlyPluginIds,
loadOptions: params.loadOptions,
});
if (registryHasScopedPluginTools(standaloneRegistry, params.onlyPluginIds)) {
return standaloneRegistry;
}
return channelRegistry ?? activeRegistry ?? standaloneRegistry;
return standaloneRegistry ?? channelRegistry ?? activeRegistry;
}
function registryHasScopedPluginTools(
@@ -670,7 +673,8 @@ function registryHasScopedPluginTools(
if (scopedPluginIds.size === 0) {
return true;
}
return registry.tools.some((entry) => scopedPluginIds.has(entry.pluginId));
const registryPluginIds = new Set(registry.tools.map((entry) => entry.pluginId));
return Array.from(scopedPluginIds).every((pluginId) => registryPluginIds.has(pluginId));
}
function resolvePluginToolLoadState(params: {