diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index b37266a94f60..03f12f89cd50 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -1,5 +1,6 @@ import { Type } from "typebox"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelMessageAdapterShape } from "../../channels/message/types.js"; import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js"; import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js"; @@ -326,6 +327,7 @@ function createChannelPlugin(params: { capabilities?: readonly ChannelMessageCapability[]; toolSchema?: MessageToolSchema | ((params: MessageToolDiscoveryContext) => MessageToolSchema); describeMessageTool?: DescribeMessageTool; + message?: ChannelMessageAdapterShape; messaging?: ChannelPlugin["messaging"]; }): ChannelPlugin { return { @@ -343,6 +345,7 @@ function createChannelPlugin(params: { listAccountIds: () => ["default"], resolveAccount: () => ({}), }, + ...(params.message ? { message: params.message } : {}), ...(params.messaging ? { messaging: params.messaging } : {}), actions: { describeMessageTool: @@ -788,6 +791,47 @@ describe("message tool secret scoping", () => { }); }); +describe("message tool delivery mode schema", () => { + it("hides bestEffort when required durable delivery is not available", () => { + const defaultTool = createMessageTool(); + const scopedTool = createMessageTool({ + config: {} as never, + currentChannelProvider: "discord", + }); + + expect(getToolProperties(defaultTool).bestEffort).toBeUndefined(); + expect(getToolProperties(scopedTool).bestEffort).toBeUndefined(); + }); + + it("exposes bestEffort only for channels that can reconcile unknown sends", () => { + const plugin = createChannelPlugin({ + id: "discord", + label: "Discord", + docsPath: "/channels/discord", + blurb: "test", + actions: ["send"], + message: { + durableFinal: { + capabilities: { reconcileUnknownSend: true }, + reconcileUnknownSend: async () => ({ status: "not_sent" }), + }, + }, + }); + setActivePluginRegistry(createTestRegistry([{ pluginId: "discord", source: "test", plugin }])); + + const tool = createMessageTool({ + config: {} as never, + currentChannelProvider: "discord", + }); + const bestEffort = getToolProperties(tool).bestEffort as + | { description?: string; type?: string } + | undefined; + + expect(bestEffort?.type).toBe("boolean"); + expect(bestEffort?.description).toContain("required durable delivery"); + }); +}); + describe("message tool agent routing", () => { it("derives agentId from the session key", async () => { mockSendResult(); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 3e109c05bee8..b0911c8f3b9d 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -1,7 +1,11 @@ import { Type, type TSchema } from "typebox"; import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js"; import type { InboundEventKind } from "../../channels/inbound-event/kind.js"; -import { listChannelPlugins } from "../../channels/plugins/index.js"; +import { + getChannelPlugin, + getLoadedChannelPlugin, + listChannelPlugins, +} from "../../channels/plugins/index.js"; import { channelSupportsMessageCapability, channelSupportsMessageCapabilityForChannel, @@ -158,7 +162,11 @@ const presentationMessageSchema = Type.Object( }, ); -function buildSendSchema(options: { includePresentation: boolean; includeDeliveryPin: boolean }) { +function buildSendSchema(options: { + includePresentation: boolean; + includeDeliveryPin: boolean; + includeBestEffort: boolean; +}) { const props: Record = { message: Type.Optional(Type.String()), effectId: Type.Optional( @@ -207,7 +215,6 @@ function buildSendSchema(options: { includePresentation: boolean; includeDeliver asVoice: Type.Optional(Type.Boolean()), silent: Type.Optional(Type.Boolean()), quoteText: Type.Optional(Type.String({ description: "Telegram reply quote text." })), - bestEffort: Type.Optional(Type.Boolean()), gifPlayback: Type.Optional(Type.Boolean()), forceDocument: Type.Optional( Type.Boolean({ @@ -223,6 +230,14 @@ function buildSendSchema(options: { includePresentation: boolean; includeDeliver if (options.includePresentation) { props.presentation = Type.Optional(presentationMessageSchema); } + if (options.includeBestEffort) { + props.bestEffort = Type.Optional( + Type.Boolean({ + description: + "Optional delivery mode. Omit or set true for ordinary replies. Set false only when required durable delivery is necessary.", + }), + ); + } if (options.includeDeliveryPin) { props.delivery = Type.Optional( Type.Object( @@ -466,6 +481,7 @@ function buildChannelManagementSchema() { function buildMessageToolSchemaProps(options: { includePresentation: boolean; includeDeliveryPin: boolean; + includeBestEffort: boolean; extraProperties?: Record; }) { return { @@ -494,6 +510,7 @@ function isSendOnlyActions(actions: readonly string[]): boolean { function buildSendOnlyMessageToolSchemaProps(options: { includePresentation: boolean; includeDeliveryPin: boolean; + includeBestEffort: boolean; extraProperties?: Record; }) { return { @@ -509,6 +526,7 @@ function buildMessageToolSchemaFromActions( options: { includePresentation: boolean; includeDeliveryPin: boolean; + includeBestEffort: boolean; extraProperties?: Record; }, ) { @@ -524,6 +542,7 @@ function buildMessageToolSchemaFromActions( const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { includePresentation: true, includeDeliveryPin: true, + includeBestEffort: false, }); type MessageToolOptions = { @@ -743,10 +762,27 @@ 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 adapter = + listChannelPlugins().find((plugin) => plugin.id === currentChannel)?.message ?? + 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) { const actions = resolveMessageToolActionSchemaActions(params); const includePresentation = resolveIncludePresentation(params); const includeDeliveryPin = resolveIncludeDeliveryPin(params); + const includeBestEffort = resolveIncludeBestEffort(params); const extraProperties = resolveChannelMessageToolSchemaProperties( buildMessageActionDiscoveryInput( params, @@ -756,6 +792,7 @@ function buildMessageToolSchema(params: MessageToolDiscoveryParams) { return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], { includePresentation, includeDeliveryPin, + includeBestEffort, extraProperties, }); } diff --git a/src/infra/outbound/message.test.ts b/src/infra/outbound/message.test.ts index 9b1fc5be7045..be694ec15cc2 100644 --- a/src/infra/outbound/message.test.ts +++ b/src/infra/outbound/message.test.ts @@ -341,16 +341,18 @@ describe("sendMessage", () => { capability: "reconcileUnknownSend", }); - await expect( - sendMessage({ - cfg: {}, - channel: "forum", - to: "123456", - content: "fallback text", - payloads: [{ text: "prepared", channelData: { forum: { card: true } } }], - queuePolicy: "required", - }), - ).rejects.toThrow("missing reconcileUnknownSend"); + const send = sendMessage({ + cfg: {}, + channel: "forum", + to: "123456", + content: "fallback text", + payloads: [{ text: "prepared", channelData: { forum: { card: true } } }], + queuePolicy: "required", + }); + + await expect(send).rejects.toThrow( + /missing reconcileUnknownSend[\s\S]*queuePolicy:"best_effort"[\s\S]*omit bestEffort:false/, + ); expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); }); diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 772236b6d457..6332672f3311 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -254,7 +254,10 @@ async function assertRequiredMessageSendDurability(params: { support.reason === "capability_mismatch" && support.capability ? `missing ${support.capability}` : support.reason; - throw new Error(`Required durable message send is unsupported for ${params.channel}: ${suffix}`); + throw new Error( + `Required durable message send is unsupported for ${params.channel}: ${suffix}. ` + + 'Use queuePolicy:"best_effort" for best-effort delivery, omit bestEffort:false in message-tool calls, or use a channel with required durable delivery support.', + ); } function resolveGatewayOptions(opts?: MessageGatewayOptions) { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json index 0ce7a90bbb45..3f43059b4f03 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json @@ -668,9 +668,6 @@ }, "type": "array" }, - "bestEffort": { - "type": "boolean" - }, "buffer": { "description": "Base64 attachment payload; data URL ok.", "type": "string" diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json index a6381712ab07..9de1a3870b92 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json @@ -668,9 +668,6 @@ }, "type": "array" }, - "bestEffort": { - "type": "boolean" - }, "buffer": { "description": "Base64 attachment payload; data URL ok.", "type": "string" diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json index b08b8d7b8c6c..07e60169eebf 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json @@ -668,9 +668,6 @@ }, "type": "array" }, - "bestEffort": { - "type": "boolean" - }, "buffer": { "description": "Base64 attachment payload; data URL ok.", "type": "string" diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index 82d38521ae9d..f4b909df60b8 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 40340, - "roughTokens": 10085 + "chars": 40277, + "roughTokens": 10070 }, "openClawDeveloperInstructions": { "chars": 2846, @@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6890 }, "totalWithDynamicToolsJson": { - "chars": 67900, - "roughTokens": 16975 + "chars": 67837, + "roughTokens": 16960 }, "userInputText": { "chars": 1629, @@ -637,9 +637,6 @@ Full JSON: `codex-dynamic-tools.discord-group.json` }, "type": "array" }, - "bestEffort": { - "type": "boolean" - }, "buffer": { "description": "Base64 attachment payload; data URL ok.", "type": "string" diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index c97c3fd23182..a07289c1f031 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 40061, - "roughTokens": 10016 + "chars": 39998, + "roughTokens": 10000 }, "openClawDeveloperInstructions": { "chars": 1822, @@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6509 }, "totalWithDynamicToolsJson": { - "chars": 66097, - "roughTokens": 16525 + "chars": 66034, + "roughTokens": 16509 }, "userInputText": { "chars": 1129, @@ -614,9 +614,6 @@ Full JSON: `codex-dynamic-tools.telegram-direct.json` }, "type": "array" }, - "bestEffort": { - "type": "boolean" - }, "buffer": { "description": "Base64 attachment payload; data URL ok.", "type": "string" diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index dcd81c70b3ba..1168cc5d810c 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -222,8 +222,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 41156, - "roughTokens": 10289 + "chars": 41093, + "roughTokens": 10274 }, "openClawDeveloperInstructions": { "chars": 1841, @@ -234,8 +234,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6745 }, "totalWithDynamicToolsJson": { - "chars": 68135, - "roughTokens": 17034 + "chars": 68072, + "roughTokens": 17018 }, "userInputText": { "chars": 1367, @@ -625,9 +625,6 @@ Full JSON: `codex-dynamic-tools.heartbeat-turn.json` }, "type": "array" }, - "bestEffort": { - "type": "boolean" - }, "buffer": { "description": "Base64 attachment payload; data URL ok.", "type": "string"