refactor(runtime): split runtime builders and stabilize cron tool seam

This commit is contained in:
Peter Steinberger
2026-02-19 16:09:50 +01:00
parent e1e91bdb4a
commit cc9be84b9c
4 changed files with 274 additions and 207 deletions

View File

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

View File

@@ -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<unknown> }>(
const res = await params.callGatewayTool<{ messages: Array<unknown> }>(
"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:

View File

@@ -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<string, { properties?: Record<string, unknown> }>;
};
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);
});
});

View File

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