diff --git a/src/agents/tools/message-tool-schema-scoping.ts b/src/agents/tools/message-tool-schema-scoping.ts new file mode 100644 index 000000000000..ceeb4e1b6e62 --- /dev/null +++ b/src/agents/tools/message-tool-schema-scoping.ts @@ -0,0 +1,196 @@ +import { Type, type TSchema } from "typebox"; +import type { ChannelMessageActionName } from "../../channels/plugins/types.public.js"; +import { stringEnum } from "../schema/typebox.js"; + +type SchemaProperties = Record; +type SchemaPropertiesBuilder = () => SchemaProperties; + +export const MESSAGE_TOOL_SEND_TEXT_DESCRIPTION = + 'Text for action="send". A send needs message or another send payload such as media, attachments, or presentation.'; + +export function buildMessageToolQuerySchemaProperties(): SchemaProperties { + return { query: Type.Optional(Type.String()) }; +} + +type SchemaGroup = + | "reaction" + | "fetch" + | "query" + | "poll" + | "channelTarget" + | "sticker" + | "thread" + | "event" + | "moderation" + | "channelManagement" + | "presence"; + +type MessageToolSchemaBuilderOptions = { + includePresentation: boolean; + includeDeliveryPin: boolean; + includeBestEffort: boolean; + scopeToActions?: boolean; + extraProperties?: SchemaProperties; +}; + +export type MessageToolSchemaBuilders = { + full: (options: MessageToolSchemaBuilderOptions) => SchemaProperties; + base: (options: MessageToolSchemaBuilderOptions) => SchemaProperties; + groups: Record; +}; + +const SCOPED_ACTION_GROUPS: ReadonlyArray<{ + group: SchemaGroup; + actions: readonly ChannelMessageActionName[]; +}> = [ + { + group: "reaction", + actions: [ + "react", + "reactions", + "read", + "edit", + "delete", + "unsend", + "pin", + "unpin", + "reply", + "thread-create", + ], + }, + { + group: "fetch", + actions: [ + "read", + "reactions", + "search", + "thread-list", + "channel-list", + "channel-info", + "list-pins", + "event-list", + "sticker-search", + "emoji-list", + ], + }, + { + // Include only actions whose handlers read query. Discord event-list historically + // advertised query through the event schema but ignores it at dispatch. + group: "query", + actions: ["search", "sticker-search", "channel-list"], + }, + { group: "poll", actions: ["poll", "poll-vote"] }, + { + group: "channelTarget", + actions: [ + "search", + "thread-list", + "thread-create", + "thread-reply", + "channel-info", + "channel-list", + "channel-create", + "channel-edit", + "channel-delete", + "channel-move", + "category-create", + "category-edit", + "category-delete", + "topic-create", + "topic-edit", + "permissions", + "member-info", + "role-info", + "role-add", + "role-remove", + "addParticipant", + "removeParticipant", + "renameGroup", + "setGroupIcon", + "leaveGroup", + "event-create", + "event-list", + "timeout", + "kick", + "ban", + "emoji-list", + "emoji-upload", + "sticker-upload", + "voice-status", + "download-file", + ], + }, + { + group: "sticker", + actions: [ + "sticker", + "sticker-search", + "sticker-upload", + "emoji-list", + "emoji-upload", + "download-file", + "upload-file", + ], + }, + { group: "thread", actions: ["thread-create", "thread-list", "thread-reply"] }, + { group: "event", actions: ["event-create", "event-list"] }, + { group: "moderation", actions: ["timeout", "kick", "ban", "delete", "unsend"] }, + { + // Keep every action that reads channel-management fields here; omission hides valid params. + group: "channelManagement", + actions: [ + "channel-create", + "channel-edit", + "channel-move", + "category-create", + "category-edit", + "category-delete", + "topic-create", + "topic-edit", + "renameGroup", + "setGroupIcon", + ], + }, + { group: "presence", actions: ["set-presence", "set-profile", "voice-status"] }, +]; + +function isSendOnly(actions: readonly string[]): boolean { + return actions.length > 0 && actions.every((action) => action === "send"); +} + +function buildScopedProperties(params: { + actions: readonly string[]; + options: MessageToolSchemaBuilderOptions; + builders: MessageToolSchemaBuilders; +}): SchemaProperties { + const activeActions = new Set(params.actions); + const properties = params.builders.base(params.options); + for (const entry of SCOPED_ACTION_GROUPS) { + if (entry.actions.some((action) => activeActions.has(action))) { + Object.assign(properties, params.builders.groups[entry.group]()); + } + } + Object.assign(properties, params.options.extraProperties); + return properties; +} + +export function buildMessageToolSchemaFromActions( + actions: readonly string[], + options: MessageToolSchemaBuilderOptions, + builders: MessageToolSchemaBuilders, +) { + // Keep one flat object: provider adapters reject per-action anyOf/oneOf schemas. + // Groups prune unavailable fields; runtime still validates each action payload. + const properties = isSendOnly(actions) + ? Object.assign(builders.base(options), options.extraProperties) + : options.scopeToActions && actions.length > 0 + ? buildScopedProperties({ actions, options, builders }) + : builders.full(options); + return Type.Object({ + action: stringEnum(actions, { + description: + 'Select one action. For action="send", provide message or another send payload; fields for other actions do not count as send content.', + }), + ...properties, + }); +} diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 7c437a0726da..218b518e24ea 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -2119,6 +2119,118 @@ describe("message tool schema scoping", () => { expect(properties).not.toHaveProperty("eventName"); }); + it("prunes fields for action groups that discovery does not advertise", () => { + const plugin = createChannelPlugin({ + id: "discord", + label: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test plugin.", + actions: [ + "send", + "read", + "react", + "reactions", + "edit", + "delete", + "pin", + "unpin", + "list-pins", + "thread-create", + "thread-list", + "thread-reply", + "upload-file", + ], + }); + + setActivePluginRegistry(createTestRegistry([{ pluginId: "discord", source: "test", plugin }])); + + const tool = createMessageTool({ + config: {} as never, + currentChannelProvider: "discord", + }); + const properties = getToolProperties(tool); + + expect(properties).toHaveProperty("message"); + expect(properties).toHaveProperty("messageId"); + expect(properties).toHaveProperty("threadName"); + + expect(properties).not.toHaveProperty("topic"); + expect(properties).not.toHaveProperty("rateLimitPerUser"); + expect(properties).not.toHaveProperty("clearParent"); + expect(properties).not.toHaveProperty("activityName"); + expect(properties).not.toHaveProperty("activityState"); + expect(properties).not.toHaveProperty("status"); + expect(properties).not.toHaveProperty("pollId"); + expect(properties).not.toHaveProperty("eventName"); + }); + + it.each<{ + action: ChannelMessageActionName; + fields: string[]; + }>([ + { action: "search", fields: ["query", "limit"] }, + { action: "reactions", fields: ["messageId", "limit"] }, + { action: "sticker-search", fields: ["query", "limit"] }, + { action: "emoji-list", fields: ["guildId", "limit"] }, + { action: "emoji-upload", fields: ["guildId", "emojiName", "media", "roleIds"] }, + { + action: "sticker-upload", + fields: ["guildId", "stickerName", "stickerDesc", "stickerTags", "media"], + }, + { action: "voice-status", fields: ["guildId", "userId"] }, + { action: "timeout", fields: ["guildId", "userId", "durationMin", "until", "reason"] }, + { action: "download-file", fields: ["fileId", "channelId", "threadId"] }, + { action: "thread-create", fields: ["messageId", "threadName", "channelId"] }, + { action: "renameGroup", fields: ["name"] }, + { action: "setGroupIcon", fields: ["name", "filename", "buffer"] }, + { action: "channel-info", fields: ["channelId", "pageSize", "pageToken"] }, + { action: "channel-list", fields: ["query", "limit"] }, + ])("keeps fields consumed by scoped $action handlers", ({ action, fields }) => { + const plugin = createChannelPlugin({ + id: "test-channel", + label: "Test Channel", + docsPath: "/channels/test-channel", + blurb: "Scoped schema contract plugin.", + actions: [action], + }); + setActivePluginRegistry( + createTestRegistry([{ pluginId: "test-channel", source: "test", plugin }]), + ); + + const properties = getToolProperties( + createMessageTool({ + config: {} as never, + currentChannelProvider: "test-channel", + }), + ); + + for (const field of fields) { + expect(properties, `${action} should advertise ${field}`).toHaveProperty(field); + } + }); + + it("describes the send payload contract on the action and message fields", () => { + const plugin = createChannelPlugin({ + id: "discord", + label: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test plugin.", + actions: ["send", "channel-info"], + }); + setActivePluginRegistry(createTestRegistry([{ pluginId: "discord", source: "test", plugin }])); + + const properties = getToolProperties( + createMessageTool({ config: {} as never, currentChannelProvider: "discord" }), + ); + + expect((properties.action as { description?: string }).description).toContain( + 'For action="send"', + ); + expect((properties.message as { description?: string }).description).toContain( + "A send needs message", + ); + }); + it("filters scoped schemas through the per-agent message action allowlist", () => { const plugin = createChannelPlugin({ id: "discord", @@ -2160,6 +2272,59 @@ describe("message tool schema scoping", () => { expect(tool.description).not.toContain("react"); }); + it("preserves channel-management params for scoped channel-move and category-delete allowlists", () => { + // Regression: SCOPED_ACTION_GROUPS previously omitted channel-move and + // category-delete from the channel-management group, so narrowing an agent + // allowlist to either action stripped position/parentId/categoryId from + // the schema even though the Discord handlers require them. + const plugin = createChannelPlugin({ + id: "discord", + label: "Discord", + docsPath: "/channels/discord", + blurb: "Discord test plugin.", + actions: ["send", "channel-move", "category-delete"], + }); + + setActivePluginRegistry(createTestRegistry([{ pluginId: "discord", source: "test", plugin }])); + + const channelMoveTool = createMessageTool({ + config: { + agents: { + list: [ + { + id: "mover", + tools: { message: { actions: { allow: ["channel-move"] } } }, + }, + ], + }, + } as never, + currentChannelProvider: "discord", + agentId: "mover", + }); + const channelMoveProps = getToolProperties(channelMoveTool); + expect(getActionEnum(channelMoveProps)).toEqual(["channel-move"]); + expect(channelMoveProps).toHaveProperty("position"); + expect(channelMoveProps).toHaveProperty("parentId"); + + const categoryDeleteTool = createMessageTool({ + config: { + agents: { + list: [ + { + id: "purger", + tools: { message: { actions: { allow: ["category-delete"] } } }, + }, + ], + }, + } as never, + currentChannelProvider: "discord", + agentId: "purger", + }); + const categoryDeleteProps = getToolProperties(categoryDeleteTool); + expect(getActionEnum(categoryDeleteProps)).toEqual(["category-delete"]); + expect(categoryDeleteProps).toHaveProperty("categoryId"); + }); + it("uses discovery account scope for other configured channel actions", () => { const currentPlugin = createChannelPlugin({ id: "discord", diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 607c7543fbb4..959ceb3d385b 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -92,6 +92,12 @@ import { appendMessageToolReadHint, appendMessageToolVisibleReplyHint, } from "./message-tool-description.js"; +import { + buildMessageToolQuerySchemaProperties, + buildMessageToolSchemaFromActions, + MESSAGE_TOOL_SEND_TEXT_DESCRIPTION, + type MessageToolSchemaBuilders, +} from "./message-tool-schema-scoping.js"; import { isPollVoteEchoText } from "./poll-vote-echo.js"; const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES; @@ -646,7 +652,7 @@ function buildSendSchema(options: { includeBestEffort: boolean; }) { const props: Record = { - message: Type.Optional(Type.String()), + message: Type.Optional(Type.String({ description: MESSAGE_TOOL_SEND_TEXT_DESCRIPTION })), effectId: Type.Optional( Type.String({ description: "sendWithEffect id/name.", @@ -878,7 +884,6 @@ function buildThreadSchema() { function buildEventSchema() { return { - query: Type.Optional(Type.String()), eventName: Type.Optional(Type.String()), eventType: Type.Optional(Type.String()), startTime: Type.Optional(Type.String()), @@ -886,8 +891,6 @@ function buildEventSchema() { desc: Type.Optional(Type.String()), location: Type.Optional(Type.String()), image: Type.Optional(Type.String({ description: "Event cover image URL/path." })), - durationMin: optionalNonNegativeIntegerSchema(), - until: Type.Optional(Type.String()), }; } @@ -895,6 +898,8 @@ function buildModerationSchema() { return { reason: Type.Optional(Type.String()), deleteDays: optionalNonNegativeIntegerSchema({ maximum: 7 }), + durationMin: optionalNonNegativeIntegerSchema(), + until: Type.Optional(Type.String()), }; } @@ -964,6 +969,7 @@ function buildMessageToolSchemaProps(options: { ...buildSendSchema(options), ...buildReactionSchema(), ...buildFetchSchema(), + ...buildMessageToolQuerySchemaProperties(), ...buildPollSchema(), ...buildChannelTargetSchema(), ...buildStickerSchema(), @@ -977,48 +983,37 @@ function buildMessageToolSchemaProps(options: { }; } -function isSendOnlyActions(actions: readonly string[]): boolean { - const uniqueActions = new Set(actions); - return uniqueActions.size === 1 && uniqueActions.has("send"); -} - -function buildSendOnlyMessageToolSchemaProps(options: { - includePresentation: boolean; - includeDeliveryPin: boolean; - includeBestEffort: boolean; - extraProperties?: Record; -}) { - return { +const MESSAGE_TOOL_SCHEMA_BUILDERS = { + full: buildMessageToolSchemaProps, + base: (options) => ({ ...buildRoutingSchema(), ...buildSendSchema(options), ...buildGatewaySchema(), - ...options.extraProperties, - }; -} - -function buildMessageToolSchemaFromActions( - actions: readonly string[], - options: { - includePresentation: boolean; - includeDeliveryPin: boolean; - includeBestEffort: boolean; - extraProperties?: Record; + }), + groups: { + reaction: buildReactionSchema, + fetch: buildFetchSchema, + query: buildMessageToolQuerySchemaProperties, + poll: buildPollSchema, + channelTarget: buildChannelTargetSchema, + sticker: buildStickerSchema, + thread: buildThreadSchema, + event: buildEventSchema, + moderation: buildModerationSchema, + channelManagement: buildChannelManagementSchema, + presence: buildPresenceSchema, }, -) { - const props = isSendOnlyActions(actions) - ? buildSendOnlyMessageToolSchemaProps(options) - : buildMessageToolSchemaProps(options); - return Type.Object({ - action: stringEnum(actions), - ...props, - }); -} +} satisfies MessageToolSchemaBuilders; -const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, { - includePresentation: true, - includeDeliveryPin: true, - includeBestEffort: false, -}); +const MessageToolSchema = buildMessageToolSchemaFromActions( + AllMessageActions, + { + includePresentation: true, + includeDeliveryPin: true, + includeBestEffort: false, + }, + MESSAGE_TOOL_SCHEMA_BUILDERS, +); type MessageToolOptions = { agentAccountId?: string; @@ -1267,12 +1262,17 @@ function buildMessageToolSchema(params: MessageToolDiscoveryParams) { normalizeMessageChannel(params.currentChannelProvider) ?? undefined, ), ); - return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], { - includePresentation, - includeDeliveryPin, - includeBestEffort, - extraProperties, - }); + return buildMessageToolSchemaFromActions( + actions.length > 0 ? actions : ["send"], + { + includePresentation, + includeDeliveryPin, + includeBestEffort, + scopeToActions: normalizeMessageChannel(params.currentChannelProvider) !== undefined, + extraProperties, + }, + MESSAGE_TOOL_SCHEMA_BUILDERS, + ); } function resolveAgentAccountId(value?: string): string | undefined { 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 e7d78d8021d9..29a9fa689f54 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 @@ -7,6 +7,7 @@ "type": "string" }, "action": { + "description": "Select one action. For action=\"send\", provide message or another send payload; fields for other actions do not count as send content.", "enum": ["send"], "type": "string" }, @@ -84,6 +85,7 @@ "type": "string" }, "message": { + "description": "Text for action=\"send\". A send needs message or another send payload such as media, attachments, or presentation.", "type": "string" }, "mimeType": { 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 e95d91959ace..c4edbbfb5f87 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 @@ -7,6 +7,7 @@ "type": "string" }, "action": { + "description": "Select one action. For action=\"send\", provide message or another send payload; fields for other actions do not count as send content.", "enum": ["send"], "type": "string" }, @@ -84,6 +85,7 @@ "type": "string" }, "message": { + "description": "Text for action=\"send\". A send needs message or another send payload such as media, attachments, or presentation.", "type": "string" }, "mimeType": { 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 a7d7565b725e..b3fbb015919a 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 @@ -7,6 +7,7 @@ "type": "string" }, "action": { + "description": "Select one action. For action=\"send\", provide message or another send payload; fields for other actions do not count as send content.", "enum": ["send"], "type": "string" }, @@ -84,6 +85,7 @@ "type": "string" }, "message": { + "description": "Text for action=\"send\". A send needs message or another send payload such as media, attachments, or presentation.", "type": "string" }, "mimeType": { 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 1922b0dde9b5..6ee023a81518 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 @@ -208,8 +208,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 52932, - "roughTokens": 13233 + "chars": 53240, + "roughTokens": 13310 }, "openClawDeveloperInstructions": { "chars": 3431, @@ -220,8 +220,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6989 }, "totalWithDynamicToolsJson": { - "chars": 80890, - "roughTokens": 20223 + "chars": 81198, + "roughTokens": 20300 }, "userInputText": { "chars": 1442, @@ -561,6 +561,7 @@ Full JSON: `codex-dynamic-tools.discord-group.json` "type": "string" }, "action": { + "description": "Select one action. For action=\"send\", provide message or another send payload; fields for other actions do not count as send content.", "enum": ["send"], "type": "string" }, @@ -638,6 +639,7 @@ Full JSON: `codex-dynamic-tools.discord-group.json` "type": "string" }, "message": { + "description": "Text for action=\"send\". A send needs message or another send payload such as media, attachments, or presentation.", "type": "string" }, "mimeType": { 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 3840e0deee8d..aaa023e5cc09 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 @@ -208,8 +208,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 52659, - "roughTokens": 13165 + "chars": 52967, + "roughTokens": 13242 }, "openClawDeveloperInstructions": { "chars": 2322, @@ -220,8 +220,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6610 }, "totalWithDynamicToolsJson": { - "chars": 79099, - "roughTokens": 19775 + "chars": 79407, + "roughTokens": 19852 }, "userInputText": { "chars": 1033, @@ -548,6 +548,7 @@ Full JSON: `codex-dynamic-tools.telegram-direct.json` "type": "string" }, "action": { + "description": "Select one action. For action=\"send\", provide message or another send payload; fields for other actions do not count as send content.", "enum": ["send"], "type": "string" }, @@ -625,6 +626,7 @@ Full JSON: `codex-dynamic-tools.telegram-direct.json` "type": "string" }, "message": { + "description": "Text for action=\"send\". A send needs message or another send payload such as media, attachments, or presentation.", "type": "string" }, "mimeType": { 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 68a25630ba7e..90f04b8e7b75 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 @@ -209,8 +209,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 53949, - "roughTokens": 13488 + "chars": 54257, + "roughTokens": 13565 }, "openClawDeveloperInstructions": { "chars": 2341, @@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6745 }, "totalWithDynamicToolsJson": { - "chars": 80928, - "roughTokens": 20232 + "chars": 81236, + "roughTokens": 20309 }, "userInputText": { "chars": 1271, @@ -556,6 +556,7 @@ Full JSON: `codex-dynamic-tools.heartbeat-turn.json` "type": "string" }, "action": { + "description": "Select one action. For action=\"send\", provide message or another send payload; fields for other actions do not count as send content.", "enum": ["send"], "type": "string" }, @@ -633,6 +634,7 @@ Full JSON: `codex-dynamic-tools.heartbeat-turn.json` "type": "string" }, "message": { + "description": "Text for action=\"send\". A send needs message or another send payload such as media, attachments, or presentation.", "type": "string" }, "mimeType": {