From 5369ea53bee38cb8ff2f1be4ebb977e00a5dba22 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 22 Mar 2026 13:19:57 -0700 Subject: [PATCH] perf(inbound): trim dispatch and command startup imports (#52374) * perf(inbound): trim dispatch and command startup imports * fix(reply): restore command alias canonicalization * style(reply): format command context * fix(reply): restore runtime shim exports * test(reply): mock ACP route seam * fix(reply): repair dispatch type seams --- src/auto-reply/reply/abort.runtime.ts | 1 + src/auto-reply/reply/commands-context.test.ts | 29 ++++++ src/auto-reply/reply/commands-context.ts | 35 +------ src/auto-reply/reply/commands-core.ts | 79 ++++------------ .../reply/commands-handlers.runtime.ts | 65 +++++++++++++ src/auto-reply/reply/dispatch-acp.runtime.ts | 1 + .../reply/dispatch-from-config.test.ts | 39 +++++++- src/auto-reply/reply/dispatch-from-config.ts | 93 ++++++++++++------- .../reply/get-reply-from-config.runtime.ts | 1 + src/auto-reply/reply/route-reply.runtime.ts | 2 +- src/tts/tts-config.ts | 19 ++++ src/tts/tts.runtime.ts | 1 + 12 files changed, 234 insertions(+), 131 deletions(-) create mode 100644 src/auto-reply/reply/abort.runtime.ts create mode 100644 src/auto-reply/reply/commands-context.test.ts create mode 100644 src/auto-reply/reply/commands-handlers.runtime.ts create mode 100644 src/auto-reply/reply/dispatch-acp.runtime.ts create mode 100644 src/auto-reply/reply/get-reply-from-config.runtime.ts create mode 100644 src/tts/tts-config.ts create mode 100644 src/tts/tts.runtime.ts diff --git a/src/auto-reply/reply/abort.runtime.ts b/src/auto-reply/reply/abort.runtime.ts new file mode 100644 index 00000000000..c5ed23bd278 --- /dev/null +++ b/src/auto-reply/reply/abort.runtime.ts @@ -0,0 +1 @@ +export { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js"; diff --git a/src/auto-reply/reply/commands-context.test.ts b/src/auto-reply/reply/commands-context.test.ts new file mode 100644 index 00000000000..a74b026d202 --- /dev/null +++ b/src/auto-reply/reply/commands-context.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { buildCommandContext } from "./commands-context.js"; +import { buildTestCtx } from "./test-ctx.js"; + +describe("buildCommandContext", () => { + it("canonicalizes registered aliases like /id to their primary command", () => { + const ctx = buildTestCtx({ + Provider: "discord", + Surface: "discord", + From: "user", + To: "bot", + Body: "/id", + RawBody: "/id", + CommandBody: "/id", + BodyForCommands: "/id", + }); + + const result = buildCommandContext({ + ctx, + cfg: {} as OpenClawConfig, + isGroup: false, + triggerBodyNormalized: "/id", + commandAuthorized: true, + }); + + expect(result.commandBodyNormalized).toBe("/whoami"); + }); +}); diff --git a/src/auto-reply/reply/commands-context.ts b/src/auto-reply/reply/commands-context.ts index 4defdb6a472..1c5056b4b46 100644 --- a/src/auto-reply/reply/commands-context.ts +++ b/src/auto-reply/reply/commands-context.ts @@ -1,39 +1,10 @@ import type { OpenClawConfig } from "../../config/config.js"; import { resolveCommandAuthorization } from "../command-auth.js"; +import { normalizeCommandBody } from "../commands-registry.js"; import type { MsgContext } from "../templating.js"; import type { CommandContext } from "./commands-types.js"; import { stripMentions } from "./mentions.js"; -function normalizeCommandBodyLite(raw: string, botUsername?: string): string { - const trimmed = raw.trim(); - if (!trimmed.startsWith("/")) { - return trimmed; - } - - const newline = trimmed.indexOf("\n"); - const singleLine = newline === -1 ? trimmed : trimmed.slice(0, newline).trim(); - const colonMatch = singleLine.match(/^\/([^\s:]+)\s*:(.*)$/); - const normalized = colonMatch - ? (() => { - const [, command, rest] = colonMatch; - const normalizedRest = rest.trimStart(); - return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`; - })() - : singleLine; - - const normalizedBotUsername = botUsername?.trim().toLowerCase(); - const mentionMatch = normalizedBotUsername - ? normalized.match(/^\/([^\s@]+)@([^\s]+)(.*)$/) - : null; - const mentionNormalized = - mentionMatch && mentionMatch[2].toLowerCase() === normalizedBotUsername - ? `/${mentionMatch[1]}${mentionMatch[3] ?? ""}` - : normalized; - return mentionNormalized.replace(/^\/([^\s]+)(.*)$/, (_, command: string, rest: string) => { - return `/${command.toLowerCase()}${rest ?? ""}`; - }); -} - export function buildCommandContext(params: { ctx: MsgContext; cfg: OpenClawConfig; @@ -53,9 +24,9 @@ export function buildCommandContext(params: { const channel = (ctx.Provider ?? surface).trim().toLowerCase(); const abortKey = sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined); const rawBodyNormalized = triggerBodyNormalized; - const commandBodyNormalized = normalizeCommandBodyLite( + const commandBodyNormalized = normalizeCommandBody( isGroup ? stripMentions(rawBodyNormalized, ctx, cfg, agentId) : rawBodyNormalized, - ctx.BotUsername, + { botUsername: ctx.BotUsername }, ); return { diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index c3425161773..cc27f28d5fd 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -6,44 +6,26 @@ import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { isAcpSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { shouldHandleTextCommands } from "../commands-registry.js"; -import { handleAcpCommand } from "./commands-acp.js"; import { resolveBoundAcpThreadSessionKey } from "./commands-acp/targets.js"; -import { handleAllowlistCommand } from "./commands-allowlist.js"; -import { handleApproveCommand } from "./commands-approve.js"; -import { handleBashCommand } from "./commands-bash.js"; -import { handleBtwCommand } from "./commands-btw.js"; -import { handleCompactCommand } from "./commands-compact.js"; -import { handleConfigCommand, handleDebugCommand } from "./commands-config.js"; -import { - handleCommandsListCommand, - handleContextCommand, - handleExportSessionCommand, - handleHelpCommand, - handleStatusCommand, - handleWhoamiCommand, -} from "./commands-info.js"; -import { handleMcpCommand } from "./commands-mcp.js"; -import { handleModelsCommand } from "./commands-models.js"; -import { handlePluginCommand } from "./commands-plugin.js"; -import { handlePluginsCommand } from "./commands-plugins.js"; -import { - handleAbortTrigger, - handleActivationCommand, - handleFastCommand, - handleRestartCommand, - handleSessionCommand, - handleSendPolicyCommand, - handleStopCommand, - handleUsageCommand, -} from "./commands-session.js"; -import { handleSubagentsCommand } from "./commands-subagents.js"; -import { handleTtsCommands } from "./commands-tts.js"; import type { CommandHandler, CommandHandlerResult, HandleCommandsParams, } from "./commands-types.js"; -import { routeReply } from "./route-reply.js"; + +let routeReplyRuntimePromise: Promise | null = null; +let commandHandlersRuntimePromise: Promise | null = + null; + +function loadRouteReplyRuntime() { + routeReplyRuntimePromise ??= import("./route-reply.runtime.js"); + return routeReplyRuntimePromise; +} + +function loadCommandHandlersRuntime() { + commandHandlersRuntimePromise ??= import("./commands-handlers.runtime.js"); + return commandHandlersRuntimePromise; +} let HANDLERS: CommandHandler[] | null = null; @@ -82,6 +64,7 @@ export async function emitResetCommandHooks(params: { const to = params.ctx.OriginatingTo || params.command.from || params.command.to; if (channel && to) { + const { routeReply } = await loadRouteReplyRuntime(); const hookReply = { text: hookEvent.messages.join("\n\n") }; await routeReply({ payload: hookReply, @@ -174,37 +157,7 @@ function resolveSessionEntryForHookSessionKey( export async function handleCommands(params: HandleCommandsParams): Promise { if (HANDLERS === null) { - HANDLERS = [ - // Plugin commands are processed first, before built-in commands - handlePluginCommand, - handleBtwCommand, - handleBashCommand, - handleActivationCommand, - handleSendPolicyCommand, - handleFastCommand, - handleUsageCommand, - handleSessionCommand, - handleRestartCommand, - handleTtsCommands, - handleHelpCommand, - handleCommandsListCommand, - handleStatusCommand, - handleAllowlistCommand, - handleApproveCommand, - handleContextCommand, - handleExportSessionCommand, - handleWhoamiCommand, - handleSubagentsCommand, - handleAcpCommand, - handleMcpCommand, - handlePluginsCommand, - handleConfigCommand, - handleDebugCommand, - handleModelsCommand, - handleStopCommand, - handleCompactCommand, - handleAbortTrigger, - ]; + HANDLERS = (await loadCommandHandlersRuntime()).loadCommandHandlers(); } const resetMatch = params.command.commandBodyNormalized.match(/^\/(new|reset)(?:\s|$)/); const resetRequested = Boolean(resetMatch); diff --git a/src/auto-reply/reply/commands-handlers.runtime.ts b/src/auto-reply/reply/commands-handlers.runtime.ts new file mode 100644 index 00000000000..5a154adee3e --- /dev/null +++ b/src/auto-reply/reply/commands-handlers.runtime.ts @@ -0,0 +1,65 @@ +import { handleAcpCommand } from "./commands-acp.js"; +import { handleAllowlistCommand } from "./commands-allowlist.js"; +import { handleApproveCommand } from "./commands-approve.js"; +import { handleBashCommand } from "./commands-bash.js"; +import { handleBtwCommand } from "./commands-btw.js"; +import { handleCompactCommand } from "./commands-compact.js"; +import { handleConfigCommand, handleDebugCommand } from "./commands-config.js"; +import { + handleCommandsListCommand, + handleContextCommand, + handleExportSessionCommand, + handleHelpCommand, + handleStatusCommand, + handleWhoamiCommand, +} from "./commands-info.js"; +import { handleMcpCommand } from "./commands-mcp.js"; +import { handleModelsCommand } from "./commands-models.js"; +import { handlePluginCommand } from "./commands-plugin.js"; +import { handlePluginsCommand } from "./commands-plugins.js"; +import { + handleAbortTrigger, + handleActivationCommand, + handleFastCommand, + handleRestartCommand, + handleSendPolicyCommand, + handleSessionCommand, + handleStopCommand, + handleUsageCommand, +} from "./commands-session.js"; +import { handleSubagentsCommand } from "./commands-subagents.js"; +import { handleTtsCommands } from "./commands-tts.js"; +import type { CommandHandler } from "./commands-types.js"; + +export function loadCommandHandlers(): CommandHandler[] { + return [ + handlePluginCommand, + handleBtwCommand, + handleBashCommand, + handleActivationCommand, + handleSendPolicyCommand, + handleFastCommand, + handleUsageCommand, + handleSessionCommand, + handleRestartCommand, + handleTtsCommands, + handleHelpCommand, + handleCommandsListCommand, + handleStatusCommand, + handleAllowlistCommand, + handleApproveCommand, + handleContextCommand, + handleExportSessionCommand, + handleWhoamiCommand, + handleSubagentsCommand, + handleAcpCommand, + handleMcpCommand, + handlePluginsCommand, + handleConfigCommand, + handleDebugCommand, + handleModelsCommand, + handleStopCommand, + handleCompactCommand, + handleAbortTrigger, + ]; +} diff --git a/src/auto-reply/reply/dispatch-acp.runtime.ts b/src/auto-reply/reply/dispatch-acp.runtime.ts new file mode 100644 index 00000000000..87b654cfc76 --- /dev/null +++ b/src/auto-reply/reply/dispatch-acp.runtime.ts @@ -0,0 +1 @@ +export { shouldBypassAcpDispatchForCommand, tryDispatchAcpReply } from "./dispatch-acp.js"; diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index fe359313bcd..5b330c4423a 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -104,6 +104,24 @@ const ttsMocks = vi.hoisted(() => { }; }); +vi.mock("./route-reply.runtime.js", () => ({ + isRoutableChannel: (channel: string | undefined) => + Boolean( + channel && + [ + "telegram", + "slack", + "discord", + "signal", + "imessage", + "whatsapp", + "feishu", + "mattermost", + ].includes(channel), + ), + routeReply: mocks.routeReply, +})); + vi.mock("./route-reply.js", () => ({ isRoutableChannel: (channel: string | undefined) => Boolean( @@ -122,7 +140,7 @@ vi.mock("./route-reply.js", () => ({ routeReply: mocks.routeReply, })); -vi.mock("./abort.js", () => ({ +vi.mock("./abort.runtime.js", () => ({ tryFastAbortFromMessage: mocks.tryFastAbortFromMessage, formatAbortReplyText: (stoppedSubagents?: number) => { if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) { @@ -138,15 +156,21 @@ vi.mock("../../logging/diagnostic.js", () => ({ logMessageProcessed: diagnosticMocks.logMessageProcessed, logSessionStateChange: diagnosticMocks.logSessionStateChange, })); -vi.mock("../../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../config/sessions/store.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadSessionStore: sessionStoreMocks.loadSessionStore, - resolveStorePath: sessionStoreMocks.resolveStorePath, resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry, }; }); +vi.mock("../../config/sessions/paths.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStorePath: sessionStoreMocks.resolveStorePath, + }; +}); vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => hookMocks.runner, @@ -192,6 +216,13 @@ vi.mock("../../tts/tts.js", () => ({ normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value), resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg), })); +vi.mock("../../tts/tts.runtime.js", () => ({ + maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params), +})); +vi.mock("../../tts/tts-config.js", () => ({ + normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value), + resolveConfiguredTtsMode: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg).mode, +})); const noAbortResult = { handled: false, aborted: false } as const; const emptyConfig = {} as OpenClawConfig; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 341f0d009a3..5b18b417a35 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -6,13 +6,10 @@ import { } from "../../bindings/records.js"; import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { - loadSessionStore, - parseSessionThreadInfo, - resolveSessionStoreEntry, - resolveStorePath, - type SessionEntry, -} from "../../config/sessions.js"; +import { parseSessionThreadInfo } from "../../config/sessions/delivery-info.js"; +import { resolveStorePath } from "../../config/sessions/paths.js"; +import { loadSessionStore, resolveSessionStoreEntry } from "../../config/sessions/store.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; import { logVerbose } from "../../globals.js"; import { fireAndForgetHook } from "../../hooks/fire-and-forget.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; @@ -41,19 +38,47 @@ import { } from "../../plugins/conversation-binding.js"; import { getGlobalHookRunner, getGlobalPluginRegistry } from "../../plugins/hook-runner-global.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; -import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js"; +import { normalizeTtsAutoMode, resolveConfiguredTtsMode } from "../../tts/tts-config.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; -import { getReplyFromConfig } from "../reply.js"; import type { FinalizedMsgContext } from "../templating.js"; -import type { GetReplyOptions, ReplyPayload } from "../types.js"; -import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js"; -import { shouldBypassAcpDispatchForCommand, tryDispatchAcpReply } from "./dispatch-acp.js"; +import type { BlockReplyContext, GetReplyOptions, ReplyPayload } from "../types.js"; import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js"; import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js"; -import { shouldSuppressReasoningPayload } from "./reply-payloads.js"; -import { isRoutableChannel, routeReply } from "./route-reply.js"; import { resolveRunTypingPolicy } from "./typing-policy.js"; +let routeReplyRuntimePromise: Promise | null = null; +let getReplyFromConfigRuntimePromise: Promise< + typeof import("./get-reply-from-config.runtime.js") +> | null = null; +let abortRuntimePromise: Promise | null = null; +let dispatchAcpRuntimePromise: Promise | null = null; +let ttsRuntimePromise: Promise | null = null; + +function loadRouteReplyRuntime() { + routeReplyRuntimePromise ??= import("./route-reply.runtime.js"); + return routeReplyRuntimePromise; +} + +function loadGetReplyFromConfigRuntime() { + getReplyFromConfigRuntimePromise ??= import("./get-reply-from-config.runtime.js"); + return getReplyFromConfigRuntimePromise; +} + +function loadAbortRuntime() { + abortRuntimePromise ??= import("./abort.runtime.js"); + return abortRuntimePromise; +} + +function loadDispatchAcpRuntime() { + dispatchAcpRuntimePromise ??= import("./dispatch-acp.runtime.js"); + return dispatchAcpRuntimePromise; +} + +function loadTtsRuntime() { + ttsRuntimePromise ??= import("../../tts/tts.runtime.js"); + return ttsRuntimePromise; +} + const AUDIO_PLACEHOLDER_RE = /^(\s*\([^)]*\))?$/i; const AUDIO_HEADER_RE = /^\[Audio\b/i; const normalizeMediaType = (value: string): string => value.split(";")[0]?.trim().toLowerCase(); @@ -126,7 +151,7 @@ export async function dispatchReplyFromConfig(params: { cfg: OpenClawConfig; dispatcher: ReplyDispatcher; replyOptions?: Omit; - replyResolver?: typeof getReplyFromConfig; + replyResolver?: typeof import("./get-reply-from-config.runtime.js").getReplyFromConfig; }): Promise { const { ctx, cfg, dispatcher } = params; const diagnosticsEnabled = isDiagnosticsEnabled(cfg); @@ -230,9 +255,10 @@ export async function dispatchReplyFromConfig(params: { currentSurface === INTERNAL_MESSAGE_CHANNEL && (surfaceChannel === INTERNAL_MESSAGE_CHANNEL || !surfaceChannel) && ctx.ExplicitDeliverRoute !== true; + const routeReplyRuntime = await loadRouteReplyRuntime(); const shouldRouteToOriginating = Boolean( !isInternalWebchatTurn && - isRoutableChannel(originatingChannel) && + routeReplyRuntime.isRoutableChannel(originatingChannel) && originatingTo && originatingChannel !== currentSurface, ); @@ -259,7 +285,7 @@ export async function dispatchReplyFromConfig(params: { if (abortSignal?.aborted) { return; } - const result = await routeReply({ + const result = await routeReplyRuntime.routeReply({ payload, channel: originatingChannel, to: originatingTo, @@ -282,7 +308,7 @@ export async function dispatchReplyFromConfig(params: { mode: "additive" | "terminal", ): Promise => { if (shouldRouteToOriginating && originatingChannel && originatingTo) { - const result = await routeReply({ + const result = await routeReplyRuntime.routeReply({ payload, channel: originatingChannel, to: originatingTo, @@ -418,15 +444,16 @@ export async function dispatchReplyFromConfig(params: { markProcessing(); try { - const fastAbort = await tryFastAbortFromMessage({ ctx, cfg }); + const abortRuntime = await loadAbortRuntime(); + const fastAbort = await abortRuntime.tryFastAbortFromMessage({ ctx, cfg }); if (fastAbort.handled) { const payload = { - text: formatAbortReplyText(fastAbort.stoppedSubagents), + text: abortRuntime.formatAbortReplyText(fastAbort.stoppedSubagents), } satisfies ReplyPayload; let queuedFinal = false; let routedFinalCount = 0; if (shouldRouteToOriginating && originatingChannel && originatingTo) { - const result = await routeReply({ + const result = await routeReplyRuntime.routeReply({ payload, channel: originatingChannel, to: originatingTo, @@ -456,7 +483,8 @@ export async function dispatchReplyFromConfig(params: { return { queuedFinal, counts }; } - const bypassAcpForCommand = shouldBypassAcpDispatchForCommand(ctx, cfg); + const dispatchAcpRuntime = await loadDispatchAcpRuntime(); + const bypassAcpForCommand = dispatchAcpRuntime.shouldBypassAcpDispatchForCommand(ctx, cfg); const sendPolicy = resolveSendPolicy({ cfg, @@ -481,7 +509,7 @@ export async function dispatchReplyFromConfig(params: { } const shouldSendToolSummaries = ctx.ChatType !== "group" && ctx.CommandSource !== "native"; - const acpDispatch = await tryDispatchAcpReply({ + const acpDispatch = await dispatchAcpRuntime.tryDispatchAcpReply({ ctx, cfg, dispatcher, @@ -508,6 +536,7 @@ export async function dispatchReplyFromConfig(params: { // TTS audio separately from the accumulated block content. let accumulatedBlockText = ""; let blockCount = 0; + const { maybeApplyTtsToPayload } = await loadTtsRuntime(); const resolveToolDeliveryPayload = (payload: ReplyPayload): ReplyPayload | null => { if ( @@ -547,7 +576,9 @@ export async function dispatchReplyFromConfig(params: { systemEvent: shouldRouteToOriginating, }); - const replyResult = await (params.replyResolver ?? getReplyFromConfig)( + const replyResolver = + params.replyResolver ?? (await loadGetReplyFromConfigRuntime()).getReplyFromConfig; + const replyResult = await replyResolver( ctx, { ...params.replyOptions, @@ -575,12 +606,12 @@ export async function dispatchReplyFromConfig(params: { }; return run(); }, - onBlockReply: (payload: ReplyPayload, context) => { + onBlockReply: (payload: ReplyPayload, context?: BlockReplyContext) => { const run = async () => { // Suppress reasoning payloads — channels using this generic dispatch // path (WhatsApp, web, etc.) do not have a dedicated reasoning lane. // Telegram has its own dispatch path that handles reasoning splitting. - if (shouldSuppressReasoningPayload(payload)) { + if (payload.isReasoning === true) { return; } // Accumulate block text for TTS generation after streaming. @@ -617,7 +648,7 @@ export async function dispatchReplyFromConfig(params: { // Command handling prepared a trailing prompt after ACP in-place reset. // Route that tail through ACP now (same turn) instead of embedded dispatch. ctx.AcpDispatchTailAfterReset = false; - const acpTailDispatch = await tryDispatchAcpReply({ + const acpTailDispatch = await dispatchAcpRuntime.tryDispatchAcpReply({ ctx, cfg, dispatcher, @@ -647,7 +678,7 @@ export async function dispatchReplyFromConfig(params: { for (const reply of replies) { // Suppress reasoning payloads from channel delivery — channels using this // generic dispatch path do not have a dedicated reasoning lane. - if (shouldSuppressReasoningPayload(reply)) { + if (reply.isReasoning === true) { continue; } const ttsReply = await maybeApplyTtsToPayload({ @@ -660,7 +691,7 @@ export async function dispatchReplyFromConfig(params: { }); if (shouldRouteToOriginating && originatingChannel && originatingTo) { // Route final reply to originating channel. - const result = await routeReply({ + const result = await routeReplyRuntime.routeReply({ payload: ttsReply, channel: originatingChannel, to: originatingTo, @@ -685,7 +716,7 @@ export async function dispatchReplyFromConfig(params: { } } - const ttsMode = resolveTtsConfig(cfg).mode ?? "final"; + const ttsMode = resolveConfiguredTtsMode(cfg); // Generate TTS-only reply after block streaming completes (when there's no final reply). // This handles the case where block streaming succeeds and drops final payloads, // but we still want TTS audio to be generated from the accumulated block content. @@ -712,7 +743,7 @@ export async function dispatchReplyFromConfig(params: { audioAsVoice: ttsSyntheticReply.audioAsVoice, }; if (shouldRouteToOriginating && originatingChannel && originatingTo) { - const result = await routeReply({ + const result = await routeReplyRuntime.routeReply({ payload: ttsOnlyPayload, channel: originatingChannel, to: originatingTo, diff --git a/src/auto-reply/reply/get-reply-from-config.runtime.ts b/src/auto-reply/reply/get-reply-from-config.runtime.ts new file mode 100644 index 00000000000..fb63b96d1c1 --- /dev/null +++ b/src/auto-reply/reply/get-reply-from-config.runtime.ts @@ -0,0 +1 @@ +export { getReplyFromConfig } from "../reply.js"; diff --git a/src/auto-reply/reply/route-reply.runtime.ts b/src/auto-reply/reply/route-reply.runtime.ts index 4d68fc7ce23..62bddb44bb5 100644 --- a/src/auto-reply/reply/route-reply.runtime.ts +++ b/src/auto-reply/reply/route-reply.runtime.ts @@ -1 +1 @@ -export { routeReply } from "./route-reply.js"; +export { isRoutableChannel, routeReply } from "./route-reply.js"; diff --git a/src/tts/tts-config.ts b/src/tts/tts-config.ts new file mode 100644 index 00000000000..59b5e265829 --- /dev/null +++ b/src/tts/tts-config.ts @@ -0,0 +1,19 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { TtsAutoMode, TtsMode } from "../config/types.tts.js"; + +const TTS_AUTO_MODES = new Set(["off", "always", "inbound", "tagged"]); + +export function normalizeTtsAutoMode(value: unknown): TtsAutoMode | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (TTS_AUTO_MODES.has(normalized as TtsAutoMode)) { + return normalized as TtsAutoMode; + } + return undefined; +} + +export function resolveConfiguredTtsMode(cfg: OpenClawConfig): TtsMode { + return cfg.messages?.tts?.mode ?? "final"; +} diff --git a/src/tts/tts.runtime.ts b/src/tts/tts.runtime.ts new file mode 100644 index 00000000000..2e20509b9e3 --- /dev/null +++ b/src/tts/tts.runtime.ts @@ -0,0 +1 @@ +export { maybeApplyTtsToPayload } from "./tts.js";