mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 16:23:54 +08:00
Compare commits
1 Commits
v2026.6.9
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
052b85b30a |
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Plugins/tools: keep plugin tool catalog visibility on manifest metadata, honor global plugin disablement, and reuse explicitly static plugin tool factories during prompt prep.
|
||||
- TTS: honor explicit short `[[tts:text]]...[[/tts:text]]` blocks while keeping untagged short auto-TTS suppressed, so tagged voice replies are synthesized instead of being dropped as empty voice-only payloads. Fixes #73758. Thanks @yfge.
|
||||
- Proxy/audio: convert standard `FormData` bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send `[object FormData]` when `HTTP_PROXY` or `HTTPS_PROXY` is configured. Fixes #48554. Thanks @dco5.
|
||||
- Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma.
|
||||
|
||||
@@ -268,6 +268,7 @@ Users enable optional tools in config:
|
||||
- Tool names must not clash with core tools (conflicts are skipped)
|
||||
- Tools with malformed registration objects, including missing `parameters`, are skipped and reported in plugin diagnostics instead of breaking agent runs
|
||||
- Use `optional: true` for tools with side effects or extra binary requirements
|
||||
- Direct tool objects are treated as static definitions and reused during prompt prep. If a factory is also context-free, register it with `{ cache: "static" }`; do not use that option when the returned tool captures sender, session, workspace, filesystem policy, browser, or runtime config state.
|
||||
- Users can enable all tools from a plugin by adding the plugin id to `tools.allow`
|
||||
|
||||
## Registering CLI commands
|
||||
|
||||
@@ -33,6 +33,58 @@
|
||||
"webSearchProviders": ["firecrawl"],
|
||||
"tools": ["firecrawl_search", "firecrawl_scrape"]
|
||||
},
|
||||
"toolMetadata": {
|
||||
"firecrawl_search": {
|
||||
"authSignals": [{ "provider": "firecrawl" }],
|
||||
"configSignals": [
|
||||
{
|
||||
"rootPath": "plugins.entries.firecrawl.config",
|
||||
"overlayPath": "webSearch",
|
||||
"required": ["apiKey"]
|
||||
},
|
||||
{
|
||||
"rootPath": "plugins.entries.firecrawl.config",
|
||||
"overlayPath": "webFetch",
|
||||
"required": ["apiKey"]
|
||||
},
|
||||
{
|
||||
"rootPath": "tools.web.search",
|
||||
"overlayPath": "firecrawl",
|
||||
"required": ["apiKey"]
|
||||
},
|
||||
{
|
||||
"rootPath": "tools.web.fetch",
|
||||
"overlayPath": "firecrawl",
|
||||
"required": ["apiKey"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"firecrawl_scrape": {
|
||||
"authSignals": [{ "provider": "firecrawl" }],
|
||||
"configSignals": [
|
||||
{
|
||||
"rootPath": "plugins.entries.firecrawl.config",
|
||||
"overlayPath": "webFetch",
|
||||
"required": ["apiKey"]
|
||||
},
|
||||
{
|
||||
"rootPath": "plugins.entries.firecrawl.config",
|
||||
"overlayPath": "webSearch",
|
||||
"required": ["apiKey"]
|
||||
},
|
||||
{
|
||||
"rootPath": "tools.web.fetch",
|
||||
"overlayPath": "firecrawl",
|
||||
"required": ["apiKey"]
|
||||
},
|
||||
{
|
||||
"rootPath": "tools.web.search",
|
||||
"overlayPath": "firecrawl",
|
||||
"required": ["apiKey"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
|
||||
},
|
||||
|
||||
@@ -23,6 +23,28 @@
|
||||
"webSearchProviders": ["tavily"],
|
||||
"tools": ["tavily_search", "tavily_extract"]
|
||||
},
|
||||
"toolMetadata": {
|
||||
"tavily_search": {
|
||||
"authSignals": [{ "provider": "tavily" }],
|
||||
"configSignals": [
|
||||
{
|
||||
"rootPath": "plugins.entries.tavily.config",
|
||||
"overlayPath": "webSearch",
|
||||
"required": ["apiKey"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"tavily_extract": {
|
||||
"authSignals": [{ "provider": "tavily" }],
|
||||
"configSignals": [
|
||||
{
|
||||
"rootPath": "plugins.entries.tavily.config",
|
||||
"overlayPath": "webSearch",
|
||||
"required": ["apiKey"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolvePluginTools } from "../../plugins/tools.js";
|
||||
import { ErrorCodes } from "../protocol/index.js";
|
||||
import { toolsCatalogHandlers } from "./tools-catalog.js";
|
||||
|
||||
@@ -11,29 +10,77 @@ vi.mock("../../agents/agent-scope.js", () => ({
|
||||
listAgentIds: vi.fn(() => ["main"]),
|
||||
resolveDefaultAgentId: vi.fn(() => "main"),
|
||||
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace-main"),
|
||||
resolveAgentDir: vi.fn(() => "/tmp/agents/main/agent"),
|
||||
}));
|
||||
|
||||
const pluginToolMetaState = new Map<string, { pluginId: string; optional: boolean }>();
|
||||
|
||||
vi.mock("../../plugins/tools.js", () => ({
|
||||
buildPluginToolMetadataKey: (pluginId: string, toolName: string) =>
|
||||
JSON.stringify([pluginId, toolName]),
|
||||
resolvePluginTools: vi.fn(() => [
|
||||
{ name: "voice_call", label: "voice_call", description: "Plugin calling tool" },
|
||||
const loadManifestContractSnapshotMock = vi.fn((_params: unknown) => ({
|
||||
index: {},
|
||||
plugins: [
|
||||
{
|
||||
name: "matrix_room",
|
||||
label: "matrix_room",
|
||||
displaySummary: "Summarized Matrix room helper.",
|
||||
description: "Matrix room helper\n\nACTIONS:\n- join\n- leave",
|
||||
id: "voice-call",
|
||||
origin: "bundled",
|
||||
enabledByDefault: true,
|
||||
contracts: { tools: ["voice_call"] },
|
||||
},
|
||||
]),
|
||||
getPluginToolMeta: vi.fn((tool: { name: string }) => pluginToolMetaState.get(tool.name)),
|
||||
{
|
||||
id: "matrix",
|
||||
origin: "bundled",
|
||||
enabledByDefault: true,
|
||||
contracts: { tools: ["matrix_room"] },
|
||||
},
|
||||
],
|
||||
}));
|
||||
const isManifestPluginAvailableForControlPlaneMock = vi.fn((_params: unknown) => true);
|
||||
const hasManifestToolAvailabilityMock = vi.fn((_params: unknown) => true);
|
||||
|
||||
vi.mock("../../plugins/manifest-contract-eligibility.js", () => ({
|
||||
loadManifestContractSnapshot: (params: unknown) => loadManifestContractSnapshotMock(params),
|
||||
isManifestPluginAvailableForControlPlane: (params: unknown) =>
|
||||
isManifestPluginAvailableForControlPlaneMock(params),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/manifest-tool-availability.js", () => ({
|
||||
hasManifestToolAvailability: (params: unknown) => hasManifestToolAvailabilityMock(params),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/runtime.js", () => ({
|
||||
getActivePluginRegistry: vi.fn(() => ({
|
||||
tools: [
|
||||
{
|
||||
pluginId: "voice-call",
|
||||
names: ["voice_call"],
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
pluginId: "matrix",
|
||||
names: ["matrix_room"],
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
toolMetadata: [
|
||||
{
|
||||
pluginId: "voice-call",
|
||||
metadata: {
|
||||
toolName: "voice_call",
|
||||
displayName: "Voice call",
|
||||
description: "Plugin calling tool",
|
||||
risk: "medium",
|
||||
tags: ["voice"],
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "matrix",
|
||||
metadata: {
|
||||
toolName: "matrix_room",
|
||||
description: "Summarized Matrix room helper.",
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
type RespondCall = [boolean, unknown?, { code: number; message: string }?];
|
||||
|
||||
function createInvokeParams(params: Record<string, unknown>) {
|
||||
function createInvokeParams(params: Record<string, unknown>, cfg: Record<string, unknown> = {}) {
|
||||
const respond = vi.fn();
|
||||
return {
|
||||
respond,
|
||||
@@ -41,7 +88,7 @@ function createInvokeParams(params: Record<string, unknown>) {
|
||||
await toolsCatalogHandlers["tools.catalog"]({
|
||||
params,
|
||||
respond: respond as never,
|
||||
context: { getRuntimeConfig: () => ({}) } as never,
|
||||
context: { getRuntimeConfig: () => cfg } as never,
|
||||
client: null,
|
||||
req: { type: "req", id: "req-1", method: "tools.catalog" },
|
||||
isWebchatConnect: () => false,
|
||||
@@ -51,9 +98,9 @@ function createInvokeParams(params: Record<string, unknown>) {
|
||||
|
||||
describe("tools.catalog handler", () => {
|
||||
beforeEach(() => {
|
||||
pluginToolMetaState.clear();
|
||||
pluginToolMetaState.set("voice_call", { pluginId: "voice-call", optional: true });
|
||||
pluginToolMetaState.set("matrix_room", { pluginId: "matrix", optional: false });
|
||||
loadManifestContractSnapshotMock.mockClear();
|
||||
isManifestPluginAvailableForControlPlaneMock.mockClear();
|
||||
hasManifestToolAvailabilityMock.mockClear();
|
||||
});
|
||||
|
||||
it("rejects invalid params", async () => {
|
||||
@@ -95,7 +142,32 @@ describe("tools.catalog handler", () => {
|
||||
expect(media?.tools.some((tool) => tool.id === "tts" && tool.source === "core")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes plugin groups with plugin metadata", async () => {
|
||||
it("excludes manifest plugin tools when plugins are globally disabled", async () => {
|
||||
const { respond, invoke } = createInvokeParams(
|
||||
{},
|
||||
{
|
||||
plugins: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await invoke();
|
||||
|
||||
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(call?.[0]).toBe(true);
|
||||
const payload = call?.[1] as
|
||||
| {
|
||||
groups: Array<{
|
||||
source: "core" | "plugin";
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
expect(payload?.groups.some((group) => group.source === "plugin")).toBe(false);
|
||||
expect(loadManifestContractSnapshotMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes manifest plugin groups with plugin metadata", async () => {
|
||||
const { respond, invoke } = createInvokeParams({});
|
||||
await invoke();
|
||||
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||
@@ -109,7 +181,10 @@ describe("tools.catalog handler", () => {
|
||||
id: string;
|
||||
source: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
label?: string;
|
||||
optional?: boolean;
|
||||
risk?: string;
|
||||
tags?: string[];
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
@@ -122,7 +197,10 @@ describe("tools.catalog handler", () => {
|
||||
expect(voiceCall).toMatchObject({
|
||||
source: "plugin",
|
||||
pluginId: "voice-call",
|
||||
label: "Voice call",
|
||||
optional: true,
|
||||
risk: "medium",
|
||||
tags: ["voice"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,14 +227,20 @@ describe("tools.catalog handler", () => {
|
||||
expect(matrixRoom?.description).toBe("Summarized Matrix room helper.");
|
||||
});
|
||||
|
||||
it("opts plugin tool catalog loads into gateway subagent binding", async () => {
|
||||
it("builds the plugin catalog from manifests instead of materializing tools", async () => {
|
||||
const { invoke } = createInvokeParams({});
|
||||
|
||||
await invoke();
|
||||
|
||||
expect(vi.mocked(resolvePluginTools)).toHaveBeenCalledWith(
|
||||
expect(loadManifestContractSnapshotMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowGatewaySubagentBinding: true,
|
||||
config: {},
|
||||
workspaceDir: "/tmp/workspace-main",
|
||||
}),
|
||||
);
|
||||
expect(hasManifestToolAvailabilityMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolNames: ["voice_call"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
listAgentIds,
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
@@ -10,13 +9,15 @@ import {
|
||||
resolveCoreToolProfiles,
|
||||
} from "../../agents/tool-catalog.js";
|
||||
import { summarizeToolDescriptionText } from "../../agents/tool-description-summary.js";
|
||||
import { normalizeToolName } from "../../agents/tool-policy.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { getActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { normalizePluginsConfig } from "../../plugins/config-state.js";
|
||||
import {
|
||||
buildPluginToolMetadataKey,
|
||||
getPluginToolMeta,
|
||||
resolvePluginTools,
|
||||
} from "../../plugins/tools.js";
|
||||
isManifestPluginAvailableForControlPlane,
|
||||
loadManifestContractSnapshot,
|
||||
} from "../../plugins/manifest-contract-eligibility.js";
|
||||
import { hasManifestToolAvailability } from "../../plugins/manifest-tool-availability.js";
|
||||
import { getActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
@@ -81,39 +82,65 @@ function buildCoreGroups(): ToolCatalogGroup[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function buildPluginToolMetadataKey(pluginId: string, toolName: string): string {
|
||||
return JSON.stringify([pluginId, toolName]);
|
||||
}
|
||||
|
||||
function buildActivePluginToolCatalogLookups() {
|
||||
const activeRegistry = getActivePluginRegistry();
|
||||
return {
|
||||
metadata: new Map(
|
||||
(activeRegistry?.toolMetadata ?? []).map((entry) => [
|
||||
buildPluginToolMetadataKey(entry.pluginId, entry.metadata.toolName),
|
||||
entry.metadata,
|
||||
]),
|
||||
),
|
||||
registrations: new Map(
|
||||
(activeRegistry?.tools ?? []).flatMap((entry) =>
|
||||
entry.names.map((name) => [buildPluginToolMetadataKey(entry.pluginId, name), entry]),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPluginGroups(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
existingToolNames: Set<string>;
|
||||
}): ToolCatalogGroup[] {
|
||||
if (!normalizePluginsConfig(params.cfg.plugins).enabled) {
|
||||
return [];
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
|
||||
const agentDir = resolveAgentDir(params.cfg, params.agentId);
|
||||
const pluginTools = resolvePluginTools({
|
||||
context: {
|
||||
config: params.cfg,
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
agentId: params.agentId,
|
||||
},
|
||||
existingToolNames: params.existingToolNames,
|
||||
toolAllowlist: ["group:plugins"],
|
||||
suppressNameConflicts: true,
|
||||
allowGatewaySubagentBinding: true,
|
||||
const snapshot = loadManifestContractSnapshot({
|
||||
config: params.cfg,
|
||||
workspaceDir,
|
||||
env: process.env,
|
||||
});
|
||||
const groups = new Map<string, ToolCatalogGroup>();
|
||||
// Key metadata by plugin ownership and tool name so we only project metadata that
|
||||
// was registered BY the tool's owning plugin. Without this scoping, plugin-X
|
||||
// could override the catalog label/description/risk/tags for another plugin's
|
||||
// tool by registering metadata with the same toolName.
|
||||
const pluginToolMetadata = new Map(
|
||||
(getActivePluginRegistry()?.toolMetadata ?? []).map((entry) => [
|
||||
buildPluginToolMetadataKey(entry.pluginId, entry.metadata.toolName),
|
||||
entry.metadata,
|
||||
]),
|
||||
const activeRegistryLookups = buildActivePluginToolCatalogLookups();
|
||||
const existingNormalized = new Set(
|
||||
Array.from(params.existingToolNames, (tool) => normalizeToolName(tool)),
|
||||
);
|
||||
for (const tool of pluginTools) {
|
||||
const meta = getPluginToolMeta(tool);
|
||||
const pluginId = meta?.pluginId ?? "plugin";
|
||||
for (const plugin of snapshot.plugins) {
|
||||
if (
|
||||
!isManifestPluginAvailableForControlPlane({
|
||||
snapshot,
|
||||
plugin,
|
||||
config: params.cfg,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const toolNames = plugin.contracts?.tools ?? [];
|
||||
if (toolNames.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const pluginId = plugin.id;
|
||||
const groupId = `plugin:${pluginId}`;
|
||||
const existing =
|
||||
groups.get(groupId) ??
|
||||
@@ -124,29 +151,41 @@ function buildPluginGroups(params: {
|
||||
pluginId,
|
||||
tools: [],
|
||||
} as ToolCatalogGroup);
|
||||
const ownedMetadata = meta?.pluginId
|
||||
? pluginToolMetadata.get(buildPluginToolMetadataKey(meta.pluginId, tool.name))
|
||||
: undefined;
|
||||
existing.tools.push({
|
||||
id: tool.name,
|
||||
label:
|
||||
normalizeOptionalString(ownedMetadata?.displayName) ??
|
||||
normalizeOptionalString(tool.label) ??
|
||||
tool.name,
|
||||
description: summarizeToolDescriptionText({
|
||||
rawDescription:
|
||||
ownedMetadata?.description ??
|
||||
(typeof tool.description === "string" ? tool.description : undefined),
|
||||
displaySummary: tool.displaySummary,
|
||||
}),
|
||||
source: "plugin",
|
||||
pluginId,
|
||||
optional: meta?.optional,
|
||||
risk: ownedMetadata?.risk,
|
||||
tags: ownedMetadata?.tags,
|
||||
defaultProfiles: [],
|
||||
});
|
||||
groups.set(groupId, existing);
|
||||
for (const toolName of toolNames) {
|
||||
if (existingNormalized.has(normalizeToolName(toolName))) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!hasManifestToolAvailability({
|
||||
plugin,
|
||||
toolNames: [toolName],
|
||||
config: params.cfg,
|
||||
env: process.env,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const ownedMetadata = activeRegistryLookups.metadata.get(
|
||||
buildPluginToolMetadataKey(plugin.id, toolName),
|
||||
);
|
||||
const runtimeRegistration = activeRegistryLookups.registrations.get(
|
||||
buildPluginToolMetadataKey(plugin.id, toolName),
|
||||
);
|
||||
existing.tools.push({
|
||||
id: toolName,
|
||||
label: normalizeOptionalString(ownedMetadata?.displayName) ?? toolName,
|
||||
description: summarizeToolDescriptionText({
|
||||
rawDescription: ownedMetadata?.description ?? `Plugin tool provided by ${plugin.id}.`,
|
||||
}),
|
||||
source: "plugin",
|
||||
pluginId,
|
||||
optional: runtimeRegistration?.optional,
|
||||
risk: ownedMetadata?.risk,
|
||||
tags: ownedMetadata?.tags,
|
||||
defaultProfiles: [],
|
||||
});
|
||||
groups.set(groupId, existing);
|
||||
}
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map((group) =>
|
||||
|
||||
@@ -70,6 +70,7 @@ export type PluginToolRegistration = {
|
||||
names: string[];
|
||||
declaredNames?: string[];
|
||||
optional: boolean;
|
||||
cache?: "static";
|
||||
source: string;
|
||||
rootDir?: string;
|
||||
};
|
||||
|
||||
@@ -448,7 +448,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
const registerTool = (
|
||||
record: PluginRecord,
|
||||
tool: AnyAgentTool | OpenClawPluginToolFactory,
|
||||
opts?: { name?: string; names?: string[]; optional?: boolean },
|
||||
opts?: { name?: string; names?: string[]; optional?: boolean; cache?: "static" },
|
||||
) => {
|
||||
if (pluginsWithChannelRegistrationConflict.has(record.id)) {
|
||||
return;
|
||||
@@ -467,6 +467,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
const optional = opts?.optional === true;
|
||||
const factory: OpenClawPluginToolFactory =
|
||||
typeof tool === "function" ? tool : (_ctx: OpenClawPluginToolContext) => tool;
|
||||
const cache = opts?.cache === "static" || typeof tool !== "function" ? "static" : undefined;
|
||||
|
||||
if (typeof tool !== "function") {
|
||||
names.push(tool.name);
|
||||
@@ -496,6 +497,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
names: normalized,
|
||||
declaredNames,
|
||||
optional,
|
||||
...(cache === "static" ? { cache: "static" as const } : {}),
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
});
|
||||
|
||||
@@ -42,6 +42,13 @@ export type OpenClawPluginToolOptions = {
|
||||
name?: string;
|
||||
names?: string[];
|
||||
optional?: boolean;
|
||||
/**
|
||||
* Reuse this factory's returned tool definitions across prompt-prep calls.
|
||||
*
|
||||
* Only use for context-free factories: no sender/session/workspace/config/browser
|
||||
* state may be captured in the returned tool object.
|
||||
*/
|
||||
cache?: "static";
|
||||
};
|
||||
|
||||
export type OpenClawPluginHookOptions = {
|
||||
|
||||
@@ -10,6 +10,7 @@ type MockRegistryToolEntry = {
|
||||
names: string[];
|
||||
declaredNames?: string[];
|
||||
factory: (ctx: unknown) => unknown;
|
||||
cache?: "static";
|
||||
};
|
||||
|
||||
const loadOpenClawPluginsMock = vi.fn();
|
||||
@@ -322,6 +323,54 @@ function createXaiToolManifest() {
|
||||
};
|
||||
}
|
||||
|
||||
function createFirecrawlToolManifest() {
|
||||
const apiKeyConfigSignals = [
|
||||
{
|
||||
rootPath: "plugins.entries.firecrawl.config",
|
||||
overlayPath: "webSearch",
|
||||
required: ["apiKey"],
|
||||
},
|
||||
{
|
||||
rootPath: "plugins.entries.firecrawl.config",
|
||||
overlayPath: "webFetch",
|
||||
required: ["apiKey"],
|
||||
},
|
||||
{
|
||||
rootPath: "tools.web.search",
|
||||
overlayPath: "firecrawl",
|
||||
required: ["apiKey"],
|
||||
},
|
||||
{
|
||||
rootPath: "tools.web.fetch",
|
||||
overlayPath: "firecrawl",
|
||||
required: ["apiKey"],
|
||||
},
|
||||
];
|
||||
return {
|
||||
id: "firecrawl",
|
||||
origin: "bundled",
|
||||
enabledByDefault: true,
|
||||
channels: [],
|
||||
providers: ["firecrawl"],
|
||||
providerAuthEnvVars: {
|
||||
firecrawl: ["FIRECRAWL_API_KEY"],
|
||||
},
|
||||
contracts: {
|
||||
tools: ["firecrawl_search", "firecrawl_scrape"],
|
||||
},
|
||||
toolMetadata: {
|
||||
firecrawl_search: {
|
||||
authSignals: [{ provider: "firecrawl" }],
|
||||
configSignals: apiKeyConfigSignals,
|
||||
},
|
||||
firecrawl_scrape: {
|
||||
authSignals: [{ provider: "firecrawl" }],
|
||||
configSignals: apiKeyConfigSignals,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function expectResolvedToolNames(
|
||||
tools: ReturnType<typeof resolvePluginTools>,
|
||||
expectedToolNames: readonly string[],
|
||||
@@ -581,6 +630,101 @@ describe("resolvePluginTools optional tools", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "scrape tool can use webSearch apiKey",
|
||||
config: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
entries: {
|
||||
firecrawl: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "firecrawl-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
toolAllowlist: ["firecrawl_scrape"],
|
||||
expectedTool: "firecrawl_scrape",
|
||||
},
|
||||
{
|
||||
name: "search tool can use webFetch apiKey",
|
||||
config: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
entries: {
|
||||
firecrawl: {
|
||||
config: {
|
||||
webFetch: {
|
||||
apiKey: "firecrawl-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
toolAllowlist: ["firecrawl_search"],
|
||||
expectedTool: "firecrawl_search",
|
||||
},
|
||||
])("keeps Firecrawl metadata aligned with runtime key resolution: $name", (params) => {
|
||||
const base = createContext();
|
||||
const config = {
|
||||
...base.config,
|
||||
plugins: {
|
||||
...base.config.plugins,
|
||||
...params.config.plugins,
|
||||
entries: params.config.plugins.entries,
|
||||
},
|
||||
};
|
||||
installToolManifestSnapshot({
|
||||
config,
|
||||
env: {},
|
||||
plugin: createFirecrawlToolManifest(),
|
||||
});
|
||||
const searchFactory = vi.fn(() => makeTool("firecrawl_search"));
|
||||
const scrapeFactory = vi.fn(() => makeTool("firecrawl_scrape"));
|
||||
loadOpenClawPluginsMock.mockReturnValue({
|
||||
tools: [
|
||||
{
|
||||
pluginId: "firecrawl",
|
||||
optional: false,
|
||||
source: "/tmp/firecrawl.js",
|
||||
names: ["firecrawl_search"],
|
||||
declaredNames: ["firecrawl_search", "firecrawl_scrape"],
|
||||
factory: searchFactory,
|
||||
},
|
||||
{
|
||||
pluginId: "firecrawl",
|
||||
optional: false,
|
||||
source: "/tmp/firecrawl.js",
|
||||
names: ["firecrawl_scrape"],
|
||||
declaredNames: ["firecrawl_search", "firecrawl_scrape"],
|
||||
factory: scrapeFactory,
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
const tools = resolvePluginTools({
|
||||
context: {
|
||||
...base,
|
||||
config,
|
||||
} as never,
|
||||
toolAllowlist: params.toolAllowlist,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expectResolvedToolNames(tools, [params.expectedTool]);
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onlyPluginIds: ["firecrawl"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips optional tools without explicit allowlist", () => {
|
||||
setOptionalDemoRegistry();
|
||||
const tools = resolveOptionalDemoTools();
|
||||
@@ -732,6 +876,45 @@ describe("resolvePluginTools optional tools", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses static plugin tool factories across prompt-prep calls", () => {
|
||||
const factory = vi.fn(() => makeTool("optional_tool"));
|
||||
setRegistry([
|
||||
{
|
||||
pluginId: "optional-demo",
|
||||
optional: true,
|
||||
source: "/tmp/optional-demo.js",
|
||||
names: ["optional_tool"],
|
||||
factory,
|
||||
cache: "static",
|
||||
},
|
||||
]);
|
||||
|
||||
const first = resolveOptionalDemoTools(["optional_tool"]);
|
||||
const second = resolveOptionalDemoTools(["optional_tool"]);
|
||||
|
||||
expectResolvedToolNames(first, ["optional_tool"]);
|
||||
expectResolvedToolNames(second, ["optional_tool"]);
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps non-static plugin tool factories per-call", () => {
|
||||
const factory = vi.fn(() => makeTool("optional_tool"));
|
||||
setRegistry([
|
||||
{
|
||||
pluginId: "optional-demo",
|
||||
optional: true,
|
||||
source: "/tmp/optional-demo.js",
|
||||
names: ["optional_tool"],
|
||||
factory,
|
||||
},
|
||||
]);
|
||||
|
||||
resolveOptionalDemoTools(["optional_tool"]);
|
||||
resolveOptionalDemoTools(["optional_tool"]);
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("warns with plugin factory timing details when a factory is slow", () => {
|
||||
vi.useFakeTimers({ now: 0 });
|
||||
const warnSpy = installConsoleMethodSpy("warn");
|
||||
@@ -978,6 +1161,7 @@ describe("resolvePluginTools optional tools", () => {
|
||||
} as never,
|
||||
toolAllowlist: ["optional_tool", "tavily"],
|
||||
allowGatewaySubagentBinding: true,
|
||||
env: { TAVILY_API_KEY: "test-tavily-key" },
|
||||
});
|
||||
|
||||
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
loadManifestContractSnapshot,
|
||||
} from "./manifest-contract-eligibility.js";
|
||||
import { hasManifestToolAvailability } from "./manifest-tool-availability.js";
|
||||
import type { PluginToolRegistration } from "./registry-types.js";
|
||||
import {
|
||||
getActivePluginChannelRegistry,
|
||||
getActivePluginRegistry,
|
||||
@@ -36,6 +37,7 @@ type PluginToolFactoryTiming = {
|
||||
result: PluginToolFactoryTimingResult;
|
||||
resultCount: number;
|
||||
optional: boolean;
|
||||
cache: "hit" | "miss" | "none";
|
||||
};
|
||||
|
||||
const log = createSubsystemLogger("plugins/tools");
|
||||
@@ -44,6 +46,10 @@ const PLUGIN_TOOL_FACTORY_WARN_FACTORY_MS = 1_000;
|
||||
const PLUGIN_TOOL_FACTORY_SUMMARY_LIMIT = 20;
|
||||
|
||||
const pluginToolMeta = new WeakMap<AnyAgentTool, PluginToolMeta>();
|
||||
const staticPluginToolFactoryCache = new WeakMap<
|
||||
PluginToolRegistration,
|
||||
AnyAgentTool | AnyAgentTool[] | null | undefined
|
||||
>();
|
||||
|
||||
export function setPluginToolMeta(tool: AnyAgentTool, meta: PluginToolMeta): void {
|
||||
pluginToolMeta.set(tool, meta);
|
||||
@@ -148,11 +154,15 @@ function formatPluginToolFactoryTiming(timing: PluginToolFactoryTiming): string
|
||||
`result=${timing.result}`,
|
||||
`count=${timing.resultCount}`,
|
||||
`optional=${String(timing.optional)}`,
|
||||
`cache=${timing.cache}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function formatPluginToolFactoryTimingSummary(params: {
|
||||
totalMs: number;
|
||||
manifestMs: number;
|
||||
registryMs: number;
|
||||
factoryMs: number;
|
||||
timings: PluginToolFactoryTiming[];
|
||||
}): string {
|
||||
const ranked = params.timings
|
||||
@@ -169,6 +179,9 @@ function formatPluginToolFactoryTimingSummary(params: {
|
||||
return [
|
||||
"[trace:plugin-tools] factory timings",
|
||||
`totalMs=${params.totalMs}`,
|
||||
`manifestMs=${params.manifestMs}`,
|
||||
`registryMs=${params.registryMs}`,
|
||||
`factoryMs=${params.factoryMs}`,
|
||||
`factoryCount=${params.timings.length}`,
|
||||
`shown=${ranked.length}`,
|
||||
`omitted=${omitted}`,
|
||||
@@ -338,6 +351,27 @@ function resolvePluginToolRegistry(params: {
|
||||
return resolveRuntimePluginRegistry(params.loadOptions);
|
||||
}
|
||||
|
||||
function resolvePluginToolFactory(params: {
|
||||
entry: PluginToolRegistration;
|
||||
context: OpenClawPluginToolContext;
|
||||
}): {
|
||||
resolved: AnyAgentTool | AnyAgentTool[] | null | undefined;
|
||||
cache: PluginToolFactoryTiming["cache"];
|
||||
} {
|
||||
if (params.entry.cache === "static") {
|
||||
if (staticPluginToolFactoryCache.has(params.entry)) {
|
||||
return {
|
||||
resolved: staticPluginToolFactoryCache.get(params.entry),
|
||||
cache: "hit",
|
||||
};
|
||||
}
|
||||
const resolved = params.entry.factory(params.context);
|
||||
staticPluginToolFactoryCache.set(params.entry, resolved);
|
||||
return { resolved, cache: "miss" };
|
||||
}
|
||||
return { resolved: params.entry.factory(params.context), cache: "none" };
|
||||
}
|
||||
|
||||
export function resolvePluginTools(params: {
|
||||
context: OpenClawPluginToolContext;
|
||||
existingToolNames?: Set<string>;
|
||||
@@ -349,6 +383,7 @@ export function resolvePluginTools(params: {
|
||||
}): AnyAgentTool[] {
|
||||
// Fast path: when plugins are effectively disabled, avoid discovery/jiti entirely.
|
||||
// This matters a lot for unit tests and for tool construction hot paths.
|
||||
const resolutionStartedAt = Date.now();
|
||||
const env = params.env ?? process.env;
|
||||
const baseConfig = applyTestPluginDefaults(params.context.config ?? {}, env);
|
||||
const context = resolvePluginRuntimeLoadContext({
|
||||
@@ -364,6 +399,7 @@ export function resolvePluginTools(params: {
|
||||
const runtimeOptions = params.allowGatewaySubagentBinding
|
||||
? { allowGatewaySubagentBinding: true as const }
|
||||
: undefined;
|
||||
const manifestStartedAt = Date.now();
|
||||
const onlyPluginIds = resolvePluginToolRuntimePluginIds({
|
||||
config: context.config,
|
||||
availabilityConfig: params.context.runtimeConfig ?? context.config,
|
||||
@@ -372,16 +408,19 @@ export function resolvePluginTools(params: {
|
||||
toolAllowlist: params.toolAllowlist,
|
||||
hasAuthForProvider: params.hasAuthForProvider,
|
||||
});
|
||||
const manifestMs = toElapsedMs(Date.now() - manifestStartedAt);
|
||||
const loadOptions = buildPluginRuntimeLoadOptions(context, {
|
||||
activate: false,
|
||||
toolDiscovery: true,
|
||||
...(onlyPluginIds !== undefined ? { onlyPluginIds } : {}),
|
||||
runtimeOptions,
|
||||
});
|
||||
const registryStartedAt = Date.now();
|
||||
const registry = resolvePluginToolRegistry({
|
||||
loadOptions,
|
||||
onlyPluginIds,
|
||||
});
|
||||
const registryMs = toElapsedMs(Date.now() - registryStartedAt);
|
||||
if (!registry) {
|
||||
return [];
|
||||
}
|
||||
@@ -430,9 +469,15 @@ export function resolvePluginTools(params: {
|
||||
}
|
||||
let resolved: AnyAgentTool | AnyAgentTool[] | null | undefined = null;
|
||||
let factoryFailed = false;
|
||||
let factoryCache: PluginToolFactoryTiming["cache"] = "none";
|
||||
const factoryStartedAt = Date.now();
|
||||
try {
|
||||
resolved = entry.factory(params.context);
|
||||
const result = resolvePluginToolFactory({
|
||||
entry,
|
||||
context: params.context,
|
||||
});
|
||||
resolved = result.resolved;
|
||||
factoryCache = result.cache;
|
||||
} catch (err) {
|
||||
factoryFailed = true;
|
||||
context.logger.error(`plugin tool failed (${entry.pluginId}): ${String(err)}`);
|
||||
@@ -447,6 +492,7 @@ export function resolvePluginTools(params: {
|
||||
result: result.result,
|
||||
resultCount: result.resultCount,
|
||||
optional: entry.optional,
|
||||
cache: factoryCache,
|
||||
});
|
||||
}
|
||||
if (factoryFailed) {
|
||||
@@ -531,9 +577,9 @@ export function resolvePluginTools(params: {
|
||||
}
|
||||
|
||||
if (factoryTimings.length > 0) {
|
||||
const totalMs =
|
||||
factoryTimings.at(-1)?.elapsedMs ?? toElapsedMs(Date.now() - factoryTimingStartedAt);
|
||||
const timingSummary = { totalMs, timings: factoryTimings };
|
||||
const factoryMs = toElapsedMs(Date.now() - factoryTimingStartedAt);
|
||||
const totalMs = toElapsedMs(Date.now() - resolutionStartedAt);
|
||||
const timingSummary = { totalMs, manifestMs, registryMs, factoryMs, timings: factoryTimings };
|
||||
if (shouldWarnPluginToolFactoryTimings(timingSummary)) {
|
||||
log.warn(formatPluginToolFactoryTimingSummary(timingSummary));
|
||||
} else if (log.isEnabled("trace")) {
|
||||
|
||||
Reference in New Issue
Block a user