Skip to content

Commit 3adbbe7

Browse files
authored
fix(plugins): dispatch cached tools by runtime name (#78716)
Fix cached descriptor-backed plugin tool dispatch for unnamed factories sharing manifest contracts. Thanks @zanni098!
1 parent 37af50f commit 3adbbe7

3 files changed

Lines changed: 120 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ Docs: https://docs.openclaw.ai
327327
- 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.
328328
- 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.
329329
- 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.
330+
- 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.
330331
- 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.
331332
- 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)
332333
- 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: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -553,26 +553,43 @@ function createCachedDescriptorPluginTool(params: {
553553
loadOptions,
554554
onlyPluginIds: [pluginId],
555555
});
556-
const entry = registry?.tools.find(
557-
(candidate) =>
558-
candidate.pluginId === pluginId &&
559-
(candidate.names.length > 0 ? candidate.names : (candidate.declaredNames ?? [])).some(
560-
(name) => normalizeToolName(name) === normalizeToolName(toolName),
561-
),
562-
);
563-
if (!entry) {
556+
const candidates = registry?.tools.filter((candidate) => candidate.pluginId === pluginId);
557+
if (!candidates || candidates.length === 0) {
564558
throw new Error(`plugin tool runtime unavailable (${pluginId}): ${toolName}`);
565559
}
566-
const resolved = entry.factory(params.ctx);
567-
const listRaw: unknown[] = Array.isArray(resolved) ? resolved : resolved ? [resolved] : [];
568-
for (const toolRaw of listRaw) {
569-
const malformedReason = describeMalformedPluginTool(toolRaw);
570-
if (malformedReason) {
571-
throw new Error(`plugin tool is malformed (${pluginId}): ${malformedReason}`);
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),
582+
);
583+
const unnamedCandidates = candidates.filter((candidate) => candidate.names.length === 0);
584+
for (const candidate of [...matchingNamedCandidates, ...unnamedCandidates]) {
585+
let matchedTool: AnyAgentTool | undefined;
586+
try {
587+
matchedTool = resolveCandidateTool(candidate);
588+
} catch {
589+
continue;
572590
}
573-
const runtimeTool = toolRaw as AnyAgentTool;
574-
if (normalizeToolName(runtimeTool.name) === normalizeToolName(toolName)) {
575-
return runtimeTool.execute(toolCallId, executeParams, signal, onUpdate);
591+
if (matchedTool) {
592+
return matchedTool.execute(toolCallId, executeParams, signal, onUpdate);
576593
}
577594
}
578595
throw new Error(`plugin tool runtime missing (${pluginId}): ${toolName}`);

0 commit comments

Comments
 (0)