From 36e687edf0ff35ca9a2e80d09354f74789b1a71d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 14:30:48 +0100 Subject: [PATCH] fix(plugins): use built code for tool discovery --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- src/plugin-sdk/channel-entry-contract.test.ts | 41 ++++++++++++++++ src/plugin-sdk/channel-entry-contract.ts | 5 ++ src/plugin-sdk/core.test.ts | 36 ++++++++++++++ src/plugin-sdk/core.ts | 4 ++ src/plugins/bundled-dir.test.ts | 6 +-- src/plugins/bundled-dir.ts | 45 ++---------------- ...memory-embedding-provider.contract.test.ts | 38 +++++++++++++++ src/plugins/jiti-loader-cache.test.ts | 33 ++++++++++++- src/plugins/jiti-loader-cache.ts | 47 ++++++++++++------- src/plugins/loader.ts | 17 ++++++- src/plugins/registry.ts | 7 +-- src/plugins/tools.ts | 2 + src/plugins/types.ts | 2 + 15 files changed, 221 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff497a4771..46b55340d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - CLI/plugins: scope install and enable slot selection to the selected plugin manifest/runtime fallback, so plugin installs no longer load every plugin runtime or broad status snapshot just to update memory/context slots. Thanks @vincentkoc. - Plugins/TTS: keep bundled speech-provider discovery available on cold package Gateway paths and add bundled plugin matrix runtime probes for health, readiness, RPC, TTS discovery, and post-ready runtime-deps watchdog coverage. Refs #75283. Thanks @vincentkoc. - Google Meet/Twilio: show delegated voice call ID, DTMF, and intro-greeting state in `googlemeet doctor`, and avoid claiming DTMF was sent when no Meet PIN sequence was configured. Refs #72478. Thanks @DougButdorf. +- Plugins/tools: prefer built bundled plugin code during tool discovery and skip channel runtime hydration while preserving companion provider registrations, reducing per-run plugin-tool prep cost without dropping executable plugin tools. Fixes #75290. Thanks @thanos-openclaw. - Voice Call/Twilio: send notify-mode initial TwiML directly in the outbound create-call request while keeping conversation and pre-connect DTMF calls webhook-driven, so one-shot notify calls do not depend on a first-answer webhook fetch. Supersedes #72758. Thanks @tyshepps. - Discord/Slack: defer status-reaction cleanup until run finalization so queued, thinking, tool, and terminal reactions no longer flicker during normal progress updates. (#75582) - Discord/voice: leave Discord voice off for text-only configs unless `channels.discord.voice` is explicitly configured, avoiding default `GuildVoiceStates` traffic and idle gateway CPU pressure for bots that do not use `/vc`. Fixes #73753; refs #74044. Thanks @sanchezm86 and @SecureCloudProjO. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 79df1f70afc..f9f21541e3b 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -e75701dd791461feb4893e7106362dbbb41668bc4341e8b42becc346001e9f0e plugin-sdk-api-baseline.json -077e30997781d3a064f00491d55f7ac78465868b02fdcfb70e07e03555bb2afe plugin-sdk-api-baseline.jsonl +c1446005a26262d6b817d72493471d11c618b98441fad2014f1cf422bfe64bc9 plugin-sdk-api-baseline.json +1b7d71eaabcae7d957396e7ff242598ef22b51851bc3fe1f4b58f2c2e5bf1459 plugin-sdk-api-baseline.jsonl diff --git a/src/plugin-sdk/channel-entry-contract.test.ts b/src/plugin-sdk/channel-entry-contract.test.ts index 8448435112f..d388258b5b4 100644 --- a/src/plugin-sdk/channel-entry-contract.test.ts +++ b/src/plugin-sdk/channel-entry-contract.test.ts @@ -24,6 +24,7 @@ function createApi(registrationMode: PluginRegistrationMode): OpenClawPluginApi registrationMode, runtime: { registrationMode } as unknown as PluginRuntime, registerChannel: vi.fn(), + registerTool: vi.fn(), } as unknown as OpenClawPluginApi; } @@ -90,6 +91,46 @@ function createBundledChannelEntry(params: { } describe("defineBundledChannelEntry", () => { + it("runs tool registrations without channel sidecar hydration during tool discovery", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-entry-tools-")); + tempDirs.push(tempRoot); + const runtimeMarker = path.join(tempRoot, "runtime-loaded"); + const pluginId = "bundled-tool-discovery"; + const { importerPath } = writeBundledChannelFixture({ + pluginRoot: path.join(tempRoot, "dist", "extensions", pluginId), + pluginId, + runtimeMarker, + }); + const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>(); + const registerFull = vi.fn<(api: OpenClawPluginApi) => void>((api) => { + api.registerTool( + { + name: "channel_tool", + label: "Channel Tool", + description: "channel tool", + parameters: {}, + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }, + { name: "channel_tool" }, + ); + }); + const entry = createBundledChannelEntry({ + importerPath, + pluginId, + registerCliMetadata, + registerFull, + }); + + const api = createApi("tool-discovery"); + entry.register(api); + + expect(api.registerChannel).not.toHaveBeenCalled(); + expect(registerCliMetadata).not.toHaveBeenCalled(); + expect(registerFull).toHaveBeenCalledWith(api); + expect(api.registerTool).toHaveBeenCalledTimes(1); + expect(fs.existsSync(runtimeMarker)).toBe(false); + }); + it("loads runtime sidecars during discovery registration", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-entry-runtime-")); tempDirs.push(tempRoot); diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index 8d944e81a19..952e98c2fcb 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -494,6 +494,11 @@ export function defineBundledChannelEntry({ registerCliMetadata?.(api); return; } + if (api.registrationMode === "tool-discovery") { + const profile = createProfiler({ pluginId: id, source: importMetaUrl }); + profile("bundled-register:registerFull", () => registerFull?.(api)); + return; + } const profile = createProfiler({ pluginId: id, source: importMetaUrl }); const channelPlugin = profile("bundled-register:loadChannelPlugin", loadChannelPlugin); profile("bundled-register:registerChannel", () => diff --git a/src/plugin-sdk/core.test.ts b/src/plugin-sdk/core.test.ts index c522b97879f..6fb75ba39da 100644 --- a/src/plugin-sdk/core.test.ts +++ b/src/plugin-sdk/core.test.ts @@ -28,10 +28,46 @@ function createApi(registrationMode: PluginRegistrationMode): OpenClawPluginApi registrationMode, runtime: { registrationMode } as unknown as PluginRuntime, registerChannel: vi.fn(), + registerTool: vi.fn(), } as unknown as OpenClawPluginApi; } describe("defineChannelPluginEntry", () => { + it("runs tool registrations without channel runtime wiring during tool discovery", () => { + const setRuntime = vi.fn<(runtime: PluginRuntime) => void>(); + const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>(); + const registerFull = vi.fn<(api: OpenClawPluginApi) => void>((api) => { + api.registerTool( + { + name: "channel_tool", + label: "Channel Tool", + description: "channel tool", + parameters: {}, + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }, + { name: "channel_tool" }, + ); + }); + const entry = defineChannelPluginEntry({ + id: "runtime-tool-discovery", + name: "Runtime Tool Discovery", + description: "runtime tool discovery test", + plugin: createChannelPlugin("runtime-tool-discovery"), + setRuntime, + registerCliMetadata, + registerFull, + }); + + const api = createApi("tool-discovery"); + entry.register(api); + + expect(api.registerChannel).not.toHaveBeenCalled(); + expect(setRuntime).not.toHaveBeenCalled(); + expect(registerCliMetadata).not.toHaveBeenCalled(); + expect(registerFull).toHaveBeenCalledWith(api); + expect(api.registerTool).toHaveBeenCalledTimes(1); + }); + it("wires runtime helpers during discovery registration", () => { const setRuntime = vi.fn<(runtime: PluginRuntime) => void>(); const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>(); diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 2aa57fadf5e..edc3872ae2b 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -523,6 +523,10 @@ export function defineChannelPluginEntry({ registerCliMetadata?.(api); return; } + if (api.registrationMode === "tool-discovery") { + registerFull?.(api); + return; + } api.registerChannel({ plugin: plugin as ChannelPlugin }); setRuntime?.(api.runtime); if (api.registrationMode === "discovery") { diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts index 61657b5ecd0..87df61e8246 100644 --- a/src/plugins/bundled-dir.test.ts +++ b/src/plugins/bundled-dir.test.ts @@ -215,9 +215,9 @@ describe("resolveBundledPluginsDir", () => { }, ], [ - "prefers source extensions during tsx-driven source execution", + "still prefers built bundled plugins during tsx-driven source execution", { - prefix: "openclaw-bundled-dir-tsx-", + prefix: "openclaw-bundled-dir-tsx-built-", hasExtensions: true, hasSrc: true, hasDistRuntimeExtensions: true, @@ -225,7 +225,7 @@ describe("resolveBundledPluginsDir", () => { hasGitCheckout: true, }, { - expectedRelativeDir: "extensions", + expectedRelativeDir: path.join("dist-runtime", "extensions"), execArgv: ["--import", "tsx"], }, ], diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index 4e6ceda56b2..c9789cef054 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -121,50 +121,17 @@ function overrideResolvesUnderPackageBundledRoot(params: { .some((trustedRoot) => pathContains(trustedRoot, realOverride)); } -function runningSourceTypeScriptProcess(): boolean { - const argv1 = process.argv[1]?.toLowerCase(); - if ( - argv1?.endsWith(".ts") || - argv1?.endsWith(".tsx") || - argv1?.endsWith(".mts") || - argv1?.endsWith(".cts") - ) { - return true; - } - - for (let index = 0; index < process.execArgv.length; index += 1) { - const arg = process.execArgv[index]?.toLowerCase(); - if (!arg) { - continue; - } - if (arg === "tsx" || arg.includes("tsx/register")) { - return true; - } - if ((arg === "--import" || arg === "--loader") && process.execArgv[index + 1]) { - const next = process.execArgv[index + 1].toLowerCase(); - if (next === "tsx" || next.includes("tsx/")) { - return true; - } - } - } - - return false; -} - -function resolveBundledDirFromPackageRoot( - packageRoot: string, - preferSourceCheckout: boolean, -): string | undefined { +function resolveBundledDirFromPackageRoot(packageRoot: string): string | undefined { const sourceExtensionsDir = path.join(packageRoot, "extensions"); const builtExtensionsDir = path.join(packageRoot, "dist", "extensions"); const sourceCheckout = isSourceCheckoutRoot(packageRoot); const hasUsableSourceTree = sourceCheckout && hasUsableBundledPluginTree(sourceExtensionsDir); - if (preferSourceCheckout && hasUsableSourceTree) { - return sourceExtensionsDir; - } // Local source checkouts stage a runtime-complete bundled plugin tree under // dist-runtime/. Prefer that over source extensions only when the paired // dist/ tree exists; otherwise wrappers can drift ahead of the last build. + // Even when OpenClaw itself runs from TypeScript, bundled plugins should use + // compiled JavaScript whenever it is available. Source plugin entries force + // jiti onto hot runtime paths such as per-run tool construction. const runtimeExtensionsDir = path.join(packageRoot, "dist-runtime", "extensions"); const hasUsableRuntimeTree = sourceCheckout ? hasUsableBundledPluginTree(runtimeExtensionsDir) @@ -209,8 +176,6 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): } } - const preferSourceCheckout = runningSourceTypeScriptProcess(); - try { const argvRoot = resolveOpenClawPackageRootSync({ argv1: process.argv[1] }); const rejectedOverrideUsesArgvRoot = Boolean( @@ -227,7 +192,7 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): (entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index, ); for (const packageRoot of packageRoots) { - const bundledDir = resolveBundledDirFromPackageRoot(packageRoot, preferSourceCheckout); + const bundledDir = resolveBundledDirFromPackageRoot(packageRoot); if (bundledDir) { return bundledDir; } diff --git a/src/plugins/contracts/memory-embedding-provider.contract.test.ts b/src/plugins/contracts/memory-embedding-provider.contract.test.ts index 8f0c089e1b9..d8ed7f382af 100644 --- a/src/plugins/contracts/memory-embedding-provider.contract.test.ts +++ b/src/plugins/contracts/memory-embedding-provider.contract.test.ts @@ -4,6 +4,7 @@ import { } from "openclaw/plugin-sdk/plugin-test-contracts"; import { describe, expect, it } from "vitest"; import { getRegisteredMemoryEmbeddingProvider } from "../memory-embedding-providers.js"; +import { createPluginRecord } from "../status.test-helpers.js"; describe("memory embedding provider registration", () => { it("rejects non-memory plugins that did not declare the capability contract", () => { @@ -81,4 +82,41 @@ describe("memory embedding provider registration", () => { ownerPluginId: "memory-core", }); }); + + it("keeps companion embedding providers available during tool discovery", () => { + const { config, registry } = createPluginRegistryFixture(); + const record = createPluginRecord({ + id: "tool-discovery-memory", + name: "Tool Discovery Memory", + kind: "memory", + }); + registry.registry.plugins.push(record); + const api = registry.createApi(record, { + config, + registrationMode: "tool-discovery", + }); + + api.registerMemoryEmbeddingProvider({ + id: "tool-discovery-embedding", + create: async () => ({ provider: null }), + }); + api.registerTool({ + name: "memory_recall", + label: "Memory Recall", + description: "Recall memory", + parameters: {}, + execute: async () => ({ content: [], details: {} }), + }); + + expect(getRegisteredMemoryEmbeddingProvider("tool-discovery-embedding")).toEqual({ + adapter: expect.objectContaining({ id: "tool-discovery-embedding" }), + ownerPluginId: "tool-discovery-memory", + }); + expect(registry.registry.tools).toEqual([ + expect.objectContaining({ + pluginId: "tool-discovery-memory", + names: ["memory_recall"], + }), + ]); + }); }); diff --git a/src/plugins/jiti-loader-cache.test.ts b/src/plugins/jiti-loader-cache.test.ts index a1fa92e9111..de770c9570d 100644 --- a/src/plugins/jiti-loader-cache.test.ts +++ b/src/plugins/jiti-loader-cache.test.ts @@ -43,6 +43,7 @@ describe("getCachedPluginJitiLoader", () => { const second = getCachedPluginJitiLoader(params); expect(second).toBe(first); + first("/repo/extensions/demo/index.ts"); expect(createJiti).toHaveBeenCalledTimes(1); expect(cache.size).toBe(1); }); @@ -70,6 +71,8 @@ describe("getCachedPluginJitiLoader", () => { }); expect(second).not.toBe(first); + first("/repo/dist/extensions/demo/api.ts"); + second("/repo/dist/extensions/demo/api.ts"); expect(createJiti).toHaveBeenNthCalledWith( 1, "file:///repo/src/plugins/public-surface-loader.ts", @@ -119,6 +122,7 @@ describe("getCachedPluginJitiLoader", () => { }); expect(second).toBe(first); + first("/repo/extensions/demo/index.ts"); expect(createJiti).toHaveBeenCalledTimes(1); expect(createJiti).toHaveBeenCalledWith( "file:///repo/src/plugins/loader.ts", @@ -161,6 +165,7 @@ describe("getCachedPluginJitiLoader", () => { }); expect(second).toBe(first); + second("/repo/dist/extensions/demo-b/api.js"); expect(createJiti).toHaveBeenCalledTimes(1); expect(cache.size).toBe(1); }); @@ -193,6 +198,29 @@ describe("getCachedPluginJitiLoader", () => { tryNative: false, }); + getCachedPluginJitiLoader({ + cache, + modulePath: "/repo/extensions/demo-a/index.ts", + importerUrl: "file:///repo/src/plugins/loader.ts", + jitiFilename: "/repo/extensions/demo-a/index.ts", + aliasMap: { + alpha: "/repo/alpha", + beta: "alpha/sub", + }, + tryNative: false, + })("/repo/extensions/demo-a/index.ts"); + getCachedPluginJitiLoader({ + cache, + modulePath: "/repo/extensions/demo-b/index.ts", + importerUrl: "file:///repo/src/plugins/loader.ts", + jitiFilename: "/repo/extensions/demo-b/index.ts", + aliasMap: { + beta: "alpha/sub", + alpha: "/repo/alpha", + }, + tryNative: false, + })("/repo/extensions/demo-b/index.ts"); + const marker = Symbol.for("pathe:normalizedAlias"); const firstAlias = (createJiti.mock.calls[0]?.[1] as { alias?: Record }).alias; const secondAlias = (createJiti.mock.calls[1]?.[1] as { alias?: Record }).alias; @@ -231,8 +259,9 @@ describe("getCachedPluginJitiLoader", () => { const result = loader("/repo/dist/extensions/demo/api.js") as { loadedFrom: string }; expect(result.loadedFrom).toBe("/repo/dist/extensions/demo/api.js"); - // jiti is created eagerly, but its loader must NOT be invoked for .js - // targets that `tryNativeRequireJavaScriptModule` resolves. + // Jiti should not be constructed or invoked for .js targets that + // `tryNativeRequireJavaScriptModule` resolves. + expect(createJiti).not.toHaveBeenCalled(); expect(jitiLoader).not.toHaveBeenCalled(); // allowWindows must be passed so the native fast path works on Windows too. expect(nativeStub).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js", { diff --git a/src/plugins/jiti-loader-cache.ts b/src/plugins/jiti-loader-cache.ts index 90dc20b7498..cd037a4087f 100644 --- a/src/plugins/jiti-loader-cache.ts +++ b/src/plugins/jiti-loader-cache.ts @@ -76,26 +76,41 @@ export function getCachedPluginJitiLoader(params: { if (cached) { return cached; } - const jitiLoader = (params.createLoader ?? createJiti)(jitiFilename, { - ...buildPluginLoaderJitiOptions(aliasMap), - tryNative, - }); - const loadWithJiti = new Proxy(jitiLoader, { - apply(target, thisArg, argArray) { - const [first, ...rest] = argArray as [unknown, ...unknown[]]; - if (typeof first === "string") { - return Reflect.apply(target, thisArg, [toSafeImportPath(first), ...rest] as never) as never; - } - return Reflect.apply(target, thisArg, argArray as never) as never; - }, - }); + let loadWithJiti: PluginJitiLoader | undefined; + const getLoadWithJiti = (): PluginJitiLoader => { + if (loadWithJiti) { + return loadWithJiti; + } + const jitiLoader = (params.createLoader ?? createJiti)(jitiFilename, { + ...buildPluginLoaderJitiOptions(aliasMap), + tryNative, + }); + loadWithJiti = new Proxy(jitiLoader, { + apply(target, thisArg, argArray) { + const [first, ...rest] = argArray as [unknown, ...unknown[]]; + if (typeof first === "string") { + return Reflect.apply(target, thisArg, [ + toSafeImportPath(first), + ...rest, + ] as never) as never; + } + return Reflect.apply(target, thisArg, argArray as never) as never; + }, + }); + return loadWithJiti; + }; // When the caller has explicitly opted out of native loading (for example // `bundled-capability-runtime` in Vitest+dist mode, which depends on // jiti's alias rewriting to surface a narrow SDK slice), route every // target through jiti so those alias rewrites still apply. if (!tryNative) { - params.cache.set(scopedCacheKey, loadWithJiti); - return loadWithJiti; + const loader = ((target: string, ...rest: unknown[]) => + (getLoadWithJiti() as (t: string, ...a: unknown[]) => unknown)( + target, + ...rest, + )) as PluginJitiLoader; + params.cache.set(scopedCacheKey, loader); + return loader; } // Otherwise prefer native require() for already-compiled JS artifacts // (the bundled plugin public surfaces shipped in dist/). jiti's transform @@ -109,7 +124,7 @@ export function getCachedPluginJitiLoader(params: { if (native.ok) { return native.moduleExport; } - return (loadWithJiti as (t: string, ...a: unknown[]) => unknown)(target, ...rest); + return (getLoadWithJiti() as (t: string, ...a: unknown[]) => unknown)(target, ...rest); }) as PluginJitiLoader; params.cache.set(scopedCacheKey, loader); return loader; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index bd482f00db8..1d3f5a0faf1 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -183,6 +183,7 @@ export type PluginLoadOptions = { * via package metadata because their setup entry covers the pre-listen startup surface. */ preferSetupRuntimeForChannelPlugins?: boolean; + toolDiscovery?: boolean; activate?: boolean; loadModules?: boolean; installBundledRuntimeDeps?: boolean; @@ -621,6 +622,7 @@ function buildCacheKey(params: { forceSetupOnlyChannelPlugins?: boolean; requireSetupEntryForSetupOnlyChannelPlugins?: boolean; preferSetupRuntimeForChannelPlugins?: boolean; + toolDiscovery?: boolean; loadModules?: boolean; installBundledRuntimeDeps?: boolean; runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable"; @@ -661,6 +663,7 @@ function buildCacheKey(params: { const startupChannelMode = params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full"; const moduleLoadMode = params.loadModules === false ? "manifest-only" : "load-modules"; + const discoveryMode = params.toolDiscovery === true ? "tool-discovery" : "default-discovery"; const bundledRuntimeDepsMode = params.installBundledRuntimeDeps === false ? "skip-runtime-deps" : "install-runtime-deps"; const runtimeSubagentMode = params.runtimeSubagentMode ?? "default"; @@ -672,7 +675,7 @@ function buildCacheKey(params: { installs, loadPaths, activationMetadataKey: params.activationMetadataKey ?? "", - })}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${bundledRuntimeDepsMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}::${activationMode}`; + })}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${setupOnlyRequirementKey}::${startupChannelMode}::${moduleLoadMode}::${discoveryMode}::${bundledRuntimeDepsMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}::${activationMode}`; } function matchesScopedPluginRequest(params: { @@ -804,6 +807,7 @@ function resolvePluginRegistrationPlan(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; preferSetupRuntimeForChannelPlugins: boolean; + toolDiscovery: boolean; }): PluginRegistrationPlan | null { if (params.canLoadScopedSetupOnlyChannelPlugin) { return { @@ -823,6 +827,15 @@ function resolvePluginRegistrationPlan(params: { if (!params.enableStateEnabled) { return null; } + if (params.toolDiscovery) { + return { + mode: "tool-discovery", + loadSetupEntry: false, + loadSetupRuntimeEntry: false, + runRuntimeCapabilityPolicy: true, + runFullActivationOnlyRegistrations: false, + }; + } const loadSetupRuntimeEntry = params.shouldLoadModules && !params.validateOnly && @@ -901,6 +914,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { forceSetupOnlyChannelPlugins, requireSetupEntryForSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, + toolDiscovery: options.toolDiscovery, loadModules: options.loadModules, installBundledRuntimeDeps: options.installBundledRuntimeDeps, runtimeSubagentMode, @@ -1530,6 +1544,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi cfg, env, preferSetupRuntimeForChannelPlugins, + toolDiscovery: options.toolDiscovery === true, }); if (!registrationPlan) { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 0e9d7de7007..99817a34770 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -256,7 +256,7 @@ type HookRollbackEntry = { name: string; previousRegistrations: HookRegistration type PluginRegistrationCapabilities = { /** Broad registry writes that discovery and live activation both need. */ capabilityHandlers: boolean; - /** Runtime channel registration is suppressed for setup-only metadata loads. */ + /** Runtime channel registration is suppressed for setup-only and tool discovery loads. */ runtimeChannel: boolean; }; @@ -268,9 +268,10 @@ type PluginRegistrationCapabilities = { function resolvePluginRegistrationCapabilities( mode: PluginRegistrationMode, ): PluginRegistrationCapabilities { + const capabilityHandlers = mode === "full" || mode === "discovery" || mode === "tool-discovery"; return { - capabilityHandlers: mode === "full" || mode === "discovery", - runtimeChannel: mode !== "setup-only", + capabilityHandlers, + runtimeChannel: mode !== "setup-only" && mode !== "tool-discovery", }; } diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index c19fe767ecc..af22f8a406c 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -135,6 +135,8 @@ export function resolvePluginTools(params: { : undefined; const loadOptions = buildPluginRuntimeLoadOptions(context, { installBundledRuntimeDeps: false, + activate: false, + toolDiscovery: true, runtimeOptions, }); const registry = resolvePluginToolRegistry({ diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 06d41516e79..e1f35e68dde 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -2217,6 +2217,7 @@ export type OpenClawPluginModule = OpenClawPluginDefinition | ((api: OpenClawPlu * * - `full`: live runtime activation; long-lived side effects may start. * - `discovery`: read-only capability discovery; skip sockets/workers/clients. + * - `tool-discovery`: capability discovery for executable tools; skip channel runtime hydration. * - `setup-only`: lightweight channel setup entry only. * - `setup-runtime`: setup flow that also needs the runtime channel entry. * - `cli-metadata`: CLI command metadata collection. @@ -2224,6 +2225,7 @@ export type OpenClawPluginModule = OpenClawPluginDefinition | ((api: OpenClawPlu export type PluginRegistrationMode = | "full" | "discovery" + | "tool-discovery" | "setup-only" | "setup-runtime" | "cli-metadata";