mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 07:51:35 +00:00
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:
committed by
GitHub
parent
b23c7bd0f8
commit
af443b4384
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
79
src/plugins/prepared-message-tool-catalog.test.ts
Normal file
79
src/plugins/prepared-message-tool-catalog.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
105
src/plugins/prepared-message-tool-catalog.ts
Normal file
105
src/plugins/prepared-message-tool-catalog.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user