Skip to content

Commit 8040057

Browse files
committed
Fix cached plugin tool dispatch
1 parent 6078f80 commit 8040057

3 files changed

Lines changed: 171 additions & 109 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ Docs: https://docs.openclaw.ai
284284
- Plugins/update: keep installed official npm and ClawHub plugins such as Codex, Discord, WhatsApp, and diagnostics plugins synced during host updates even when disabled or previously exact-pinned, while preserving third-party plugin pins. Thanks @vincentkoc.
285285
- Doctor/status: warn when `OPENCLAW_GATEWAY_TOKEN` would shadow a different active `gateway.auth.token` source for local CLI commands, while avoiding false positives when config points at the same env token. Fixes #74271. Thanks @yelog.
286286
- Gateway/HTTP: avoid loading managed outgoing-image media handlers for unrelated requests, so disabled OpenAI-compatible routes return 404 without waiting on lazy media sidecars. Thanks @vincentkoc.
287+
- Plugins: dispatch cached descriptor-backed tools by the resolved runtime tool name for unnamed factories, fixing multi-tool plugins whose shared manifest contracts exposed sibling tools but failed at execution. Fixes #78671. Thanks @zanni098.
287288
- Gateway/OpenAI-compatible: send the assistant role SSE chunk as soon as streaming chat-completion headers are accepted, so cold agent setup cannot leave `/v1/chat/completions` clients with a bodyless 200 response until their idle timeout fires.
288289
- Agents/media: avoid direct generated-media completion fallback while the announce-agent run is still pending, so async video and music completions do not duplicate raw media messages. (#77754)
289290
- WebChat/Codex media: stage Codex app-server generated local images into managed media before Gateway display, so Codex-home image paths no longer hit `LocalMediaAccessError` while keeping Codex home out of the display allowlist. Thanks @frankekn.

src/plugins/tools.optional.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1724,6 +1724,91 @@ describe("resolvePluginTools optional tools", () => {
17241724
expect(factory).toHaveBeenCalledTimes(2);
17251725
});
17261726

1727+
it("executes the matching cached plugin tool when unnamed factories share declared names", async () => {
1728+
const alphaFactory = vi.fn(() => ({
1729+
...makeTool("implicit_alpha"),
1730+
async execute() {
1731+
return { content: [{ type: "text", text: "implicit-alpha-ok" }] };
1732+
},
1733+
}));
1734+
const betaFactory = vi.fn(() => ({
1735+
...makeTool("implicit_beta"),
1736+
async execute() {
1737+
return { content: [{ type: "text", text: "implicit-beta-ok" }] };
1738+
},
1739+
}));
1740+
setRegistry([
1741+
{
1742+
pluginId: "implicit-owner",
1743+
optional: false,
1744+
source: "/tmp/implicit-owner.js",
1745+
names: [],
1746+
declaredNames: ["implicit_alpha", "implicit_beta"],
1747+
factory: alphaFactory,
1748+
},
1749+
{
1750+
pluginId: "implicit-owner",
1751+
optional: false,
1752+
source: "/tmp/implicit-owner.js",
1753+
names: [],
1754+
declaredNames: ["implicit_alpha", "implicit_beta"],
1755+
factory: betaFactory,
1756+
},
1757+
]);
1758+
1759+
const first = resolvePluginTools(createResolveToolsParams());
1760+
const second = resolvePluginTools(createResolveToolsParams());
1761+
const betaTool = second.find((tool) => tool.name === "implicit_beta");
1762+
1763+
expectResolvedToolNames(first, ["implicit_alpha", "implicit_beta"]);
1764+
expectResolvedToolNames(second, ["implicit_alpha", "implicit_beta"]);
1765+
await expect(betaTool?.execute("call", {}, undefined)).resolves.toEqual({
1766+
content: [{ type: "text", text: "implicit-beta-ok" }],
1767+
});
1768+
expect(alphaFactory).toHaveBeenCalledTimes(2);
1769+
expect(betaFactory).toHaveBeenCalledTimes(2);
1770+
});
1771+
1772+
it("does not invoke unrelated named factories before cached unnamed tool fallback", async () => {
1773+
const namedFactory = vi.fn(() => makeTool("unrelated_tool"));
1774+
const implicitFactory = vi.fn(() => ({
1775+
...makeTool("implicit_tool"),
1776+
async execute() {
1777+
return { content: [{ type: "text", text: "implicit-ok" }] };
1778+
},
1779+
}));
1780+
setRegistry([
1781+
{
1782+
pluginId: "implicit-owner",
1783+
optional: false,
1784+
source: "/tmp/implicit-owner.js",
1785+
names: ["unrelated_tool"],
1786+
declaredNames: ["unrelated_tool"],
1787+
factory: namedFactory,
1788+
},
1789+
{
1790+
pluginId: "implicit-owner",
1791+
optional: false,
1792+
source: "/tmp/implicit-owner.js",
1793+
names: [],
1794+
declaredNames: ["implicit_tool"],
1795+
factory: implicitFactory,
1796+
},
1797+
]);
1798+
1799+
resolvePluginTools(createResolveToolsParams());
1800+
const cachedTools = resolvePluginTools(createResolveToolsParams());
1801+
namedFactory.mockClear();
1802+
implicitFactory.mockClear();
1803+
1804+
const implicitTool = cachedTools.find((tool) => tool.name === "implicit_tool");
1805+
await expect(implicitTool?.execute("call", {}, undefined)).resolves.toEqual({
1806+
content: [{ type: "text", text: "implicit-ok" }],
1807+
});
1808+
expect(namedFactory).not.toHaveBeenCalled();
1809+
expect(implicitFactory).toHaveBeenCalledTimes(1);
1810+
});
1811+
17271812
it("skips factory-returned tools outside the manifest tool contract", () => {
17281813
const registry = setRegistry([
17291814
{

src/plugins/tools.ts

Lines changed: 85 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -553,66 +553,46 @@ function createCachedDescriptorPluginTool(params: {
553553
loadOptions,
554554
onlyPluginIds: [pluginId],
555555
});
556-
const candidates = registry?.tools.filter(
557-
(candidate) => candidate.pluginId === pluginId,
558-
);
556+
const candidates = registry?.tools.filter((candidate) => candidate.pluginId === pluginId);
559557
if (!candidates || candidates.length === 0) {
560558
throw new Error(`plugin tool runtime unavailable (${pluginId}): ${toolName}`);
561559
}
562-
// First try named candidates, then fall back to all candidates if no match
563-
const namedCandidates = candidates.filter(
564-
(candidate) => candidate.names.length > 0,
560+
const requestedToolName = normalizeToolName(toolName);
561+
const resolveCandidateTool = (
562+
candidate: PluginToolRegistration,
563+
): AnyAgentTool | undefined => {
564+
const resolved = candidate.factory(params.ctx);
565+
const listRaw: unknown[] = Array.isArray(resolved) ? resolved : resolved ? [resolved] : [];
566+
for (const toolRaw of listRaw) {
567+
const malformedReason = describeMalformedPluginTool(toolRaw);
568+
if (malformedReason) {
569+
throw new Error(`plugin tool is malformed (${pluginId}): ${malformedReason}`);
570+
}
571+
const runtimeTool = toolRaw as AnyAgentTool;
572+
if (normalizeToolName(runtimeTool.name) === requestedToolName) {
573+
return runtimeTool;
574+
}
575+
}
576+
return undefined;
577+
};
578+
const matchingNamedCandidates = candidates.filter(
579+
(candidate) =>
580+
candidate.names.length > 0 &&
581+
candidate.names.some((name) => normalizeToolName(name) === requestedToolName),
565582
);
566-
// Try named candidates first
583+
const unnamedCandidates = candidates.filter((candidate) => candidate.names.length === 0);
567584
let matchedTool: AnyAgentTool | undefined;
568-
for (const candidate of namedCandidates) {
585+
for (const candidate of [...matchingNamedCandidates, ...unnamedCandidates]) {
569586
try {
570-
const resolved = candidate.factory(params.ctx);
571-
const listRaw: unknown[] = Array.isArray(resolved) ? resolved : resolved ? [resolved] : [];
572-
for (const toolRaw of listRaw) {
573-
const malformedReason = describeMalformedPluginTool(toolRaw);
574-
if (malformedReason) {
575-
throw new Error(`plugin tool is malformed (${pluginId}): ${malformedReason}`);
576-
}
577-
const runtimeTool = toolRaw as AnyAgentTool;
578-
if (normalizeToolName(runtimeTool.name) === normalizeToolName(toolName)) {
579-
matchedTool = runtimeTool;
580-
break;
581-
}
587+
matchedTool = resolveCandidateTool(candidate);
588+
if (matchedTool) {
589+
break;
582590
}
583-
if (matchedTool) { break; }
584591
} catch {
585592
// Continue to next candidate if factory/materialization fails
586593
continue;
587594
}
588595
}
589-
// If no match in named candidates, try unnamed candidates only
590-
if (!matchedTool) {
591-
const unnamedCandidates = candidates.filter(
592-
(candidate) => candidate.names.length === 0,
593-
);
594-
for (const candidate of unnamedCandidates) {
595-
try {
596-
const resolved = candidate.factory(params.ctx);
597-
const listRaw: unknown[] = Array.isArray(resolved) ? resolved : resolved ? [resolved] : [];
598-
for (const toolRaw of listRaw) {
599-
const malformedReason = describeMalformedPluginTool(toolRaw);
600-
if (malformedReason) {
601-
throw new Error(`plugin tool is malformed (${pluginId}): ${malformedReason}`);
602-
}
603-
const runtimeTool = toolRaw as AnyAgentTool;
604-
if (normalizeToolName(runtimeTool.name) === normalizeToolName(toolName)) {
605-
matchedTool = runtimeTool;
606-
break;
607-
}
608-
}
609-
if (matchedTool) { break; }
610-
} catch {
611-
// Continue to next candidate if factory/materialization fails
612-
continue;
613-
}
614-
}
615-
}
616596
if (matchedTool) {
617597
// Return outside try blocks to preserve tool execution errors
618598
return matchedTool.execute(toolCallId, executeParams, signal, onUpdate);
@@ -844,13 +824,13 @@ function resolvePluginToolLoadState(params: {
844824
env?: NodeJS.ProcessEnv;
845825
}):
846826
| {
847-
context: ReturnType<typeof resolvePluginRuntimeLoadContext>;
848-
env: NodeJS.ProcessEnv;
849-
loadOptions: PluginLoadOptions;
850-
onlyPluginIds: string[];
851-
runtimeOptions: PluginLoadOptions["runtimeOptions"];
852-
snapshot: PluginMetadataManifestView;
853-
}
827+
context: ReturnType<typeof resolvePluginRuntimeLoadContext>;
828+
env: NodeJS.ProcessEnv;
829+
loadOptions: PluginLoadOptions;
830+
onlyPluginIds: string[];
831+
runtimeOptions: PluginLoadOptions["runtimeOptions"];
832+
snapshot: PluginMetadataManifestView;
833+
}
854834
| undefined {
855835
const env = params.env ?? process.env;
856836
const baseConfig = applyTestPluginDefaults(params.context.config ?? {}, env);
@@ -988,7 +968,8 @@ export function resolvePluginTools(params: {
988968
});
989969
} catch (error) {
990970
context.logger.error(
991-
`failed to cold-load plugin tool registry for plugin ids [${runtimePluginIds.join(", ")}]: ${error instanceof Error ? error.message : String(error)
971+
`failed to cold-load plugin tool registry for plugin ids [${runtimePluginIds.join(", ")}]: ${
972+
error instanceof Error ? error.message : String(error)
992973
}`,
993974
);
994975
throw error;
@@ -1057,19 +1038,19 @@ export function resolvePluginTools(params: {
10571038
declaredNames.length > 0 ? declaredNames : (entry.declaredNames ?? []);
10581039
const allowlistNames = manifestPlugin
10591040
? filterManifestToolNamesForAvailability({
1060-
plugin: manifestPlugin,
1061-
toolNames: availabilityNames,
1062-
config: params.context.runtimeConfig ?? context.config,
1063-
env,
1064-
hasAuthForProvider: params.hasAuthForProvider,
1065-
}).filter(
1066-
(toolName) =>
1067-
!denylistBlocksPluginTool({
1068-
pluginId: entry.pluginId,
1069-
toolName,
1070-
denylist,
1071-
}),
1072-
)
1041+
plugin: manifestPlugin,
1042+
toolNames: availabilityNames,
1043+
config: params.context.runtimeConfig ?? context.config,
1044+
env,
1045+
hasAuthForProvider: params.hasAuthForProvider,
1046+
}).filter(
1047+
(toolName) =>
1048+
!denylistBlocksPluginTool({
1049+
pluginId: entry.pluginId,
1050+
toolName,
1051+
denylist,
1052+
}),
1053+
)
10731054
: declaredNames;
10741055
if (manifestPlugin && availabilityNames.length > 0 && allowlistNames.length === 0) {
10751056
continue;
@@ -1115,33 +1096,33 @@ export function resolvePluginTools(params: {
11151096
: undefined;
11161097
const availableList = manifestPlugin
11171098
? listRaw.filter((tool) => {
1118-
const toolName = readPluginToolName(tool);
1119-
const normalizedToolName = normalizeToolName(toolName);
1120-
if (
1121-
isManifestToolOptional(manifestPlugin, toolName) &&
1122-
!isOptionalToolAllowed({
1099+
const toolName = readPluginToolName(tool);
1100+
const normalizedToolName = normalizeToolName(toolName);
1101+
if (
1102+
isManifestToolOptional(manifestPlugin, toolName) &&
1103+
!isOptionalToolAllowed({
1104+
toolName,
1105+
pluginId: entry.pluginId,
1106+
allowlist,
1107+
})
1108+
) {
1109+
return false;
1110+
}
1111+
if (
1112+
selectedManifestToolNames &&
1113+
manifestContractToolNames?.has(normalizedToolName) &&
1114+
!selectedManifestToolNames.has(normalizedToolName)
1115+
) {
1116+
return false;
1117+
}
1118+
return isManifestToolNameAvailable({
1119+
plugin: manifestPlugin,
11231120
toolName,
1124-
pluginId: entry.pluginId,
1125-
allowlist,
1126-
})
1127-
) {
1128-
return false;
1129-
}
1130-
if (
1131-
selectedManifestToolNames &&
1132-
manifestContractToolNames?.has(normalizedToolName) &&
1133-
!selectedManifestToolNames.has(normalizedToolName)
1134-
) {
1135-
return false;
1136-
}
1137-
return isManifestToolNameAvailable({
1138-
plugin: manifestPlugin,
1139-
toolName,
1140-
config: params.context.runtimeConfig ?? context.config,
1141-
env,
1142-
hasAuthForProvider: params.hasAuthForProvider,
1143-
});
1144-
})
1121+
config: params.context.runtimeConfig ?? context.config,
1122+
env,
1123+
hasAuthForProvider: params.hasAuthForProvider,
1124+
});
1125+
})
11451126
: listRaw;
11461127
const policyAvailableList = availableList.filter(
11471128
(tool) =>
@@ -1153,12 +1134,12 @@ export function resolvePluginTools(params: {
11531134
);
11541135
const list = entry.optional
11551136
? policyAvailableList.filter((tool) =>
1156-
isOptionalToolAllowed({
1157-
toolName: readPluginToolName(tool),
1158-
pluginId: entry.pluginId,
1159-
allowlist,
1160-
}),
1161-
)
1137+
isOptionalToolAllowed({
1138+
toolName: readPluginToolName(tool),
1139+
pluginId: entry.pluginId,
1140+
allowlist,
1141+
}),
1142+
)
11621143
: policyAvailableList;
11631144
if (list.length === 0) {
11641145
continue;
@@ -1182,9 +1163,9 @@ export function resolvePluginTools(params: {
11821163
const tool = toolRaw as AnyAgentTool;
11831164
const undeclared = entry.declaredNames
11841165
? findUndeclaredPluginToolNames({
1185-
declaredNames: entry.declaredNames,
1186-
toolNames: [tool.name],
1187-
})
1166+
declaredNames: entry.declaredNames,
1167+
toolNames: [tool.name],
1168+
})
11881169
: [];
11891170
if (undeclared.length > 0) {
11901171
const message = `plugin tool is undeclared (${entry.pluginId}): ${undeclared.join(", ")}`;
@@ -1287,8 +1268,3 @@ export function resolvePluginTools(params: {
12871268

12881269
return tools;
12891270
}
1290-
1291-
1292-
1293-
1294-

0 commit comments

Comments
 (0)