diff --git a/.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml b/.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml index 1276a418a6f4..9d84d6104abf 100644 --- a/.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml +++ b/.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml @@ -34,7 +34,7 @@ paths: - src/agents/embedded-agent-runner/tool-name-allowlist.ts - src/agents/embedded-agent-runner/tool-schema-runtime.ts - src/agents/tools/gateway-tool.ts - - src/agents/tools/message-tool.ts + - src/agents/tools/message-tool-*.ts - src/agents/tools/sessions-send-tool.ts - src/agents/tools/sessions-spawn-tool.ts - src/agents/tools/subagents-tool.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 25969bb22d27..6bdeb533b32a 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -463,7 +463,6 @@ src/agents/tools/image-tool.test.ts src/agents/tools/image-tool.ts src/agents/tools/media-generate-background-shared.test.ts src/agents/tools/message-tool.test.ts -src/agents/tools/message-tool.ts src/agents/tools/music-generate-tool.test.ts src/agents/tools/music-generate-tool.ts src/agents/tools/session-status-tool.ts diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 567d808c585a..630ad186cc09 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -70,7 +70,7 @@ import { createHeartbeatResponseTool } from "./tools/heartbeat-response-tool.js" import { createImageGenerateTool } from "./tools/image-generate-tool.js"; import { createImageTool } from "./tools/image-tool.js"; import { callAgentToolGatewayRequest } from "./tools/in-process-gateway.js"; -import { createMessageTool } from "./tools/message-tool.js"; +import { createMessageTool } from "./tools/message-tool-execution.js"; import { createMobileUiTool } from "./tools/mobile-ui-tool.js"; import { createMusicGenerateTool } from "./tools/music-generate-tool.js"; import { createNodesTool } from "./tools/nodes-tool.js"; diff --git a/src/agents/openclaw-tools.tts-config.test.ts b/src/agents/openclaw-tools.tts-config.test.ts index d1045508edad..ff4f8ce59166 100644 --- a/src/agents/openclaw-tools.tts-config.test.ts +++ b/src/agents/openclaw-tools.tts-config.test.ts @@ -66,7 +66,7 @@ vi.mock("./tools/image-tool.js", () => ({ createImageTool: () => mocks.stubTool("image"), })); -vi.mock("./tools/message-tool.js", () => ({ +vi.mock("./tools/message-tool-execution.js", () => ({ createMessageTool: () => mocks.stubTool("message"), })); diff --git a/src/agents/test-helpers/fast-openclaw-tools-sessions.ts b/src/agents/test-helpers/fast-openclaw-tools-sessions.ts index 6ee313d4b536..9492503f8fb1 100644 --- a/src/agents/test-helpers/fast-openclaw-tools-sessions.ts +++ b/src/agents/test-helpers/fast-openclaw-tools-sessions.ts @@ -23,7 +23,7 @@ vi.mock("../tools/gateway-tool.js", () => ({ createGatewayTool: () => stubTool("gateway"), })); -vi.mock("../tools/message-tool.js", () => ({ +vi.mock("../tools/message-tool-execution.js", () => ({ createMessageTool: () => stubTool("message"), })); diff --git a/src/agents/tools/message-tool-discovery.ts b/src/agents/tools/message-tool-discovery.ts new file mode 100644 index 000000000000..afd5023a4158 --- /dev/null +++ b/src/agents/tools/message-tool-discovery.ts @@ -0,0 +1,308 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { sortUniqueStrings, uniqueValues } from "@openclaw/normalization-core/string-normalization"; +import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; +import type { ChatType } from "../../channels/chat-type.js"; +import { + getChannelPlugin, + getLoadedChannelPlugin, + listChannelPlugins, +} from "../../channels/plugins/index.js"; +import { + channelSupportsMessageCapability, + channelSupportsMessageCapabilityForChannel, + type ChannelMessageActionDiscoveryInput, + listCrossChannelSchemaSupportedMessageActions, + type PreparedMessageToolCatalog, + resolveChannelMessageToolSchemaProperties, +} from "../../channels/plugins/message-action-discovery.js"; +import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; +import type { ChannelMessageActionName } from "../../channels/plugins/types.public.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveAllowedMessageActions } from "../../infra/outbound/outbound-policy.js"; +import { normalizeAccountId, parseSessionDeliveryRoute } from "../../routing/session-key.js"; +import { normalizeMessageChannel } from "../../utils/message-channel.js"; +import { listAllChannelSupportedActions, listChannelSupportedActions } from "../channel-tools.js"; +import { + appendMessageToolReadHint, + appendMessageToolVisibleReplyHint, +} from "./message-tool-description.js"; +import { buildMessageToolSchemaFromActions } from "./message-tool-schema-scoping.js"; +import { MESSAGE_TOOL_SCHEMA_BUILDERS } from "./message-tool-schema.js"; +export type MessageToolDiscoveryParams = { + cfg: OpenClawConfig; + currentChannelProvider?: string; + currentChannelId?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + sessionKey?: string; + sessionId?: string; + agentId?: string; + requesterSenderId?: string; + senderIsOwner?: boolean; + preparedMessageToolCatalog?: PreparedMessageToolCatalog; +}; + +type MessageActionDiscoveryInput = Omit & { + cfg: OpenClawConfig; + channel?: string; + preparedMessageToolCatalog?: PreparedMessageToolCatalog; +}; + +type MessageToolCurrentContextOptions = { + agentSessionKey?: string; + currentChannelId?: string; + currentChannelProvider?: string; + currentChatType?: ChatType; + currentMessagingTarget?: string; +}; +type InferredSessionDelivery = { + accountId?: string; + channel: string; + chatType?: ChatType; + threadId?: string; + to: string; +}; + +function formatSessionDeliveryTarget(channel: string, peerKind: string, to: string): string { + return (peerKind === "direct" || peerKind === "dm") && + getChannelPlugin(channel)?.messaging?.directTargetStyle === "user-prefixed" + ? `user:${to}` + : to; +} + +function resolveSessionDeliveryChatType(peerKind: string): ChatType | undefined { + if (peerKind === "direct" || peerKind === "dm") { + return "direct"; + } + if (peerKind === "group" || peerKind === "channel") { + return peerKind; + } + return undefined; +} + +function inferDeliveryFromSessionKey( + sessionKey: string | undefined, +): InferredSessionDelivery | null { + const route = parseSessionDeliveryRoute(sessionKey); + if (!route) { + return null; + } + const channel = normalizeMessageChannel(route.channel); + if (!channel) { + return null; + } + const accountId = route.accountId ? resolveAgentAccountId(route.accountId) : undefined; + return { + accountId, + channel, + chatType: resolveSessionDeliveryChatType(route.peerKind), + threadId: route.threadId, + to: formatSessionDeliveryTarget(channel, route.peerKind, route.peerId), + }; +} + +export function resolveEffectiveCurrentChannelContext(options?: MessageToolCurrentContextOptions): { + accountId?: string; + currentChannelId?: string; + currentChatType?: ChatType; + currentMessagingTarget?: string; + currentChannelProvider?: string; + currentThreadTs?: string; +} { + const currentChannelProvider = options?.currentChannelProvider; + const currentChannelId = options?.currentChannelId; + const sessionDelivery = inferDeliveryFromSessionKey(options?.agentSessionKey); + const sessionDeliveryChannel = normalizeMessageChannel(sessionDelivery?.channel); + const preferSessionDeliveryContext = + normalizeMessageChannel(currentChannelProvider) === "webchat" && + sessionDeliveryChannel !== undefined && + sessionDeliveryChannel !== "webchat" && + Boolean(sessionDelivery?.to); + + if (!preferSessionDeliveryContext) { + return { + currentChannelProvider, + currentChannelId, + currentChatType: options?.currentChatType, + currentMessagingTarget: options?.currentMessagingTarget, + }; + } + return { + accountId: sessionDelivery?.accountId, + currentChannelProvider: sessionDeliveryChannel, + currentChannelId: sessionDelivery?.to, + currentChatType: sessionDelivery?.chatType, + currentMessagingTarget: sessionDelivery?.to, + currentThreadTs: sessionDelivery?.threadId, + }; +} + +function buildMessageActionDiscoveryInput( + params: MessageToolDiscoveryParams, + channel?: string, +): MessageActionDiscoveryInput { + return { + cfg: params.cfg, + ...(channel ? { channel } : {}), + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + currentMessageId: params.currentMessageId, + accountId: params.currentAccountId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + requesterSenderId: params.requesterSenderId, + senderIsOwner: params.senderIsOwner, + preparedMessageToolCatalog: params.preparedMessageToolCatalog, + }; +} + +function resolveMessageToolSchemaActions(params: MessageToolDiscoveryParams): string[] { + const currentChannel = normalizeMessageChannel(params.currentChannelProvider); + if (currentChannel) { + const scopedActions = listChannelSupportedActions( + buildMessageActionDiscoveryInput(params, currentChannel), + ); + const allActions = new Set(["send", ...scopedActions]); + // Include actions from other configured channels so isolated/cron agents + // can invoke cross-channel actions without validation errors. + const channels = params.preparedMessageToolCatalog?.channels ?? listChannelPlugins(); + for (const plugin of channels) { + if (plugin.id === currentChannel) { + continue; + } + for (const action of listCrossChannelSchemaSupportedMessageActions( + buildMessageActionDiscoveryInput(params, plugin.id), + )) { + allActions.add(action); + } + } + return Array.from(allActions); + } + return listAllMessageToolActions(params); +} + +export function resolveMessageToolActionSchemaActions( + params: MessageToolDiscoveryParams, +): string[] { + const discoveredActions = resolveMessageToolSchemaActions(params); + const allowedActions = resolveAllowedMessageActions({ + cfg: params.cfg, + agentId: params.agentId, + }); + if (!allowedActions) { + return discoveredActions; + } + const allow = new Set(allowedActions); + const filtered = discoveredActions.filter((action) => allow.has(action)); + return filtered.length > 0 ? filtered : allowedActions; +} + +function listAllMessageToolActions(params: MessageToolDiscoveryParams): ChannelMessageActionName[] { + const pluginActions = listAllChannelSupportedActions(buildMessageActionDiscoveryInput(params)); + return uniqueValues(["send", "broadcast", ...pluginActions]); +} + +function resolveIncludeCapability( + params: MessageToolDiscoveryParams, + capability: ChannelMessageCapability, +): boolean { + const currentChannel = normalizeMessageChannel(params.currentChannelProvider); + if (currentChannel) { + return channelSupportsMessageCapabilityForChannel( + buildMessageActionDiscoveryInput(params, currentChannel), + capability, + ); + } + return channelSupportsMessageCapability( + params.cfg, + capability, + params.preparedMessageToolCatalog, + ); +} + +function resolveIncludePresentation(params: MessageToolDiscoveryParams): boolean { + return resolveIncludeCapability(params, "presentation"); +} + +function resolveIncludeDeliveryPin(params: MessageToolDiscoveryParams): boolean { + return resolveIncludeCapability(params, "delivery-pin"); +} + +function resolveIncludeBestEffort(params: MessageToolDiscoveryParams): boolean { + const currentChannel = normalizeMessageChannel(params.currentChannelProvider); + if (!currentChannel) { + return false; + } + const prepared = params.preparedMessageToolCatalog?.getChannel(currentChannel); + if (params.preparedMessageToolCatalog) { + // The prepared catalog is the exact runtime-registry generation for this + // turn. A missing channel is an authoritative absence, not permission to + // rediscover bundled plugins on the request path. + return prepared?.reconcilesUnknownSend ?? false; + } + const adapter = + getLoadedChannelPlugin(currentChannel as Parameters[0]) + ?.message ?? + getChannelPlugin(currentChannel as Parameters[0])?.message; + return ( + adapter?.durableFinal?.capabilities?.reconcileUnknownSend === true && + typeof adapter.durableFinal.reconcileUnknownSend === "function" + ); +} + +export function buildMessageToolSchema(params: MessageToolDiscoveryParams, actions: string[]) { + const includePresentation = resolveIncludePresentation(params); + const includeDeliveryPin = resolveIncludeDeliveryPin(params); + const includeBestEffort = resolveIncludeBestEffort(params); + const extraProperties = resolveChannelMessageToolSchemaProperties( + buildMessageActionDiscoveryInput( + params, + normalizeMessageChannel(params.currentChannelProvider) ?? undefined, + ), + ); + return buildMessageToolSchemaFromActions( + actions.length > 0 ? actions : ["send"], + { + includePresentation, + includeDeliveryPin, + includeBestEffort, + scopeToActions: normalizeMessageChannel(params.currentChannelProvider) !== undefined, + extraProperties, + }, + MESSAGE_TOOL_SCHEMA_BUILDERS, + ); +} + +export function resolveAgentAccountId(value?: string): string | undefined { + const trimmed = normalizeOptionalString(value); + if (!trimmed) { + return undefined; + } + return normalizeAccountId(trimmed); +} + +export function buildMessageToolDescription( + actions: string[] | undefined, + sourceReplyDeliveryMode?: SourceReplyDeliveryMode, + requireExplicitTarget?: boolean, +): string { + const baseDescription = "Send/manage channel messages."; + if (actions && actions.length > 0) { + const sortedActions = sortUniqueStrings(actions) as Array; + return appendMessageToolReadHint( + appendMessageToolVisibleReplyHint( + `${baseDescription} Supports actions: ${sortedActions.join(", ")}.`, + sourceReplyDeliveryMode, + requireExplicitTarget, + ), + sortedActions, + ); + } + return appendMessageToolVisibleReplyHint( + `${baseDescription} Action families (availability depends on the channel): sending/editing/unsend, reactions, polls, pins, threads, file upload/download, moderation (timeout/kick/ban), roles, channel + category management, profile/presence.`, + sourceReplyDeliveryMode, + requireExplicitTarget, + ); +} diff --git a/src/agents/tools/message-tool-execution.ts b/src/agents/tools/message-tool-execution.ts new file mode 100644 index 000000000000..c16cd9d4e18b --- /dev/null +++ b/src/agents/tools/message-tool-execution.ts @@ -0,0 +1,696 @@ +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, + normalizeOptionalStringifiedId, +} from "@openclaw/normalization-core/string-coerce"; +import { + GATEWAY_CLIENT_IDS, + GATEWAY_CLIENT_MODES, +} from "../../../packages/gateway-protocol/src/client-info.js"; +import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; +import type { ChatType } from "../../channels/chat-type.js"; +import type { InboundEventKind } from "../../channels/inbound-event/kind.js"; +import type { ConversationReadInvocationOrigin } from "../../channels/plugins/conversation-read-origin.js"; +import { getChannelPlugin } from "../../channels/plugins/index.js"; +import type { PreparedMessageToolCatalog } from "../../channels/plugins/message-action-discovery.js"; +import type { ChannelMessageActionName } from "../../channels/plugins/types.public.js"; +import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; +import { getScopedChannelsCommandSecretTargets } from "../../cli/command-secret-targets.js"; +import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js"; +import { getRuntimeConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveMessageActionTurnCapability } from "../../gateway/message-action-turn-capability.js"; +import { createAbortError } from "../../infra/abort-signal.js"; +import { sha256Base64UrlPrefix } from "../../infra/crypto-digest.js"; +import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; +import { + resolveMessageBroadcastAccountPlan, + validateExplicitMessageAccountSelection, +} from "../../infra/outbound/message-account-selection.js"; +import type { + MessageActionGateway, + MessageActionResult, +} from "../../infra/outbound/message-action-contracts.js"; +import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; +import { resolveActionDeliveryTargetAlias } from "../../infra/outbound/message-action-spec.js"; +import { shouldApplyCrossContextMarker } from "../../infra/outbound/outbound-policy.js"; +import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; +import { getPreparedMessageToolCatalog } from "../../plugins/prepared-message-tool-catalog.js"; +import { normalizeAccountId } from "../../routing/session-key.js"; +import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; +import { resolveSessionAgentId } from "../agent-scope.js"; +import type { AnyAgentTool } from "./common.js"; +import { jsonResult, readToolStringParam } from "./common.js"; +import { + readGatewayCallOptions, + resolveGatewayOptions, + resolveMessageActionAgentRuntimeIdentityToken, + type GatewayCallOptions, +} from "./gateway.js"; +import { appendMessageToolVisibleReplyHint } from "./message-tool-description.js"; +import { + buildMessageToolDescription, + buildMessageToolSchema, + type MessageToolDiscoveryParams, + resolveAgentAccountId, + resolveEffectiveCurrentChannelContext, + resolveMessageToolActionSchemaActions, +} from "./message-tool-discovery.js"; +import { MessageToolSchema } from "./message-tool-schema.js"; +import { + addSourceReplyFinalControl, + enforceSourceReplyOnlyMessageAction, + enforceSourceReplyOnlyTextDirectives, + enforceTrustedTurnExplicitAccount, + SOURCE_REPLY_ONLY_MESSAGE_SCHEMA, +} from "./message-tool-source-policy.js"; +import { + hasSanitizedSendPayloadContent, + sanitizeMessageToolVisiblePayload, + type VisibleTextSuppressionReason, +} from "./message-tool-visible-content.js"; +import { isPollVoteEchoText } from "./poll-vote-echo.js"; + +function actionNeedsExplicitTarget(action: ChannelMessageActionName): boolean { + return action === "broadcast" || shouldApplyCrossContextMarker(action); +} + +function normalizeMessageToolIdempotencyKeyPart(value: unknown): string | undefined { + const normalized = normalizeOptionalString(value); + if (!normalized) { + return undefined; + } + return normalized.replace(/[^A-Za-z0-9._:-]+/gu, "_"); +} + +const MESSAGE_TOOL_IDEMPOTENCY_ENVELOPE_PARAM_NAMES = [ + "gatewayToken", + "gatewayUrl", + "idempotencyKey", + "timeoutMs", +] satisfies Array; +const MESSAGE_TOOL_IDEMPOTENCY_ENVELOPE_PARAM_KEYS = new Set( + MESSAGE_TOOL_IDEMPOTENCY_ENVELOPE_PARAM_NAMES, +); + +function stripMessageToolIdempotencyEnvelope( + params: Record, +): Record { + const out: Record = {}; + for (const key of Object.keys(params).toSorted()) { + if (!MESSAGE_TOOL_IDEMPOTENCY_ENVELOPE_PARAM_KEYS.has(key)) { + out[key] = params[key]; + } + } + return out; +} + +function canonicalizeMessageToolIdempotencyValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => canonicalizeMessageToolIdempotencyValue(entry)); + } + if (!value || typeof value !== "object") { + return value; + } + const record = value as Record; + const out: Record = {}; + for (const key of Object.keys(record).toSorted()) { + out[key] = canonicalizeMessageToolIdempotencyValue(record[key]); + } + return out; +} + +function buildMessageToolDeliveryFingerprint(params: { + action: ChannelMessageActionName; + params: Record; +}): string { + const canonical = JSON.stringify( + canonicalizeMessageToolIdempotencyValue({ + action: params.action, + params: stripMessageToolIdempotencyEnvelope(params.params), + }), + ); + return sha256Base64UrlPrefix(canonical, 24); +} + +function buildMessageToolAutogeneratedIdempotencyKey(params: { + runId: string; + deliveryFingerprint: string; + operationId: string; +}): string { + return `${params.runId}:message-tool:${params.deliveryFingerprint}:${params.operationId}`; +} + +const POLL_VOTE_ECHO_TTL_MS = 30_000; + +// Keyed by agent session (conversation), NOT per message-tool instance: a native +// poll and its accompanying comment arrive as separate inbound messages and are +// processed in separate agent runs, each with a fresh tool instance. An +// instance-local record would be lost before the follow-up text run, so the echo +// (the agent restating its vote in prose) would leak. Session-scoped + +// route-checked storage lets the vote in one run suppress the restatement in the +// next while never crossing conversations. Single slot per session, TTL-bounded. +const recentPollVoteBySession = new Map< + string, + { option: string; route: string; recordedAt: number } +>(); + +function resolvePollVoteEchoRoute(params: { + action: ChannelMessageActionName; + args: Record; + channel?: string | null; + accountId?: string; + currentChannelId?: string; + currentChatType?: ChatType; + currentMessagingTarget?: string; +}): string | undefined { + const channel = normalizeMessageChannel(params.channel); + if (!channel) { + return undefined; + } + let deliveryAliasTarget: string | undefined; + try { + deliveryAliasTarget = resolveActionDeliveryTargetAlias(params.action, params.args, { + channel, + aliasSpec: getChannelPlugin(channel)?.actions?.messageActionTargetAliases?.[params.action], + }); + } catch { + return undefined; + } + const targets = ["target", "to", "channelId"] + .map((key) => normalizeOptionalStringifiedId(params.args[key])) + .concat(deliveryAliasTarget ?? []) + .filter((value): value is string => Boolean(value)); + if (new Set(targets).size > 1) { + return undefined; + } + const target = targets[0]; + const currentTargets = new Set( + [params.currentMessagingTarget, params.currentChannelId].filter((value): value is string => + Boolean(value), + ), + ); + // Plugin-declared aliases keep owner-specific target fields out of core. + // A route mismatch fails open; provider/account keys prevent cross-send suppression. + const routeTarget = !target || currentTargets.has(target) ? "" : target; + return `${channel}\0${normalizeAccountId(params.accountId ?? "default")}\0${routeTarget}`; +} + +type MessageToolOptions = { + agentAccountId?: string; + agentSessionKey?: string; + runSessionKey?: string; + runId?: string; + sessionId?: string; + agentId?: string; + config?: OpenClawConfig; + preparedMessageToolCatalog?: PreparedMessageToolCatalog; + getRuntimeConfig?: () => OpenClawConfig; + getScopedChannelsCommandSecretTargets?: typeof getScopedChannelsCommandSecretTargets; + resolveCommandSecretRefsViaGateway?: typeof resolveCommandSecretRefsViaGateway; + runMessageAction?: typeof runMessageAction; + currentChannelId?: string; + currentChatType?: ChatType; + currentMessagingTarget?: string; + messageActionTurnCapability?: string; + currentChannelProvider?: string; + currentThreadTs?: string; + agentThreadId?: string | number; + currentMessageId?: string | number; + currentInboundAudio?: boolean; + hasCurrentInboundAudio?: () => boolean; + replyToMode?: "off" | "first" | "all" | "batched"; + hasRepliedRef?: { value: boolean }; + sameChannelThreadRequired?: boolean; + sandboxRoot?: string; + requireExplicitTarget?: boolean; + sourceReplyDeliveryMode?: SourceReplyDeliveryMode; + /** Process-local completion authority: send only to the current source route. */ + sourceReplyOnly?: boolean; + inboundEventKind?: InboundEventKind; + requesterSenderId?: string; + senderIsOwner?: boolean; + conversationReadOrigin?: ConversationReadInvocationOrigin; +}; + +export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { + const loadConfigForTool = options?.getRuntimeConfig ?? getRuntimeConfig; + const getScopedSecretTargetsForTool = + options?.getScopedChannelsCommandSecretTargets ?? getScopedChannelsCommandSecretTargets; + const resolveSecretRefsForTool = + options?.resolveCommandSecretRefsViaGateway ?? resolveCommandSecretRefsViaGateway; + const runMessageActionForTool = options?.runMessageAction ?? runMessageAction; + 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 + // null session key disables the guard. + const pollEchoSessionKey = options?.agentSessionKey?.trim() || undefined; + const failedAutogeneratedIdempotencyKeys = new Map(); + const effectiveCurrentChannel = resolveEffectiveCurrentChannelContext(options); + const currentThreadTs = + options?.currentThreadTs ?? + (options?.agentThreadId != null + ? stringifyRouteThreadId(options.agentThreadId) + : effectiveCurrentChannel.currentThreadTs); + const replyToMode = options?.replyToMode ?? (currentThreadTs ? "all" : undefined); + const agentAccountId = + resolveAgentAccountId(options?.agentAccountId) ?? effectiveCurrentChannel.accountId; + const currentChannelIsInternal = + normalizeMessageChannel(effectiveCurrentChannel.currentChannelProvider) === + INTERNAL_MESSAGE_CHANNEL; + // WebChat tool sends use the private sink without changing the run-level + // contract: ordinary final answers must remain automatic and visible. + const sourceReplySinkDeliveryMode = currentChannelIsInternal + ? "message_tool_only" + : options?.sourceReplyDeliveryMode; + const resolvedAgentId = + options?.agentId ?? + (options?.agentSessionKey + ? resolveSessionAgentId({ + sessionKey: options.agentSessionKey, + config: options?.config, + }) + : undefined); + const messageToolDiscoveryParams: MessageToolDiscoveryParams | undefined = + options?.config && !options.sourceReplyOnly + ? { + cfg: options.config, + currentChannelProvider: effectiveCurrentChannel.currentChannelProvider, + currentChannelId: effectiveCurrentChannel.currentChannelId, + currentThreadTs, + currentMessageId: options.currentMessageId, + currentAccountId: agentAccountId, + sessionKey: options.agentSessionKey, + sessionId: options.sessionId, + agentId: resolvedAgentId, + requesterSenderId: options.requesterSenderId, + senderIsOwner: options.senderIsOwner, + preparedMessageToolCatalog: + options.preparedMessageToolCatalog ?? getPreparedMessageToolCatalog(), + } + : undefined; + // Schema and prompt must use the same snapshot; repeated discovery can drift + // across plugin hooks while needlessly loading channel action metadata twice. + const actions = messageToolDiscoveryParams + ? resolveMessageToolActionSchemaActions(messageToolDiscoveryParams) + : undefined; + const baseSchema = options?.sourceReplyOnly + ? SOURCE_REPLY_ONLY_MESSAGE_SCHEMA + : messageToolDiscoveryParams + ? buildMessageToolSchema(messageToolDiscoveryParams, actions ?? []) + : MessageToolSchema; + const schema = addSourceReplyFinalControl(baseSchema, sourceReplySinkDeliveryMode); + const description = options?.sourceReplyOnly + ? appendMessageToolVisibleReplyHint( + "Send a message to the current source conversation. Supports actions: send.", + options.sourceReplyDeliveryMode, + options.requireExplicitTarget, + ) + : buildMessageToolDescription( + actions, + options?.sourceReplyDeliveryMode, + options?.requireExplicitTarget, + ); + + return { + label: "Message", + name: "message", + displaySummary: "Send and manage messages across configured channels.", + description, + parameters: schema, + execute: async (toolCallId, args, signal) => { + if (signal?.aborted) { + throw createAbortError("Message send aborted"); + } + // Shallow-copy so we don't mutate the original event args (used for logging/dedup). + const params = { ...(args as Record) }; + const action = readToolStringParam(params, "action", { + required: true, + }) as ChannelMessageActionName; + const trustedTurnContext = + resolvedAgentId && options?.agentSessionKey + ? resolveMessageActionTurnCapability({ + token: options.messageActionTurnCapability, + agentId: resolvedAgentId, + runId: options.runId, + sessionKey: options.agentSessionKey, + sessionId: options.sessionId, + }) + : undefined; + if (normalizeOptionalString(options?.messageActionTurnCapability) && !trustedTurnContext) { + throw new Error("message action turn capability is no longer active"); + } + if (options?.sourceReplyOnly) { + enforceSourceReplyOnlyMessageAction({ + action, + args: params, + currentChannelProvider: effectiveCurrentChannel.currentChannelProvider, + currentChannelId: effectiveCurrentChannel.currentChannelId, + currentMessagingTarget: effectiveCurrentChannel.currentMessagingTarget, + currentThreadTs, + currentMessageId: options.currentMessageId, + currentAccountId: agentAccountId, + trustedTurnContext, + }); + } + // `final` is a Codex app-server-only source-delivery control. It must + // not be dispatched to a provider or participate in idempotency. + const requestedSourceReplyFinal = + typeof params.final === "boolean" ? params.final : undefined; + delete params.final; + + const suppressedVisiblePayloadReason = sanitizeMessageToolVisiblePayload( + params, + options?.agentSessionKey, + ); + if (options?.sourceReplyOnly) { + enforceSourceReplyOnlyTextDirectives(params); + } + + if ( + suppressedVisiblePayloadReason && + action === "send" && + !hasSanitizedSendPayloadContent(params) + ) { + return jsonResult({ + status: "suppressed", + reason: suppressedVisiblePayloadReason, + message: + suppressedVisiblePayloadReason === "inbound_metadata_echo" + ? "Suppressed outbound message text because it matched inbound runtime metadata." + : "Suppressed outbound message text because it matched internal runtime context.", + }); + } + const requireExplicitTarget = options?.requireExplicitTarget === true; + if (requireExplicitTarget && actionNeedsExplicitTarget(action)) { + const explicitTarget = + (typeof params.target === "string" && params.target.trim().length > 0) || + (typeof params.to === "string" && params.to.trim().length > 0) || + (typeof params.channelId === "string" && params.channelId.trim().length > 0) || + (Array.isArray(params.targets) && + params.targets.some((value) => typeof value === "string" && value.trim().length > 0)); + if (!explicitTarget) { + throw new Error( + "Explicit message target required for this run. Provide target/targets (and channel when needed).", + ); + } + } + + const gatewayOpts = readGatewayCallOptions(params); + const rawConfig = options?.config ?? loadConfigForTool(); + const requestedAccountId = readToolStringParam(params, "accountId"); + validateExplicitMessageAccountSelection({ + cfg: rawConfig, + accountId: requestedAccountId, + checkResolvedAccount: false, + }); + const requestedBroadcastChannel = normalizeOptionalLowercaseString(params.channel); + if ( + action === "broadcast" && + requestedBroadcastChannel && + requestedBroadcastChannel !== "all" + ) { + // Authorize and execute the same canonical provider. Otherwise an unavailable + // hint can fall back to the current provider only after account authorization. + const selection = await resolveMessageChannelSelection({ + cfg: rawConfig, + channel: requestedBroadcastChannel, + fallbackChannel: effectiveCurrentChannel.currentChannelProvider, + }); + params.channel = selection.channel; + } + const scope = resolveMessageSecretScope({ + channel: params.channel, + target: params.target, + targets: params.targets, + fallbackChannel: effectiveCurrentChannel.currentChannelProvider, + accountId: requestedAccountId, + fallbackAccountId: agentAccountId, + }); + // Broadcast execution only narrows on an explicit non-all channel. Target + // prefixes cannot authorize fewer providers than the runner will execute. + const unscopedExplicitBroadcast = + action === "broadcast" && + (!requestedBroadcastChannel || requestedBroadcastChannel === "all") && + requestedAccountId !== undefined; + const explicitAccountId = validateExplicitMessageAccountSelection({ + cfg: rawConfig, + channel: unscopedExplicitBroadcast ? undefined : scope.channel, + accountId: requestedAccountId, + checkResolvedAccount: false, + }); + const broadcastAccountPlan = + unscopedExplicitBroadcast && explicitAccountId + ? resolveMessageBroadcastAccountPlan({ + cfg: rawConfig, + accountId: explicitAccountId, + }) + : undefined; + enforceTrustedTurnExplicitAccount({ + explicitAccountId, + selectedChannels: broadcastAccountPlan + ? broadcastAccountPlan.candidateChannels + : [scope.channel], + trustedCurrentChannel: trustedTurnContext?.toolContext?.currentChannelProvider, + trustedRequesterAccountId: trustedTurnContext?.requesterAccountId, + hasTrustedTurnContext: trustedTurnContext !== undefined, + }); + if (explicitAccountId) { + scope.accountId = explicitAccountId; + params.accountId = explicitAccountId; + } + const scopedTargets = getScopedSecretTargetsForTool({ + config: rawConfig, + channel: broadcastAccountPlan ? undefined : scope.channel, + ...(broadcastAccountPlan ? { channels: broadcastAccountPlan.secretChannels } : {}), + accountId: scope.accountId, + }); + const cfg = ( + await resolveSecretRefsForTool({ + config: rawConfig, + commandName: "tools.message", + targetIds: scopedTargets.targetIds, + ...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}), + mode: "enforce_resolved", + }) + ).resolvedConfig; + + const accountId = explicitAccountId ?? agentAccountId; + const pollVoteEchoRoute = resolvePollVoteEchoRoute({ + action, + args: params, + channel: scope.channel ?? effectiveCurrentChannel.currentChannelProvider, + accountId, + currentChannelId: effectiveCurrentChannel.currentChannelId, + currentMessagingTarget: effectiveCurrentChannel.currentMessagingTarget, + }); + const recentPollVote = pollEchoSessionKey + ? recentPollVoteBySession.get(pollEchoSessionKey) + : undefined; + if ( + recentPollVote && + pollEchoSessionKey && + sourceReplySinkDeliveryMode === "message_tool_only" && + (action === "send" || action === "reply") + ) { + if (Date.now() - recentPollVote.recordedAt > POLL_VOTE_ECHO_TTL_MS) { + recentPollVoteBySession.delete(pollEchoSessionKey); + } else if (pollVoteEchoRoute === recentPollVote.route) { + const vote = recentPollVote; + recentPollVoteBySession.delete(pollEchoSessionKey); + const outboundText = + readToolStringParam(params, "text") ?? + readToolStringParam(params, "message") ?? + readToolStringParam(params, "content"); + if (outboundText && isPollVoteEchoText(vote.option, outboundText)) { + return jsonResult({ + status: "suppressed", + reason: "poll_vote_echo" satisfies VisibleTextSuppressionReason, + message: "Suppressed outbound text because it only restated the poll vote just cast.", + }); + } + } + } + + const gatewayResolved = resolveGatewayOptions(gatewayOpts); + const { token: gatewayToken } = gatewayResolved; + const callerOwnsTerminalReceipt = + gatewayResolved.target === "remote" || + normalizeOptionalString(gatewayOpts.gatewayUrl) !== undefined || + normalizeOptionalString(gatewayOpts.gatewayToken) !== undefined; + // Direct tool invocations already execute inside the authenticated + // Gateway request. Keep their authority operation-local by dispatching + // channel actions in-process instead of laundering it through a new + // backend connection. + const gateway: MessageActionGateway | undefined = + options?.conversationReadOrigin === "direct-operator" + ? undefined + : { + url: gatewayResolved.url, + token: gatewayToken, + timeoutMs: gatewayResolved.timeoutMs, + clientName: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, + clientDisplayName: "agent", + mode: GATEWAY_CLIENT_MODES.BACKEND, + ...(callerOwnsTerminalReceipt + ? { terminalSourceReplyReceiptOwner: "caller" as const } + : {}), + resolveAgentRuntimeIdentityToken: (context) => + resolveMessageActionAgentRuntimeIdentityToken({ + opts: gatewayOpts, + target: gatewayResolved.target, + turnCapability: options?.messageActionTurnCapability, + runId: options?.runId, + sessionId: options?.sessionId, + sourceReplyFinal: context?.sourceReplyFinal, + sourceReplyToolCallId: context?.sourceReplyToolCallId, + callerOwnsTerminalReceipt, + }), + }; + const hasCurrentMessageId = + typeof options?.currentMessageId === "number" || + (typeof options?.currentMessageId === "string" && + options.currentMessageId.trim().length > 0); + + const toolContext = + effectiveCurrentChannel.currentChannelId || + effectiveCurrentChannel.currentChatType || + effectiveCurrentChannel.currentChannelProvider || + effectiveCurrentChannel.currentMessagingTarget || + currentThreadTs || + hasCurrentMessageId || + replyToMode || + options?.hasRepliedRef || + options?.sameChannelThreadRequired + ? { + currentChannelId: effectiveCurrentChannel.currentChannelId, + currentChatType: effectiveCurrentChannel.currentChatType, + currentMessagingTarget: effectiveCurrentChannel.currentMessagingTarget, + currentChannelProvider: effectiveCurrentChannel.currentChannelProvider, + currentThreadTs, + currentMessageId: options?.currentMessageId, + replyToMode, + hasRepliedRef: options?.hasRepliedRef, + sameChannelThreadRequired: options?.sameChannelThreadRequired, + // Direct tool invocations should not add cross-context decoration. + // The agent is composing a message, not forwarding from another chat. + skipCrossContextDecoration: true, + } + : undefined; + let autogeneratedDeliveryFingerprint: string | undefined; + let actionIdempotencyKey = normalizeOptionalString(params.idempotencyKey); + if (!actionIdempotencyKey && options?.runId) { + autogeneratedDeliveryFingerprint = buildMessageToolDeliveryFingerprint({ action, params }); + actionIdempotencyKey = failedAutogeneratedIdempotencyKeys.get( + autogeneratedDeliveryFingerprint, + ); + if (!actionIdempotencyKey) { + const operationId = + normalizeMessageToolIdempotencyKeyPart(toolCallId) ?? + String(++generatedIdempotencyCounter); + actionIdempotencyKey = buildMessageToolAutogeneratedIdempotencyKey({ + runId: normalizeMessageToolIdempotencyKeyPart(options.runId) ?? options.runId, + deliveryFingerprint: autogeneratedDeliveryFingerprint, + operationId, + }); + } + } + const actionParams = actionIdempotencyKey + ? { ...params, idempotencyKey: actionIdempotencyKey } + : params; + const hasExactSourceTurn = + action === "send" && + sourceReplySinkDeliveryMode === "message_tool_only" && + normalizeOptionalString(trustedTurnContext?.toolContext?.currentSourceTurnId) !== undefined; + let result: MessageActionResult; + try { + result = await runMessageActionForTool({ + cfg, + action, + params: actionParams, + actionOrigin: "message-tool", + defaultAccountId: accountId ?? undefined, + requesterAccountId: trustedTurnContext?.requesterAccountId, + requesterSenderId: trustedTurnContext?.requesterSenderId, + messageActionAuthorization: { + requesterAccountId: trustedTurnContext?.requesterAccountId, + requesterSenderId: trustedTurnContext?.requesterSenderId, + toolContext: trustedTurnContext?.toolContext, + }, + senderIsOwner: options?.senderIsOwner, + conversationReadOrigin: options?.conversationReadOrigin, + broadcastAccountPlan, + gateway, + toolContext, + sessionKey: options?.agentSessionKey, + sourceReplySessionKey: options?.runSessionKey, + sessionId: options?.sessionId, + agentId: resolvedAgentId, + sandboxRoot: options?.sandboxRoot, + sourceReplyDeliveryMode: sourceReplySinkDeliveryMode, + // Only an admitted channel source can arm terminal restart reconciliation. + // Source-less scheduled and ambient sends remain ordinary message actions. + sourceReplyFinal: hasExactSourceTurn ? (requestedSourceReplyFinal ?? true) : undefined, + sourceReplyToolCallId: hasExactSourceTurn ? toolCallId : undefined, + inboundEventKind: options?.inboundEventKind, + inboundAudio: options?.hasCurrentInboundAudio?.() ?? options?.currentInboundAudio, + abortSignal: signal, + }); + } catch (error) { + if (autogeneratedDeliveryFingerprint && actionIdempotencyKey) { + failedAutogeneratedIdempotencyKeys.set( + autogeneratedDeliveryFingerprint, + actionIdempotencyKey, + ); + } + throw error; + } + if ( + autogeneratedDeliveryFingerprint && + failedAutogeneratedIdempotencyKeys.get(autogeneratedDeliveryFingerprint) === + actionIdempotencyKey + ) { + failedAutogeneratedIdempotencyKeys.delete(autogeneratedDeliveryFingerprint); + } + const toolResult = getToolResult(result); + const normalizationNotice = result.kind === "send" ? result.normalization?.notice : undefined; + if (normalizationNotice) { + const normalizedResult = toolResult ?? jsonResult(result.payload); + return { + ...normalizedResult, + content: [...normalizedResult.content, { type: "text", text: normalizationNotice }], + }; + } + if ( + action === "poll-vote" && + pollVoteEchoRoute && + pollEchoSessionKey && + sourceReplySinkDeliveryMode === "message_tool_only" + ) { + const details = toolResult?.details as { pollVotedOption?: unknown } | undefined; + const option = + typeof details?.pollVotedOption === "string" ? details.pollVotedOption.trim() : ""; + if (option) { + const recordedAt = Date.now(); + // Prune expired entries on write so a session that votes but never + // sends a follow-up text can't leak a record forever in a long-lived + // gateway; the map stays bounded to sessions that voted within the TTL. + for (const [key, entry] of recentPollVoteBySession) { + if (recordedAt - entry.recordedAt > POLL_VOTE_ECHO_TTL_MS) { + recentPollVoteBySession.delete(key); + } + } + recentPollVoteBySession.set(pollEchoSessionKey, { + option, + route: pollVoteEchoRoute, + recordedAt, + }); + } + } + if (toolResult) { + return toolResult; + } + return jsonResult(result.payload); + }, + }; +} diff --git a/src/agents/tools/message-tool-schema.ts b/src/agents/tools/message-tool-schema.ts new file mode 100644 index 000000000000..1c2255080dad --- /dev/null +++ b/src/agents/tools/message-tool-schema.ts @@ -0,0 +1,498 @@ +import { Type, type TSchema } from "typebox"; +import { CHANNEL_MESSAGE_ACTION_NAMES } from "../../channels/plugins/message-action-names.js"; +import { POLL_CREATION_PARAM_DEFS, SHARED_POLL_CREATION_PARAM_NAMES } from "../../poll-params.js"; +import { + channelTargetSchema, + channelTargetsSchema, + optionalNonNegativeIntegerSchema, + optionalPositiveIntegerSchema, + stringEnum, +} from "../schema/typebox.js"; +import { gatewayCallOptionSchemaProperties } from "./gateway-schema.js"; +import { + buildMessageToolQuerySchemaProperties, + buildMessageToolSchemaFromActions, + MESSAGE_TOOL_SEND_TEXT_DESCRIPTION, + type MessageToolSchemaBuilders, +} from "./message-tool-schema-scoping.js"; + +const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES; +function buildRoutingSchema() { + return { + channel: Type.Optional(Type.String()), + target: Type.Optional(channelTargetSchema()), + targets: Type.Optional(channelTargetsSchema()), + accountId: Type.Optional(Type.String()), + dryRun: Type.Optional(Type.Boolean()), + }; +} + +const presentationCommandActionSchema = Type.Object({ + type: Type.Literal("command"), + command: Type.String(), +}); + +const presentationCallbackActionSchema = Type.Object({ + type: Type.Literal("callback"), + value: Type.String(), +}); + +const presentationCommandOrCallbackActionSchema = Type.Union([ + presentationCommandActionSchema, + presentationCallbackActionSchema, +]); + +// Approval and question actions carry server-issued IDs and are runtime-authored +// only. The message tool exposes the remaining actions models may safely author. +const presentationButtonActionSchema = Type.Union([ + presentationCommandActionSchema, + presentationCallbackActionSchema, + Type.Object({ + type: Type.Literal("url"), + url: Type.String(), + }), + Type.Object({ + type: Type.Literal("web-app"), + url: Type.String(), + widgetId: Type.Optional(Type.String()), + }), + Type.Object({ + type: Type.Literal("web-app"), + url: Type.Optional(Type.String()), + widgetId: Type.String(), + }), +]); + +const presentationOptionSchema = Type.Object({ + label: Type.String(), + action: Type.Optional(presentationCommandOrCallbackActionSchema), + value: Type.Optional(Type.String()), +}); + +const presentationButtonSchema = Type.Object({ + label: Type.String(), + action: Type.Optional(presentationButtonActionSchema), + value: Type.Optional(Type.String()), + url: Type.Optional(Type.String()), + webApp: Type.Optional(Type.Object({ url: Type.String() })), + web_app: Type.Optional(Type.Object({ url: Type.String() })), + disabled: Type.Optional(Type.Boolean()), + reusable: Type.Optional(Type.Boolean()), + style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger"])), +}); + +const presentationChartSegmentSchema = Type.Object({ + label: Type.String(), + value: Type.Number(), +}); + +const presentationChartSeriesSchema = Type.Object({ + name: Type.String(), + values: Type.Array(Type.Number(), { minItems: 1 }), +}); + +// Keep this flat: some provider tool-schema validators reject an anyOf nested +// under presentation.blocks.items. Runtime normalization enforces block shapes. +const presentationBlockSchema = Type.Object({ + type: stringEnum(["text", "context", "divider", "buttons", "select", "chart", "table"]), + text: Type.Optional(Type.String()), + buttons: Type.Optional(Type.Array(presentationButtonSchema)), + placeholder: Type.Optional(Type.String()), + options: Type.Optional(Type.Array(presentationOptionSchema)), + chartType: Type.Optional(stringEnum(["pie", "bar", "area", "line"])), + title: Type.Optional(Type.String()), + segments: Type.Optional(Type.Array(presentationChartSegmentSchema, { minItems: 1 })), + categories: Type.Optional(Type.Array(Type.String(), { minItems: 1 })), + series: Type.Optional(Type.Array(presentationChartSeriesSchema, { minItems: 1 })), + xLabel: Type.Optional(Type.String()), + yLabel: Type.Optional(Type.String()), + caption: Type.Optional(Type.String()), + headers: Type.Optional(Type.Array(Type.String(), { minItems: 1 })), + rows: Type.Optional( + Type.Array( + Type.Array(Type.Unsafe({ type: ["string", "number"] }), { minItems: 1 }), + { minItems: 1 }, + ), + ), + rowHeaderColumnIndex: Type.Optional(Type.Integer({ minimum: 0 })), +}); + +const presentationMessageSchema = Type.Object( + { + title: Type.Optional(Type.String()), + tone: Type.Optional(stringEnum(["info", "success", "warning", "danger", "neutral"])), + blocks: Type.Array(presentationBlockSchema), + }, + { + description: "Rich text/chart/table/button/select/context; unsupported degrades to text.", + }, +); + +function buildSendSchema(options: { + includePresentation: boolean; + includeDeliveryPin: boolean; + includeBestEffort: boolean; +}) { + const props: Record = { + message: Type.Optional(Type.String({ description: MESSAGE_TOOL_SEND_TEXT_DESCRIPTION })), + effectId: Type.Optional( + Type.String({ + description: "sendWithEffect id/name.", + }), + ), + effect: Type.Optional(Type.String({ description: "Alias for effectId." })), + media: Type.Optional( + Type.String({ + description: "Media URL/path. data: use buffer.", + }), + ), + filename: Type.Optional(Type.String()), + buffer: Type.Optional( + Type.String({ + description: "Base64/data-URL attachment.", + }), + ), + contentType: Type.Optional(Type.String()), + mimeType: Type.Optional(Type.String()), + caption: Type.Optional(Type.String()), + attachments: Type.Optional( + Type.Array( + Type.Object({ + type: Type.Optional(stringEnum(["image", "audio", "video", "file"])), + media: Type.Optional(Type.String()), + name: Type.Optional(Type.String()), + mimeType: Type.Optional(Type.String()), + }), + { + description: "Attachments; each uses media.", + }, + ), + ), + replyTo: Type.Optional(Type.String()), + threadId: Type.Optional(Type.String()), + asVoice: Type.Optional(Type.Boolean()), + silent: Type.Optional(Type.Boolean()), + quoteText: Type.Optional(Type.String({ description: "Telegram reply quote text." })), + gifPlayback: Type.Optional(Type.Boolean()), + forceDocument: Type.Optional( + Type.Boolean({ + description: "Send media as document; no compression.", + }), + ), + asDocument: Type.Optional( + Type.Boolean({ + description: "Alias for forceDocument.", + }), + ), + }; + if (options.includePresentation) { + props.presentation = Type.Optional(presentationMessageSchema); + } + if (options.includeBestEffort) { + props.bestEffort = Type.Optional( + Type.Boolean({ + description: "Ordinary reply omit/true; false only requiring durable delivery.", + }), + ); + } + if (options.includeDeliveryPin) { + props.delivery = Type.Optional( + Type.Object( + { + pin: Type.Optional( + Type.Union([ + Type.Boolean(), + Type.Object({ + enabled: Type.Boolean(), + notify: Type.Optional(Type.Boolean()), + required: Type.Optional(Type.Boolean()), + }), + ]), + ), + }, + { + description: "Delivery prefs; pin when supported.", + }, + ), + ); + } + return props; +} + +function buildReactionSchema() { + return { + messageId: Type.Optional( + Type.String({ + description: + "Target read/react/edit/delete/pin/unpin id; reactions default current inbound.", + }), + ), + message_id: Type.Optional( + Type.String({ + // Intentional duplicate alias for tool-schema discoverability in LLMs. + description: "snake_case alias of messageId; same defaults.", + }), + ), + emoji: Type.Optional(Type.String()), + remove: Type.Optional(Type.Boolean()), + trackToolCalls: Type.Optional( + Type.Boolean({ + description: "Use reacted current message for tool-progress reactions.", + }), + ), + track_tool_calls: Type.Optional( + Type.Boolean({ + description: "snake_case alias of trackToolCalls.", + }), + ), + targetAuthor: Type.Optional(Type.String()), + targetAuthorUuid: Type.Optional(Type.String()), + groupId: Type.Optional(Type.String()), + }; +} + +function buildFetchSchema() { + return { + limit: optionalPositiveIntegerSchema(), + pageSize: optionalPositiveIntegerSchema(), + pageToken: Type.Optional(Type.String()), + before: Type.Optional(Type.String()), + after: Type.Optional(Type.String()), + around: Type.Optional(Type.String()), + fromMe: Type.Optional(Type.Boolean()), + includeArchived: Type.Optional(Type.Boolean()), + }; +} + +function buildPollSchema() { + const props: Record = { + pollId: Type.Optional(Type.String()), + pollOptionId: Type.Optional( + Type.String({ + description: "Poll answer id.", + }), + ), + pollOptionIds: Type.Optional( + Type.Array( + Type.String({ + description: "Poll answer ids for multiselect.", + }), + ), + ), + pollOptionIndex: Type.Optional( + Type.Integer({ + minimum: 1, + description: "1-based poll option number.", + }), + ), + pollOptionIndexes: Type.Optional( + Type.Array( + Type.Integer({ + minimum: 1, + description: "1-based poll option numbers for multiselect.", + }), + ), + ), + }; + for (const name of SHARED_POLL_CREATION_PARAM_NAMES) { + const def = POLL_CREATION_PARAM_DEFS[name]; + if (!def) { + continue; + } + switch (def.kind) { + case "string": + props[name] = Type.Optional(Type.String()); + break; + case "stringArray": + props[name] = Type.Optional(Type.Array(Type.String())); + break; + case "positiveInteger": + props[name] = optionalPositiveIntegerSchema(); + break; + case "boolean": + props[name] = Type.Optional(Type.Boolean()); + break; + } + } + return props; +} + +function buildChannelTargetSchema() { + return { + channelId: Type.Optional(Type.String({ description: "Channel id filter." })), + chatId: Type.Optional(Type.String({ description: "Chat id for chat metadata." })), + channelIds: Type.Optional(Type.Array(Type.String({ description: "Channel id filter." }))), + memberId: Type.Optional(Type.String()), + memberIdType: Type.Optional(Type.String()), + guildId: Type.Optional(Type.String()), + userId: Type.Optional( + Type.String({ + description: + "member-info/moderation/participant user id; member-info uses userId, not target.", + }), + ), + openId: Type.Optional(Type.String()), + unionId: Type.Optional(Type.String()), + authorId: Type.Optional(Type.String()), + authorIds: Type.Optional(Type.Array(Type.String())), + roleId: Type.Optional(Type.String()), + roleIds: Type.Optional(Type.Array(Type.String())), + participant: Type.Optional(Type.String()), + includeMembers: Type.Optional(Type.Boolean()), + members: Type.Optional(Type.Boolean()), + scope: Type.Optional(Type.String()), + kind: Type.Optional(Type.String()), + }; +} + +function buildStickerSchema() { + return { + fileId: Type.Optional(Type.String()), + emojiName: Type.Optional(Type.String()), + stickerId: Type.Optional(Type.Array(Type.String())), + stickerName: Type.Optional(Type.String()), + stickerDesc: Type.Optional(Type.String()), + stickerTags: Type.Optional(Type.String()), + }; +} + +function buildThreadSchema() { + return { + threadName: Type.Optional(Type.String()), + autoArchiveMin: optionalPositiveIntegerSchema(), + appliedTags: Type.Optional(Type.Array(Type.String())), + }; +} + +function buildEventSchema() { + return { + eventName: Type.Optional(Type.String()), + eventType: Type.Optional(Type.String()), + startTime: Type.Optional(Type.String()), + endTime: Type.Optional(Type.String()), + desc: Type.Optional(Type.String()), + location: Type.Optional(Type.String()), + image: Type.Optional(Type.String({ description: "Event cover image URL/path." })), + }; +} + +function buildModerationSchema() { + return { + reason: Type.Optional(Type.String()), + deleteDays: optionalNonNegativeIntegerSchema({ maximum: 7 }), + durationMin: optionalNonNegativeIntegerSchema(), + until: Type.Optional(Type.String()), + }; +} + +function buildGatewaySchema() { + return gatewayCallOptionSchemaProperties(); +} + +function buildPresenceSchema() { + return { + activityType: Type.Optional( + Type.String({ + description: "Activity type: playing, streaming, listening, watching, competing, custom.", + }), + ), + activityName: Type.Optional( + Type.String({ + description: "Activity name shown in sidebar; ignored for custom.", + }), + ), + activityUrl: Type.Optional( + Type.String({ + description: "Streaming URL; streaming type only.", + }), + ), + activityState: Type.Optional( + Type.String({ + description: "State text; custom type uses as status text.", + }), + ), + status: Type.Optional( + Type.String({ description: "Bot status: online, dnd, idle, invisible." }), + ), + }; +} + +function buildChannelManagementSchema() { + return { + name: Type.Optional(Type.String()), + channelType: Type.Optional( + Type.Integer({ + minimum: 0, + description: "Numeric channel type; avoids schema type collision.", + }), + ), + parentId: Type.Optional(Type.String()), + topic: Type.Optional(Type.String()), + position: optionalNonNegativeIntegerSchema(), + nsfw: Type.Optional(Type.Boolean()), + rateLimitPerUser: optionalNonNegativeIntegerSchema(), + categoryId: Type.Optional(Type.String()), + clearParent: Type.Optional( + Type.Boolean({ + description: "Clear parent/category when supported.", + }), + ), + }; +} + +function buildMessageToolSchemaProps(options: { + includePresentation: boolean; + includeDeliveryPin: boolean; + includeBestEffort: boolean; + extraProperties?: Record; +}) { + return { + ...buildRoutingSchema(), + ...buildSendSchema(options), + ...buildReactionSchema(), + ...buildFetchSchema(), + ...buildMessageToolQuerySchemaProperties(), + ...buildPollSchema(), + ...buildChannelTargetSchema(), + ...buildStickerSchema(), + ...buildThreadSchema(), + ...buildEventSchema(), + ...buildModerationSchema(), + ...buildGatewaySchema(), + ...buildChannelManagementSchema(), + ...buildPresenceSchema(), + ...options.extraProperties, + }; +} + +export const MESSAGE_TOOL_SCHEMA_BUILDERS = { + full: buildMessageToolSchemaProps, + base: (options) => ({ + ...buildRoutingSchema(), + ...buildSendSchema(options), + ...buildGatewaySchema(), + }), + groups: { + reaction: buildReactionSchema, + fetch: buildFetchSchema, + query: buildMessageToolQuerySchemaProperties, + poll: buildPollSchema, + channelTarget: buildChannelTargetSchema, + sticker: buildStickerSchema, + thread: buildThreadSchema, + event: buildEventSchema, + moderation: buildModerationSchema, + channelManagement: buildChannelManagementSchema, + presence: buildPresenceSchema, + }, +} satisfies MessageToolSchemaBuilders; + +export const MessageToolSchema = buildMessageToolSchemaFromActions( + AllMessageActions, + { + includePresentation: true, + includeDeliveryPin: true, + includeBestEffort: false, + }, + MESSAGE_TOOL_SCHEMA_BUILDERS, +); diff --git a/src/agents/tools/message-tool-source-policy.ts b/src/agents/tools/message-tool-source-policy.ts new file mode 100644 index 000000000000..b9527c1bd537 --- /dev/null +++ b/src/agents/tools/message-tool-source-policy.ts @@ -0,0 +1,197 @@ +import { + normalizeOptionalString, + normalizeOptionalStringifiedId, +} from "@openclaw/normalization-core/string-coerce"; +import { uniqueValues } from "@openclaw/normalization-core/string-normalization"; +import { Type, type TObject } from "typebox"; +import { stripPlainTextToolCallBlocks } from "../../../packages/tool-call-repair/src/index.js"; +import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; +import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; +import type { ChannelMessageActionName } from "../../channels/plugins/types.public.js"; +import type { AgentRuntimeMessageActionContext } from "../../gateway/message-action-turn-capability.js"; +import { sourceDeliveryTargetsMatch } from "../../infra/outbound/source-delivery-plan.js"; +import { normalizeOptionalAccountId } from "../../routing/account-id.js"; +import { stripUnsupportedCitationControlMarkers } from "../../shared/text/citation-control-markers.js"; +import { normalizeMessageChannel } from "../../utils/message-channel.js"; +import { channelTargetSchema, stringEnum } from "../schema/typebox.js"; +import { readToolStringParam } from "./common.js"; +import { normalizeEscapedLineBreaksForVisibleText } from "./message-tool-visible-content.js"; +export const SOURCE_REPLY_ONLY_MESSAGE_SCHEMA = Type.Object({ + action: stringEnum(["send"], { + description: "Send a text reply to the current source conversation.", + }), + channel: Type.Optional(Type.String()), + target: Type.Optional(channelTargetSchema()), + accountId: Type.Optional(Type.String()), + message: Type.Optional( + Type.String({ description: "Text to send to the current source conversation." }), + ), + replyTo: Type.Optional(Type.String()), + threadId: Type.Optional(Type.String()), +}); +const SOURCE_REPLY_ONLY_RUNTIME_ARG_NAMES = new Set(["to", "channelId", "final"]); +const SOURCE_REPLY_FINAL_PROPERTY = Type.Optional( + Type.Boolean({ + description: + "Set false for progress. Set true, or omit, for the completed current-source reply.", + }), +); + +export function addSourceReplyFinalControl( + schema: T, + sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined, +): T | TObject { + if (sourceReplyDeliveryMode !== "message_tool_only") { + return schema; + } + return Type.Object({ ...schema.properties, final: SOURCE_REPLY_FINAL_PROPERTY }); +} + +export function enforceSourceReplyOnlyTextDirectives(args: Record): void { + if (typeof args.message !== "string" || !args.message.trim()) { + throw new Error("Completion source replies require non-empty visible text."); + } + // Use the outbound owner's parser: sanitization can assemble directives that + // change routes, attach local files, deliver audio, or perform reactions. + const message = normalizeEscapedLineBreaksForVisibleText(args.message); + const withoutCitationMarkers = stripUnsupportedCitationControlMarkers(message); + for (const normalized of new Set([ + message, + withoutCitationMarkers, + stripPlainTextToolCallBlocks(withoutCitationMarkers), + ])) { + const directives = parseReplyDirectives(normalized, { extractMarkdownImages: true }); + if ( + directives.replyToTag || + directives.audioAsVoice || + directives.mediaUrls?.length || + directives.reaction || + directives.isSilent + ) { + throw new Error("Completion source replies cannot contain non-text or silent directives."); + } + } +} + +// A live channel turn grants delegated use of that provider account, not a +// model-selected sibling account. Keep cross-provider and source-less routing intact. +export function enforceTrustedTurnExplicitAccount(params: { + explicitAccountId?: string; + selectedChannels: Array; + trustedCurrentChannel?: string; + trustedRequesterAccountId?: string; + hasTrustedTurnContext: boolean; +}): void { + if (!params.explicitAccountId || !params.hasTrustedTurnContext) { + return; + } + const trustedCurrentChannel = normalizeMessageChannel(params.trustedCurrentChannel); + if (!trustedCurrentChannel) { + throw new Error("Trusted current account is missing its channel identity."); + } + const includesTrustedCurrentChannel = params.selectedChannels.some( + (channel) => normalizeMessageChannel(channel) === trustedCurrentChannel, + ); + if (!includesTrustedCurrentChannel) { + return; + } + if (normalizeOptionalAccountId(params.trustedRequesterAccountId) !== params.explicitAccountId) { + throw new Error("Explicit account does not match the trusted current account."); + } +} + +export function enforceSourceReplyOnlyMessageAction(params: { + action: ChannelMessageActionName; + args: Record; + currentChannelProvider?: string; + currentChannelId?: string; + currentMessagingTarget?: string; + currentThreadTs?: string; + currentMessageId?: string | number; + currentAccountId?: string; + trustedTurnContext?: AgentRuntimeMessageActionContext; +}): void { + if (params.action !== "send") { + throw new Error(`Completion source replies permit only action "send", not "${params.action}".`); + } + for (const name of Object.keys(params.args)) { + if ( + !Object.hasOwn(SOURCE_REPLY_ONLY_MESSAGE_SCHEMA.properties, name) && + !SOURCE_REPLY_ONLY_RUNTIME_ARG_NAMES.has(name) + ) { + throw new Error(`Completion source replies cannot use the "${name}" argument.`); + } + } + enforceSourceReplyOnlyTextDirectives(params.args); + + const sourceContext = params.trustedTurnContext?.toolContext ?? params; + const sourceChannel = normalizeMessageChannel(sourceContext.currentChannelProvider); + const sourceTargets = uniqueValues( + [sourceContext.currentMessagingTarget, sourceContext.currentChannelId] + .map((target) => normalizeOptionalString(target)) + .filter((target): target is string => Boolean(target)), + ); + if (!sourceChannel || sourceTargets.length === 0) { + throw new Error("Completion source replies require an authoritative current conversation."); + } + + const requestedChannel = readToolStringParam(params.args, "channel"); + if (requestedChannel && normalizeMessageChannel(requestedChannel) !== sourceChannel) { + throw new Error("Completion source replies cannot target another channel."); + } + + const requestedAccountId = readToolStringParam(params.args, "accountId"); + const sourceAccountId = params.trustedTurnContext + ? params.trustedTurnContext.requesterAccountId + : params.currentAccountId; + if ( + requestedAccountId && + normalizeOptionalAccountId(requestedAccountId) !== normalizeOptionalAccountId(sourceAccountId) + ) { + throw new Error("Completion source replies cannot use another channel account."); + } + + const sourceThreadId = normalizeOptionalString(sourceContext.currentThreadTs); + const requestedThreadId = normalizeOptionalStringifiedId(params.args.threadId); + if (requestedThreadId && requestedThreadId !== sourceThreadId) { + throw new Error("Completion source replies cannot target another thread."); + } + + const requestedReplyTo = readToolStringParam(params.args, "replyTo"); + const sourceMessageId = normalizeOptionalStringifiedId(sourceContext.currentMessageId); + if ( + requestedReplyTo && + requestedReplyTo !== sourceMessageId && + requestedReplyTo !== sourceThreadId + ) { + throw new Error("Completion source replies cannot reply outside the current thread."); + } + + const explicitTargets = uniqueValues( + [params.args.target, params.args.to, params.args.channelId] + .map((target) => normalizeOptionalStringifiedId(target)) + .filter((target): target is string => Boolean(target)), + ); + for (const requestedTarget of explicitTargets) { + if ( + !sourceTargets.some((sourceTarget) => + sourceDeliveryTargetsMatch( + { + provider: sourceChannel, + accountId: sourceAccountId, + to: requestedTarget, + threadImplicit: true, + }, + { + channel: sourceChannel, + accountId: sourceAccountId, + to: sourceTarget, + threadId: sourceThreadId, + }, + ), + ) + ) { + throw new Error("Completion source replies cannot target another conversation or thread."); + } + } +} diff --git a/src/agents/tools/message-tool-visible-content.ts b/src/agents/tools/message-tool-visible-content.ts new file mode 100644 index 000000000000..4c432e17735d --- /dev/null +++ b/src/agents/tools/message-tool-visible-content.ts @@ -0,0 +1,393 @@ +import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { + hasInboundMetadataSentinel, + stripInboundMetadata, +} from "../../auto-reply/reply/strip-inbound-meta.js"; +import { + getBootEchoContextForSession, + stripBootEchoFromOutboundText, +} from "../../gateway/boot-echo-guard.js"; +import { + parseInteractiveParam, + parseJsonMessageParam, +} from "../../infra/outbound/message-action-params.js"; +import { hasReplyPayloadContent } from "../../interactive/payload.js"; +import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js"; +import { stripInternalRuntimeContext } from "../internal-runtime-context.js"; +import { readStringArrayParam, readToolStringParam } from "./common.js"; +export function normalizeEscapedLineBreaksForVisibleText(text: string): string { + if (!text.includes("\\")) { + return text; + } + // The send path turns literal "\n" sequences into line breaks later; match + // that before privacy stripping so escaped delimiter lines cannot bypass it. + return text.replace(/\\r\\n|\\n|\\r/g, "\n"); +} + +export type VisibleTextSuppressionReason = + | "internal_runtime_context_echo" + | "inbound_metadata_echo" + | "poll_vote_echo"; + +function sanitizeUserVisibleToolTextResult( + text: string, + bootPrompt: string | undefined, +): { + text: string; + suppressionReason?: VisibleTextSuppressionReason; +} { + const normalized = normalizeEscapedLineBreaksForVisibleText(text); + const strippedReasoning = stripFormattedReasoningMessage(normalized); + const strippedInternal = stripInternalRuntimeContext(strippedReasoning); + const strippedBoot = stripBootEchoFromOutboundText(strippedInternal, bootPrompt); + const strippedInbound = hasInboundMetadataSentinel(strippedBoot) + ? stripInboundMetadata(strippedBoot) + : strippedBoot; + const suppressionReason = + strippedBoot.trim().length === 0 && + strippedReasoning.trim().length > 0 && + (strippedInternal !== strippedReasoning || strippedBoot !== strippedInternal) + ? "internal_runtime_context_echo" + : strippedInbound.trim().length === 0 && + strippedBoot.trim().length > 0 && + strippedInbound !== strippedBoot + ? "inbound_metadata_echo" + : undefined; + return { + text: strippedInbound, + ...(suppressionReason ? { suppressionReason } : {}), + }; +} + +function sanitizeStringParam( + params: Record, + field: string, + bootPrompt: string | undefined, +): VisibleTextSuppressionReason | undefined { + if (typeof params[field] !== "string") { + return undefined; + } + const sanitized = sanitizeUserVisibleToolTextResult(params[field], bootPrompt); + params[field] = sanitized.text; + return sanitized.suppressionReason; +} + +function sanitizeStringArrayParam( + params: Record, + field: string, + bootPrompt: string | undefined, +): VisibleTextSuppressionReason | undefined { + const value = params[field]; + if (typeof value === "string") { + const sanitized = sanitizeUserVisibleToolTextResult(value, bootPrompt); + params[field] = sanitized.text; + return sanitized.suppressionReason; + } + if (!Array.isArray(value)) { + return undefined; + } + let suppressionReason: VisibleTextSuppressionReason | undefined; + params[field] = value.map((entry) => { + if (typeof entry !== "string") { + return entry; + } + const sanitized = sanitizeUserVisibleToolTextResult(entry, bootPrompt); + suppressionReason ??= sanitized.suppressionReason; + return sanitized.text; + }); + return suppressionReason; +} + +function sanitizePresentationTextFieldsResult( + value: unknown, + bootPrompt: string | undefined, +): { value: unknown; suppressionReason?: VisibleTextSuppressionReason } { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { value }; + } + let suppressionReason: VisibleTextSuppressionReason | undefined; + const presentation = { ...(value as Record) }; + if (typeof presentation.title === "string") { + const sanitized = sanitizeUserVisibleToolTextResult(presentation.title, bootPrompt); + presentation.title = sanitized.text; + suppressionReason ??= sanitized.suppressionReason; + } + if (Array.isArray(presentation.blocks)) { + presentation.blocks = presentation.blocks.map((block) => { + if (!block || typeof block !== "object" || Array.isArray(block)) { + return block; + } + const sanitizedBlock = { ...(block as Record) }; + for (const field of ["text", "placeholder", "title", "xLabel", "yLabel"]) { + if (typeof sanitizedBlock[field] === "string") { + const sanitized = sanitizeUserVisibleToolTextResult(sanitizedBlock[field], bootPrompt); + sanitizedBlock[field] = sanitized.text; + suppressionReason ??= sanitized.suppressionReason; + } + } + if (normalizeOptionalLowercaseString(sanitizedBlock.type) === "table") { + if (typeof sanitizedBlock.caption === "string") { + const sanitized = sanitizeUserVisibleToolTextResult(sanitizedBlock.caption, bootPrompt); + sanitizedBlock.caption = sanitized.text.trim(); + suppressionReason ??= sanitized.suppressionReason; + } + if (Array.isArray(sanitizedBlock.headers)) { + sanitizedBlock.headers = sanitizedBlock.headers.map((header) => { + if (typeof header !== "string") { + return header; + } + const sanitized = sanitizeUserVisibleToolTextResult(header, bootPrompt); + suppressionReason ??= sanitized.suppressionReason; + return sanitized.text.trim(); + }); + } + if (Array.isArray(sanitizedBlock.rows)) { + sanitizedBlock.rows = sanitizedBlock.rows.map((row) => { + if (!Array.isArray(row)) { + return row; + } + return row.map((cell) => { + if (typeof cell !== "string") { + return cell; + } + const sanitized = sanitizeUserVisibleToolTextResult(cell, bootPrompt); + suppressionReason ??= sanitized.suppressionReason; + return sanitized.text.trim(); + }); + }); + } + } + if (Array.isArray(sanitizedBlock.buttons)) { + sanitizedBlock.buttons = sanitizedBlock.buttons.map((button) => { + if (!button || typeof button !== "object" || Array.isArray(button)) { + return button; + } + const sanitizedButton = { ...(button as Record) }; + if (typeof sanitizedButton.label === "string") { + const sanitized = sanitizeUserVisibleToolTextResult(sanitizedButton.label, bootPrompt); + sanitizedButton.label = sanitized.text; + suppressionReason ??= sanitized.suppressionReason; + } + if (typeof sanitizedButton.url === "string") { + const sanitized = sanitizeUserVisibleToolTextResult(sanitizedButton.url, bootPrompt); + if (sanitized.text) { + sanitizedButton.url = sanitized.text; + } else { + delete sanitizedButton.url; + } + suppressionReason ??= sanitized.suppressionReason; + } + for (const webAppField of ["webApp", "web_app"]) { + const webApp = sanitizedButton[webAppField]; + if (!webApp || typeof webApp !== "object" || Array.isArray(webApp)) { + continue; + } + const sanitizedWebApp = { ...(webApp as Record) }; + if (typeof sanitizedWebApp.url !== "string") { + continue; + } + const sanitized = sanitizeUserVisibleToolTextResult(sanitizedWebApp.url, bootPrompt); + if (sanitized.text) { + sanitizedWebApp.url = sanitized.text; + sanitizedButton[webAppField] = sanitizedWebApp; + } else { + delete sanitizedButton[webAppField]; + } + suppressionReason ??= sanitized.suppressionReason; + } + const action = sanitizedButton.action; + if (action && typeof action === "object" && !Array.isArray(action)) { + const sanitizedAction = { ...(action as Record) }; + if ( + (sanitizedAction.type === "url" || sanitizedAction.type === "web-app") && + typeof sanitizedAction.url === "string" + ) { + const sanitized = sanitizeUserVisibleToolTextResult(sanitizedAction.url, bootPrompt); + if (sanitized.text) { + sanitizedAction.url = sanitized.text; + sanitizedButton.action = sanitizedAction; + } else if ( + sanitizedAction.type === "web-app" && + typeof sanitizedAction.widgetId === "string" && + sanitizedAction.widgetId.trim() + ) { + delete sanitizedAction.url; + sanitizedButton.action = sanitizedAction; + } else { + // Explicit typed actions own the control. If sanitization removes + // the target, legacy shadow fields must not become active fallbacks. + delete sanitizedButton.action; + delete sanitizedButton.value; + delete sanitizedButton.url; + delete sanitizedButton.webApp; + delete sanitizedButton.web_app; + } + suppressionReason ??= sanitized.suppressionReason; + } + } + return sanitizedButton; + }); + } + if (Array.isArray(sanitizedBlock.options)) { + sanitizedBlock.options = sanitizedBlock.options.map((option) => { + if (!option || typeof option !== "object" || Array.isArray(option)) { + return option; + } + const sanitizedOption = { ...(option as Record) }; + if (typeof sanitizedOption.label === "string") { + const sanitized = sanitizeUserVisibleToolTextResult(sanitizedOption.label, bootPrompt); + sanitizedOption.label = sanitized.text; + suppressionReason ??= sanitized.suppressionReason; + } + return sanitizedOption; + }); + } + if (Array.isArray(sanitizedBlock.categories)) { + sanitizedBlock.categories = sanitizedBlock.categories.map((category) => { + if (typeof category !== "string") { + return category; + } + const sanitized = sanitizeUserVisibleToolTextResult(category, bootPrompt); + suppressionReason ??= sanitized.suppressionReason; + return sanitized.text; + }); + } + if (Array.isArray(sanitizedBlock.segments)) { + sanitizedBlock.segments = sanitizedBlock.segments.map((segment) => { + if (!segment || typeof segment !== "object" || Array.isArray(segment)) { + return segment; + } + const sanitizedSegment = { ...(segment as Record) }; + if (typeof sanitizedSegment.label === "string") { + const sanitized = sanitizeUserVisibleToolTextResult(sanitizedSegment.label, bootPrompt); + sanitizedSegment.label = sanitized.text; + suppressionReason ??= sanitized.suppressionReason; + } + return sanitizedSegment; + }); + } + if (Array.isArray(sanitizedBlock.series)) { + sanitizedBlock.series = sanitizedBlock.series.map((series) => { + if (!series || typeof series !== "object" || Array.isArray(series)) { + return series; + } + const sanitizedSeries = { ...(series as Record) }; + if (typeof sanitizedSeries.name === "string") { + const sanitized = sanitizeUserVisibleToolTextResult(sanitizedSeries.name, bootPrompt); + sanitizedSeries.name = sanitized.text; + suppressionReason ??= sanitized.suppressionReason; + } + return sanitizedSeries; + }); + } + return sanitizedBlock; + }); + } + return { value: presentation, ...(suppressionReason ? { suppressionReason } : {}) }; +} + +function readFirstStringParam(params: Record, keys: readonly string[]): string { + for (const key of keys) { + const value = readToolStringParam(params, key); + if (value) { + return value; + } + } + return ""; +} + +function readStructuredAttachmentMediaParams(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + const values: string[] = []; + for (const attachment of value) { + if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) { + continue; + } + const record = attachment as Record; + for (const key of ["media", "mediaUrl", "path", "filePath", "fileUrl", "url"]) { + const candidate = readToolStringParam(record, key); + if (candidate) { + values.push(candidate); + } + } + } + return values; +} + +export function hasSanitizedSendPayloadContent(params: Record): boolean { + const text = ["message", "text", "content", "caption", "SendMessage"] + .map((field) => (typeof params[field] === "string" ? params[field] : "")) + .filter((value) => value.trim()) + .join("\n"); + const mediaUrls = [ + ...(readStringArrayParam(params, "mediaUrls") ?? []), + ...readStructuredAttachmentMediaParams(params.attachments), + ]; + return hasReplyPayloadContent( + { + text, + mediaUrl: readFirstStringParam(params, ["media", "mediaUrl", "path", "filePath", "fileUrl"]), + mediaUrls, + presentation: params.presentation, + interactive: params.interactive, + }, + { trimText: true }, + ); +} + +export function sanitizeMessageToolVisiblePayload( + params: Record, + agentSessionKey?: string, +): VisibleTextSuppressionReason | undefined { + // Sanitize outbound text fields in three layers: + // + // 1. `stripFormattedReasoningMessage` — drops reasoning blocks + // that some models emit into tool arguments. + // 2. `stripInternalRuntimeContext` — removes internal-runtime-context + // delimited blocks (the same strip applied to final replies via + // `sanitizeUserFacingText`). Catches wrapped BOOT.md or webchat + // runtime-context echoes that preserve the marker lines. + // 3. `stripBootEchoFromOutboundText` — defense-in-depth check against + // the active boot prompt for this session. Catches verbatim echoes + // that paraphrase out the wrapper markers but reproduce a + // substantial chunk of the boot prompt content. Refs #53732. + const bootPromptForSession = getBootEchoContextForSession(agentSessionKey); + let suppressedVisiblePayloadReason: VisibleTextSuppressionReason | undefined; + parseJsonMessageParam(params, "presentation"); + parseInteractiveParam(params); + for (const field of [ + "text", + "content", + "message", + "caption", + "SendMessage", + "quoteText", + "quote_text", + ]) { + const suppressionReason = sanitizeStringParam(params, field, bootPromptForSession); + suppressedVisiblePayloadReason ??= suppressionReason; + } + for (const field of ["pollQuestion", "poll_question"]) { + const suppressionReason = sanitizeStringParam(params, field, bootPromptForSession); + suppressedVisiblePayloadReason ??= suppressionReason; + } + for (const field of ["pollOption", "poll_option"]) { + const suppressionReason = sanitizeStringArrayParam(params, field, bootPromptForSession); + suppressedVisiblePayloadReason ??= suppressionReason; + } + const sanitizedPresentation = sanitizePresentationTextFieldsResult( + params.presentation, + bootPromptForSession, + ); + params.presentation = sanitizedPresentation.value; + suppressedVisiblePayloadReason ??= sanitizedPresentation.suppressionReason; + const sanitizedInteractive = sanitizePresentationTextFieldsResult( + params.interactive, + bootPromptForSession, + ); + params.interactive = sanitizedInteractive.value; + suppressedVisiblePayloadReason ??= sanitizedInteractive.suppressionReason; + return suppressedVisiblePayloadReason; +} diff --git a/src/agents/tools/message-tool.internal-source-reply.integration.test.ts b/src/agents/tools/message-tool.internal-source-reply.integration.test.ts index 53bbb6285baa..a5b9fe4c32c4 100644 --- a/src/agents/tools/message-tool.internal-source-reply.integration.test.ts +++ b/src/agents/tools/message-tool.internal-source-reply.integration.test.ts @@ -5,7 +5,7 @@ import { getReplyPayloadMetadata } from "../../auto-reply/reply-payload.js"; import { buildReplyPayloads } from "../../auto-reply/reply/agent-runner-payloads.js"; import { buildEmbeddedRunPayloads } from "../embedded-agent-runner/run/payloads.js"; import { extractMessagingToolSourceReplyPayload } from "../embedded-agent-subscribe.tools.js"; -import { createMessageTool } from "./message-tool.js"; +import { createMessageTool } from "./message-tool-execution.js"; describe("WebChat message tool internal source reply", () => { it("projects a real targetless send and preserves the automatic final reply", async () => { diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 5e560ab62fbd..fb9f4903aede 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -17,7 +17,7 @@ import { MESSAGE_TOOL_ONLY_DELIVERY_HINT, } from "../../plugin-sdk/message-tool-delivery-hints.js"; import { wrapToolWithBeforeToolCallHook } from "../agent-tools.before-tool-call.js"; -type CreateMessageTool = typeof import("./message-tool.js").createMessageTool; +type CreateMessageTool = typeof import("./message-tool-execution.js").createMessageTool; type CreateOpenClawTools = typeof import("../openclaw-tools.js").createOpenClawTools; type ResetPluginRuntimeStateForTest = typeof import("../../plugins/runtime.js").resetPluginRuntimeStateForTest; @@ -370,7 +370,7 @@ beforeAll(async () => { ({ resetPluginRuntimeStateForTest, setActivePluginRegistry } = await import("../../plugins/runtime.js")); ({ createTestRegistry } = await import("../../test-utils/channel-plugins.js")); - ({ createMessageTool } = await import("./message-tool.js")); + ({ createMessageTool } = await import("./message-tool-execution.js")); ({ createOpenClawTools } = await import("../openclaw-tools.js")); }); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts deleted file mode 100644 index 78e4d629d524..000000000000 --- a/src/agents/tools/message-tool.ts +++ /dev/null @@ -1,2037 +0,0 @@ -/** - * message built-in tool. - * - * Sends, edits, reacts to, polls, and routes messages through channel plugins and Gateway-backed actions. - */ -import { - normalizeOptionalLowercaseString, - normalizeOptionalString, - normalizeOptionalStringifiedId, -} from "@openclaw/normalization-core/string-coerce"; -import { sortUniqueStrings, uniqueValues } from "@openclaw/normalization-core/string-normalization"; -import { Type, type TObject, type TSchema } from "typebox"; -import { - GATEWAY_CLIENT_IDS, - GATEWAY_CLIENT_MODES, -} from "../../../packages/gateway-protocol/src/client-info.js"; -import { stripPlainTextToolCallBlocks } from "../../../packages/tool-call-repair/src/index.js"; -import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; -import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; -import { - hasInboundMetadataSentinel, - stripInboundMetadata, -} 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 type { ConversationReadInvocationOrigin } from "../../channels/plugins/conversation-read-origin.js"; -import { - getChannelPlugin, - getLoadedChannelPlugin, - listChannelPlugins, -} from "../../channels/plugins/index.js"; -import { - channelSupportsMessageCapability, - 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"; -import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; -import type { ChannelMessageActionName } from "../../channels/plugins/types.public.js"; -import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; -import { getScopedChannelsCommandSecretTargets } from "../../cli/command-secret-targets.js"; -import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js"; -import { getRuntimeConfig } from "../../config/config.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { - getBootEchoContextForSession, - stripBootEchoFromOutboundText, -} from "../../gateway/boot-echo-guard.js"; -import { - resolveMessageActionTurnCapability, - type AgentRuntimeMessageActionContext, -} from "../../gateway/message-action-turn-capability.js"; -import { createAbortError } from "../../infra/abort-signal.js"; -import { sha256Base64UrlPrefix } from "../../infra/crypto-digest.js"; -import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; -import { - resolveMessageBroadcastAccountPlan, - validateExplicitMessageAccountSelection, -} from "../../infra/outbound/message-account-selection.js"; -import type { - MessageActionGateway, - MessageActionResult, -} from "../../infra/outbound/message-action-contracts.js"; -import { - parseInteractiveParam, - parseJsonMessageParam, -} from "../../infra/outbound/message-action-params.js"; -import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; -import { resolveActionDeliveryTargetAlias } from "../../infra/outbound/message-action-spec.js"; -import { - resolveAllowedMessageActions, - shouldApplyCrossContextMarker, -} from "../../infra/outbound/outbound-policy.js"; -import { sourceDeliveryTargetsMatch } from "../../infra/outbound/source-delivery-plan.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 { normalizeOptionalAccountId } from "../../routing/account-id.js"; -import { normalizeAccountId, parseSessionDeliveryRoute } from "../../routing/session-key.js"; -import { stripUnsupportedCitationControlMarkers } from "../../shared/text/citation-control-markers.js"; -import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js"; -import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; -import { resolveSessionAgentId } from "../agent-scope.js"; -import { listAllChannelSupportedActions, listChannelSupportedActions } from "../channel-tools.js"; -import { stripInternalRuntimeContext } from "../internal-runtime-context.js"; -import { - channelTargetSchema, - channelTargetsSchema, - optionalNonNegativeIntegerSchema, - optionalPositiveIntegerSchema, - stringEnum, -} from "../schema/typebox.js"; -import type { AnyAgentTool } from "./common.js"; -import { jsonResult, readStringArrayParam, readToolStringParam } from "./common.js"; -import { gatewayCallOptionSchemaProperties } from "./gateway-schema.js"; -import { - readGatewayCallOptions, - resolveGatewayOptions, - resolveMessageActionAgentRuntimeIdentityToken, - type GatewayCallOptions, -} from "./gateway.js"; -import { - appendMessageToolReadHint, - appendMessageToolVisibleReplyHint, -} from "./message-tool-description.js"; -import { - buildMessageToolQuerySchemaProperties, - buildMessageToolSchemaFromActions, - MESSAGE_TOOL_SEND_TEXT_DESCRIPTION, - type MessageToolSchemaBuilders, -} from "./message-tool-schema-scoping.js"; -import { isPollVoteEchoText } from "./poll-vote-echo.js"; - -const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES; -function actionNeedsExplicitTarget(action: ChannelMessageActionName): boolean { - return action === "broadcast" || shouldApplyCrossContextMarker(action); -} - -function normalizeMessageToolIdempotencyKeyPart(value: unknown): string | undefined { - const normalized = normalizeOptionalString(value); - if (!normalized) { - return undefined; - } - return normalized.replace(/[^A-Za-z0-9._:-]+/gu, "_"); -} - -const MESSAGE_TOOL_IDEMPOTENCY_ENVELOPE_PARAM_NAMES = [ - "gatewayToken", - "gatewayUrl", - "idempotencyKey", - "timeoutMs", -] satisfies Array; -const MESSAGE_TOOL_IDEMPOTENCY_ENVELOPE_PARAM_KEYS = new Set( - MESSAGE_TOOL_IDEMPOTENCY_ENVELOPE_PARAM_NAMES, -); - -function stripMessageToolIdempotencyEnvelope( - params: Record, -): Record { - const out: Record = {}; - for (const key of Object.keys(params).toSorted()) { - if (!MESSAGE_TOOL_IDEMPOTENCY_ENVELOPE_PARAM_KEYS.has(key)) { - out[key] = params[key]; - } - } - return out; -} - -function canonicalizeMessageToolIdempotencyValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((entry) => canonicalizeMessageToolIdempotencyValue(entry)); - } - if (!value || typeof value !== "object") { - return value; - } - const record = value as Record; - const out: Record = {}; - for (const key of Object.keys(record).toSorted()) { - out[key] = canonicalizeMessageToolIdempotencyValue(record[key]); - } - return out; -} - -function buildMessageToolDeliveryFingerprint(params: { - action: ChannelMessageActionName; - params: Record; -}): string { - const canonical = JSON.stringify( - canonicalizeMessageToolIdempotencyValue({ - action: params.action, - params: stripMessageToolIdempotencyEnvelope(params.params), - }), - ); - return sha256Base64UrlPrefix(canonical, 24); -} - -function buildMessageToolAutogeneratedIdempotencyKey(params: { - runId: string; - deliveryFingerprint: string; - operationId: string; -}): string { - return `${params.runId}:message-tool:${params.deliveryFingerprint}:${params.operationId}`; -} - -function normalizeEscapedLineBreaksForVisibleText(text: string): string { - if (!text.includes("\\")) { - return text; - } - // The send path turns literal "\n" sequences into line breaks later; match - // that before privacy stripping so escaped delimiter lines cannot bypass it. - return text.replace(/\\r\\n|\\n|\\r/g, "\n"); -} - -type VisibleTextSuppressionReason = - | "internal_runtime_context_echo" - | "inbound_metadata_echo" - | "poll_vote_echo"; - -const POLL_VOTE_ECHO_TTL_MS = 30_000; - -// Keyed by agent session (conversation), NOT per message-tool instance: a native -// poll and its accompanying comment arrive as separate inbound messages and are -// processed in separate agent runs, each with a fresh tool instance. An -// instance-local record would be lost before the follow-up text run, so the echo -// (the agent restating its vote in prose) would leak. Session-scoped + -// route-checked storage lets the vote in one run suppress the restatement in the -// next while never crossing conversations. Single slot per session, TTL-bounded. -const recentPollVoteBySession = new Map< - string, - { option: string; route: string; recordedAt: number } ->(); - -function resolvePollVoteEchoRoute(params: { - action: ChannelMessageActionName; - args: Record; - channel?: string | null; - accountId?: string; - currentChannelId?: string; - currentChatType?: ChatType; - currentMessagingTarget?: string; -}): string | undefined { - const channel = normalizeMessageChannel(params.channel); - if (!channel) { - return undefined; - } - let deliveryAliasTarget: string | undefined; - try { - deliveryAliasTarget = resolveActionDeliveryTargetAlias(params.action, params.args, { - channel, - aliasSpec: getChannelPlugin(channel)?.actions?.messageActionTargetAliases?.[params.action], - }); - } catch { - return undefined; - } - const targets = ["target", "to", "channelId"] - .map((key) => normalizeOptionalStringifiedId(params.args[key])) - .concat(deliveryAliasTarget ?? []) - .filter((value): value is string => Boolean(value)); - if (new Set(targets).size > 1) { - return undefined; - } - const target = targets[0]; - const currentTargets = new Set( - [params.currentMessagingTarget, params.currentChannelId].filter((value): value is string => - Boolean(value), - ), - ); - // Plugin-declared aliases keep owner-specific target fields out of core. - // A route mismatch fails open; provider/account keys prevent cross-send suppression. - const routeTarget = !target || currentTargets.has(target) ? "" : target; - return `${channel}\0${normalizeAccountId(params.accountId ?? "default")}\0${routeTarget}`; -} - -function sanitizeUserVisibleToolTextResult( - text: string, - bootPrompt: string | undefined, -): { - text: string; - suppressionReason?: VisibleTextSuppressionReason; -} { - const normalized = normalizeEscapedLineBreaksForVisibleText(text); - const strippedReasoning = stripFormattedReasoningMessage(normalized); - const strippedInternal = stripInternalRuntimeContext(strippedReasoning); - const strippedBoot = stripBootEchoFromOutboundText(strippedInternal, bootPrompt); - const strippedInbound = hasInboundMetadataSentinel(strippedBoot) - ? stripInboundMetadata(strippedBoot) - : strippedBoot; - const suppressionReason = - strippedBoot.trim().length === 0 && - strippedReasoning.trim().length > 0 && - (strippedInternal !== strippedReasoning || strippedBoot !== strippedInternal) - ? "internal_runtime_context_echo" - : strippedInbound.trim().length === 0 && - strippedBoot.trim().length > 0 && - strippedInbound !== strippedBoot - ? "inbound_metadata_echo" - : undefined; - return { - text: strippedInbound, - ...(suppressionReason ? { suppressionReason } : {}), - }; -} - -function sanitizeStringParam( - params: Record, - field: string, - bootPrompt: string | undefined, -): VisibleTextSuppressionReason | undefined { - if (typeof params[field] !== "string") { - return undefined; - } - const sanitized = sanitizeUserVisibleToolTextResult(params[field], bootPrompt); - params[field] = sanitized.text; - return sanitized.suppressionReason; -} - -function sanitizeStringArrayParam( - params: Record, - field: string, - bootPrompt: string | undefined, -): VisibleTextSuppressionReason | undefined { - const value = params[field]; - if (typeof value === "string") { - const sanitized = sanitizeUserVisibleToolTextResult(value, bootPrompt); - params[field] = sanitized.text; - return sanitized.suppressionReason; - } - if (!Array.isArray(value)) { - return undefined; - } - let suppressionReason: VisibleTextSuppressionReason | undefined; - params[field] = value.map((entry) => { - if (typeof entry !== "string") { - return entry; - } - const sanitized = sanitizeUserVisibleToolTextResult(entry, bootPrompt); - suppressionReason ??= sanitized.suppressionReason; - return sanitized.text; - }); - return suppressionReason; -} - -function sanitizePresentationTextFieldsResult( - value: unknown, - bootPrompt: string | undefined, -): { value: unknown; suppressionReason?: VisibleTextSuppressionReason } { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return { value }; - } - let suppressionReason: VisibleTextSuppressionReason | undefined; - const presentation = { ...(value as Record) }; - if (typeof presentation.title === "string") { - const sanitized = sanitizeUserVisibleToolTextResult(presentation.title, bootPrompt); - presentation.title = sanitized.text; - suppressionReason ??= sanitized.suppressionReason; - } - if (Array.isArray(presentation.blocks)) { - presentation.blocks = presentation.blocks.map((block) => { - if (!block || typeof block !== "object" || Array.isArray(block)) { - return block; - } - const sanitizedBlock = { ...(block as Record) }; - for (const field of ["text", "placeholder", "title", "xLabel", "yLabel"]) { - if (typeof sanitizedBlock[field] === "string") { - const sanitized = sanitizeUserVisibleToolTextResult(sanitizedBlock[field], bootPrompt); - sanitizedBlock[field] = sanitized.text; - suppressionReason ??= sanitized.suppressionReason; - } - } - if (normalizeOptionalLowercaseString(sanitizedBlock.type) === "table") { - if (typeof sanitizedBlock.caption === "string") { - const sanitized = sanitizeUserVisibleToolTextResult(sanitizedBlock.caption, bootPrompt); - sanitizedBlock.caption = sanitized.text.trim(); - suppressionReason ??= sanitized.suppressionReason; - } - if (Array.isArray(sanitizedBlock.headers)) { - sanitizedBlock.headers = sanitizedBlock.headers.map((header) => { - if (typeof header !== "string") { - return header; - } - const sanitized = sanitizeUserVisibleToolTextResult(header, bootPrompt); - suppressionReason ??= sanitized.suppressionReason; - return sanitized.text.trim(); - }); - } - if (Array.isArray(sanitizedBlock.rows)) { - sanitizedBlock.rows = sanitizedBlock.rows.map((row) => { - if (!Array.isArray(row)) { - return row; - } - return row.map((cell) => { - if (typeof cell !== "string") { - return cell; - } - const sanitized = sanitizeUserVisibleToolTextResult(cell, bootPrompt); - suppressionReason ??= sanitized.suppressionReason; - return sanitized.text.trim(); - }); - }); - } - } - if (Array.isArray(sanitizedBlock.buttons)) { - sanitizedBlock.buttons = sanitizedBlock.buttons.map((button) => { - if (!button || typeof button !== "object" || Array.isArray(button)) { - return button; - } - const sanitizedButton = { ...(button as Record) }; - if (typeof sanitizedButton.label === "string") { - const sanitized = sanitizeUserVisibleToolTextResult(sanitizedButton.label, bootPrompt); - sanitizedButton.label = sanitized.text; - suppressionReason ??= sanitized.suppressionReason; - } - if (typeof sanitizedButton.url === "string") { - const sanitized = sanitizeUserVisibleToolTextResult(sanitizedButton.url, bootPrompt); - if (sanitized.text) { - sanitizedButton.url = sanitized.text; - } else { - delete sanitizedButton.url; - } - suppressionReason ??= sanitized.suppressionReason; - } - for (const webAppField of ["webApp", "web_app"]) { - const webApp = sanitizedButton[webAppField]; - if (!webApp || typeof webApp !== "object" || Array.isArray(webApp)) { - continue; - } - const sanitizedWebApp = { ...(webApp as Record) }; - if (typeof sanitizedWebApp.url !== "string") { - continue; - } - const sanitized = sanitizeUserVisibleToolTextResult(sanitizedWebApp.url, bootPrompt); - if (sanitized.text) { - sanitizedWebApp.url = sanitized.text; - sanitizedButton[webAppField] = sanitizedWebApp; - } else { - delete sanitizedButton[webAppField]; - } - suppressionReason ??= sanitized.suppressionReason; - } - const action = sanitizedButton.action; - if (action && typeof action === "object" && !Array.isArray(action)) { - const sanitizedAction = { ...(action as Record) }; - if ( - (sanitizedAction.type === "url" || sanitizedAction.type === "web-app") && - typeof sanitizedAction.url === "string" - ) { - const sanitized = sanitizeUserVisibleToolTextResult(sanitizedAction.url, bootPrompt); - if (sanitized.text) { - sanitizedAction.url = sanitized.text; - sanitizedButton.action = sanitizedAction; - } else if ( - sanitizedAction.type === "web-app" && - typeof sanitizedAction.widgetId === "string" && - sanitizedAction.widgetId.trim() - ) { - delete sanitizedAction.url; - sanitizedButton.action = sanitizedAction; - } else { - // Explicit typed actions own the control. If sanitization removes - // the target, legacy shadow fields must not become active fallbacks. - delete sanitizedButton.action; - delete sanitizedButton.value; - delete sanitizedButton.url; - delete sanitizedButton.webApp; - delete sanitizedButton.web_app; - } - suppressionReason ??= sanitized.suppressionReason; - } - } - return sanitizedButton; - }); - } - if (Array.isArray(sanitizedBlock.options)) { - sanitizedBlock.options = sanitizedBlock.options.map((option) => { - if (!option || typeof option !== "object" || Array.isArray(option)) { - return option; - } - const sanitizedOption = { ...(option as Record) }; - if (typeof sanitizedOption.label === "string") { - const sanitized = sanitizeUserVisibleToolTextResult(sanitizedOption.label, bootPrompt); - sanitizedOption.label = sanitized.text; - suppressionReason ??= sanitized.suppressionReason; - } - return sanitizedOption; - }); - } - if (Array.isArray(sanitizedBlock.categories)) { - sanitizedBlock.categories = sanitizedBlock.categories.map((category) => { - if (typeof category !== "string") { - return category; - } - const sanitized = sanitizeUserVisibleToolTextResult(category, bootPrompt); - suppressionReason ??= sanitized.suppressionReason; - return sanitized.text; - }); - } - if (Array.isArray(sanitizedBlock.segments)) { - sanitizedBlock.segments = sanitizedBlock.segments.map((segment) => { - if (!segment || typeof segment !== "object" || Array.isArray(segment)) { - return segment; - } - const sanitizedSegment = { ...(segment as Record) }; - if (typeof sanitizedSegment.label === "string") { - const sanitized = sanitizeUserVisibleToolTextResult(sanitizedSegment.label, bootPrompt); - sanitizedSegment.label = sanitized.text; - suppressionReason ??= sanitized.suppressionReason; - } - return sanitizedSegment; - }); - } - if (Array.isArray(sanitizedBlock.series)) { - sanitizedBlock.series = sanitizedBlock.series.map((series) => { - if (!series || typeof series !== "object" || Array.isArray(series)) { - return series; - } - const sanitizedSeries = { ...(series as Record) }; - if (typeof sanitizedSeries.name === "string") { - const sanitized = sanitizeUserVisibleToolTextResult(sanitizedSeries.name, bootPrompt); - sanitizedSeries.name = sanitized.text; - suppressionReason ??= sanitized.suppressionReason; - } - return sanitizedSeries; - }); - } - return sanitizedBlock; - }); - } - return { value: presentation, ...(suppressionReason ? { suppressionReason } : {}) }; -} - -function readFirstStringParam(params: Record, keys: readonly string[]): string { - for (const key of keys) { - const value = readToolStringParam(params, key); - if (value) { - return value; - } - } - return ""; -} - -function readStructuredAttachmentMediaParams(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - const values: string[] = []; - for (const attachment of value) { - if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) { - continue; - } - const record = attachment as Record; - for (const key of ["media", "mediaUrl", "path", "filePath", "fileUrl", "url"]) { - const candidate = readToolStringParam(record, key); - if (candidate) { - values.push(candidate); - } - } - } - return values; -} - -function hasSanitizedSendPayloadContent(params: Record): boolean { - const text = ["message", "text", "content", "caption", "SendMessage"] - .map((field) => (typeof params[field] === "string" ? params[field] : "")) - .filter((value) => value.trim()) - .join("\n"); - const mediaUrls = [ - ...(readStringArrayParam(params, "mediaUrls") ?? []), - ...readStructuredAttachmentMediaParams(params.attachments), - ]; - return hasReplyPayloadContent( - { - text, - mediaUrl: readFirstStringParam(params, ["media", "mediaUrl", "path", "filePath", "fileUrl"]), - mediaUrls, - presentation: params.presentation, - interactive: params.interactive, - }, - { trimText: true }, - ); -} - -function buildRoutingSchema() { - return { - channel: Type.Optional(Type.String()), - target: Type.Optional(channelTargetSchema()), - targets: Type.Optional(channelTargetsSchema()), - accountId: Type.Optional(Type.String()), - dryRun: Type.Optional(Type.Boolean()), - }; -} - -const presentationCommandActionSchema = Type.Object({ - type: Type.Literal("command"), - command: Type.String(), -}); - -const presentationCallbackActionSchema = Type.Object({ - type: Type.Literal("callback"), - value: Type.String(), -}); - -const presentationCommandOrCallbackActionSchema = Type.Union([ - presentationCommandActionSchema, - presentationCallbackActionSchema, -]); - -// Approval and question actions carry server-issued IDs and are runtime-authored -// only. The message tool exposes the remaining actions models may safely author. -const presentationButtonActionSchema = Type.Union([ - presentationCommandActionSchema, - presentationCallbackActionSchema, - Type.Object({ - type: Type.Literal("url"), - url: Type.String(), - }), - Type.Object({ - type: Type.Literal("web-app"), - url: Type.String(), - widgetId: Type.Optional(Type.String()), - }), - Type.Object({ - type: Type.Literal("web-app"), - url: Type.Optional(Type.String()), - widgetId: Type.String(), - }), -]); - -const presentationOptionSchema = Type.Object({ - label: Type.String(), - action: Type.Optional(presentationCommandOrCallbackActionSchema), - value: Type.Optional(Type.String()), -}); - -const presentationButtonSchema = Type.Object({ - label: Type.String(), - action: Type.Optional(presentationButtonActionSchema), - value: Type.Optional(Type.String()), - url: Type.Optional(Type.String()), - webApp: Type.Optional(Type.Object({ url: Type.String() })), - web_app: Type.Optional(Type.Object({ url: Type.String() })), - disabled: Type.Optional(Type.Boolean()), - reusable: Type.Optional(Type.Boolean()), - style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger"])), -}); - -const presentationChartSegmentSchema = Type.Object({ - label: Type.String(), - value: Type.Number(), -}); - -const presentationChartSeriesSchema = Type.Object({ - name: Type.String(), - values: Type.Array(Type.Number(), { minItems: 1 }), -}); - -// Keep this flat: some provider tool-schema validators reject an anyOf nested -// under presentation.blocks.items. Runtime normalization enforces block shapes. -const presentationBlockSchema = Type.Object({ - type: stringEnum(["text", "context", "divider", "buttons", "select", "chart", "table"]), - text: Type.Optional(Type.String()), - buttons: Type.Optional(Type.Array(presentationButtonSchema)), - placeholder: Type.Optional(Type.String()), - options: Type.Optional(Type.Array(presentationOptionSchema)), - chartType: Type.Optional(stringEnum(["pie", "bar", "area", "line"])), - title: Type.Optional(Type.String()), - segments: Type.Optional(Type.Array(presentationChartSegmentSchema, { minItems: 1 })), - categories: Type.Optional(Type.Array(Type.String(), { minItems: 1 })), - series: Type.Optional(Type.Array(presentationChartSeriesSchema, { minItems: 1 })), - xLabel: Type.Optional(Type.String()), - yLabel: Type.Optional(Type.String()), - caption: Type.Optional(Type.String()), - headers: Type.Optional(Type.Array(Type.String(), { minItems: 1 })), - rows: Type.Optional( - Type.Array( - Type.Array(Type.Unsafe({ type: ["string", "number"] }), { minItems: 1 }), - { minItems: 1 }, - ), - ), - rowHeaderColumnIndex: Type.Optional(Type.Integer({ minimum: 0 })), -}); - -const presentationMessageSchema = Type.Object( - { - title: Type.Optional(Type.String()), - tone: Type.Optional(stringEnum(["info", "success", "warning", "danger", "neutral"])), - blocks: Type.Array(presentationBlockSchema), - }, - { - description: "Rich text/chart/table/button/select/context; unsupported degrades to text.", - }, -); - -function buildSendSchema(options: { - includePresentation: boolean; - includeDeliveryPin: boolean; - includeBestEffort: boolean; -}) { - const props: Record = { - message: Type.Optional(Type.String({ description: MESSAGE_TOOL_SEND_TEXT_DESCRIPTION })), - effectId: Type.Optional( - Type.String({ - description: "sendWithEffect id/name.", - }), - ), - effect: Type.Optional(Type.String({ description: "Alias for effectId." })), - media: Type.Optional( - Type.String({ - description: "Media URL/path. data: use buffer.", - }), - ), - filename: Type.Optional(Type.String()), - buffer: Type.Optional( - Type.String({ - description: "Base64/data-URL attachment.", - }), - ), - contentType: Type.Optional(Type.String()), - mimeType: Type.Optional(Type.String()), - caption: Type.Optional(Type.String()), - attachments: Type.Optional( - Type.Array( - Type.Object({ - type: Type.Optional(stringEnum(["image", "audio", "video", "file"])), - media: Type.Optional(Type.String()), - name: Type.Optional(Type.String()), - mimeType: Type.Optional(Type.String()), - }), - { - description: "Attachments; each uses media.", - }, - ), - ), - replyTo: Type.Optional(Type.String()), - threadId: Type.Optional(Type.String()), - asVoice: Type.Optional(Type.Boolean()), - silent: Type.Optional(Type.Boolean()), - quoteText: Type.Optional(Type.String({ description: "Telegram reply quote text." })), - gifPlayback: Type.Optional(Type.Boolean()), - forceDocument: Type.Optional( - Type.Boolean({ - description: "Send media as document; no compression.", - }), - ), - asDocument: Type.Optional( - Type.Boolean({ - description: "Alias for forceDocument.", - }), - ), - }; - if (options.includePresentation) { - props.presentation = Type.Optional(presentationMessageSchema); - } - if (options.includeBestEffort) { - props.bestEffort = Type.Optional( - Type.Boolean({ - description: "Ordinary reply omit/true; false only requiring durable delivery.", - }), - ); - } - if (options.includeDeliveryPin) { - props.delivery = Type.Optional( - Type.Object( - { - pin: Type.Optional( - Type.Union([ - Type.Boolean(), - Type.Object({ - enabled: Type.Boolean(), - notify: Type.Optional(Type.Boolean()), - required: Type.Optional(Type.Boolean()), - }), - ]), - ), - }, - { - description: "Delivery prefs; pin when supported.", - }, - ), - ); - } - return props; -} - -function buildReactionSchema() { - return { - messageId: Type.Optional( - Type.String({ - description: - "Target read/react/edit/delete/pin/unpin id; reactions default current inbound.", - }), - ), - message_id: Type.Optional( - Type.String({ - // Intentional duplicate alias for tool-schema discoverability in LLMs. - description: "snake_case alias of messageId; same defaults.", - }), - ), - emoji: Type.Optional(Type.String()), - remove: Type.Optional(Type.Boolean()), - trackToolCalls: Type.Optional( - Type.Boolean({ - description: "Use reacted current message for tool-progress reactions.", - }), - ), - track_tool_calls: Type.Optional( - Type.Boolean({ - description: "snake_case alias of trackToolCalls.", - }), - ), - targetAuthor: Type.Optional(Type.String()), - targetAuthorUuid: Type.Optional(Type.String()), - groupId: Type.Optional(Type.String()), - }; -} - -function buildFetchSchema() { - return { - limit: optionalPositiveIntegerSchema(), - pageSize: optionalPositiveIntegerSchema(), - pageToken: Type.Optional(Type.String()), - before: Type.Optional(Type.String()), - after: Type.Optional(Type.String()), - around: Type.Optional(Type.String()), - fromMe: Type.Optional(Type.Boolean()), - includeArchived: Type.Optional(Type.Boolean()), - }; -} - -function buildPollSchema() { - const props: Record = { - pollId: Type.Optional(Type.String()), - pollOptionId: Type.Optional( - Type.String({ - description: "Poll answer id.", - }), - ), - pollOptionIds: Type.Optional( - Type.Array( - Type.String({ - description: "Poll answer ids for multiselect.", - }), - ), - ), - pollOptionIndex: Type.Optional( - Type.Integer({ - minimum: 1, - description: "1-based poll option number.", - }), - ), - pollOptionIndexes: Type.Optional( - Type.Array( - Type.Integer({ - minimum: 1, - description: "1-based poll option numbers for multiselect.", - }), - ), - ), - }; - for (const name of SHARED_POLL_CREATION_PARAM_NAMES) { - const def = POLL_CREATION_PARAM_DEFS[name]; - if (!def) { - continue; - } - switch (def.kind) { - case "string": - props[name] = Type.Optional(Type.String()); - break; - case "stringArray": - props[name] = Type.Optional(Type.Array(Type.String())); - break; - case "positiveInteger": - props[name] = optionalPositiveIntegerSchema(); - break; - case "boolean": - props[name] = Type.Optional(Type.Boolean()); - break; - } - } - return props; -} - -function buildChannelTargetSchema() { - return { - channelId: Type.Optional(Type.String({ description: "Channel id filter." })), - chatId: Type.Optional(Type.String({ description: "Chat id for chat metadata." })), - channelIds: Type.Optional(Type.Array(Type.String({ description: "Channel id filter." }))), - memberId: Type.Optional(Type.String()), - memberIdType: Type.Optional(Type.String()), - guildId: Type.Optional(Type.String()), - userId: Type.Optional( - Type.String({ - description: - "member-info/moderation/participant user id; member-info uses userId, not target.", - }), - ), - openId: Type.Optional(Type.String()), - unionId: Type.Optional(Type.String()), - authorId: Type.Optional(Type.String()), - authorIds: Type.Optional(Type.Array(Type.String())), - roleId: Type.Optional(Type.String()), - roleIds: Type.Optional(Type.Array(Type.String())), - participant: Type.Optional(Type.String()), - includeMembers: Type.Optional(Type.Boolean()), - members: Type.Optional(Type.Boolean()), - scope: Type.Optional(Type.String()), - kind: Type.Optional(Type.String()), - }; -} - -function buildStickerSchema() { - return { - fileId: Type.Optional(Type.String()), - emojiName: Type.Optional(Type.String()), - stickerId: Type.Optional(Type.Array(Type.String())), - stickerName: Type.Optional(Type.String()), - stickerDesc: Type.Optional(Type.String()), - stickerTags: Type.Optional(Type.String()), - }; -} - -function buildThreadSchema() { - return { - threadName: Type.Optional(Type.String()), - autoArchiveMin: optionalPositiveIntegerSchema(), - appliedTags: Type.Optional(Type.Array(Type.String())), - }; -} - -function buildEventSchema() { - return { - eventName: Type.Optional(Type.String()), - eventType: Type.Optional(Type.String()), - startTime: Type.Optional(Type.String()), - endTime: Type.Optional(Type.String()), - desc: Type.Optional(Type.String()), - location: Type.Optional(Type.String()), - image: Type.Optional(Type.String({ description: "Event cover image URL/path." })), - }; -} - -function buildModerationSchema() { - return { - reason: Type.Optional(Type.String()), - deleteDays: optionalNonNegativeIntegerSchema({ maximum: 7 }), - durationMin: optionalNonNegativeIntegerSchema(), - until: Type.Optional(Type.String()), - }; -} - -function buildGatewaySchema() { - return gatewayCallOptionSchemaProperties(); -} - -function buildPresenceSchema() { - return { - activityType: Type.Optional( - Type.String({ - description: "Activity type: playing, streaming, listening, watching, competing, custom.", - }), - ), - activityName: Type.Optional( - Type.String({ - description: "Activity name shown in sidebar; ignored for custom.", - }), - ), - activityUrl: Type.Optional( - Type.String({ - description: "Streaming URL; streaming type only.", - }), - ), - activityState: Type.Optional( - Type.String({ - description: "State text; custom type uses as status text.", - }), - ), - status: Type.Optional( - Type.String({ description: "Bot status: online, dnd, idle, invisible." }), - ), - }; -} - -function buildChannelManagementSchema() { - return { - name: Type.Optional(Type.String()), - channelType: Type.Optional( - Type.Integer({ - minimum: 0, - description: "Numeric channel type; avoids schema type collision.", - }), - ), - parentId: Type.Optional(Type.String()), - topic: Type.Optional(Type.String()), - position: optionalNonNegativeIntegerSchema(), - nsfw: Type.Optional(Type.Boolean()), - rateLimitPerUser: optionalNonNegativeIntegerSchema(), - categoryId: Type.Optional(Type.String()), - clearParent: Type.Optional( - Type.Boolean({ - description: "Clear parent/category when supported.", - }), - ), - }; -} - -function buildMessageToolSchemaProps(options: { - includePresentation: boolean; - includeDeliveryPin: boolean; - includeBestEffort: boolean; - extraProperties?: Record; -}) { - return { - ...buildRoutingSchema(), - ...buildSendSchema(options), - ...buildReactionSchema(), - ...buildFetchSchema(), - ...buildMessageToolQuerySchemaProperties(), - ...buildPollSchema(), - ...buildChannelTargetSchema(), - ...buildStickerSchema(), - ...buildThreadSchema(), - ...buildEventSchema(), - ...buildModerationSchema(), - ...buildGatewaySchema(), - ...buildChannelManagementSchema(), - ...buildPresenceSchema(), - ...options.extraProperties, - }; -} - -const MESSAGE_TOOL_SCHEMA_BUILDERS = { - full: buildMessageToolSchemaProps, - base: (options) => ({ - ...buildRoutingSchema(), - ...buildSendSchema(options), - ...buildGatewaySchema(), - }), - groups: { - reaction: buildReactionSchema, - fetch: buildFetchSchema, - query: buildMessageToolQuerySchemaProperties, - poll: buildPollSchema, - channelTarget: buildChannelTargetSchema, - sticker: buildStickerSchema, - thread: buildThreadSchema, - event: buildEventSchema, - moderation: buildModerationSchema, - channelManagement: buildChannelManagementSchema, - presence: buildPresenceSchema, - }, -} satisfies MessageToolSchemaBuilders; - -const MessageToolSchema = buildMessageToolSchemaFromActions( - AllMessageActions, - { - includePresentation: true, - includeDeliveryPin: true, - includeBestEffort: false, - }, - MESSAGE_TOOL_SCHEMA_BUILDERS, -); - -type MessageToolOptions = { - agentAccountId?: string; - agentSessionKey?: string; - runSessionKey?: string; - runId?: string; - sessionId?: string; - agentId?: string; - config?: OpenClawConfig; - preparedMessageToolCatalog?: PreparedMessageToolCatalog; - getRuntimeConfig?: () => OpenClawConfig; - getScopedChannelsCommandSecretTargets?: typeof getScopedChannelsCommandSecretTargets; - resolveCommandSecretRefsViaGateway?: typeof resolveCommandSecretRefsViaGateway; - runMessageAction?: typeof runMessageAction; - currentChannelId?: string; - currentChatType?: ChatType; - currentMessagingTarget?: string; - messageActionTurnCapability?: string; - currentChannelProvider?: string; - currentThreadTs?: string; - agentThreadId?: string | number; - currentMessageId?: string | number; - currentInboundAudio?: boolean; - hasCurrentInboundAudio?: () => boolean; - replyToMode?: "off" | "first" | "all" | "batched"; - hasRepliedRef?: { value: boolean }; - sameChannelThreadRequired?: boolean; - sandboxRoot?: string; - requireExplicitTarget?: boolean; - sourceReplyDeliveryMode?: SourceReplyDeliveryMode; - /** Process-local completion authority: send only to the current source route. */ - sourceReplyOnly?: boolean; - inboundEventKind?: InboundEventKind; - requesterSenderId?: string; - senderIsOwner?: boolean; - conversationReadOrigin?: ConversationReadInvocationOrigin; -}; - -const SOURCE_REPLY_ONLY_MESSAGE_SCHEMA = Type.Object({ - action: stringEnum(["send"], { - description: "Send a text reply to the current source conversation.", - }), - channel: Type.Optional(Type.String()), - target: Type.Optional(channelTargetSchema()), - accountId: Type.Optional(Type.String()), - message: Type.Optional( - Type.String({ description: "Text to send to the current source conversation." }), - ), - replyTo: Type.Optional(Type.String()), - threadId: Type.Optional(Type.String()), -}); -const SOURCE_REPLY_ONLY_RUNTIME_ARG_NAMES = new Set(["to", "channelId", "final"]); -const SOURCE_REPLY_FINAL_PROPERTY = Type.Optional( - Type.Boolean({ - description: - "Set false for progress. Set true, or omit, for the completed current-source reply.", - }), -); - -function addSourceReplyFinalControl( - schema: T, - sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined, -): T | TObject { - if (sourceReplyDeliveryMode !== "message_tool_only") { - return schema; - } - return Type.Object({ ...schema.properties, final: SOURCE_REPLY_FINAL_PROPERTY }); -} - -function enforceSourceReplyOnlyTextDirectives(args: Record): void { - if (typeof args.message !== "string" || !args.message.trim()) { - throw new Error("Completion source replies require non-empty visible text."); - } - // Use the outbound owner's parser: sanitization can assemble directives that - // change routes, attach local files, deliver audio, or perform reactions. - const message = normalizeEscapedLineBreaksForVisibleText(args.message); - const withoutCitationMarkers = stripUnsupportedCitationControlMarkers(message); - for (const normalized of new Set([ - message, - withoutCitationMarkers, - stripPlainTextToolCallBlocks(withoutCitationMarkers), - ])) { - const directives = parseReplyDirectives(normalized, { extractMarkdownImages: true }); - if ( - directives.replyToTag || - directives.audioAsVoice || - directives.mediaUrls?.length || - directives.reaction || - directives.isSilent - ) { - throw new Error("Completion source replies cannot contain non-text or silent directives."); - } - } -} - -// A live channel turn grants delegated use of that provider account, not a -// model-selected sibling account. Keep cross-provider and source-less routing intact. -function enforceTrustedTurnExplicitAccount(params: { - explicitAccountId?: string; - selectedChannels: Array; - trustedCurrentChannel?: string; - trustedRequesterAccountId?: string; - hasTrustedTurnContext: boolean; -}): void { - if (!params.explicitAccountId || !params.hasTrustedTurnContext) { - return; - } - const trustedCurrentChannel = normalizeMessageChannel(params.trustedCurrentChannel); - if (!trustedCurrentChannel) { - throw new Error("Trusted current account is missing its channel identity."); - } - const includesTrustedCurrentChannel = params.selectedChannels.some( - (channel) => normalizeMessageChannel(channel) === trustedCurrentChannel, - ); - if (!includesTrustedCurrentChannel) { - return; - } - if (normalizeOptionalAccountId(params.trustedRequesterAccountId) !== params.explicitAccountId) { - throw new Error("Explicit account does not match the trusted current account."); - } -} - -function enforceSourceReplyOnlyMessageAction(params: { - action: ChannelMessageActionName; - args: Record; - currentChannelProvider?: string; - currentChannelId?: string; - currentMessagingTarget?: string; - currentThreadTs?: string; - currentMessageId?: string | number; - currentAccountId?: string; - trustedTurnContext?: AgentRuntimeMessageActionContext; -}): void { - if (params.action !== "send") { - throw new Error(`Completion source replies permit only action "send", not "${params.action}".`); - } - for (const name of Object.keys(params.args)) { - if ( - !Object.hasOwn(SOURCE_REPLY_ONLY_MESSAGE_SCHEMA.properties, name) && - !SOURCE_REPLY_ONLY_RUNTIME_ARG_NAMES.has(name) - ) { - throw new Error(`Completion source replies cannot use the "${name}" argument.`); - } - } - enforceSourceReplyOnlyTextDirectives(params.args); - - const sourceContext = params.trustedTurnContext?.toolContext ?? params; - const sourceChannel = normalizeMessageChannel(sourceContext.currentChannelProvider); - const sourceTargets = uniqueValues( - [sourceContext.currentMessagingTarget, sourceContext.currentChannelId] - .map((target) => normalizeOptionalString(target)) - .filter((target): target is string => Boolean(target)), - ); - if (!sourceChannel || sourceTargets.length === 0) { - throw new Error("Completion source replies require an authoritative current conversation."); - } - - const requestedChannel = readToolStringParam(params.args, "channel"); - if (requestedChannel && normalizeMessageChannel(requestedChannel) !== sourceChannel) { - throw new Error("Completion source replies cannot target another channel."); - } - - const requestedAccountId = readToolStringParam(params.args, "accountId"); - const sourceAccountId = params.trustedTurnContext - ? params.trustedTurnContext.requesterAccountId - : params.currentAccountId; - if ( - requestedAccountId && - normalizeOptionalAccountId(requestedAccountId) !== normalizeOptionalAccountId(sourceAccountId) - ) { - throw new Error("Completion source replies cannot use another channel account."); - } - - const sourceThreadId = normalizeOptionalString(sourceContext.currentThreadTs); - const requestedThreadId = normalizeOptionalStringifiedId(params.args.threadId); - if (requestedThreadId && requestedThreadId !== sourceThreadId) { - throw new Error("Completion source replies cannot target another thread."); - } - - const requestedReplyTo = readToolStringParam(params.args, "replyTo"); - const sourceMessageId = normalizeOptionalStringifiedId(sourceContext.currentMessageId); - if ( - requestedReplyTo && - requestedReplyTo !== sourceMessageId && - requestedReplyTo !== sourceThreadId - ) { - throw new Error("Completion source replies cannot reply outside the current thread."); - } - - const explicitTargets = uniqueValues( - [params.args.target, params.args.to, params.args.channelId] - .map((target) => normalizeOptionalStringifiedId(target)) - .filter((target): target is string => Boolean(target)), - ); - for (const requestedTarget of explicitTargets) { - if ( - !sourceTargets.some((sourceTarget) => - sourceDeliveryTargetsMatch( - { - provider: sourceChannel, - accountId: sourceAccountId, - to: requestedTarget, - threadImplicit: true, - }, - { - channel: sourceChannel, - accountId: sourceAccountId, - to: sourceTarget, - threadId: sourceThreadId, - }, - ), - ) - ) { - throw new Error("Completion source replies cannot target another conversation or thread."); - } - } -} - -type MessageToolDiscoveryParams = { - cfg: OpenClawConfig; - currentChannelProvider?: string; - currentChannelId?: string; - currentThreadTs?: string; - currentMessageId?: string | number; - currentAccountId?: string; - sessionKey?: string; - sessionId?: string; - agentId?: string; - requesterSenderId?: string; - senderIsOwner?: boolean; - preparedMessageToolCatalog?: PreparedMessageToolCatalog; -}; - -type MessageActionDiscoveryInput = Omit & { - cfg: OpenClawConfig; - channel?: string; - preparedMessageToolCatalog?: PreparedMessageToolCatalog; -}; - -type InferredSessionDelivery = { - accountId?: string; - channel: string; - chatType?: ChatType; - threadId?: string; - to: string; -}; - -function formatSessionDeliveryTarget(channel: string, peerKind: string, to: string): string { - return (peerKind === "direct" || peerKind === "dm") && - getChannelPlugin(channel)?.messaging?.directTargetStyle === "user-prefixed" - ? `user:${to}` - : to; -} - -function resolveSessionDeliveryChatType(peerKind: string): ChatType | undefined { - if (peerKind === "direct" || peerKind === "dm") { - return "direct"; - } - if (peerKind === "group" || peerKind === "channel") { - return peerKind; - } - return undefined; -} - -function inferDeliveryFromSessionKey( - sessionKey: string | undefined, -): InferredSessionDelivery | null { - const route = parseSessionDeliveryRoute(sessionKey); - if (!route) { - return null; - } - const channel = normalizeMessageChannel(route.channel); - if (!channel) { - return null; - } - const accountId = route.accountId ? resolveAgentAccountId(route.accountId) : undefined; - return { - accountId, - channel, - chatType: resolveSessionDeliveryChatType(route.peerKind), - threadId: route.threadId, - to: formatSessionDeliveryTarget(channel, route.peerKind, route.peerId), - }; -} - -function resolveEffectiveCurrentChannelContext(options?: MessageToolOptions): { - accountId?: string; - currentChannelId?: string; - currentChatType?: ChatType; - currentMessagingTarget?: string; - currentChannelProvider?: string; - currentThreadTs?: string; -} { - const currentChannelProvider = options?.currentChannelProvider; - const currentChannelId = options?.currentChannelId; - const sessionDelivery = inferDeliveryFromSessionKey(options?.agentSessionKey); - const sessionDeliveryChannel = normalizeMessageChannel(sessionDelivery?.channel); - const preferSessionDeliveryContext = - normalizeMessageChannel(currentChannelProvider) === "webchat" && - sessionDeliveryChannel !== undefined && - sessionDeliveryChannel !== "webchat" && - Boolean(sessionDelivery?.to); - - if (!preferSessionDeliveryContext) { - return { - currentChannelProvider, - currentChannelId, - currentChatType: options?.currentChatType, - currentMessagingTarget: options?.currentMessagingTarget, - }; - } - return { - accountId: sessionDelivery?.accountId, - currentChannelProvider: sessionDeliveryChannel, - currentChannelId: sessionDelivery?.to, - currentChatType: sessionDelivery?.chatType, - currentMessagingTarget: sessionDelivery?.to, - currentThreadTs: sessionDelivery?.threadId, - }; -} - -function buildMessageActionDiscoveryInput( - params: MessageToolDiscoveryParams, - channel?: string, -): MessageActionDiscoveryInput { - return { - cfg: params.cfg, - ...(channel ? { channel } : {}), - currentChannelId: params.currentChannelId, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.currentAccountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - requesterSenderId: params.requesterSenderId, - senderIsOwner: params.senderIsOwner, - preparedMessageToolCatalog: params.preparedMessageToolCatalog, - }; -} - -function resolveMessageToolSchemaActions(params: MessageToolDiscoveryParams): string[] { - const currentChannel = normalizeMessageChannel(params.currentChannelProvider); - if (currentChannel) { - const scopedActions = listChannelSupportedActions( - buildMessageActionDiscoveryInput(params, currentChannel), - ); - const allActions = new Set(["send", ...scopedActions]); - // Include actions from other configured channels so isolated/cron agents - // can invoke cross-channel actions without validation errors. - const channels = params.preparedMessageToolCatalog?.channels ?? listChannelPlugins(); - for (const plugin of channels) { - if (plugin.id === currentChannel) { - continue; - } - for (const action of listCrossChannelSchemaSupportedMessageActions( - buildMessageActionDiscoveryInput(params, plugin.id), - )) { - allActions.add(action); - } - } - return Array.from(allActions); - } - return listAllMessageToolActions(params); -} - -function resolveMessageToolActionSchemaActions(params: MessageToolDiscoveryParams): string[] { - const discoveredActions = resolveMessageToolSchemaActions(params); - const allowedActions = resolveAllowedMessageActions({ - cfg: params.cfg, - agentId: params.agentId, - }); - if (!allowedActions) { - return discoveredActions; - } - const allow = new Set(allowedActions); - const filtered = discoveredActions.filter((action) => allow.has(action)); - return filtered.length > 0 ? filtered : allowedActions; -} - -function listAllMessageToolActions(params: MessageToolDiscoveryParams): ChannelMessageActionName[] { - const pluginActions = listAllChannelSupportedActions(buildMessageActionDiscoveryInput(params)); - return uniqueValues(["send", "broadcast", ...pluginActions]); -} - -function resolveIncludeCapability( - params: MessageToolDiscoveryParams, - capability: ChannelMessageCapability, -): boolean { - const currentChannel = normalizeMessageChannel(params.currentChannelProvider); - if (currentChannel) { - return channelSupportsMessageCapabilityForChannel( - buildMessageActionDiscoveryInput(params, currentChannel), - capability, - ); - } - return channelSupportsMessageCapability( - params.cfg, - capability, - params.preparedMessageToolCatalog, - ); -} - -function resolveIncludePresentation(params: MessageToolDiscoveryParams): boolean { - return resolveIncludeCapability(params, "presentation"); -} - -function resolveIncludeDeliveryPin(params: MessageToolDiscoveryParams): boolean { - return resolveIncludeCapability(params, "delivery-pin"); -} - -function resolveIncludeBestEffort(params: MessageToolDiscoveryParams): boolean { - const currentChannel = normalizeMessageChannel(params.currentChannelProvider); - if (!currentChannel) { - return false; - } - const prepared = params.preparedMessageToolCatalog?.getChannel(currentChannel); - if (params.preparedMessageToolCatalog) { - // The prepared catalog is the exact runtime-registry generation for this - // turn. A missing channel is an authoritative absence, not permission to - // rediscover bundled plugins on the request path. - return prepared?.reconcilesUnknownSend ?? false; - } - const adapter = - getLoadedChannelPlugin(currentChannel as Parameters[0]) - ?.message ?? - getChannelPlugin(currentChannel as Parameters[0])?.message; - return ( - adapter?.durableFinal?.capabilities?.reconcileUnknownSend === true && - typeof adapter.durableFinal.reconcileUnknownSend === "function" - ); -} - -function buildMessageToolSchema(params: MessageToolDiscoveryParams, actions: string[]) { - const includePresentation = resolveIncludePresentation(params); - const includeDeliveryPin = resolveIncludeDeliveryPin(params); - const includeBestEffort = resolveIncludeBestEffort(params); - const extraProperties = resolveChannelMessageToolSchemaProperties( - buildMessageActionDiscoveryInput( - params, - normalizeMessageChannel(params.currentChannelProvider) ?? undefined, - ), - ); - return buildMessageToolSchemaFromActions( - actions.length > 0 ? actions : ["send"], - { - includePresentation, - includeDeliveryPin, - includeBestEffort, - scopeToActions: normalizeMessageChannel(params.currentChannelProvider) !== undefined, - extraProperties, - }, - MESSAGE_TOOL_SCHEMA_BUILDERS, - ); -} - -function resolveAgentAccountId(value?: string): string | undefined { - const trimmed = normalizeOptionalString(value); - if (!trimmed) { - return undefined; - } - return normalizeAccountId(trimmed); -} - -function buildMessageToolDescription( - actions: string[] | undefined, - sourceReplyDeliveryMode?: SourceReplyDeliveryMode, - requireExplicitTarget?: boolean, -): string { - const baseDescription = "Send/manage channel messages."; - if (actions && actions.length > 0) { - const sortedActions = sortUniqueStrings(actions) as Array; - return appendMessageToolReadHint( - appendMessageToolVisibleReplyHint( - `${baseDescription} Supports actions: ${sortedActions.join(", ")}.`, - sourceReplyDeliveryMode, - requireExplicitTarget, - ), - sortedActions, - ); - } - return appendMessageToolVisibleReplyHint( - `${baseDescription} Action families (availability depends on the channel): sending/editing/unsend, reactions, polls, pins, threads, file upload/download, moderation (timeout/kick/ban), roles, channel + category management, profile/presence.`, - sourceReplyDeliveryMode, - requireExplicitTarget, - ); -} - -export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { - const loadConfigForTool = options?.getRuntimeConfig ?? getRuntimeConfig; - const getScopedSecretTargetsForTool = - options?.getScopedChannelsCommandSecretTargets ?? getScopedChannelsCommandSecretTargets; - const resolveSecretRefsForTool = - options?.resolveCommandSecretRefsViaGateway ?? resolveCommandSecretRefsViaGateway; - const runMessageActionForTool = options?.runMessageAction ?? runMessageAction; - 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 - // null session key disables the guard. - const pollEchoSessionKey = options?.agentSessionKey?.trim() || undefined; - const failedAutogeneratedIdempotencyKeys = new Map(); - const effectiveCurrentChannel = resolveEffectiveCurrentChannelContext(options); - const currentThreadTs = - options?.currentThreadTs ?? - (options?.agentThreadId != null - ? stringifyRouteThreadId(options.agentThreadId) - : effectiveCurrentChannel.currentThreadTs); - const replyToMode = options?.replyToMode ?? (currentThreadTs ? "all" : undefined); - const agentAccountId = - resolveAgentAccountId(options?.agentAccountId) ?? effectiveCurrentChannel.accountId; - const currentChannelIsInternal = - normalizeMessageChannel(effectiveCurrentChannel.currentChannelProvider) === - INTERNAL_MESSAGE_CHANNEL; - // WebChat tool sends use the private sink without changing the run-level - // contract: ordinary final answers must remain automatic and visible. - const sourceReplySinkDeliveryMode = currentChannelIsInternal - ? "message_tool_only" - : options?.sourceReplyDeliveryMode; - const resolvedAgentId = - options?.agentId ?? - (options?.agentSessionKey - ? resolveSessionAgentId({ - sessionKey: options.agentSessionKey, - config: options?.config, - }) - : undefined); - const messageToolDiscoveryParams: MessageToolDiscoveryParams | undefined = - options?.config && !options.sourceReplyOnly - ? { - cfg: options.config, - currentChannelProvider: effectiveCurrentChannel.currentChannelProvider, - currentChannelId: effectiveCurrentChannel.currentChannelId, - currentThreadTs, - currentMessageId: options.currentMessageId, - currentAccountId: agentAccountId, - sessionKey: options.agentSessionKey, - sessionId: options.sessionId, - agentId: resolvedAgentId, - requesterSenderId: options.requesterSenderId, - senderIsOwner: options.senderIsOwner, - preparedMessageToolCatalog: - options.preparedMessageToolCatalog ?? getPreparedMessageToolCatalog(), - } - : undefined; - // Schema and prompt must use the same snapshot; repeated discovery can drift - // across plugin hooks while needlessly loading channel action metadata twice. - const actions = messageToolDiscoveryParams - ? resolveMessageToolActionSchemaActions(messageToolDiscoveryParams) - : undefined; - const baseSchema = options?.sourceReplyOnly - ? SOURCE_REPLY_ONLY_MESSAGE_SCHEMA - : messageToolDiscoveryParams - ? buildMessageToolSchema(messageToolDiscoveryParams, actions ?? []) - : MessageToolSchema; - const schema = addSourceReplyFinalControl(baseSchema, sourceReplySinkDeliveryMode); - const description = options?.sourceReplyOnly - ? appendMessageToolVisibleReplyHint( - "Send a message to the current source conversation. Supports actions: send.", - options.sourceReplyDeliveryMode, - options.requireExplicitTarget, - ) - : buildMessageToolDescription( - actions, - options?.sourceReplyDeliveryMode, - options?.requireExplicitTarget, - ); - - return { - label: "Message", - name: "message", - displaySummary: "Send and manage messages across configured channels.", - description, - parameters: schema, - execute: async (toolCallId, args, signal) => { - if (signal?.aborted) { - throw createAbortError("Message send aborted"); - } - // Shallow-copy so we don't mutate the original event args (used for logging/dedup). - const params = { ...(args as Record) }; - const action = readToolStringParam(params, "action", { - required: true, - }) as ChannelMessageActionName; - const trustedTurnContext = - resolvedAgentId && options?.agentSessionKey - ? resolveMessageActionTurnCapability({ - token: options.messageActionTurnCapability, - agentId: resolvedAgentId, - runId: options.runId, - sessionKey: options.agentSessionKey, - sessionId: options.sessionId, - }) - : undefined; - if (normalizeOptionalString(options?.messageActionTurnCapability) && !trustedTurnContext) { - throw new Error("message action turn capability is no longer active"); - } - if (options?.sourceReplyOnly) { - enforceSourceReplyOnlyMessageAction({ - action, - args: params, - currentChannelProvider: effectiveCurrentChannel.currentChannelProvider, - currentChannelId: effectiveCurrentChannel.currentChannelId, - currentMessagingTarget: effectiveCurrentChannel.currentMessagingTarget, - currentThreadTs, - currentMessageId: options.currentMessageId, - currentAccountId: agentAccountId, - trustedTurnContext, - }); - } - // `final` is a Codex app-server-only source-delivery control. It must - // not be dispatched to a provider or participate in idempotency. - const requestedSourceReplyFinal = - typeof params.final === "boolean" ? params.final : undefined; - delete params.final; - - // Sanitize outbound text fields in three layers: - // - // 1. `stripFormattedReasoningMessage` — drops reasoning blocks - // that some models emit into tool arguments. - // 2. `stripInternalRuntimeContext` — removes internal-runtime-context - // delimited blocks (the same strip applied to final replies via - // `sanitizeUserFacingText`). Catches wrapped BOOT.md or webchat - // runtime-context echoes that preserve the marker lines. - // 3. `stripBootEchoFromOutboundText` — defense-in-depth check against - // the active boot prompt for this session. Catches verbatim echoes - // that paraphrase out the wrapper markers but reproduce a - // substantial chunk of the boot prompt content. Refs #53732. - const bootPromptForSession = getBootEchoContextForSession(options?.agentSessionKey); - let suppressedVisiblePayloadReason: VisibleTextSuppressionReason | undefined; - parseJsonMessageParam(params, "presentation"); - parseInteractiveParam(params); - for (const field of [ - "text", - "content", - "message", - "caption", - "SendMessage", - "quoteText", - "quote_text", - ]) { - const suppressionReason = sanitizeStringParam(params, field, bootPromptForSession); - suppressedVisiblePayloadReason ??= suppressionReason; - } - for (const field of ["pollQuestion", "poll_question"]) { - const suppressionReason = sanitizeStringParam(params, field, bootPromptForSession); - suppressedVisiblePayloadReason ??= suppressionReason; - } - for (const field of ["pollOption", "poll_option"]) { - const suppressionReason = sanitizeStringArrayParam(params, field, bootPromptForSession); - suppressedVisiblePayloadReason ??= suppressionReason; - } - const sanitizedPresentation = sanitizePresentationTextFieldsResult( - params.presentation, - bootPromptForSession, - ); - params.presentation = sanitizedPresentation.value; - suppressedVisiblePayloadReason ??= sanitizedPresentation.suppressionReason; - const sanitizedInteractive = sanitizePresentationTextFieldsResult( - params.interactive, - bootPromptForSession, - ); - params.interactive = sanitizedInteractive.value; - suppressedVisiblePayloadReason ??= sanitizedInteractive.suppressionReason; - if (options?.sourceReplyOnly) { - enforceSourceReplyOnlyTextDirectives(params); - } - - if ( - suppressedVisiblePayloadReason && - action === "send" && - !hasSanitizedSendPayloadContent(params) - ) { - return jsonResult({ - status: "suppressed", - reason: suppressedVisiblePayloadReason, - message: - suppressedVisiblePayloadReason === "inbound_metadata_echo" - ? "Suppressed outbound message text because it matched inbound runtime metadata." - : "Suppressed outbound message text because it matched internal runtime context.", - }); - } - const requireExplicitTarget = options?.requireExplicitTarget === true; - if (requireExplicitTarget && actionNeedsExplicitTarget(action)) { - const explicitTarget = - (typeof params.target === "string" && params.target.trim().length > 0) || - (typeof params.to === "string" && params.to.trim().length > 0) || - (typeof params.channelId === "string" && params.channelId.trim().length > 0) || - (Array.isArray(params.targets) && - params.targets.some((value) => typeof value === "string" && value.trim().length > 0)); - if (!explicitTarget) { - throw new Error( - "Explicit message target required for this run. Provide target/targets (and channel when needed).", - ); - } - } - - const gatewayOpts = readGatewayCallOptions(params); - const rawConfig = options?.config ?? loadConfigForTool(); - const requestedAccountId = readToolStringParam(params, "accountId"); - validateExplicitMessageAccountSelection({ - cfg: rawConfig, - accountId: requestedAccountId, - checkResolvedAccount: false, - }); - const requestedBroadcastChannel = normalizeOptionalLowercaseString(params.channel); - if ( - action === "broadcast" && - requestedBroadcastChannel && - requestedBroadcastChannel !== "all" - ) { - // Authorize and execute the same canonical provider. Otherwise an unavailable - // hint can fall back to the current provider only after account authorization. - const selection = await resolveMessageChannelSelection({ - cfg: rawConfig, - channel: requestedBroadcastChannel, - fallbackChannel: effectiveCurrentChannel.currentChannelProvider, - }); - params.channel = selection.channel; - } - const scope = resolveMessageSecretScope({ - channel: params.channel, - target: params.target, - targets: params.targets, - fallbackChannel: effectiveCurrentChannel.currentChannelProvider, - accountId: requestedAccountId, - fallbackAccountId: agentAccountId, - }); - // Broadcast execution only narrows on an explicit non-all channel. Target - // prefixes cannot authorize fewer providers than the runner will execute. - const unscopedExplicitBroadcast = - action === "broadcast" && - (!requestedBroadcastChannel || requestedBroadcastChannel === "all") && - requestedAccountId !== undefined; - const explicitAccountId = validateExplicitMessageAccountSelection({ - cfg: rawConfig, - channel: unscopedExplicitBroadcast ? undefined : scope.channel, - accountId: requestedAccountId, - checkResolvedAccount: false, - }); - const broadcastAccountPlan = - unscopedExplicitBroadcast && explicitAccountId - ? resolveMessageBroadcastAccountPlan({ - cfg: rawConfig, - accountId: explicitAccountId, - }) - : undefined; - enforceTrustedTurnExplicitAccount({ - explicitAccountId, - selectedChannels: broadcastAccountPlan - ? broadcastAccountPlan.candidateChannels - : [scope.channel], - trustedCurrentChannel: trustedTurnContext?.toolContext?.currentChannelProvider, - trustedRequesterAccountId: trustedTurnContext?.requesterAccountId, - hasTrustedTurnContext: trustedTurnContext !== undefined, - }); - if (explicitAccountId) { - scope.accountId = explicitAccountId; - params.accountId = explicitAccountId; - } - const scopedTargets = getScopedSecretTargetsForTool({ - config: rawConfig, - channel: broadcastAccountPlan ? undefined : scope.channel, - ...(broadcastAccountPlan ? { channels: broadcastAccountPlan.secretChannels } : {}), - accountId: scope.accountId, - }); - const cfg = ( - await resolveSecretRefsForTool({ - config: rawConfig, - commandName: "tools.message", - targetIds: scopedTargets.targetIds, - ...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}), - mode: "enforce_resolved", - }) - ).resolvedConfig; - - const accountId = explicitAccountId ?? agentAccountId; - const pollVoteEchoRoute = resolvePollVoteEchoRoute({ - action, - args: params, - channel: scope.channel ?? effectiveCurrentChannel.currentChannelProvider, - accountId, - currentChannelId: effectiveCurrentChannel.currentChannelId, - currentMessagingTarget: effectiveCurrentChannel.currentMessagingTarget, - }); - const recentPollVote = pollEchoSessionKey - ? recentPollVoteBySession.get(pollEchoSessionKey) - : undefined; - if ( - recentPollVote && - pollEchoSessionKey && - sourceReplySinkDeliveryMode === "message_tool_only" && - (action === "send" || action === "reply") - ) { - if (Date.now() - recentPollVote.recordedAt > POLL_VOTE_ECHO_TTL_MS) { - recentPollVoteBySession.delete(pollEchoSessionKey); - } else if (pollVoteEchoRoute === recentPollVote.route) { - const vote = recentPollVote; - recentPollVoteBySession.delete(pollEchoSessionKey); - const outboundText = - readToolStringParam(params, "text") ?? - readToolStringParam(params, "message") ?? - readToolStringParam(params, "content"); - if (outboundText && isPollVoteEchoText(vote.option, outboundText)) { - return jsonResult({ - status: "suppressed", - reason: "poll_vote_echo" satisfies VisibleTextSuppressionReason, - message: "Suppressed outbound text because it only restated the poll vote just cast.", - }); - } - } - } - - const gatewayResolved = resolveGatewayOptions(gatewayOpts); - const callerOwnsTerminalReceipt = - gatewayResolved.target === "remote" || - normalizeOptionalString(gatewayOpts.gatewayUrl) !== undefined || - normalizeOptionalString(gatewayOpts.gatewayToken) !== undefined; - // Direct tool invocations already execute inside the authenticated - // Gateway request. Keep their authority operation-local by dispatching - // channel actions in-process instead of laundering it through a new - // backend connection. - const gateway: MessageActionGateway | undefined = - options?.conversationReadOrigin === "direct-operator" - ? undefined - : { - url: gatewayResolved.url, - token: gatewayResolved.token, - timeoutMs: gatewayResolved.timeoutMs, - clientName: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, - clientDisplayName: "agent", - mode: GATEWAY_CLIENT_MODES.BACKEND, - ...(callerOwnsTerminalReceipt - ? { terminalSourceReplyReceiptOwner: "caller" as const } - : {}), - resolveAgentRuntimeIdentityToken: (context) => - resolveMessageActionAgentRuntimeIdentityToken({ - opts: gatewayOpts, - target: gatewayResolved.target, - turnCapability: options?.messageActionTurnCapability, - runId: options?.runId, - sessionId: options?.sessionId, - sourceReplyFinal: context?.sourceReplyFinal, - sourceReplyToolCallId: context?.sourceReplyToolCallId, - callerOwnsTerminalReceipt, - }), - }; - const hasCurrentMessageId = - typeof options?.currentMessageId === "number" || - (typeof options?.currentMessageId === "string" && - options.currentMessageId.trim().length > 0); - - const toolContext = - effectiveCurrentChannel.currentChannelId || - effectiveCurrentChannel.currentChatType || - effectiveCurrentChannel.currentChannelProvider || - effectiveCurrentChannel.currentMessagingTarget || - currentThreadTs || - hasCurrentMessageId || - replyToMode || - options?.hasRepliedRef || - options?.sameChannelThreadRequired - ? { - currentChannelId: effectiveCurrentChannel.currentChannelId, - currentChatType: effectiveCurrentChannel.currentChatType, - currentMessagingTarget: effectiveCurrentChannel.currentMessagingTarget, - currentChannelProvider: effectiveCurrentChannel.currentChannelProvider, - currentThreadTs, - currentMessageId: options?.currentMessageId, - replyToMode, - hasRepliedRef: options?.hasRepliedRef, - sameChannelThreadRequired: options?.sameChannelThreadRequired, - // Direct tool invocations should not add cross-context decoration. - // The agent is composing a message, not forwarding from another chat. - skipCrossContextDecoration: true, - } - : undefined; - let autogeneratedDeliveryFingerprint: string | undefined; - let actionIdempotencyKey = normalizeOptionalString(params.idempotencyKey); - if (!actionIdempotencyKey && options?.runId) { - autogeneratedDeliveryFingerprint = buildMessageToolDeliveryFingerprint({ action, params }); - actionIdempotencyKey = failedAutogeneratedIdempotencyKeys.get( - autogeneratedDeliveryFingerprint, - ); - if (!actionIdempotencyKey) { - const operationId = - normalizeMessageToolIdempotencyKeyPart(toolCallId) ?? - String(++generatedIdempotencyCounter); - actionIdempotencyKey = buildMessageToolAutogeneratedIdempotencyKey({ - runId: normalizeMessageToolIdempotencyKeyPart(options.runId) ?? options.runId, - deliveryFingerprint: autogeneratedDeliveryFingerprint, - operationId, - }); - } - } - const actionParams = actionIdempotencyKey - ? { ...params, idempotencyKey: actionIdempotencyKey } - : params; - const hasExactSourceTurn = - action === "send" && - sourceReplySinkDeliveryMode === "message_tool_only" && - normalizeOptionalString(trustedTurnContext?.toolContext?.currentSourceTurnId) !== undefined; - let result: MessageActionResult; - try { - result = await runMessageActionForTool({ - cfg, - action, - params: actionParams, - actionOrigin: "message-tool", - defaultAccountId: accountId ?? undefined, - requesterAccountId: trustedTurnContext?.requesterAccountId, - requesterSenderId: trustedTurnContext?.requesterSenderId, - messageActionAuthorization: { - requesterAccountId: trustedTurnContext?.requesterAccountId, - requesterSenderId: trustedTurnContext?.requesterSenderId, - toolContext: trustedTurnContext?.toolContext, - }, - senderIsOwner: options?.senderIsOwner, - conversationReadOrigin: options?.conversationReadOrigin, - broadcastAccountPlan, - gateway, - toolContext, - sessionKey: options?.agentSessionKey, - sourceReplySessionKey: options?.runSessionKey, - sessionId: options?.sessionId, - agentId: resolvedAgentId, - sandboxRoot: options?.sandboxRoot, - sourceReplyDeliveryMode: sourceReplySinkDeliveryMode, - // Only an admitted channel source can arm terminal restart reconciliation. - // Source-less scheduled and ambient sends remain ordinary message actions. - sourceReplyFinal: hasExactSourceTurn ? (requestedSourceReplyFinal ?? true) : undefined, - sourceReplyToolCallId: hasExactSourceTurn ? toolCallId : undefined, - inboundEventKind: options?.inboundEventKind, - inboundAudio: options?.hasCurrentInboundAudio?.() ?? options?.currentInboundAudio, - abortSignal: signal, - }); - } catch (error) { - if (autogeneratedDeliveryFingerprint && actionIdempotencyKey) { - failedAutogeneratedIdempotencyKeys.set( - autogeneratedDeliveryFingerprint, - actionIdempotencyKey, - ); - } - throw error; - } - if ( - autogeneratedDeliveryFingerprint && - failedAutogeneratedIdempotencyKeys.get(autogeneratedDeliveryFingerprint) === - actionIdempotencyKey - ) { - failedAutogeneratedIdempotencyKeys.delete(autogeneratedDeliveryFingerprint); - } - const toolResult = getToolResult(result); - const normalizationNotice = result.kind === "send" ? result.normalization?.notice : undefined; - if (normalizationNotice) { - const normalizedResult = toolResult ?? jsonResult(result.payload); - return { - ...normalizedResult, - content: [...normalizedResult.content, { type: "text", text: normalizationNotice }], - }; - } - if ( - action === "poll-vote" && - pollVoteEchoRoute && - pollEchoSessionKey && - sourceReplySinkDeliveryMode === "message_tool_only" - ) { - const details = toolResult?.details as { pollVotedOption?: unknown } | undefined; - const option = - typeof details?.pollVotedOption === "string" ? details.pollVotedOption.trim() : ""; - if (option) { - const recordedAt = Date.now(); - // Prune expired entries on write so a session that votes but never - // sends a follow-up text can't leak a record forever in a long-lived - // gateway; the map stays bounded to sessions that voted within the TTL. - for (const [key, entry] of recentPollVoteBySession) { - if (recordedAt - entry.recordedAt > POLL_VOTE_ECHO_TTL_MS) { - recentPollVoteBySession.delete(key); - } - } - recentPollVoteBySession.set(pollEchoSessionKey, { - option, - route: pollVoteEchoRoute, - recordedAt, - }); - } - } - if (toolResult) { - return toolResult; - } - return jsonResult(result.payload); - }, - }; -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */