From af443b438406ea14d66fdbf67a415ed7080edf99 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Jul 2026 16:14:22 -0700 Subject: [PATCH] refactor(agents): prepare hot-path runtime facts (#113167) * perf(agents): prepare hot-path runtime facts * fix(plugins): preserve catalog registry contracts --- src/agents/channel-tools.ts | 10 +- .../embedded-agent-runner/run-orchestrator.ts | 14 ++- .../run/attempt-system-prompt-prepare.ts | 3 + src/agents/openclaw-tools.ts | 1 + src/agents/prepared-model-runtime.owner.ts | 14 +++ src/agents/system-prompt-params.test.ts | 26 ++++- src/agents/system-prompt-params.ts | 11 +- src/agents/tools/message-tool.test.ts | 26 +++++ src/agents/tools/message-tool.ts | 36 ++++-- .../plugins/message-action-discovery.ts | 81 +++++++++++--- .../prepared-message-tool-catalog.test.ts | 79 +++++++++++++ src/plugins/prepared-message-tool-catalog.ts | 105 ++++++++++++++++++ src/plugins/runtime.ts | 9 ++ 13 files changed, 381 insertions(+), 34 deletions(-) create mode 100644 src/plugins/prepared-message-tool-catalog.test.ts create mode 100644 src/plugins/prepared-message-tool-catalog.ts diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 83c5a37bf3f6..49a30cf41008 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -10,6 +10,7 @@ import { resolveMessageActionDiscoveryForPlugin, resolveMessageActionDiscoveryChannelId, resolveCurrentChannelMessageToolDiscoveryAdapter, + type PreparedMessageToolCatalog, } from "../channels/plugins/message-action-discovery.js"; import { channelPluginHasNativeApprovalPromptUi, @@ -35,6 +36,7 @@ type ChannelMessageActionDiscoveryParams = { sessionId?: string | null; agentId?: string | null; requesterSenderId?: string | null; + preparedMessageToolCatalog?: PreparedMessageToolCatalog; }; /** @@ -50,7 +52,10 @@ export function listChannelSupportedActions( if (!channelId) { return []; } - const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter(channelId); + const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter( + channelId, + params.preparedMessageToolCatalog, + ); if (!pluginActions?.actions) { return []; } @@ -69,7 +74,8 @@ export function listAllChannelSupportedActions( params: ChannelMessageActionDiscoveryParams, ): ChannelMessageActionName[] { const actions = new Set(); - for (const plugin of listChannelPlugins()) { + const channels = params.preparedMessageToolCatalog?.channels ?? listChannelPlugins(); + for (const plugin of channels) { const channelActions = resolveMessageActionDiscoveryForPlugin({ pluginId: plugin.id, actions: plugin.actions, diff --git a/src/agents/embedded-agent-runner/run-orchestrator.ts b/src/agents/embedded-agent-runner/run-orchestrator.ts index ba791638c46b..b5a21848d17f 100644 --- a/src/agents/embedded-agent-runner/run-orchestrator.ts +++ b/src/agents/embedded-agent-runner/run-orchestrator.ts @@ -41,6 +41,7 @@ import { suspendSession, type SessionSuspensionParams, } from "../session-suspension.js"; +import { resolveSystemPromptRepoRoot } from "../system-prompt-params.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; import { runEmbeddedAgentViaCliBackendIfEligible } from "./cli-backend-dispatch.js"; import { waitForDeferredTurnMaintenanceForSession } from "./context-engine-maintenance.js"; @@ -213,17 +214,26 @@ async function runEmbeddedAgentInternal( params.preparedModelRuntimeMode === "isolated-read-only" ? await acquireReadOnlyPreparedModelRuntime(preparedInput) : await acquireAgentRunPreparedModelRuntime(preparedInput, { retainIdleRunOwner }); - const preparedModelRuntime = preparedModelRuntimeLease.snapshot; + const preparedModelRuntimeOwnerSnapshot = preparedModelRuntimeLease.snapshot; try { // A reload may complete while admission waits. The committed generation owns config, // directories, model selection, hooks, fallbacks, and every later run projection. const rebound = bindRunToPreparedModelRuntime({ runParams: params, requestedWorkspaceResolution, - preparedModelRuntime, + preparedModelRuntime: preparedModelRuntimeOwnerSnapshot, }); params = rebound.runParams; const workspaceResolution = rebound.workspaceResolution; + const preparedModelRuntime = Object.freeze({ + ...preparedModelRuntimeOwnerSnapshot, + repoRoot: + resolveSystemPromptRepoRoot({ + config: rebound.runParams.config, + workspaceDir: workspaceResolution.workspaceDir, + cwd: rebound.runParams.cwd, + }) ?? null, + }); const preparedAgentId = workspaceResolution.agentId; const resolvedWorkspace = workspaceResolution.workspaceDir; const agentDir = preparedModelRuntime.agentDir; diff --git a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts index 46025a4589dd..785b0ff748a6 100644 --- a/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-system-prompt-prepare.ts @@ -167,6 +167,9 @@ export async function prepareEmbeddedAttemptSystemPrompt(params: { agentId: params.sessionAgentId, workspaceDir: params.effectiveWorkspace, cwd: params.effectiveCwd, + ...(attempt.preparedModelRuntime && Object.hasOwn(attempt.preparedModelRuntime, "repoRoot") + ? { preparedRepoRoot: attempt.preparedModelRuntime.repoRoot } + : {}), runtime: { sessionKey: attempt.sessionKey, sessionId: attempt.sessionId, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 19e91dee081e..b5bf10eb7f3f 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -398,6 +398,7 @@ export function createOpenClawTools( sessionId: options?.sessionId, messageActionTurnCapability: options?.messageActionTurnCapability, config: options?.config, + preparedMessageToolCatalog: options?.preparedModelRuntime?.messageToolCatalog, currentChannelId: options?.currentChannelId, currentChatType: options?.currentChatType, currentMessagingTarget: options?.currentMessagingTarget, diff --git a/src/agents/prepared-model-runtime.owner.ts b/src/agents/prepared-model-runtime.owner.ts index cd34ba1f5bc9..c97575009b2f 100644 --- a/src/agents/prepared-model-runtime.owner.ts +++ b/src/agents/prepared-model-runtime.owner.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configured-model-refs"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import type { PreparedMessageToolCatalog } from "../channels/plugins/message-action-discovery.js"; import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js"; import { MODEL_APIS } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -8,6 +9,11 @@ import { withTimeout } from "../node-host/with-timeout.js"; import { prepareMediaCapabilityProviders } from "../plugins/capability-provider-runtime.js"; import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; +import { + EMPTY_PREPARED_MESSAGE_TOOL_CATALOG, + getPreparedMessageToolCatalog, + getPreparedMessageToolCatalogForRegistry, +} from "../plugins/prepared-message-tool-catalog.js"; import { isReservedSystemAgentId } from "../system-agent/agent-id.js"; import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js"; import { @@ -34,8 +40,11 @@ export type PreparedModelRuntimeSnapshot = Readonly<{ agentDir: string; inheritedAuthDir?: string; workspaceDir?: string; + /** Run-prepared repository root; null means discovery completed without a match. */ + repoRoot?: string | null; config: OpenClawConfig; metadataSnapshot: PluginMetadataSnapshot; + messageToolCatalog?: PreparedMessageToolCatalog; mediaCapabilityProviders?: ReturnType; modelCatalog: ModelCatalogSnapshot; createStores: () => PreparedModelRuntimeStores; @@ -405,6 +414,10 @@ async function buildSnapshot( pluginMetadataSnapshot, registry: runtimePluginRegistry, }); + const messageToolCatalog = + (runtimePluginRegistry + ? getPreparedMessageToolCatalogForRegistry(runtimePluginRegistry) + : getPreparedMessageToolCatalog()) ?? EMPTY_PREPARED_MESSAGE_TOOL_CATALOG; const templateAuthStorage = discoverAuthStorage(input.agentDir, { config: input.config, // Snapshot construction never initializes, migrates, or externally syncs auth. ModelRegistry @@ -468,6 +481,7 @@ async function buildSnapshot( ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), config: input.config, metadataSnapshot: pluginMetadataSnapshot, + messageToolCatalog, ...(mediaCapabilityProviders ? { mediaCapabilityProviders } : {}), modelCatalog: { ...modelCatalog, staticEntries }, createStores, diff --git a/src/agents/system-prompt-params.test.ts b/src/agents/system-prompt-params.test.ts index a8f38d176e57..30622e5745b5 100644 --- a/src/agents/system-prompt-params.test.ts +++ b/src/agents/system-prompt-params.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { setActiveNodeContext } from "../infra/active-node-context.js"; -import { buildSystemPromptParams } from "./system-prompt-params.js"; +import { buildSystemPromptParams, resolveSystemPromptRepoRoot } from "./system-prompt-params.js"; async function makeTempDir(label: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${label}-`)); @@ -17,10 +17,12 @@ async function makeRepoRoot(root: string): Promise { } function buildParams(params: { config?: OpenClawConfig; workspaceDir?: string; cwd?: string }) { + const preparedRepoRoot = resolveSystemPromptRepoRoot(params); return buildSystemPromptParams({ config: params.config, workspaceDir: params.workspaceDir, cwd: params.cwd, + preparedRepoRoot, runtime: { host: "host", os: "os", @@ -128,6 +130,28 @@ describe("buildSystemPromptParams", () => { expect(runtimeInfo.repoRoot).toBeUndefined(); }); + it("does not rediscover the repository after preparation", async () => { + const workspaceDir = await makeTempDir("prepared-norepo"); + const repoRoot = await makeTempDir("late-repo"); + const preparedRepoRoot = resolveSystemPromptRepoRoot({ workspaceDir }); + await makeRepoRoot(repoRoot); + + const { runtimeInfo } = buildSystemPromptParams({ + preparedRepoRoot, + workspaceDir, + cwd: repoRoot, + runtime: { + host: "host", + os: "os", + arch: "arch", + node: "node", + model: "model", + }, + }); + + expect(runtimeInfo.repoRoot).toBeUndefined(); + }); + it("carries session identity into runtime info", () => { const { runtimeInfo } = buildSystemPromptParams({ agentId: "main", diff --git a/src/agents/system-prompt-params.ts b/src/agents/system-prompt-params.ts index b32604255474..fc179bdafab8 100644 --- a/src/agents/system-prompt-params.ts +++ b/src/agents/system-prompt-params.ts @@ -55,12 +55,11 @@ export function buildSystemPromptParams(params: { runtime: Omit; workspaceDir?: string; cwd?: string; + preparedRepoRoot?: string | null; }): SystemPromptRuntimeParams { - const repoRoot = resolveRepoRoot({ - config: params.config, - workspaceDir: params.workspaceDir, - cwd: params.cwd, - }); + const repoRoot = Object.hasOwn(params, "preparedRepoRoot") + ? (params.preparedRepoRoot ?? undefined) + : resolveSystemPromptRepoRoot(params); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimeFormat = resolveUserTimeFormat(undefined); const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); @@ -78,7 +77,7 @@ export function buildSystemPromptParams(params: { }; } -function resolveRepoRoot(params: { +export function resolveSystemPromptRepoRoot(params: { config?: OpenClawConfig; workspaceDir?: string; cwd?: string; diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index ad9554fe9db3..a1f48744075f 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -15,6 +15,7 @@ import { MESSAGE_TOOL_DELIVERY_HINTS, MESSAGE_TOOL_ONLY_DELIVERY_HINT, } from "../../plugin-sdk/message-tool-delivery-hints.js"; +import { EMPTY_PREPARED_MESSAGE_TOOL_CATALOG } from "../../plugins/prepared-message-tool-catalog.js"; import { wrapToolWithBeforeToolCallHook } from "../agent-tools.before-tool-call.js"; type CreateMessageTool = typeof import("./message-tool.js").createMessageTool; type CreateOpenClawTools = typeof import("../openclaw-tools.js").createOpenClawTools; @@ -1698,6 +1699,31 @@ describe("message tool delivery mode schema", () => { expect(bestEffort?.type).toBe("boolean"); expect(bestEffort?.description).toContain("requiring durable delivery"); }); + + it("does not rediscover an active catalog after a prepared absence", () => { + const plugin = createChannelPlugin({ + id: "discord", + label: "Discord", + docsPath: "/channels/discord", + blurb: "test", + actions: ["send"], + message: { + durableFinal: { + capabilities: { reconcileUnknownSend: true }, + reconcileUnknownSend: async () => ({ status: "not_sent" }), + }, + }, + }); + setActivePluginRegistry(createTestRegistry([{ pluginId: "discord", source: "test", plugin }])); + + const tool = createMessageTool({ + config: {} as never, + currentChannelProvider: "discord", + preparedMessageToolCatalog: EMPTY_PREPARED_MESSAGE_TOOL_CATALOG, + }); + + expect(getToolProperties(tool).bestEffort).toBeUndefined(); + }); }); describe("message tool agent routing", () => { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 8abdc4307193..7674b3c21bf3 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -21,6 +21,7 @@ import { } from "../../auto-reply/reply/strip-inbound-meta.js"; import type { ChatType } from "../../channels/chat-type.js"; import type { InboundEventKind } from "../../channels/inbound-event/kind.js"; +import { getBundledChannelPlugin } from "../../channels/plugins/bundled.js"; import type { ConversationReadInvocationOrigin } from "../../channels/plugins/conversation-read-origin.js"; import { getChannelPlugin, @@ -32,6 +33,7 @@ import { channelSupportsMessageCapabilityForChannel, type ChannelMessageActionDiscoveryInput, listCrossChannelSchemaSupportedMessageActions, + type PreparedMessageToolCatalog, resolveChannelMessageToolSchemaProperties, } from "../../channels/plugins/message-action-discovery.js"; import { CHANNEL_MESSAGE_ACTION_NAMES } from "../../channels/plugins/message-action-names.js"; @@ -66,6 +68,7 @@ import { } from "../../infra/outbound/outbound-policy.js"; import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; +import { getPreparedMessageToolCatalog } from "../../plugins/prepared-message-tool-catalog.js"; import { POLL_CREATION_PARAM_DEFS, SHARED_POLL_CREATION_PARAM_NAMES } from "../../poll-params.js"; import { normalizeAccountId, parseSessionDeliveryRoute } from "../../routing/session-key.js"; import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js"; @@ -1036,6 +1039,7 @@ type MessageToolOptions = { sessionId?: string; agentId?: string; config?: OpenClawConfig; + preparedMessageToolCatalog?: PreparedMessageToolCatalog; getRuntimeConfig?: () => OpenClawConfig; getScopedChannelsCommandSecretTargets?: typeof getScopedChannelsCommandSecretTargets; resolveCommandSecretRefsViaGateway?: typeof resolveCommandSecretRefsViaGateway; @@ -1074,11 +1078,13 @@ type MessageToolDiscoveryParams = { agentId?: string; requesterSenderId?: string; senderIsOwner?: boolean; + preparedMessageToolCatalog?: PreparedMessageToolCatalog; }; type MessageActionDiscoveryInput = Omit & { cfg: OpenClawConfig; channel?: string; + preparedMessageToolCatalog?: PreparedMessageToolCatalog; }; type InferredSessionDelivery = { @@ -1179,6 +1185,7 @@ function buildMessageActionDiscoveryInput( agentId: params.agentId, requesterSenderId: params.requesterSenderId, senderIsOwner: params.senderIsOwner, + preparedMessageToolCatalog: params.preparedMessageToolCatalog, }; } @@ -1191,7 +1198,8 @@ function resolveMessageToolSchemaActions(params: MessageToolDiscoveryParams): st const allActions = new Set(["send", ...scopedActions]); // Include actions from other configured channels so isolated/cron agents // can invoke cross-channel actions without validation errors. - for (const plugin of listChannelPlugins()) { + const channels = params.preparedMessageToolCatalog?.channels ?? listChannelPlugins(); + for (const plugin of channels) { if (plugin.id === currentChannel) { continue; } @@ -1236,7 +1244,11 @@ function resolveIncludeCapability( capability, ); } - return channelSupportsMessageCapability(params.cfg, capability); + return channelSupportsMessageCapability( + params.cfg, + capability, + params.preparedMessageToolCatalog, + ); } function resolveIncludePresentation(params: MessageToolDiscoveryParams): boolean { @@ -1252,11 +1264,15 @@ function resolveIncludeBestEffort(params: MessageToolDiscoveryParams): boolean { if (!currentChannel) { return false; } - const adapter = - listChannelPlugins().find((plugin) => plugin.id === currentChannel)?.message ?? - getLoadedChannelPlugin(currentChannel as Parameters[0]) - ?.message ?? - getChannelPlugin(currentChannel as Parameters[0])?.message; + const prepared = params.preparedMessageToolCatalog?.getChannel(currentChannel); + if (prepared) { + return prepared.reconcilesUnknownSend; + } + const adapter = params.preparedMessageToolCatalog + ? getBundledChannelPlugin(currentChannel)?.message + : (getLoadedChannelPlugin(currentChannel as Parameters[0]) + ?.message ?? + getChannelPlugin(currentChannel as Parameters[0])?.message); return ( adapter?.durableFinal?.capabilities?.reconcileUnknownSend === true && typeof adapter.durableFinal.reconcileUnknownSend === "function" @@ -1309,6 +1325,7 @@ function buildMessageToolDescription(options?: { sourceReplyDeliveryMode?: SourceReplyDeliveryMode; requesterSenderId?: string; senderIsOwner?: boolean; + preparedMessageToolCatalog?: PreparedMessageToolCatalog; }): string { const baseDescription = "Send/manage channel messages."; const resolvedOptions = options ?? {}; @@ -1325,6 +1342,7 @@ function buildMessageToolDescription(options?: { agentId: resolvedOptions.agentId, requesterSenderId: resolvedOptions.requesterSenderId, senderIsOwner: resolvedOptions.senderIsOwner, + preparedMessageToolCatalog: resolvedOptions.preparedMessageToolCatalog, } : undefined; @@ -1357,6 +1375,8 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const resolveSecretRefsForTool = options?.resolveCommandSecretRefsViaGateway ?? resolveCommandSecretRefsViaGateway; const runMessageActionForTool = options?.runMessageAction ?? runMessageAction; + const preparedMessageToolCatalog = + options?.preparedMessageToolCatalog ?? getPreparedMessageToolCatalog(); let generatedIdempotencyCounter = 0; // Poll-vote echo record lives in the session-scoped map (recentPollVoteBySession) // so it survives the run boundary between the vote and the follow-up text; a @@ -1401,6 +1421,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { agentId: resolvedAgentId, requesterSenderId: options.requesterSenderId, senderIsOwner: options.senderIsOwner, + preparedMessageToolCatalog, }) : MessageToolSchema; const description = buildMessageToolDescription({ @@ -1417,6 +1438,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { sourceReplyDeliveryMode: options?.sourceReplyDeliveryMode, requesterSenderId: options?.requesterSenderId, senderIsOwner: options?.senderIsOwner, + preparedMessageToolCatalog, }); return { diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts index 259406dc8ecb..8ad8b0e9cdde 100644 --- a/src/channels/plugins/message-action-discovery.ts +++ b/src/channels/plugins/message-action-discovery.ts @@ -17,12 +17,25 @@ import { type ChannelMessageToolDiscoveryAdapter, } from "./message-tool-api.js"; import type { + ChannelMessageActionAdapter, ChannelMessageActionDiscoveryContext, ChannelMessageActionName, ChannelMessageToolDiscovery, ChannelMessageToolSchemaContribution, } from "./types.public.js"; +type PreparedMessageToolCatalogEntry = Readonly<{ + id: string; + actions?: ChannelMessageActionAdapter; + reconcilesUnknownSend: boolean; +}>; + +export type PreparedMessageToolCatalog = Readonly<{ + version: number; + channels: readonly PreparedMessageToolCatalogEntry[]; + getChannel: (id: string) => PreparedMessageToolCatalogEntry | undefined; +}>; + /** * Input used to discover channel message actions for agent tool schemas. */ @@ -43,6 +56,7 @@ export type ChannelMessageActionDiscoveryInput = { type ChannelMessageActionDiscoveryParams = ChannelMessageActionDiscoveryInput & { cfg: OpenClawConfig; + preparedMessageToolCatalog?: PreparedMessageToolCatalog; }; type ChannelMessageToolMediaSourceParamKeyInput = ChannelMessageActionDiscoveryParams & { @@ -169,7 +183,10 @@ function normalizeMessageToolMediaSourceParams( /** * Finds the lightest available message-tool discovery adapter for one channel. */ -export function resolveCurrentChannelMessageToolDiscoveryAdapter(channel?: string | null): { +export function resolveCurrentChannelMessageToolDiscoveryAdapter( + channel?: string | null, + preparedMessageToolCatalog?: PreparedMessageToolCatalog, +): { pluginId: string; actions: ChannelMessageToolDiscoveryAdapter; } | null { @@ -177,12 +194,20 @@ export function resolveCurrentChannelMessageToolDiscoveryAdapter(channel?: strin if (!channelId) { return null; } - const loadedPlugin = getLoadedChannelPlugin(channelId as Parameters[0]); - if (loadedPlugin?.actions) { - return { - pluginId: loadedPlugin.id, - actions: loadedPlugin.actions, - }; + const prepared = preparedMessageToolCatalog?.getChannel(channelId); + if (prepared?.actions) { + return { pluginId: prepared.id, actions: prepared.actions }; + } + if (!preparedMessageToolCatalog) { + const loadedPlugin = getLoadedChannelPlugin( + channelId as Parameters[0], + ); + if (loadedPlugin?.actions) { + return { + pluginId: loadedPlugin.id, + actions: loadedPlugin.actions, + }; + } } // Prefer the bundled public artifact before full plugin materialization so // schema construction stays cheap on hot agent/tool paths. @@ -193,6 +218,9 @@ export function resolveCurrentChannelMessageToolDiscoveryAdapter(channel?: strin actions: bundledActions, }; } + if (preparedMessageToolCatalog) { + return null; + } const plugin = getChannelPlugin(channelId as Parameters[0]); if (!plugin?.actions) { return null; @@ -259,7 +287,10 @@ export function listCrossChannelSchemaSupportedMessageActions( if (!channelId) { return []; } - const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter(channelId); + const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter( + channelId, + params.preparedMessageToolCatalog, + ); if (!pluginActions?.actions) { return []; } @@ -297,13 +328,17 @@ export function listCrossChannelSchemaSupportedMessageActions( /** * Lists message capabilities advertised across registered channel plugins. */ -function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] { +function listChannelMessageCapabilities(params: { + cfg: OpenClawConfig; + preparedMessageToolCatalog?: PreparedMessageToolCatalog; +}): ChannelMessageCapability[] { const capabilities = new Set(); - for (const plugin of listChannelPlugins()) { + const channels = params.preparedMessageToolCatalog?.channels ?? listChannelPlugins(); + for (const plugin of channels) { for (const capability of resolveMessageActionDiscoveryForPlugin({ pluginId: plugin.id, actions: plugin.actions, - context: { cfg }, + context: { cfg: params.cfg }, includeCapabilities: true, }).capabilities) { capabilities.add(capability); @@ -318,7 +353,10 @@ function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapa function listChannelMessageCapabilitiesForChannel( params: ChannelMessageActionDiscoveryParams, ): ChannelMessageCapability[] { - const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter(params.channel); + const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter( + params.channel, + params.preparedMessageToolCatalog, + ); if (!pluginActions) { return []; } @@ -365,7 +403,8 @@ export function resolveChannelMessageToolSchemaProperties( const discoveryBase = createMessageActionDiscoveryContext(params); const seenPluginIds = new Set(); - for (const plugin of listChannelPlugins()) { + const channels = params.preparedMessageToolCatalog?.channels ?? listChannelPlugins(); + for (const plugin of channels) { if (!plugin.actions) { continue; } @@ -389,7 +428,10 @@ export function resolveChannelMessageToolSchemaProperties( if (currentChannel && !seenPluginIds.has(currentChannel)) { // The active channel may be bundled but not configured/registered yet; use // its lightweight discovery artifact so current-channel schemas still work. - const currentActions = resolveCurrentChannelMessageToolDiscoveryAdapter(currentChannel); + const currentActions = resolveCurrentChannelMessageToolDiscoveryAdapter( + currentChannel, + params.preparedMessageToolCatalog, + ); if (currentActions?.actions) { for (const contribution of resolveMessageActionDiscoveryForPlugin({ pluginId: currentActions.pluginId, @@ -414,7 +456,10 @@ export function resolveChannelMessageToolSchemaProperties( export function resolveChannelMessageToolMediaSourceParamKeys( params: ChannelMessageToolMediaSourceParamKeyInput, ): string[] { - const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter(params.channel); + const pluginActions = resolveCurrentChannelMessageToolDiscoveryAdapter( + params.channel, + params.preparedMessageToolCatalog, + ); if (!pluginActions) { return []; } @@ -434,8 +479,12 @@ export function resolveChannelMessageToolMediaSourceParamKeys( export function channelSupportsMessageCapability( cfg: OpenClawConfig, capability: ChannelMessageCapability, + preparedMessageToolCatalog?: PreparedMessageToolCatalog, ): boolean { - return listChannelMessageCapabilities(cfg).includes(capability); + return listChannelMessageCapabilities({ + cfg, + preparedMessageToolCatalog, + }).includes(capability); } /** diff --git a/src/plugins/prepared-message-tool-catalog.test.ts b/src/plugins/prepared-message-tool-catalog.test.ts new file mode 100644 index 000000000000..0e562932a09c --- /dev/null +++ b/src/plugins/prepared-message-tool-catalog.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { resolveCurrentChannelMessageToolDiscoveryAdapter } from "../channels/plugins/message-action-discovery.js"; +import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; +import { + getPreparedMessageToolCatalog, + getPreparedMessageToolCatalogForRegistry, + settlePreparedMessageToolCatalog, +} from "./prepared-message-tool-catalog.js"; +import { + pinActivePluginChannelRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "./runtime.js"; + +function channel(id: string, reconcilesUnknownSend = false): ChannelPlugin { + return { + ...createChannelTestPluginBase({ id: id as ChannelPlugin["id"] }), + actions: { + describeMessageTool: () => ({ actions: ["send"] }), + }, + message: reconcilesUnknownSend + ? { + durableFinal: { + capabilities: { reconcileUnknownSend: true }, + reconcileUnknownSend: async () => ({ status: "unresolved" }), + }, + } + : undefined, + }; +} + +describe("prepared message-tool catalog", () => { + afterEach(() => resetPluginRuntimeStateForTest()); + + it("settles one versioned catalog per active channel registry generation", () => { + const first = createTestRegistry([ + { pluginId: "alpha", source: "test", plugin: channel("alpha", true) }, + ]); + setActivePluginRegistry(first); + + const prepared = getPreparedMessageToolCatalog(); + expect(prepared).toBeDefined(); + expect(settlePreparedMessageToolCatalog()).toBe(prepared); + expect(prepared?.channels.map((entry) => entry.id)).toEqual(["alpha"]); + expect(prepared?.getChannel("alpha")?.reconcilesUnknownSend).toBe(true); + + const second = createTestRegistry([ + { pluginId: "beta", source: "test", plugin: channel("beta") }, + ]); + setActivePluginRegistry(second); + + const replaced = getPreparedMessageToolCatalog(); + expect(replaced).not.toBe(prepared); + expect(replaced?.version).not.toBe(prepared?.version); + expect(replaced?.channels.map((entry) => entry.id)).toEqual(["beta"]); + expect(resolveCurrentChannelMessageToolDiscoveryAdapter("beta", prepared)).toBeNull(); + expect(resolveCurrentChannelMessageToolDiscoveryAdapter("alpha", prepared)?.pluginId).toBe( + "alpha", + ); + }); + + it("settles the exact runtime registry while another channel registry is pinned", () => { + const pinned = createTestRegistry([ + { pluginId: "alpha", source: "test", plugin: channel("alpha") }, + ]); + const runtime = createTestRegistry([ + { pluginId: "beta", source: "test", plugin: channel("beta") }, + ]); + setActivePluginRegistry(pinned); + pinActivePluginChannelRegistry(pinned); + setActivePluginRegistry(runtime); + + expect(getPreparedMessageToolCatalog()?.channels.map((entry) => entry.id)).toEqual(["alpha"]); + expect( + getPreparedMessageToolCatalogForRegistry(runtime)?.channels.map((entry) => entry.id), + ).toEqual(["beta"]); + }); +}); diff --git a/src/plugins/prepared-message-tool-catalog.ts b/src/plugins/prepared-message-tool-catalog.ts new file mode 100644 index 000000000000..c99c7e60f9bc --- /dev/null +++ b/src/plugins/prepared-message-tool-catalog.ts @@ -0,0 +1,105 @@ +/** Registry-owned message-tool metadata prepared once per channel registry generation. */ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { PreparedMessageToolCatalog } from "../channels/plugins/message-action-discovery.js"; +import { CHAT_CHANNEL_ORDER } from "../channels/registry.js"; +import type { PluginRegistry } from "./registry-types.js"; +import { + getActivePluginChannelRegistrySnapshotFromState, + type ActivePluginChannelRegistrySnapshot, +} from "./runtime-channel-state.js"; + +const catalogsByRegistry = new WeakMap>(); +const latestCatalogByRegistry = new WeakMap(); + +export const EMPTY_PREPARED_MESSAGE_TOOL_CATALOG: PreparedMessageToolCatalog = Object.freeze({ + version: 0, + channels: Object.freeze([]), + getChannel: () => undefined, +}); + +function listPreparedChannels(registry: PluginRegistry) { + const byId = new Map(); + (registry.channels ?? []).forEach((registration) => { + const id = normalizeOptionalString(registration.plugin.id); + if (id && !byId.has(id)) { + byId.set(id, registration.plugin); + } + }); + return [...byId.values()].toSorted((left, right) => { + const leftId = normalizeOptionalString(left.id) ?? ""; + const rightId = normalizeOptionalString(right.id) ?? ""; + const leftKnownOrder = CHAT_CHANNEL_ORDER.indexOf(leftId); + const rightKnownOrder = CHAT_CHANNEL_ORDER.indexOf(rightId); + const leftOrder = left.meta.order ?? (leftKnownOrder === -1 ? 999 : leftKnownOrder); + const rightOrder = right.meta.order ?? (rightKnownOrder === -1 ? 999 : rightKnownOrder); + return leftOrder === rightOrder ? leftId.localeCompare(rightId) : leftOrder - rightOrder; + }); +} + +function selectedRegistry( + snapshot: ActivePluginChannelRegistrySnapshot, +): PluginRegistry | undefined { + return (snapshot.registry as PluginRegistry | null | undefined) ?? undefined; +} + +/** Settles the catalog after the channel registry surface changes. */ +export function settlePreparedMessageToolCatalog( + preparedRegistry?: PluginRegistry, + preparedVersion?: number, +): PreparedMessageToolCatalog | undefined { + const snapshot = + preparedRegistry && preparedVersion !== undefined + ? undefined + : getActivePluginChannelRegistrySnapshotFromState(); + const registry = preparedRegistry ?? (snapshot ? selectedRegistry(snapshot) : undefined); + if (!registry) { + return undefined; + } + const version = preparedVersion ?? snapshot?.version ?? 0; + let catalogs = catalogsByRegistry.get(registry); + const existing = catalogs?.get(version); + if (existing) { + return existing; + } + const channels = Object.freeze( + listPreparedChannels(registry).map((plugin) => + Object.freeze({ + id: plugin.id, + ...(plugin.actions ? { actions: plugin.actions } : {}), + reconcilesUnknownSend: + plugin.message?.durableFinal?.capabilities?.reconcileUnknownSend === true && + typeof plugin.message.durableFinal.reconcileUnknownSend === "function", + }), + ), + ); + const byId = new Map(channels.map((entry) => [entry.id, entry] as const)); + const catalog = Object.freeze({ + version, + channels, + getChannel: (id: string) => byId.get(id), + }); + if (!catalogs) { + catalogs = new Map(); + catalogsByRegistry.set(registry, catalogs); + } + catalogs.set(version, catalog); + latestCatalogByRegistry.set(registry, catalog); + return catalog; +} + +/** Returns the catalog for the active channel generation without rebuilding it. */ +export function getPreparedMessageToolCatalog(): PreparedMessageToolCatalog | undefined { + const snapshot = getActivePluginChannelRegistrySnapshotFromState(); + const registry = selectedRegistry(snapshot); + if (!registry) { + return undefined; + } + return catalogsByRegistry.get(registry)?.get(snapshot.version); +} + +/** Returns the catalog settled for one exact runtime registry generation. */ +export function getPreparedMessageToolCatalogForRegistry( + registry: PluginRegistry, +): PreparedMessageToolCatalog | undefined { + return latestCatalogByRegistry.get(registry); +} diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index b8b3dd2d91e8..3d235c3e3a80 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -6,6 +6,7 @@ import { dispatchPluginAgentEventSubscriptions, } from "./host-hook-runtime.js"; import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; +import { settlePreparedMessageToolCatalog } from "./prepared-message-tool-catalog.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; import { markPluginRegistryActive, markPluginRegistryRetired } from "./registry-lifecycle.js"; import type { PluginRegistry } from "./registry-types.js"; @@ -207,6 +208,10 @@ export function setActivePluginRegistry( state.activeVersion += 1; syncTrackedSurface(state.httpRoute, registry, true); syncTrackedSurface(state.channel, registry, true); + settlePreparedMessageToolCatalog( + registry, + state.channel.pinned ? state.activeVersion : state.channel.version, + ); syncTrackedSurface(state.sessionExtension, registry, true); state.key = cacheKey ?? null; state.workspaceDir = workspaceDir ?? null; @@ -236,6 +241,7 @@ export function requireActivePluginRegistry(): PluginRegistry { state.activeVersion += 1; syncTrackedSurface(state.httpRoute, state.activeRegistry); syncTrackedSurface(state.channel, state.activeRegistry); + settlePreparedMessageToolCatalog(state.activeRegistry, state.channel.version); syncTrackedSurface(state.sessionExtension, state.activeRegistry); } return asPluginRegistry(state.activeRegistry)!; @@ -300,6 +306,7 @@ export function resolveActivePluginHttpRouteRegistry(fallback: PluginRegistry): export function pinActivePluginChannelRegistry(registry: PluginRegistry) { const previousRegistry = asPluginRegistry(state.channel.registry); installSurfaceRegistry(state.channel, registry, true); + settlePreparedMessageToolCatalog(); markPluginRegistryActive(registry); syncPluginAgentEventBridge(); if (retirePluginRegistryIfUnused(previousRegistry)) { @@ -313,6 +320,7 @@ export function releasePinnedPluginChannelRegistry(registry?: PluginRegistry) { } const previousRegistry = asPluginRegistry(state.channel.registry); installSurfaceRegistry(state.channel, state.activeRegistry, false); + settlePreparedMessageToolCatalog(); syncPluginAgentEventBridge(); if (retirePluginRegistryIfUnused(previousRegistry)) { cleanupRetiredPluginHostRegistry(previousRegistry!); @@ -449,6 +457,7 @@ export function resetPluginRuntimeStateForTest(): void { state.activeVersion += 1; installSurfaceRegistry(state.httpRoute, null, false); installSurfaceRegistry(state.channel, null, false); + settlePreparedMessageToolCatalog(); installSurfaceRegistry(state.sessionExtension, null, false); state.key = null; state.workspaceDir = null;