fix(plugins): use built code for tool discovery

This commit is contained in:
Peter Steinberger
2026-05-01 14:30:48 +01:00
parent 24fc40b133
commit 36e687edf0
15 changed files with 221 additions and 67 deletions

View File

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

View File

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

View File

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

View File

@@ -494,6 +494,11 @@ export function defineBundledChannelEntry<TPlugin = ChannelPlugin>({
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", () =>

View File

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

View File

@@ -523,6 +523,10 @@ export function defineChannelPluginEntry<TPlugin>({
registerCliMetadata?.(api);
return;
}
if (api.registrationMode === "tool-discovery") {
registerFull?.(api);
return;
}
api.registerChannel({ plugin: plugin as ChannelPlugin });
setRuntime?.(api.runtime);
if (api.registrationMode === "discovery") {

View File

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

View File

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

View File

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

View File

@@ -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<string, string> }).alias;
const secondAlias = (createJiti.mock.calls[1]?.[1] as { alias?: Record<string, string> }).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", {

View File

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

View File

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

View File

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

View File

@@ -135,6 +135,8 @@ export function resolvePluginTools(params: {
: undefined;
const loadOptions = buildPluginRuntimeLoadOptions(context, {
installBundledRuntimeDeps: false,
activate: false,
toolDiscovery: true,
runtimeOptions,
});
const registry = resolvePluginToolRegistry({

View File

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