Compare commits

...

1 Commits

Author SHA1 Message Date
Peter Steinberger
052b85b30a fix: streamline plugin tool catalog prep 2026-05-02 10:03:44 +01:00
11 changed files with 517 additions and 78 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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"]
},

View File

@@ -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"]
},

View File

@@ -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"],
}),
);
});

View File

@@ -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) =>

View File

@@ -70,6 +70,7 @@ export type PluginToolRegistration = {
names: string[];
declaredNames?: string[];
optional: boolean;
cache?: "static";
source: string;
rootDir?: string;
};

View File

@@ -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,
});

View File

@@ -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 = {

View File

@@ -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(

View File

@@ -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")) {