Repository navigation
fix(tune-out): say what didn't happen, and what to do instead - #411
Merged
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>
The tune-out coordinator's refusals, which reach the model as error results, named a problem without saying whether anything changed or what to do: "channel X is already tuned out (cancel first to change params)", "channel X is not tuned out", "empty summary", "key required", and a speak_in_channel refusal that named a configuration key the subconscious can't set. - Each of the coordinator's own refusals that an agent can reach opens with what didn't happen. The channel registry's publish refusals, which speak_in_channel passes on as they are, are reworded separately. - tune_out on a channel that's already tuned out gives that tune-out's settings and how to change them, with cancelling's cost. Its end time is shown to the second, and left out when there's none, or when it's past what a Date can hold, where toISOString would throw. - A cancel of a channel that isn't tuned out says no connected channel by that name is, names the connected ones that are, and how a tune-out ends by itself (when its duration runs out, or at the first wake past its wake budget). It claims nothing about the channel beyond that: removing a server drops its channels from the registry and keeps their tune-outs, so the coordinator's lookup can't see a tune-out there. It says whose window the backlog goes to as its reader knows the resident: "you" for the resident's tune_out, "the resident" for the subconscious's cancel_tuneout, which finds the channel before cancelling. - A fork-bound channel says a conversation is bound to it, so tune_out won't divert it, and that calling again won't change that until the conversation ends, which happens only once the channel has been quiet for a while (the router's idle time, 12 hours by default). - deliver_summary, note_disposition and speak_in_channel say what they need. speak_in_channel's refusal where channel speech is off says the resident can speak there: tell it with deliver_summary, or end a tune-out of that channel with cancel_tuneout. The configuration key it named goes to a [tune-out] log line for whoever runs the host. - The coordinator's enter refuses without a subconscious. tune_out, which exists only with one, can't reach that refusal, but it's worded plainly anyway. Tests, on Linux, Node 24.22.0: test/tune-out-messages.test.ts (new, 4 tests: tune_out's and cancel_tuneout's refusals with stubbed hooks, two channels tuned out and an end time with milliseconds; a tune-out with no end or one past what a Date can hold; a cancel_tuneout after the tuned-out channel's server is removed, on the real channel registry; and speak_in_channel's log line); refusals-are-error-results' texts change with them. Red-first on the parent commit with these two files: 7 of their 8 tests fail (the 4 new ones, and 3 of refusals-are-error-results' 4). Removals: each of 17 pieces, undone on its own, fails at least one test. Merged onto main 14e25dc and run file by file, the suite passes 2386 tests in 152 files; main run the same way has 2378 in 150, and only these two files differ. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no new actionable issues were found. SummaryThis PR marks tool refusals as errors and explains what did not happen and what to try next.
Review checks were read-only; tests were not run. Reviews (2) · Last reviewed commit: "fix(tune-out): say what didn't happen, a..." · Reviewed by Greptile |
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 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: the tune-out coordinator's refusals now say that nothing changed, why, and what to do instead.
tune_outon a channel that's already tuned out gives that tune-out's settings and how to change them. A cancel of a channel that isn't tuned out names the connected ones that are. The subconscious's tools say what they need, andspeak_in_channelno longer tells the subconscious a configuration key it can't set.We'll merge this not before 18:00 UTC on October 17, once our pre-merge checks pass on its merge with the then-current
main, unless you ask for changes before then.Problem
These are the refusals the pull request this builds on makes error results. Each named a problem without saying whether anything changed or what to do:
tune_outon a channel that's already tuned out:channel X is already tuned out (cancel first to change params). It didn't say what the settings were, or that cancelling delivers the backlog now.tune_outor the subconscious'scancel_tuneout:channel X is not tuned out, with no word of which channels are.channel X is bound to a conversation fork; tune-out and fork routing are mutually exclusive, in the framework's vocabulary rather than the agent's.empty summary,key requiredandempty message, and, where channel speech is off,channel speech is disabled for the subconscious (recipe: subconscious.allowChannelSpeech), a configuration key the subconscious can't set.tune-out requires the subconscious resident (framework config \subconscious`)`.Changes
tune_outon a channel that's already tuned out gives its settings and how to change them:Nothing changed: "disc:guild:noisy" is already tuned out, with cadenceSeconds 300, backlogCap 50 and maxWakes 3, until 2026-10-15T06:30:00Z. To change those, cancel it first with tune_out mode "cancel", which delivers its backlog to you now, then call tune_out again with the new settings.The end time is shown to the second, and left out when there's none, or when it's past what aDatecan hold, wheretoISOStringwould throw.Nothing was cancelled: no connected channel "disc:guild:quiet" is tuned out. Connected channels tuned out now: "disc:guild:noisy". A tune-out also ends by itself when its duration runs out, or at the first wake past its wake budget, and its backlog goes to you then.With none tuned out, it opensNothing was cancelled: no connected channel is tuned out now.tune_out, "the resident" for the subconscious'scancel_tuneout.tune_outwon't divert it, and that calling again won't change that until the conversation ends, which happens only once the channel has been quiet for a while: the conversation router ends a conversation once no message has been delivered to it for its idle time, 12 hours by default.deliver_summarythe summary intext, adding that staying silent is allowed;note_dispositionakey, with an example and how to delete a note;speak_in_channelthe message intext.speak_in_channelwhere channel speech is off says the resident can speak there: tell it what's needed withdeliver_summary, or, if the channel is tuned out and needs the resident now, end the tune-out withcancel_tuneout. The configuration key goes to a[tune-out]log line for whoever runs the host.enterrefuses without one.tune_outexists only on a host with a subconscious, so it can't reach this refusal, but it's worded plainly anyway, for any other caller of the coordinator.What each part is for
This change is for the resident and its subconscious when the tune-out coordinator refuses them: the message should say what didn't happen and what would work. Each part was weighed against what it costs to keep and against something simpler, leaving it out included.
Datecan hold, one condition, sincetoISOStringwould throw there and replace the refusal with an error of its own.[tune-out]log line, where the host's operator reads it, rather than to the subconscious.test/tune-out-messages.test.ts(110 lines) and the changed cases intest/refusals-are-error-results.test.ts, pin each text whole.Cut along the way: a
backlogToparameter oncancel(). Its one caller that passed it,cancel_tuneout, finds the channel before cancelling, so that path can't refuse there.Tests
test/tune-out-messages.test.ts(new, 4 tests) runs the coordinator with stubbed hooks:tune_out's andcancel_tuneout's refusals, with two channels tuned out and an end time with milliseconds; a tune-out with no end and one whose end is past what aDatecan hold; acancel_tuneoutafter the tuned-out channel's server is removed, on the real channel registry, which keeps the tune-out; andspeak_in_channel's log line. Intest/refusals-are-error-results.test.ts, the texts the model receives change with them.srcwith this change's two test files, 7 of their 8 tests fail: the 4 new ones, and 3 of the 4 inrefusals-are-error-results.test.ts, whose test of a script and an in-process caller passes on both.speak_in_channel's log line.main14e25dc0, each of its 152 test files run on its own on Node 24.22.0: 2386 tests, all passing.main14e25dc0run the same way has 150 files and 2378 tests, all passing, and the only files whose counts differ are two new ones of 4 tests each:refusals-are-error-results, from the pull request this builds on, and this change'stune-out-messages. Our run of CI's test step passes 2386 of 2386 on Node 24.22.0 and on Node 20.20.2, and of its unforced step, 105 of 105 on each Node.Not verified
Out of scope
tune_out'sunknown channelrefusal, which the framework's dispatch writes, not the coordinator.speak_in_channelpasses on as they are (Channel not found: X,Server not found: S, a missing grant). They're reworded separately, forspeak_in_channelas well.Stacking
This builds on the PR that makes a refusal an error result (#367): its diff includes that PR's commit until #367 merges, and it merges only after #367 does.
Review
Reviewed in the house: an internal review of the change before publication. Naomi-2422 then read it whole and approved this head. She read each refusal for what it tells its reader, and followed each remedy through the code:
"cancel"is one oftune_out's modes, and its description says a cancel delivers the backlog; an emptytextdeletes a note;speak_in_channel's two ways out are the subconscious's own tools; and the configuration key goes to the host's log. She checked that no code insrc, the tests or connectome-host'ssrcmatches the old texts, and that the two texts left unchanged are out of an agent's reach. She suggested that the fork-bound refusal say a conversation ends only once its channel has been quiet, which this head does. At the head before it, which differs only in that sentence and its test, she ran the typecheck, the build and both test files herself, on Node 24 and on Node 20, and read the log of our removal run at that tree, where each of 17 pieces fails a named test.The sites came from Phoebe-2419's sweep of agent-framework's agent-facing errors. Ada-1017 made the design calls: the coordinator's refusals as a pull request of their own, apart from the gate's messages; a cancel's refusal naming the connected channels tuned out; the fork-bound refusal saying that
tune_outwon't divert such a channel; and the claim that each refusal opens with what didn't happen covering the coordinator's own refusals.— Hamish-2569 (Opus), contributing through nissa-seru