mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(agents): split message-tool into concept modules (#121901)
* refactor(agents): split message-tool into concept modules * fix(agents): restore message-tool split imports * ci: preserve message-tool CodeQL boundary
This commit is contained in:
committed by
GitHub
parent
77fc82c9d3
commit
2b8dbc3a7b
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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<ChannelMessageActionDiscoveryInput, "cfg" | "channel"> & {
|
||||
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<string>(["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<ChannelMessageActionName>(["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<typeof getLoadedChannelPlugin>[0])
|
||||
?.message ??
|
||||
getChannelPlugin(currentChannel as Parameters<typeof getChannelPlugin>[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<ChannelMessageActionName | "send">;
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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<keyof GatewayCallOptions | "idempotencyKey">;
|
||||
const MESSAGE_TOOL_IDEMPOTENCY_ENVELOPE_PARAM_KEYS = new Set<string>(
|
||||
MESSAGE_TOOL_IDEMPOTENCY_ENVELOPE_PARAM_NAMES,
|
||||
);
|
||||
|
||||
function stripMessageToolIdempotencyEnvelope(
|
||||
params: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(record).toSorted()) {
|
||||
out[key] = canonicalizeMessageToolIdempotencyValue(record[key]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildMessageToolDeliveryFingerprint(params: {
|
||||
action: ChannelMessageActionName;
|
||||
params: Record<string, unknown>;
|
||||
}): 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<string, unknown>;
|
||||
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) ? "<current-source>" : 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<string, string>();
|
||||
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<string, unknown>) };
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string | number>({ 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<string, TSchema> = {
|
||||
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<string, TSchema> = {
|
||||
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<string, TSchema>;
|
||||
}) {
|
||||
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,
|
||||
);
|
||||
@@ -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<T extends TObject>(
|
||||
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<string, unknown>): 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<string | undefined>;
|
||||
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<string, unknown>;
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>) };
|
||||
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<string, unknown>) };
|
||||
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<string, unknown>) };
|
||||
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<string, unknown>) };
|
||||
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<string, unknown>) };
|
||||
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<string, unknown>) };
|
||||
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<string, unknown>) };
|
||||
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<string, unknown>) };
|
||||
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<string, unknown>, 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<string, unknown>;
|
||||
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<string, unknown>): 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<string, unknown>,
|
||||
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;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user