Repository navigation
fix(discord): tools say what Discord refused and what to do; health and api name their tools - #512
Merged
nissa-seru merged 1 commit intoOct 18, 2026
Conversation
…nd api name their tools The Discord module's tools now tell the agent what was refused and what to do instead, whether Discord refused the request or discord.js refused it before sending one. Each tool's requests run through one place that turns a refusal into a DiscordRefusal, led by what didn't happen and naming the ids the call gave; a refused reply names its reply context's. An error that isn't a refusal says whether the call may have taken effect, and for a send how to check before sending it again. A lookup that stops before a tool changes anything says nothing was done, and an empty message id is refused before any request. A send whose thread wasn't started says the message went out, and one into a channel that can't hold threads says no thread was started. A rate limit, a missing reply context, a module that hasn't started and an unknown tool name each say what to do, and the health and api modules name their own tools for a name they don't have. The notices for a text attachment that couldn't be fetched, and for one the host didn't read in, say what the agent can do instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] This PR appears safe to merge. SummaryThis PR gives Discord refusals clearer explanations and retry advice. It also updates attachment notices and the unknown-tool answers from Discord, health, and API.
Reviews (1) · Last reviewed commit: "fix(discord): tools say what Discord ref..." · Reviewed by Greptile |
1 task done
nissa-seru
pushed a commit
that referenced
this pull request
Oct 18, 2026
…e it opens with withoutGoneAttachments found a note's filename with /^\[Attachment: (.*) \([^()]*\)\]$/, which fits the two note forms the live conversion writes today and no note whose words after the filename differ. A trial merge with an open change that rewords the notes (agent-framework #512) showed it: on that tree an edit kept every note of an attachment it took away, and the edit-attachments test failed. A block is now matched against each attachment still there, by the opening the conversion gives it: an image by its URL path, a file's text by its `\n--- File: <filename> ---\n` header, and a note by `[Attachment: <filename> `, which every note the module has written for an attachment since rows came text-first opens with, whatever its words after it. A block no attachment still there opens stands for one that's gone; a block that stands for no attachment stays, as before. The extraction helper and its regexes go. The edit-attachments test checks the stored notes' openings rather than their words, and adds a row whose notes are in other words: the one whose attachment is still there stays, the other goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
…nima-research#193 (train T2), anima-research#512, anima-research#516 and anima-research#519, with no conflicts Brings in main's first-parent merges since d1b9d35: anima-research#514, anima-research#515 (main at 5f5444d, where 0.22.3 is cut), anima-research#469 (one tool name has one server: refuseMcplConnect refuses a runtime connect or restart before it writes anything), anima-research#193, anima-research#512, anima-research#516 and anima-research#519. Git merged every file on its own and nothing was resolved by hand: git show --remerge-diff is empty. The in-flight refusal Ada placed in this merge (room-203 #143658) follows as its own commit.
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
main is 68 commits past 5f5444d, the 0.22.3 cut this branch merged just before: anima-research#469, anima-research#193, anima-research#512, anima-research#516, anima-research#519, anima-research#522, anima-research#204 and anima-research#523. One hunk conflicted, in runRestartMcplServer: anima-research#469 refuses, before the running server is disconnected, what the connect would refuse (refuseMcplConnect), right after the awareness read this branch narrows to a server that derives its reaction baseline from the journal. Both stay, in that order: the journal read, then anima-research#469's refusal, then the disconnect. The rest merged as git left it. Where main's changes meet this branch's within a few lines, they are independent: anima-research#204's release of deregistered windows at the head of runResume's flush, beside this branch's reopen of the planes in its finally; anima-research#469's cleanup of an id's stale prefix in connectMcplServerInternal, after this branch's journal read; and the fields' comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Discord module's tools now tell the agent what was refused and what to do instead, whether Discord refused the request or discord.js refused it before sending one, and the
discord,healthandapimodules name their own tools when the agent calls one that doesn't exist.This is part of a series of small PRs from a sweep of the errors and notices agent-framework gives agents, against one bar: an error should tell the agent how to fix it, or say plainly that nothing can be done. It rewords #369's notice for a text attachment that couldn't be fetched. The api module's unknown-tool answer keeps the
isErrorthat #367 gave it.Problem
The Discord errors an agent read weren't the module's. In discord.js 14.27,
client.channels.fetch,client.guilds.fetchandclient.users.fetchthrow aDiscordAPIErrorfor an id Discord doesn't know or a target the bot can't reach; they don't returnnullfor those. So a mistyped channel id reached the agent as Discord's bare "Unknown Channel", through the module's catch, with nothing about which id or what to call to find the right one. The module's own messages fired for other cases than their words say:null.list_channels' "Guild X not found" and "channels not available - may need to re-fetch with force" couldn't fire at all, and the second named aforceargument the tool doesn't have.discord.js refuses some calls itself, before any request, with its own errors:
Message#startThreadfor a message whose channel isn't a server's text or announcement channel, or that already has a thread, and a reaction whose emoji it can't read. These reached the agent as discord.js's sentences.A send whose thread failed read as a failed send.
sendwithcreateThreadposts the message and then starts the thread. When the second step failed, the agent read an error and nothing saying the message had gone out, so it could send it again. In a channel that can't hold threads, such as a DM, the thread was skipped and the result was a plain success, with nothing saying so.The rest named a problem without a remedy: "Rate limited", "No conversation context", "Module not started", "Unknown tool: post", the health module's "HealthModule not bound — host must call bind(framework, store) before tool dispatch", and the api module's bare "Unknown tool".
Changes
Refusals (
src/modules/discord/discord-js-client.ts). Each tool's requests run in one place (asked) that turns a refusal of any of them into aDiscordRefusal(new,refusal.ts), led by what didn't happen, except where a retried request of this very call may have done it:create_threadon a message Discord says has a thread, anddeleteof a message that isn't there now. For Discord's refusal, Discord's error is kept as itscause. A tool's change is its last request, so a refusal means it wasn't made. Each sentence names the ids the call gave, and after a send the sent message's.reply's call names none, so a refused reply names its reply context's and says so: "…A reply goes where its reply context points: channel C5, answering message M5. discord--set_reply_context changes that."editUnknown ChannelUnknown Guildlist_channels) Nothing was listed: no server G1 is visible to this bot. discord--list_guilds shows the servers it is in, with their ids.Unknown Messagedelete, with no lead: "Channel C1 has no message M1 now: it's deleted, or the id is wrong…", since an earlier call, or a retried request of this one, may have deleted it.)Unknown Userdm) Nothing was sent: Discord knows no user U1. The id may be mistyped or remembered rather than copied; discord--fetch_history of a channel they wrote in shows its messages' authors' ids.Unknown Emojireact) No reaction was added: Discord doesn't know the emoji ":smile:". Use a Unicode emoji, such as "👍", or a custom one as name:id from a server this bot is in.Missing Accesslist_channelsnames the server;dmnames the user.)Cannot edit a message authored by another userCannot send messages to this userdm) Nothing was sent: Discord won't deliver a direct message from this bot to user U1, usually because they share no server or don't accept direct messages, and calling again won't change that. (sendinto a DM channel names the channel.)Missing Permissionsdmnames no channel or server, so its sentence names no place.)A thread has already been created for this messagecreate_thread) Message M1 has a thread already, whose id is the message's own: discord--send with channelId M1 posts in it. (A repeat call, or a retried request that Discord had already carried out; after a send, below.)MessageThreadParent)create_thread) No thread was started: channel C1 can't hold threads. A message can start one only in a server's text or announcement channel.MessageExistingThread)EmojiType)react) No reaction was added: this bot can't read "" as an emoji, so Discord wasn't asked. Use a Unicode emoji, such as "👍", or a custom one as name:id from a server this bot is in.The thread's id is the message's own because that's how discord.js finds a message's thread (
Message#thread). The leads are "Nothing was sent" (send,reply,dm), "Nothing was edited", "Nothing was deleted", "No reaction was added", "No thread was started", "Nothing was listed" and "No history was read".A channel that holds no messages is named as what it is: "Nothing was sent: channel C1 is a category, which holds channels, not messages. discord--list_channels with a guildId shows the server's channels and their ids." A forum, or a media channel, says its messages are in its posts, each a thread with its own id, which this module has no tool to list, and a channel discord.js returns as
nullsays it's a kind this bot doesn't handle, or that its server isn't loaded here, that calling again won't change that, and to ask whoever runs this host if it's needed.A thread after a send. The message is out by then. When its thread wasn't started,
sendMessageresolves with #488'sthreadFailure, andsendreturns that as an error, asmaindid.DiscordJsClientnow words it to say the message was sent and not to send it again:create_threadcall starts one.In a channel that can't hold threads, such as a DM, the thread is skipped and the send succeeds, as on
main. Its data now carries a note: "No thread was started: channel C2 can't hold threads. A message can start one only in a server's text or announcement channel."Unexpected errors (
src/modules/discord/index.ts). The module's catch passes aDiscordRefusalon as worded. Anything else, such as a network failure, reads "discord--X stopped on an unexpected error:<message>.", followed by:send: "Whether the message was sent is unknown: discord--fetch_history with channelId C1 shows it, so check before you send it again.";replynames the channel its conversation posted in, a thread's if it had one, as it stood when the reply was sent, since a message that arrives meanwhile can move the conversation, and says it's its reply context's, or, when that context names an empty thread id, how to put that right;dm: "Whether the message was sent is unknown. If an earlier discord--dm to this user gave you its channelId, discord--fetch_history with it shows whether this one went; without it, sending it again may send it twice.";edit,react,deleteandcreate_thread: "Whether it took effect is unknown. Calling it again is safe: if it did, the second call changes nothing more.";list_guilds,list_channelsandfetch_history: "It changes nothing on Discord, so you can call it again."; for the others: "Calling it again is safe."A send looks its channel up first;
dmits user, then the direct message channel with them; andedit,delete,reactandcreate_threadthe message they act on, in its channel. A lookup sends, edits and removes nothing, so one that stops on an unexpected error is a refusal with a known outcome, and calling again is safe: opening a direct message channel, the one lookup that can make something on Discord, opens the same channel again. It reads "Nothing was sent: looking up channel C1 stopped on an unexpected error: fetch failed. Calling it again is safe.", or "Nothing was edited: looking up message M1 in channel C1 stopped…". An empty message id is refused before any request, since discord.js fetches a channel's recent messages for one: "Nothing was edited: the call gave no message id. discord--fetch_history with channelId C1 shows its recent messages and their ids."The rest:
send, rate limitRate limitedreply, rate limitRate limitedreply, no contextNo conversation contextactivate,deactivatebefore startModule not starteddiscord--toolUnknown tool: posthealth--toolUnknown tool: statusapi--toolUnknown toolhealth--snapshot, unbound[Attachment: notes.txt (failed to fetch)][Attachment: big.txt (text/plain)][Attachment: report.pdf (application/pdf)]main, and gets the note for an attachment the host didn't read in, which holds for it too; the limit's words come from the one constant that sets it.rateLimitWaitreplacescheckRateLimitand returns them. One second reads "1 second". The window ismain's: after the clock is set back, it ends when the clock reaches its end again, and the wait says how long that is.unknownToolMessage(src/modules/unknown-tool.ts), and each module passes its owngetTools(), so the list can't drift. The registry still hands everymodule--nameto its module, since some hosts' modules route names they don't declare.DiscordRefusalis exported, so anotherDiscordClientInterfaceimplementation can throw one and have its words passed on.list_channels' description reads "List the channels in a Discord server, with their ids. Threads aren't listed.", where it read "List all channels in a Discord server".Two additions, with no outcome changed:
dm's result carries the direct message channel's id beside the message's (channelId), which nothing gave the agent before. The remedy indm's unexpected-error words uses it.What each part is for
askedandDiscordRefusalput every tool's requests through one catch, so a refusal reads as what didn't happen and why, with Discord's error kept as itscause, and the module's catch can tell a refusal, whose outcome is known, from an unexpected error, whose outcome isn't. A catch at each of its eight call sites would repeat the same lines.refusalReason's cases each word one code of the table above: the id the call gave and the call that shows the right one, or who can change what Discord refused.targetpicks the channel, server or user the call named, andoverrideslets the thread after a send word Missing Permissions as starting threads. Any other code keeps Discord's words after the lead.localRefusalReasonwords the three refusals discord.js makes before it sends a request, which reached the agent as discord.js's sentences.lookup: a lookup sends, edits and removes nothing, so one that stops on an unexpected error is a refusal with a known outcome, and the agent can be told that calling again is safe.messageChannel: a category, a forum and a media channel hold no messages of their own, and a channel discord.js returns asnullis a kind this bot doesn't handle or one whose server isn't loaded here. Each is named as what it is, wheremainsaid it wasn't found.rateLimitWaitreturns the seconds left in the channel's window, which the refusal names.unknownToolMessageis one sentence for three modules, each passing its owngetTools(), so a module's list can't drift from its tools.dm'schannelIdis whatdm's unexpected-error words point at to check whether the message went.sendorreplyat another channel. The agent knows its channels, and the message may belong only where it meant it to go, so the refusal says whom to ask.Tests
test/discord-tool-errors.test.ts(new) drives the realDiscordJsClientwith discord.js's lookups stubbed, and the module, health and api with stub clients:cause;startThreadandreactas discord.js throws them;null; an error that isn't a refusal passing through unchanged; and each tool's lookup that stops on one, refused as nothing done: a send's channel, adm's user and its direct message channel, and the messageedit,delete,reactandcreate_threadact on, and each of those four refusing an empty message id without looking anything up;sendwithout permission and to a channel the bot can't see,dm,create_thread,fetch_historyandlist_channels; anddelete's message that isn't there, from its lookup and from the delete itself, worded without a lead;activateanddeactivateincluded; a reply whose conversation moves while it's sent, and one whose context's thread id is empty, which goes to that empty id as it always has and says what to change; and a refused reply, naming its reply context;getTools();reply, into a thread too;replywithout a context,activateanddeactivatebefore start, and health's not-bound message and console line.test/discord-attachment-notice.test.ts(from #369) takes the new notice wording, and adds the attachment the host doesn't read in, the text file too large to read in at 150,000 and at 100,000 bytes, one of 99,999 bytes read in, and one of size 0 left unfetched.test/discord-send-thread.test.ts(#488's) takes the thread after a send in the new words: Discord's refusal, without permission and with Missing Access, a retried start's 160004, an unexpected error, a thread started, and a channel that can't hold one, whose send succeeds with its note, which the module's result carries, and its send whose client throws takes the new unexpected-error words.test/discord-history-host-blocks.test.ts(#488's) takes the new note wording where the live path writes it, its rebuild whose file wasn't fetched included, and keeps the old where a stored row holds it: such a row still counts as holding its note, so the count asks for no rebuild (b11), and only #488's one-time rebuild of an older row of a message without text, such as b4, gives it the new words.test/refusals-are-error-results.test.ts(#367's) expects the api module's new unknown-tool answer, andtest/discord-thoughts-own-turn.test.ts(#445's) the new words of a send or reply over the rate limit and of a reply with no conversation.Red-first: with this PR's changed sources as
mainhas them (its new file kept, andDiscordRefusalexported from the module so the tests load), 33 of the 92 tests in the nine Discord test files and #367's refusals test fail: every test this PR adds and every one whose expected values it changes, 14 indiscord-tool-errors, 4 indiscord-attachment-notice, 3 indiscord-send-thread, 4 indiscord-history-host-blocks, 6 indiscord-thoughts-own-turnand 2 inrefusals-are-error-results. The other 59 pass on the base.Each piece removed on its own (rebuilt, the nine Discord test files and #367's refusals test run each time), 84 pieces, each failing at least one test. The tests are named by these labels: 10003, a channel id Discord doesn't know; a category and channel kinds, the two tests of a channel that holds no messages; each code, the per-code test; lookups, an error that isn't Discord's answer; the module's catch; dm's channel id; a refused reply; unknown tools; send's rate limit and reply's rate limit; reply and activate; health; not fetched, not read in and too large, the attachment notes; thread after a send and the module's note, in
discord-send-thread; #488's throwing send, indiscord-send-thread; #488's live rows, #488's rebuild, #488's race and #488's not fetched, indiscord-history-host-blocks; #445's thoughts, indiscord-thoughts-own-turn; #367's model and #367's script and module, inrefusals-are-error-results; and empty message id.These ran on Linux, on Node 24.22.0, with a clean typecheck under TypeScript 7.0.2. The pieces ran just before this branch was rebased past #497, a rebase that kept one import beside #497's in
src/modules/health/index.tsand changed no piece; the typecheck, the tests and red-first ran again after it, with the same results.CI passes at
ea917e65: Ubuntu and macOS on Node 20 and 24, the packed release's install and the changelog check (runs 39118930806 and 39118930846).Not verified
RESTJSONErrorCodesas discord.js 14.27 exports them, and the tests constructDiscordAPIErrors and discord.js's own errors rather than receive them. discord.js's side was run against a fake Discord API on localhost, outside this PR: its channel, server and user lookups threwDiscordAPIErrors with these codes for 404 and 403 answers, came backnullonly for a server channel whose server wasn't loaded and for a channel type it doesn't make, and its refusals of a thread or an emoji came with no request sent.@discordjs/rest2.6.3'shandleErrors, which answers a 5xx below its retry limit by sending the request again, but not reproduced.Out of scope
activate), speech with no reply context, or over the rate limit, is dropped with only a console line. That's a dropped message, not wording, so it isn't changed here.DiscordJsClient.fetchMessageandsendTyping, which only the module's speech path and its typing indicator use, keep their own errors, which reach only a console line.changelog.d/fix-discord-api-health-errors.fixed.mdFound by Phoebe-2419's sweep of agent-facing errors, and by this change's internal reviews; Ada-1017 settled the design. Our internal reviews read the change, its earlier fix rounds, and the whole change again on
main. Alfred-2451 read the cut that took out its offers of another channel, and Esther-2584 read the whole change with its last fix round and its rebase ontomain.Written by Alfred-2570 (Claude Opus 5.5), contributing through nissa-seru.
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared
nissa-seruaccount.Description checked and posted by Tabitha-2696 (Claude Opus 5.5).