From fac06a2320ef0ef0ebe8f0efdae9b95be03ac157 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 1 May 2026 19:29:52 +0100 Subject: [PATCH] perf: scope reply runtime plugin startup --- CHANGELOG.md | 1 + src/agents/runtime-plugins.test.ts | 85 ++++++++++++++++++++++++++++++ src/agents/runtime-plugins.ts | 72 ++++++++++++++++++++++++- src/plugins/tools.optional.test.ts | 64 ++++++++++++++++++---- src/plugins/tools.ts | 62 +++++++++++++++++++--- 5 files changed, 265 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ae84e82a8..83d981eaac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -160,6 +160,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/tools: skip unavailable media generation and PDF tool factories from the live reply path when Gateway metadata and the active auth store prove no configured provider can back them, while keeping explicit config and auth-backed providers on the normal factory path. Thanks @shakkernerd. +- Agents/runtime: reuse the Gateway metadata startup plan when ensuring reply runtime plugins are loaded, so live agent turns do not broad-load plugin runtimes after the Gateway already scoped startup activation. Thanks @shakkernerd. - Agents/tools: route media and generation capability lookups through the Gateway plugin metadata snapshot during reply tool registration, avoiding repeated manifest registry reloads on the live reply path. Thanks @shakkernerd. - Agents/tools: reuse the auth profile store already loaded for the active run when deciding media and generation tool availability, avoiding repeated provider-auth runtime discovery during reply startup. Thanks @shakkernerd. - Agents/tools: keep image, video, and music generation tool registration on manifest/auth control-plane checks instead of loading runtime provider registries during reply startup, reducing live-path tool-prep blocking while leaving provider runtime resolution for execution and list actions. Thanks @shakkernerd. diff --git a/src/agents/runtime-plugins.test.ts b/src/agents/runtime-plugins.test.ts index 4b0d1efddd4..8e67eef80aa 100644 --- a/src/agents/runtime-plugins.test.ts +++ b/src/agents/runtime-plugins.test.ts @@ -1,17 +1,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => ({ + getCurrentPluginMetadataSnapshot: vi.fn(), resolveRuntimePluginRegistry: vi.fn(), + getActivePluginRegistry: vi.fn(), + getActivePluginRegistryWorkspaceDir: vi.fn(), getActivePluginRuntimeSubagentMode: vi.fn<() => "default" | "explicit" | "gateway-bindable">( () => "default", ), })); +vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({ + getCurrentPluginMetadataSnapshot: hoisted.getCurrentPluginMetadataSnapshot, +})); + vi.mock("../plugins/loader.js", () => ({ resolveRuntimePluginRegistry: hoisted.resolveRuntimePluginRegistry, })); vi.mock("../plugins/runtime.js", () => ({ + getActivePluginRegistry: hoisted.getActivePluginRegistry, + getActivePluginRegistryWorkspaceDir: hoisted.getActivePluginRegistryWorkspaceDir, getActivePluginRuntimeSubagentMode: hoisted.getActivePluginRuntimeSubagentMode, })); @@ -19,8 +28,14 @@ describe("ensureRuntimePluginsLoaded", () => { let ensureRuntimePluginsLoaded: typeof import("./runtime-plugins.js").ensureRuntimePluginsLoaded; beforeEach(async () => { + hoisted.getCurrentPluginMetadataSnapshot.mockReset(); + hoisted.getCurrentPluginMetadataSnapshot.mockReturnValue(undefined); hoisted.resolveRuntimePluginRegistry.mockReset(); hoisted.resolveRuntimePluginRegistry.mockReturnValue(undefined); + hoisted.getActivePluginRegistry.mockReset(); + hoisted.getActivePluginRegistry.mockReturnValue(null); + hoisted.getActivePluginRegistryWorkspaceDir.mockReset(); + hoisted.getActivePluginRegistryWorkspaceDir.mockReturnValue(undefined); hoisted.getActivePluginRuntimeSubagentMode.mockReset(); hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("default"); vi.resetModules(); @@ -55,6 +70,76 @@ describe("ensureRuntimePluginsLoaded", () => { }); }); + it("scopes runtime plugin loading to the current gateway startup plan", async () => { + const config = {} as never; + hoisted.getCurrentPluginMetadataSnapshot.mockReturnValue({ + startup: { + pluginIds: ["telegram", "memory-core"], + }, + }); + + ensureRuntimePluginsLoaded({ + config, + workspaceDir: "/tmp/workspace", + allowGatewaySubagentBinding: true, + }); + + expect(hoisted.getCurrentPluginMetadataSnapshot).toHaveBeenCalledWith({ + config, + workspaceDir: "/tmp/workspace", + }); + expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({ + config, + workspaceDir: "/tmp/workspace", + onlyPluginIds: ["telegram", "memory-core"], + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }); + }); + + it("reuses an active gateway registry that already covers the startup plan", async () => { + hoisted.getCurrentPluginMetadataSnapshot.mockReturnValue({ + startup: { + pluginIds: ["telegram"], + }, + }); + hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("gateway-bindable"); + hoisted.getActivePluginRegistryWorkspaceDir.mockReturnValue("/tmp/workspace"); + hoisted.getActivePluginRegistry.mockReturnValue({ + plugins: [{ id: "telegram", status: "loaded" }], + }); + + ensureRuntimePluginsLoaded({ + config: {} as never, + workspaceDir: "/tmp/workspace", + allowGatewaySubagentBinding: true, + }); + + expect(hoisted.resolveRuntimePluginRegistry).not.toHaveBeenCalled(); + }); + + it("does not reuse an active gateway registry for another workspace", async () => { + hoisted.getCurrentPluginMetadataSnapshot.mockReturnValue({ + startup: { + pluginIds: ["telegram"], + }, + }); + hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("gateway-bindable"); + hoisted.getActivePluginRegistryWorkspaceDir.mockReturnValue("/tmp/other-workspace"); + hoisted.getActivePluginRegistry.mockReturnValue({ + plugins: [{ id: "telegram", status: "loaded" }], + }); + + ensureRuntimePluginsLoaded({ + config: {} as never, + workspaceDir: "/tmp/workspace", + allowGatewaySubagentBinding: true, + }); + + expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1); + }); + it("does not enable gateway subagent binding for normal runtime loads", async () => { ensureRuntimePluginsLoaded({ config: {} as never, diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts index 6838c258a5d..cdfe8b13b74 100644 --- a/src/agents/runtime-plugins.ts +++ b/src/agents/runtime-plugins.ts @@ -1,8 +1,63 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; import { resolveRuntimePluginRegistry } from "../plugins/loader.js"; -import { getActivePluginRuntimeSubagentMode } from "../plugins/runtime.js"; +import { + getActivePluginRegistry, + getActivePluginRegistryWorkspaceDir, + getActivePluginRuntimeSubagentMode, +} from "../plugins/runtime.js"; import { resolveUserPath } from "../utils.js"; +type StartupScopedPluginSnapshot = NonNullable< + ReturnType +> & { + startup?: { + pluginIds?: readonly unknown[]; + }; +}; + +function resolveStartupPluginIdsFromCurrentSnapshot(params: { + config?: OpenClawConfig; + workspaceDir?: string; +}): string[] | undefined { + const snapshot = getCurrentPluginMetadataSnapshot({ + config: params.config, + workspaceDir: params.workspaceDir, + }) as StartupScopedPluginSnapshot | undefined; + const pluginIds = snapshot?.startup?.pluginIds; + if (!Array.isArray(pluginIds)) { + return undefined; + } + return pluginIds.filter((pluginId): pluginId is string => typeof pluginId === "string"); +} + +function activeRegistryCoversStartupScope(params: { + pluginIds: readonly string[]; + workspaceDir?: string; + allowGatewaySubagentBinding: boolean; +}): boolean { + const activeRegistry = getActivePluginRegistry(); + if (!activeRegistry) { + return false; + } + if ( + params.allowGatewaySubagentBinding && + getActivePluginRuntimeSubagentMode() !== "gateway-bindable" + ) { + return false; + } + const activeWorkspaceDir = getActivePluginRegistryWorkspaceDir(); + if ( + activeWorkspaceDir !== undefined && + params.workspaceDir !== undefined && + activeWorkspaceDir !== params.workspaceDir + ) { + return false; + } + const activePluginIds = new Set(activeRegistry.plugins.map((plugin) => plugin.id)); + return params.pluginIds.every((pluginId) => activePluginIds.has(pluginId)); +} + export function ensureRuntimePluginsLoaded(params: { config?: OpenClawConfig; workspaceDir?: string | null; @@ -15,9 +70,24 @@ export function ensureRuntimePluginsLoaded(params: { const allowGatewaySubagentBinding = params.allowGatewaySubagentBinding === true || getActivePluginRuntimeSubagentMode() === "gateway-bindable"; + const startupPluginIds = resolveStartupPluginIdsFromCurrentSnapshot({ + config: params.config, + workspaceDir, + }); + if ( + startupPluginIds && + activeRegistryCoversStartupScope({ + pluginIds: startupPluginIds, + workspaceDir, + allowGatewaySubagentBinding, + }) + ) { + return; + } const loadOptions = { config: params.config, workspaceDir, + ...(startupPluginIds ? { onlyPluginIds: startupPluginIds } : {}), runtimeOptions: allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true, diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 3b53a11eb8e..5e3eaf8a17d 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -25,6 +25,7 @@ vi.mock("../config/plugin-auto-enable.js", () => ({ let resolvePluginTools: typeof import("./tools.js").resolvePluginTools; let buildPluginToolMetadataKey: typeof import("./tools.js").buildPluginToolMetadataKey; +let pinActivePluginChannelRegistry: typeof import("./runtime.js").pinActivePluginChannelRegistry; let resetPluginRuntimeStateForTest: typeof import("./runtime.js").resetPluginRuntimeStateForTest; let setActivePluginRegistry: typeof import("./runtime.js").setActivePluginRegistry; @@ -226,7 +227,8 @@ function expectConflictingCoreNameResolution(params: { describe("resolvePluginTools optional tools", () => { beforeAll(async () => { ({ buildPluginToolMetadataKey, resolvePluginTools } = await import("./tools.js")); - ({ resetPluginRuntimeStateForTest, setActivePluginRegistry } = await import("./runtime.js")); + ({ pinActivePluginChannelRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry } = + await import("./runtime.js")); }); beforeEach(() => { @@ -544,7 +546,7 @@ describe("resolvePluginTools optional tools", () => { expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); - it("routes gateway-bindable tool loads through scoped runtime compatibility", () => { + it("reuses the gateway-bindable registry when it covers the tool runtime scope", () => { const activeRegistry = createOptionalDemoActiveRegistry(); setActivePluginRegistry(activeRegistry as never, "gateway-startup", "gateway-bindable"); resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry); @@ -557,14 +559,7 @@ describe("resolvePluginTools optional tools", () => { ); expectResolvedToolNames(tools, ["optional_tool"]); - expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledWith( - expect.objectContaining({ - onlyPluginIds: ["optional-demo"], - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - }), - ); + expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); @@ -597,6 +592,55 @@ describe("resolvePluginTools optional tools", () => { ); }); + it("reuses the pinned gateway channel registry after provider runtime loads replace active registry", () => { + const gatewayRegistry = createOptionalDemoActiveRegistry(); + pinActivePluginChannelRegistry(gatewayRegistry as never); + setActivePluginRegistry( + { + tools: [], + diagnostics: [], + } as never, + "provider-runtime", + "default", + ); + resolveRuntimePluginRegistryMock.mockReturnValue(undefined); + + const tools = resolvePluginTools( + createResolveToolsParams({ + toolAllowlist: ["optional_tool"], + allowGatewaySubagentBinding: true, + }), + ); + + expectResolvedToolNames(tools, ["optional_tool"]); + expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + + it("reuses the pinned gateway channel registry even when the caller omits gateway binding", () => { + const gatewayRegistry = createOptionalDemoActiveRegistry(); + pinActivePluginChannelRegistry(gatewayRegistry as never); + setActivePluginRegistry( + { + tools: [], + diagnostics: [], + } as never, + "provider-runtime", + "default", + ); + resolveRuntimePluginRegistryMock.mockReturnValue(undefined); + + const tools = resolvePluginTools( + createResolveToolsParams({ + toolAllowlist: ["optional_tool"], + }), + ); + + expectResolvedToolNames(tools, ["optional_tool"]); + expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + it("loads plugin tools when gateway-bindable tool loads have no active registry", () => { setOptionalDemoRegistry(); diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 55dff4b0a4c..70cfeadf991 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -5,7 +5,12 @@ import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state. import { listEnabledInstalledPluginRecords } from "./installed-plugin-index.js"; import { resolveRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js"; import { loadPluginRegistrySnapshot } from "./plugin-registry-snapshot.js"; -import { getActivePluginRegistry } from "./runtime.js"; +import { + getActivePluginChannelRegistry, + getActivePluginRegistry, + getActivePluginRegistryKey, + getActivePluginRuntimeSubagentMode, +} from "./runtime.js"; import { buildPluginRuntimeLoadOptions, resolvePluginRuntimeLoadContext, @@ -194,18 +199,25 @@ function describeMalformedPluginTool(tool: unknown): string | undefined { return undefined; } +function addLoadedPluginIdsFromRegistry( + registry: ReturnType, + pluginIds: Set, +): void { + for (const plugin of registry?.plugins ?? []) { + if (plugin.status === undefined || plugin.status === "loaded") { + pluginIds.add(plugin.id); + } + } +} + function resolvePluginToolRuntimePluginIds(params: { config: PluginLoadOptions["config"]; workspaceDir?: string; env: NodeJS.ProcessEnv; }): string[] | undefined { const pluginIds = new Set(); - const activeRegistry = getActivePluginRegistry(); - for (const plugin of activeRegistry?.plugins ?? []) { - if (plugin.status === undefined || plugin.status === "loaded") { - pluginIds.add(plugin.id); - } - } + addLoadedPluginIdsFromRegistry(getActivePluginChannelRegistry(), pluginIds); + addLoadedPluginIdsFromRegistry(getActivePluginRegistry(), pluginIds); const index = loadPluginRegistrySnapshot({ config: params.config, workspaceDir: params.workspaceDir, @@ -219,6 +231,37 @@ function resolvePluginToolRuntimePluginIds(params: { : undefined; } +function registryContainsPluginIds( + registry: ReturnType, + pluginIds?: readonly string[], +): boolean { + if (!registry || pluginIds === undefined) { + return false; + } + const loadedPluginIds = new Set(); + addLoadedPluginIdsFromRegistry(registry, loadedPluginIds); + return pluginIds.every((pluginId) => loadedPluginIds.has(pluginId)); +} + +function resolvePluginToolRegistry(params: { + loadOptions: PluginLoadOptions; + onlyPluginIds?: readonly string[]; +}) { + const activeRegistry = getActivePluginRegistry(); + const channelRegistry = getActivePluginChannelRegistry(); + const activeRegistryIsGatewayBindable = + getActivePluginRegistryKey() && getActivePluginRuntimeSubagentMode() === "gateway-bindable"; + const hasPinnedGatewayRegistry = Boolean(channelRegistry && channelRegistry !== activeRegistry); + if ( + channelRegistry && + (activeRegistryIsGatewayBindable || hasPinnedGatewayRegistry) && + registryContainsPluginIds(channelRegistry, params.onlyPluginIds) + ) { + return channelRegistry; + } + return resolveRuntimePluginRegistry(params.loadOptions); +} + export function resolvePluginTools(params: { context: OpenClawPluginToolContext; existingToolNames?: Set; @@ -255,7 +298,10 @@ export function resolvePluginTools(params: { ...(onlyPluginIds !== undefined ? { onlyPluginIds } : {}), runtimeOptions, }); - const registry = resolveRuntimePluginRegistry(loadOptions); + const registry = resolvePluginToolRegistry({ + loadOptions, + onlyPluginIds, + }); if (!registry) { return []; }