Skip to content

fix(tune-out): say what didn't happen, and what to do instead - #411

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/tune-out-errors
Oct 17, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/tune-out-errors

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR: the tune-out coordinator's refusals now say that nothing changed, why, and what to do instead. tune_out on 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, and speak_in_channel no 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_out on 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.
  • A cancel of a channel that isn't tuned out, through tune_out or the subconscious's cancel_tuneout: channel X is not tuned out, with no word of which channels are.
  • A fork-bound channel: 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.
  • The subconscious's tools: empty summary, key required and empty 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.
  • No subconscious: tune-out requires the subconscious resident (framework config \subconscious`)`.

Changes

  • Each of the coordinator's own refusals that an agent can reach opens with what didn't happen: "Nothing changed", "Nothing was cancelled", "Nothing was delivered", "Nothing was recorded" or "Nothing was posted".
  • tune_out on 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 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: 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 opens Nothing was cancelled: no connected channel is tuned out now.
    • Why "connected": the coordinator finds tune-outs through the channels the registry has registered. Removing an MCPL server at runtime drops its channels and keeps their tune-outs, so the coordinator can neither list nor find a tune-out there, and the text claims nothing about one.
    • The backlog's reader is named as the message's reader knows the resident: "you" for the resident's tune_out, "the resident" for the subconscious's cancel_tuneout.
  • 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 conversation router ends a conversation once no message has been delivered to it for its idle time, 12 hours by default.
  • The subconscious's tools say what they need: deliver_summary the summary in text, adding that staying silent is allowed; note_disposition a key, with an example and how to delete a note; speak_in_channel the message in text.
  • speak_in_channel where channel speech is off says the resident can speak there: tell it what's needed with deliver_summary, or, if the channel is tuned out and needs the resident now, end the tune-out with cancel_tuneout. The configuration key goes to a [tune-out] log line for whoever runs the host.
  • No subconscious: the coordinator's enter refuses without one. tune_out exists 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.

  • The refusals' texts, each opening with what didn't happen, are the change itself. The texts before are the simpler shape, and the Problem section shows where they left the reader without a next step.
  • An already tuned-out channel's settings come from the tune-out the coordinator holds, so a resident that wants other settings learns them, and how to change them, in one message.
  • A cancel's refusal names the connected channels tuned out, from the list the coordinator already keeps. It says "connected" because the coordinator finds tune-outs only through registered channels.
  • The end time is left out where it's past what a Date can hold, one condition, since toISOString would throw there and replace the refusal with an error of its own.
  • The fork-bound text says the conversation ends once the channel has been quiet for a while, with no number, since the idle time is the host's setting. The 12-hour default is in this description.
  • The configuration key goes to a [tune-out] log line, where the host's operator reads it, rather than to the subconscious.
  • The tests, test/tune-out-messages.test.ts (110 lines) and the changed cases in test/refusals-are-error-results.test.ts, pin each text whole.

Cut along the way: a backlogTo parameter on cancel(). 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 and cancel_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 a Date can hold; a cancel_tuneout after the tuned-out channel's server is removed, on the real channel registry, which keeps the tune-out; and speak_in_channel's log line. In test/refusals-are-error-results.test.ts, the texts the model receives change with them.

  • Red first: on the parent commit's src with this change's two test files, 7 of their 8 tests fail: the 4 new ones, and 3 of the 4 in refusals-are-error-results.test.ts, whose test of a script and an in-process caller passes on both.
  • Each piece: 17 pieces were each undone on their own, and every one fails at least one test of those two files. They cover each refusal's wording, the end time's guard, its text and its milliseconds, the channels listed, all of them and as connected ones, the opening when no connected channel is tuned out, the fork-bound sentence, the backlog's reader for the subconscious, and speak_in_channel's log line.
  • The suite: At this head merged onto main 14e25dc0, each of its 152 test files run on its own on Node 24.22.0: 2386 tests, all passing. main 14e25dc0 run 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's tune-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.
  • 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/38983951317.

Not verified

  • Windows. Our runs were on Linux, Node 24.22.0, and our run of CI's test step on Node 20.20.2 too. GitHub's CI ran on Ubuntu and macOS.

Out of scope

  • tune_out's unknown channel refusal, which the framework's dispatch writes, not the coordinator.
  • The channel registry's publish refusals, which speak_in_channel passes on as they are (Channel not found: X, Server not found: S, a missing grant). They're reworded separately, for speak_in_channel as well.
  • Where the coordinator doesn't yet keep the contract its texts describe. A tune-out whose server was removed can't be cancelled by the resident or the subconscious, as above. And the timer that ends a tune-out at its duration is handed the remaining time unclamped, and Node sets a delay over 2³¹−1 ms, about 24.8 days, to 1 ms. Each is left for a change of its own.
  • The event gate's messages, which a follow-up pull request rewords once the gate's validation settles.

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 of tune_out's modes, and its description says a cancel delivers the backlog; an empty text deletes 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 in src, the tests or connectome-host's src matches 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_out won'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

Hamish-2569 and others added 2 commits October 15, 2026 00:40
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>
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; no new actionable issues were found.

Summary

This PR marks tool refusals as errors and explains what did not happen and what to try next.

  • Tune-out refusals explain what happened and what to try next.
  • Tool refusals reach callers as errors, not successful answers.

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 nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
@nissa-seru
nissa-seru merged commit 187146e into anima-research:main Oct 17, 2026
7 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant