From cc9be84b9c5b1ee1cf50113f6edc52e62229baeb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 16:09:50 +0100 Subject: [PATCH] refactor(runtime): split runtime builders and stabilize cron tool seam --- .../tools/cron-tool.flat-params.test.ts | 30 +- src/agents/tools/cron-tool.ts | 31 +- src/config/plugins-runtime-boundary.test.ts | 38 ++ src/plugins/runtime/index.ts | 382 +++++++++--------- 4 files changed, 274 insertions(+), 207 deletions(-) create mode 100644 src/config/plugins-runtime-boundary.test.ts diff --git a/src/agents/tools/cron-tool.flat-params.test.ts b/src/agents/tools/cron-tool.flat-params.test.ts index 4a7c17753b..627a65e1b8 100644 --- a/src/agents/tools/cron-tool.flat-params.test.ts +++ b/src/agents/tools/cron-tool.flat-params.test.ts @@ -1,11 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { callGatewayMock } = vi.hoisted(() => ({ - callGatewayMock: vi.fn(), -})); - -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), +const { callGatewayToolMock } = vi.hoisted(() => ({ + callGatewayToolMock: vi.fn(), })); vi.mock("../agent-scope.js", () => ({ @@ -16,12 +12,15 @@ import { createCronTool } from "./cron-tool.js"; describe("cron tool flat-params", () => { beforeEach(() => { - callGatewayMock.mockReset(); - callGatewayMock.mockResolvedValue({ ok: true }); + callGatewayToolMock.mockReset(); + callGatewayToolMock.mockResolvedValue({ ok: true }); }); it("preserves explicit top-level sessionKey during flat-params recovery", async () => { - const tool = createCronTool({ agentSessionKey: "agent:main:discord:channel:ops" }); + const tool = createCronTool( + { agentSessionKey: "agent:main:discord:channel:ops" }, + { callGatewayTool: callGatewayToolMock }, + ); await tool.execute("call-flat-session-key", { action: "add", sessionKey: "agent:main:telegram:group:-100123:topic:99", @@ -29,11 +28,12 @@ describe("cron tool flat-params", () => { message: "do stuff", }); - const call = callGatewayMock.mock.calls[0]?.[0] as { - method?: string; - params?: { sessionKey?: string }; - }; - expect(call.method).toBe("cron.add"); - expect(call.params?.sessionKey).toBe("agent:main:telegram:group:-100123:topic:99"); + const [method, _gatewayOpts, params] = callGatewayToolMock.mock.calls[0] as [ + string, + unknown, + { sessionKey?: string }, + ]; + expect(method).toBe("cron.add"); + expect(params.sessionKey).toBe("agent:main:telegram:group:-100123:topic:99"); }); }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 35997b4e9b..aecbf24cbb 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -1,7 +1,7 @@ import { Type } from "@sinclair/typebox"; +import type { CronDelivery, CronMessageChannel } from "../../cron/types.js"; import { loadConfig } from "../../config/config.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; -import type { CronDelivery, CronMessageChannel } from "../../cron/types.js"; import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { extractTextFromChatContent } from "../../shared/chat-content.js"; @@ -50,6 +50,12 @@ type CronToolOptions = { agentSessionKey?: string; }; +type GatewayToolCaller = typeof callGatewayTool; + +type CronToolDeps = { + callGatewayTool?: GatewayToolCaller; +}; + type ChatMessage = { role?: unknown; content?: unknown; @@ -84,6 +90,7 @@ async function buildReminderContextLines(params: { agentSessionKey?: string; gatewayOpts: GatewayCallOptions; contextMessages: number; + callGatewayTool: GatewayToolCaller; }) { const maxMessages = Math.min( REMINDER_CONTEXT_MESSAGES_MAX, @@ -100,7 +107,7 @@ async function buildReminderContextLines(params: { const { mainKey, alias } = resolveMainSessionAlias(cfg); const resolvedKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey }); try { - const res = await callGatewayTool<{ messages: Array }>( + const res = await params.callGatewayTool<{ messages: Array }>( "chat.history", params.gatewayOpts, { @@ -197,7 +204,8 @@ function inferDeliveryFromSessionKey(agentSessionKey?: string): CronDelivery | n return delivery; } -export function createCronTool(opts?: CronToolOptions): AnyAgentTool { +export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): AnyAgentTool { + const callGateway = deps?.callGatewayTool ?? callGatewayTool; return { label: "Cron", name: "cron", @@ -272,10 +280,10 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con switch (action) { case "status": - return jsonResult(await callGatewayTool("cron.status", gatewayOpts, {})); + return jsonResult(await callGateway("cron.status", gatewayOpts, {})); case "list": return jsonResult( - await callGatewayTool("cron.list", gatewayOpts, { + await callGateway("cron.list", gatewayOpts, { includeDisabled: Boolean(params.includeDisabled), }), ); @@ -412,6 +420,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con agentSessionKey: opts?.agentSessionKey, gatewayOpts, contextMessages, + callGatewayTool: callGateway, }); if (contextLines.length > 0) { const baseText = stripExistingContext(payload.text); @@ -419,7 +428,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con } } } - return jsonResult(await callGatewayTool("cron.add", gatewayOpts, job)); + return jsonResult(await callGateway("cron.add", gatewayOpts, job)); } case "update": { const id = readStringParam(params, "jobId") ?? readStringParam(params, "id"); @@ -431,7 +440,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con } const patch = normalizeCronJobPatch(params.patch) ?? params.patch; return jsonResult( - await callGatewayTool("cron.update", gatewayOpts, { + await callGateway("cron.update", gatewayOpts, { id, patch, }), @@ -442,7 +451,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con if (!id) { throw new Error("jobId required (id accepted for backward compatibility)"); } - return jsonResult(await callGatewayTool("cron.remove", gatewayOpts, { id })); + return jsonResult(await callGateway("cron.remove", gatewayOpts, { id })); } case "run": { const id = readStringParam(params, "jobId") ?? readStringParam(params, "id"); @@ -451,14 +460,14 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con } const runMode = params.runMode === "due" || params.runMode === "force" ? params.runMode : "force"; - return jsonResult(await callGatewayTool("cron.run", gatewayOpts, { id, mode: runMode })); + return jsonResult(await callGateway("cron.run", gatewayOpts, { id, mode: runMode })); } case "runs": { const id = readStringParam(params, "jobId") ?? readStringParam(params, "id"); if (!id) { throw new Error("jobId required (id accepted for backward compatibility)"); } - return jsonResult(await callGatewayTool("cron.runs", gatewayOpts, { id })); + return jsonResult(await callGateway("cron.runs", gatewayOpts, { id })); } case "wake": { const text = readStringParam(params, "text", { required: true }); @@ -467,7 +476,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con ? params.mode : "next-heartbeat"; return jsonResult( - await callGatewayTool("wake", gatewayOpts, { mode, text }, { expectFinal: false }), + await callGateway("wake", gatewayOpts, { mode, text }, { expectFinal: false }), ); } default: diff --git a/src/config/plugins-runtime-boundary.test.ts b/src/config/plugins-runtime-boundary.test.ts new file mode 100644 index 0000000000..4f9b40a61a --- /dev/null +++ b/src/config/plugins-runtime-boundary.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { FIELD_HELP } from "./schema.help.js"; +import { FIELD_LABELS } from "./schema.labels.js"; +import { OpenClawSchema } from "./zod-schema.js"; + +function hasLegacyPluginsRuntimeKeys(keys: string[]): boolean { + return keys.some((key) => key === "plugins.runtime" || key.startsWith("plugins.runtime.")); +} + +describe("plugins runtime boundary config", () => { + it("omits legacy plugins.runtime keys from schema metadata", () => { + expect(hasLegacyPluginsRuntimeKeys(Object.keys(FIELD_HELP))).toBe(false); + expect(hasLegacyPluginsRuntimeKeys(Object.keys(FIELD_LABELS))).toBe(false); + }); + + it("omits plugins.runtime from the generated config schema", () => { + const schema = OpenClawSchema.toJSONSchema({ + target: "draft-7", + io: "input", + reused: "ref", + }) as { + properties?: Record }>; + }; + const pluginsProperties = schema.properties?.plugins?.properties ?? {}; + expect("runtime" in pluginsProperties).toBe(false); + }); + + it("rejects legacy plugins.runtime config entries", () => { + const result = OpenClawSchema.safeParse({ + plugins: { + runtime: { + allowLegacyExec: true, + }, + }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index d5abe65600..e531a3d1c6 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -239,195 +239,215 @@ function loadWhatsAppActions() { export function createPluginRuntime(): PluginRuntime { return { version: resolveVersion(), - config: { - loadConfig, - writeConfigFile, + config: createRuntimeConfig(), + system: createRuntimeSystem(), + media: createRuntimeMedia(), + tts: { textToSpeechTelephony }, + tools: createRuntimeTools(), + channel: createRuntimeChannel(), + logging: createRuntimeLogging(), + state: { resolveStateDir }, + }; +} + +function createRuntimeConfig(): PluginRuntime["config"] { + return { + loadConfig, + writeConfigFile, + }; +} + +function createRuntimeSystem(): PluginRuntime["system"] { + return { + enqueueSystemEvent, + runCommandWithTimeout, + formatNativeDependencyHint, + }; +} + +function createRuntimeMedia(): PluginRuntime["media"] { + return { + loadWebMedia, + detectMime, + mediaKindFromMime, + isVoiceCompatibleAudio, + getImageMetadata, + resizeToJpeg, + }; +} + +function createRuntimeTools(): PluginRuntime["tools"] { + return { + createMemoryGetTool, + createMemorySearchTool, + registerMemoryCli, + }; +} + +function createRuntimeChannel(): PluginRuntime["channel"] { + return { + text: { + chunkByNewline, + chunkMarkdownText, + chunkMarkdownTextWithMode, + chunkText, + chunkTextWithMode, + resolveChunkMode, + resolveTextChunkLimit, + hasControlCommand, + resolveMarkdownTableMode, + convertMarkdownTables, }, - system: { - enqueueSystemEvent, - runCommandWithTimeout, - formatNativeDependencyHint, + reply: { + dispatchReplyWithBufferedBlockDispatcher, + createReplyDispatcherWithTyping, + resolveEffectiveMessagesConfig, + resolveHumanDelayConfig, + dispatchReplyFromConfig, + finalizeInboundContext, + formatAgentEnvelope, + /** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */ + formatInboundEnvelope, + resolveEnvelopeFormatOptions, + }, + routing: { + resolveAgentRoute, + }, + pairing: { + buildPairingReply, + readAllowFromStore: readChannelAllowFromStore, + upsertPairingRequest: upsertChannelPairingRequest, }, media: { - loadWebMedia, - detectMime, - mediaKindFromMime, - isVoiceCompatibleAudio, - getImageMetadata, - resizeToJpeg, + fetchRemoteMedia, + saveMediaBuffer, }, - tts: { - textToSpeechTelephony, + activity: { + record: recordChannelActivity, + get: getChannelActivity, }, - tools: { - createMemoryGetTool, - createMemorySearchTool, - registerMemoryCli, + session: { + resolveStorePath, + readSessionUpdatedAt, + recordSessionMetaFromInbound, + recordInboundSession, + updateLastRoute, }, - channel: { - text: { - chunkByNewline, - chunkMarkdownText, - chunkMarkdownTextWithMode, - chunkText, - chunkTextWithMode, - resolveChunkMode, - resolveTextChunkLimit, - hasControlCommand, - resolveMarkdownTableMode, - convertMarkdownTables, - }, - reply: { - dispatchReplyWithBufferedBlockDispatcher, - createReplyDispatcherWithTyping, - resolveEffectiveMessagesConfig, - resolveHumanDelayConfig, - dispatchReplyFromConfig, - finalizeInboundContext, - formatAgentEnvelope, - /** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */ - formatInboundEnvelope, - resolveEnvelopeFormatOptions, - }, - routing: { - resolveAgentRoute, - }, - pairing: { - buildPairingReply, - readAllowFromStore: readChannelAllowFromStore, - upsertPairingRequest: upsertChannelPairingRequest, - }, - media: { - fetchRemoteMedia, - saveMediaBuffer, - }, - activity: { - record: recordChannelActivity, - get: getChannelActivity, - }, - session: { - resolveStorePath, - readSessionUpdatedAt, - recordSessionMetaFromInbound, - recordInboundSession, - updateLastRoute, - }, - mentions: { - buildMentionRegexes, - matchesMentionPatterns, - matchesMentionWithExplicit, - }, - reactions: { - shouldAckReaction, - removeAckReactionAfterReply, - }, - groups: { - resolveGroupPolicy: resolveChannelGroupPolicy, - resolveRequireMention: resolveChannelGroupRequireMention, - }, - debounce: { - createInboundDebouncer, - resolveInboundDebounceMs, - }, - commands: { - resolveCommandAuthorizedFromAuthorizers, - isControlCommandMessage, - shouldComputeCommandAuthorized, - shouldHandleTextCommands, - }, - discord: { - messageActions: discordMessageActions, - auditChannelPermissions: auditDiscordChannelPermissions, - listDirectoryGroupsLive: listDiscordDirectoryGroupsLive, - listDirectoryPeersLive: listDiscordDirectoryPeersLive, - probeDiscord, - resolveChannelAllowlist: resolveDiscordChannelAllowlist, - resolveUserAllowlist: resolveDiscordUserAllowlist, - sendMessageDiscord, - sendPollDiscord, - monitorDiscordProvider, - }, - slack: { - listDirectoryGroupsLive: listSlackDirectoryGroupsLive, - listDirectoryPeersLive: listSlackDirectoryPeersLive, - probeSlack, - resolveChannelAllowlist: resolveSlackChannelAllowlist, - resolveUserAllowlist: resolveSlackUserAllowlist, - sendMessageSlack, - monitorSlackProvider, - handleSlackAction, - }, - telegram: { - auditGroupMembership: auditTelegramGroupMembership, - collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds, - probeTelegram, - resolveTelegramToken, - sendMessageTelegram, - sendPollTelegram, - monitorTelegramProvider, - messageActions: telegramMessageActions, - }, - signal: { - probeSignal, - sendMessageSignal, - monitorSignalProvider, - messageActions: signalMessageActions, - }, - imessage: { - monitorIMessageProvider, - probeIMessage, - sendMessageIMessage, - }, - whatsapp: { - getActiveWebListener, - getWebAuthAgeMs, - logoutWeb, - logWebSelfId, - readWebSelfId, - webAuthExists, - sendMessageWhatsApp: sendMessageWhatsAppLazy, - sendPollWhatsApp: sendPollWhatsAppLazy, - loginWeb: loginWebLazy, - startWebLoginWithQr: startWebLoginWithQrLazy, - waitForWebLogin: waitForWebLoginLazy, - monitorWebChannel: monitorWebChannelLazy, - handleWhatsAppAction: handleWhatsAppActionLazy, - createLoginTool: createWhatsAppLoginTool, - }, - line: { - listLineAccountIds, - resolveDefaultLineAccountId, - resolveLineAccount, - normalizeAccountId: normalizeLineAccountId, - probeLineBot, - sendMessageLine, - pushMessageLine, - pushMessagesLine, - pushFlexMessage, - pushTemplateMessage, - pushLocationMessage, - pushTextMessageWithQuickReplies, - createQuickReplyItems, - buildTemplateMessageFromPayload, - monitorLineProvider, - }, + mentions: { + buildMentionRegexes, + matchesMentionPatterns, + matchesMentionWithExplicit, }, - logging: { - shouldLogVerbose, - getChildLogger: (bindings, opts) => { - const logger = getChildLogger(bindings, { - level: opts?.level ? normalizeLogLevel(opts.level) : undefined, - }); - return { - debug: (message) => logger.debug?.(message), - info: (message) => logger.info(message), - warn: (message) => logger.warn(message), - error: (message) => logger.error(message), - }; - }, + reactions: { + shouldAckReaction, + removeAckReactionAfterReply, }, - state: { - resolveStateDir, + groups: { + resolveGroupPolicy: resolveChannelGroupPolicy, + resolveRequireMention: resolveChannelGroupRequireMention, + }, + debounce: { + createInboundDebouncer, + resolveInboundDebounceMs, + }, + commands: { + resolveCommandAuthorizedFromAuthorizers, + isControlCommandMessage, + shouldComputeCommandAuthorized, + shouldHandleTextCommands, + }, + discord: { + messageActions: discordMessageActions, + auditChannelPermissions: auditDiscordChannelPermissions, + listDirectoryGroupsLive: listDiscordDirectoryGroupsLive, + listDirectoryPeersLive: listDiscordDirectoryPeersLive, + probeDiscord, + resolveChannelAllowlist: resolveDiscordChannelAllowlist, + resolveUserAllowlist: resolveDiscordUserAllowlist, + sendMessageDiscord, + sendPollDiscord, + monitorDiscordProvider, + }, + slack: { + listDirectoryGroupsLive: listSlackDirectoryGroupsLive, + listDirectoryPeersLive: listSlackDirectoryPeersLive, + probeSlack, + resolveChannelAllowlist: resolveSlackChannelAllowlist, + resolveUserAllowlist: resolveSlackUserAllowlist, + sendMessageSlack, + monitorSlackProvider, + handleSlackAction, + }, + telegram: { + auditGroupMembership: auditTelegramGroupMembership, + collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds, + probeTelegram, + resolveTelegramToken, + sendMessageTelegram, + sendPollTelegram, + monitorTelegramProvider, + messageActions: telegramMessageActions, + }, + signal: { + probeSignal, + sendMessageSignal, + monitorSignalProvider, + messageActions: signalMessageActions, + }, + imessage: { + monitorIMessageProvider, + probeIMessage, + sendMessageIMessage, + }, + whatsapp: { + getActiveWebListener, + getWebAuthAgeMs, + logoutWeb, + logWebSelfId, + readWebSelfId, + webAuthExists, + sendMessageWhatsApp: sendMessageWhatsAppLazy, + sendPollWhatsApp: sendPollWhatsAppLazy, + loginWeb: loginWebLazy, + startWebLoginWithQr: startWebLoginWithQrLazy, + waitForWebLogin: waitForWebLoginLazy, + monitorWebChannel: monitorWebChannelLazy, + handleWhatsAppAction: handleWhatsAppActionLazy, + createLoginTool: createWhatsAppLoginTool, + }, + line: { + listLineAccountIds, + resolveDefaultLineAccountId, + resolveLineAccount, + normalizeAccountId: normalizeLineAccountId, + probeLineBot, + sendMessageLine, + pushMessageLine, + pushMessagesLine, + pushFlexMessage, + pushTemplateMessage, + pushLocationMessage, + pushTextMessageWithQuickReplies, + createQuickReplyItems, + buildTemplateMessageFromPayload, + monitorLineProvider, + }, + }; +} + +function createRuntimeLogging(): PluginRuntime["logging"] { + return { + shouldLogVerbose, + getChildLogger: (bindings, opts) => { + const logger = getChildLogger(bindings, { + level: opts?.level ? normalizeLogLevel(opts.level) : undefined, + }); + return { + debug: (message) => logger.debug?.(message), + info: (message) => logger.info(message), + warn: (message) => logger.warn(message), + error: (message) => logger.error(message), + }; }, }; }