refactor(agents): prepare hot-path runtime facts (#113167)

* perf(agents): prepare hot-path runtime facts

* fix(plugins): preserve catalog registry contracts
This commit is contained in:
Peter Steinberger
2026-07-23 16:14:22 -07:00
committed by GitHub
parent b23c7bd0f8
commit af443b4384
13 changed files with 381 additions and 34 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof prepareMediaCapabilityProviders>;
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,

View File

@@ -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<string> {
return fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${label}-`));
@@ -17,10 +17,12 @@ async function makeRepoRoot(root: string): Promise<void> {
}
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",

View File

@@ -55,12 +55,11 @@ export function buildSystemPromptParams(params: {
runtime: Omit<RuntimeInfoInput, "agentId">;
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;

View File

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

View File

@@ -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<ChannelMessageActionDiscoveryInput, "cfg" | "channel"> & {
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<string>(["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<typeof getLoadedChannelPlugin>[0])
?.message ??
getChannelPlugin(currentChannel as Parameters<typeof getChannelPlugin>[0])?.message;
const prepared = params.preparedMessageToolCatalog?.getChannel(currentChannel);
if (prepared) {
return prepared.reconcilesUnknownSend;
}
const adapter = params.preparedMessageToolCatalog
? getBundledChannelPlugin(currentChannel)?.message
: (getLoadedChannelPlugin(currentChannel as Parameters<typeof getLoadedChannelPlugin>[0])
?.message ??
getChannelPlugin(currentChannel as Parameters<typeof getChannelPlugin>[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 {

View File

@@ -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<typeof getChannelPlugin>[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<typeof getChannelPlugin>[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<typeof getChannelPlugin>[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<ChannelMessageCapability>();
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<string>();
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);
}
/**

View File

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

View File

@@ -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<PluginRegistry, Map<number, PreparedMessageToolCatalog>>();
const latestCatalogByRegistry = new WeakMap<PluginRegistry, PreparedMessageToolCatalog>();
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<string, PluginRegistry["channels"][number]["plugin"]>();
(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);
}

View File

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