mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user