Repository navigation
fix(tools): a refusal is an error result - #367
Merged
nissa-seru merged 1 commit intoOct 17, 2026
Merged
Conversation
tune_out's refusals, channel_open on a tuned-out channel, all but one of
the subconscious's refusals and the api module's "Unknown tool" came back
unflagged: isError false, or for the api module no isError at all
(deliver_summary's "subconscious not configured" was already flagged). A
result that isn't an error reaches the model as a tool's ordinary answer:
in native tool use without is_error, in XML tool mode as
<result><stdout>, and in a script's inner call without "Error: ". So the
model could take a refusal for a success. channel_open's refusal also
carries data ({refusal: 'tuned-out', channelId}), and a result that isn't
an error is rendered from its data, so the model saw only that JSON and
never the sentence pointing it to tune_out's cancel mode.
Each now returns isError: true, and the model reads the refusal's text:
- framework.ts, dispatchTuneOutToolCall: an unknown channel, a cancel the
coordinator refuses, and an enter it refuses (already tuned out, a
channel bound to a conversation fork, or no subconscious, which can't
happen, since the coordinator exists only with one).
- mcpl/channel-registry.ts, handleToolOpen: a tuned-out channel. The
refusal keeps its data, so an in-process caller can still read why.
- tune-out/coordinator.ts, handleSubconsciousTool: deliver_summary with no
text, cancel_tuneout of a channel that isn't tuned out, note_disposition
with no key, and speak_in_channel with channel speech off or no text.
cancel_tuneout's second refusal, the cancel failing after its lookup
found the channel, can't be reached, since both read the same state
synchronously; it changes with the rest.
- modules/api/index.ts: "Unknown tool".
Each message's wording is unchanged here; follow-up pull requests reword
them. channel_close's refusal of a machine close of an explicit open
stays isError false. It answers module-origin closes only, so its result
goes back to the module that called, never straight to a model.
test/refusals-are-error-results.test.ts makes a refusal through each
reachable flag as a model-issued call and checks what goes back to the
model: an error result, with the refusal's text. A fourth test makes the
same refusals from a script, which gets "Error: <text>", and from a
module calling in process, which gets the result flagged, with the
tuned-out refusal's data kept. All four tests fail on main's src.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] This PR appears safe to merge; no actionable issues were found. SummaryMarks existing
No actionable issues found. Review used code inspection; tests were not run. Reviews (1) · Last reviewed commit: "fix(tools): a refusal is an error result" · Reviewed by Greptile |
This was referenced Oct 16, 2026
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 17, 2026
main had taken 26 commits since 3af13bf: anima-research#367 (a refusal is an error result), anima-research#411 (tune-out errors), anima-research#453 (quiesce writes its record first) and anima-research#207 (end-turn accounting). One conflict, in src/framework.ts: both sides add a private field to AgentFramework at the same place, anima-research#247's inboundAcceptanceObserver and anima-research#207's streamBoundaryEnds. Both are kept. None of main's new lines reads or writes anima-research#247's turn silence, its cause, its drafts or its receipts. anima-research#207 records a tool-ended turn as a completion at the endTurn boundary itself, and leaves driveStream's turn_ended cancel, where the turn's receipt is written, as it was. 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 17, 2026
…ima-research#367, anima-research#411, anima-research#453 and anima-research#207 The conflict, and what it keeps: - runResume's finally: anima-research#453's reset of resumeInFlight, then this branch's reopen of the MCPL data planes with readyMcplPlanes(), with no data-plane gate. anima-research#453 changed only the flag there. The barrier funnel beside it on main is the gate this branch removes. anima-research#453's other changes merge without a conflict. quiesce() writes the host-mode record before it sets the flag, createOn marks a quiesced boot's record as possibly quiesced, and a resume() on a serving host clears the record when it may still say quiesced. anima-research#367, anima-research#411 and anima-research#207 merge without a conflict, and in src/framework.ts and src/types/framework.ts they change no member or declaration this branch changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 18, 2026
This was referenced Oct 18, 2026
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.
TL;DR: a refusal now reaches the model as an error result.
tune_out's refusals,channel_openon a tuned-out channel, all but one of the subconscious's refusals and the api module'sUnknown toolcame back unflagged, so the model read each one as a tool that ran and answered.channel_open's refusal also showed the model only{"refusal":"tuned-out","channelId":"…"}, never its sentence saying how to cancel the tune-out. This PR changes the flags only; follow-up PRs reword the messages, and some of them depend on this one.Problem
A tool result that isn't an error reaches the model as the tool's ordinary answer: in native tool use, a
tool_resultwithoutis_error; in XML tool mode,<result><stdout>…</stdout></result>rather than<error>; in acode_executionscript, the bare text rather thanError: …. These refusals were returned that way, withisError: falseor, for the api module, noisErrorat all:tune_out(src/framework.ts,dispatchTuneOutToolCall): an unknown channel; a cancel the coordinator refuses (channel <id> is not tuned out); and an enter it refuses: a channel already tuned out, a channel bound to a conversation fork, or no subconscious.channel_openon a tuned-out channel (src/mcpl/channel-registry.ts,handleToolOpen). The refusal also carriesdata: { refusal: 'tuned-out', channelId }, and a result that isn't an error is rendered from itsdata(nonErrorResultText). So the model read{"refusal":"tuned-out","channelId":"<id>"}and never the refusal's text, the only place that says to calltune_outwith modecancel.src/tune-out/coordinator.ts,handleSubconsciousTool):deliver_summarywith no text,cancel_tuneoutof a channel that isn't tuned out,note_dispositionwith no key, andspeak_in_channelwith channel speech off or with no text. (deliver_summary'ssubconscious not configuredwas already flagged.)src/modules/api/index.ts), which answers anyapi--<name>call withUnknown tooland noisError.Changes
Each of those results now has
isError: true. What the model receives, before and after:tune_outon an unknown channelunknown channel: <id>tune_outmodecancelon a channel that isn't tuned outchannel <id> is not tuned outtune_outon a channel already tuned outchannel <id> is already tuned out (cancel first to change params)channel_openon a tuned-out channel{"refusal":"tuned-out","channelId":"<id>"}Channel <id> is tuned out. Cancel the tune-out (tune_out with mode: 'cancel') to resume normal attention — cancelling delivers the diverted backlog.deliver_summarywith no textempty summarycancel_tuneoutof a channel that isn't tuned outchannel <id> is not tuned outnote_dispositionwith no keykey requiredspeak_in_channelwith channel speech offchannel speech is disabled for the subconscious (recipe: subconscious.allowChannelSpeech)speak_in_channelwith no textempty messageapi--<name>Unknown toolError: <text>, as for any failed call.data. An in-process caller (ModuleContext.callTool) gets the same result as before withisErrorset, and can still readrefusal.tune_out's "requires the subconscious resident" can't be reached, since the coordinator, and with it thetune_outtool, exists only when a subconscious is configured. Neither cancancel_tuneout's second refusal, a cancel that fails after its lookup found the channel, since the lookup andcancelTuneOutread the same state synchronously.channel_close's refusal of a machine close of an explicitly opened channel (handleToolClose) staysisError: false. It answers module-origin closes only, so its result goes back to the module that called, never straight to a model, and a module may branch on it. connectome-host's two callers readsuccess,erroranddata.refusal, not the flag: its subscription GC stands down ondata.refusal, and its channel-mode module putserrorinto its own result, which it flags itself.What each part is for
This change is for the model, whose tooling reads
isErrorto tell a failed call from one that worked: a refusal should be an error result. Each part was weighed against what it costs to keep and against something simpler, leaving it out included.isError: truewhere each refusal is returned, in four files. Two of the flags are at sites that each return several. The wording waits for the follow-up pull requests, so this change is the flag alone.data. With the flag the model reads itserrorsentence, where it saw onlydata's JSON before. Droppingdatawould be simpler, but an in-process caller (ModuleContext.callTool) can readrefusalthere.channel_close's refusal of a machine close stays as it is: it answers modules only, which may branch on it, and a model never sees it.test/refusals-are-error-results.test.ts(190 lines) calls each tool as a model, a script or an in-process module does, and reads the result's flag and text.Cut: nothing. Each part is the flag where a refusal is returned.
Tests
test/refusals-are-error-results.test.ts(new, 4 tests) runs a resident and its subconscious against the tune-out MCPL fixture, with the api module loaded.tune_outenter that succeeds still reads as a success, with its parameters.tune_out, the api module andchannel_opengetsError: <text>, with the same control. A module'sctx.callToolofchannel_opengets the registry's result whole, flagged and with itsdata, and of the api module the flaggedUnknown tool.The tests protect what an agent is told: a resident or the subconscious can tell a refused call from one that ran, and reads why it was refused. They also protect what an in-process caller reads: the flag, and the tuned-out refusal's
data.main'ssrc(92125b07) with this test file, all 4 tests fail. Forchannel_open, a probe onmainshowed the model receives{"refusal":"tuned-out","channelId":"disc:guild:noisy"}withisError: false.mainhas it, on its own, and the new file run after a rebuild. Each of the 10 reachable ones fails a test, and the unreachablecancel_tuneoutone fails none. Removing the tuned-out refusal'sdatafails the fourth test.node --test --test-force-exitoverdist/test/*.test.js) passes 2233 of 2233 on Node 24.21.0 and on Node 20.20.2, and of its unforced step, 103 of 103 on each. GitHub's CI passed at this head on Ubuntu and macOS, each on Node 20 and Node 24: https://github.com/anima-research/agent-framework/actions/runs/38685007996. Each of the 145 test files, run on its own on Node 24.21.0, passes: 2233 tests in all.main(92125b07), run file by file the same way, passes 2229 tests in 144 files. The difference is this PR's new file and its 4 tests.mainatca512a0) shows a stored tool result's flag in its web UI (tool result · error) and in its/puppetcommand (→ error, with the error text in place of the data). Both now show these refusals as errors, and/puppetshows a puppetedchannel_openrefusal's sentence where it showed itsdata.channel_openandchannel_closein process and readsuccess,erroranddata.refusal, which this leaves as they were.success: falseresult as an error (src/mcpl/tool-lifecycle.ts,onResult), so they don't change.git merge-treetrial against the 59 open PRs (Oct 15): the 27 that merge cleanly withmainmerge cleanly with this branch too, and the other 32 conflict with both. Three diffs come near these results, and none asserts their flags or texts. fix(tune-out): enforce diversion across live delivery and recovery #201's test callsspeak_in_channelwith channel speech off and asserts onlysuccess: false. feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262 and feat(mcp): MCP servers on the 2025 revisions over Streamable HTTP #337 rework the script path's result handling and keepError: <text>for an error result.Not verified
isErroras thetool_resultblock'sis_error(src/formatters/native.ts,src/providers/anthropic.ts);<error>…</error>, and any other result as<result><stdout>…</stdout></result>(formatToolResults,src/utils/tool-parser.ts).puppetToolCallisn't run here. It callsexecuteToolCalland stores the pair as a model's call is stored.Out of scope
dispatchChannelToolCalltracestool:completedfor every result its promise resolves with. So a channel tool's error result, such aschannel_openon an unknown channel, was already traced as completed onmain, and the tuned-out refusal joins them.tune_outand the subconscious's tools trace none. Both are operator-facing and unchanged here.isError: falseshape, which would want the same flag when it lands.Review
Reviewed in the house: an internal review of the change before publication, whose findings this head answers. Naomi-2422 then read it whole. She traced each changed flag to an agent by its code, through the model's path, a script's and an in-process caller's, with membrane 0.5.87 packed to read its rendering. She read connectome-host's readers of these results, and checked that
channel_close's module-only refusal is rightly left. She ran the new file at this head on Node 24.21.0 and 20.20.2, where it passes 4 of 4, and on Node 24.21.0 withmain'ssrc, where all 4 fail.The sites came from Phoebe-2419's sweep of agent-framework's agent-facing errors. Ada-1017 made the design calls: the tuned-out refusal keeps its
data, and this PR changes the flags only.— Hamish-2569 (Opus), contributing through nissa-seru