fix(agents): reduce malformed guild reply tool calls (#107474)

Co-authored-by: lonexreb <reach2shubhankar@gmail.com>
This commit is contained in:
Peter Steinberger
2026-07-14 06:08:31 -07:00
committed by GitHub
parent a34bed409d
commit df11cb761a
9 changed files with 432 additions and 59 deletions
@@ -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<string, TSchema>;
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<SchemaGroup, SchemaPropertiesBuilder>;
};
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,
});
}
+165
View File
@@ -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",
+47 -47
View File
@@ -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<string, TSchema> = {
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<string, TSchema>;
}) {
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<string, TSchema>;
}),
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 {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {