Skip to content

Proposal: tune_out's check, peek and cancel reach a tune-out whose channel its server removed #950

Open

Description

@nissa-seru

We propose changing three rules that agent-framework's changelog gives tune_out and the subconscious's cancel_tuneout, so that a tune-out whose channel its server removed is listed by check, and its channel can be peeked and cancelled. Under those rules, once an MCPL server sends channels/changed with a tuned-out channel in removed, check no longer lists the tune-out, and peek and tune_out's cancel refuse its channel as unknown. In our run at main (a64cfc010f), a tune-out entered with no durationSeconds then stayed on record with its message held. None of the resident's cancels ended it, and neither did the subconscious's cancel_tuneout by the channel's id. tune_out's published text says such a tune-out lasts "until cancelled" (its durationSeconds parameter) and that check "reports what is held" (its description), so for this case the published statements disagree, and we propose that the changelog's rules yield.

@antra-tess, we're asking you to say whether agent-framework should change these rules, since each is part of what it documents.

Below are the run at main, the published statements on each side with where they're published, the rules we propose in place of the changelog's and why, and what the run doesn't show.

What happens at main

We ran agent-framework at a64cfc010f with a probe's two files added, on Linux with Node 24.22.2 and npm 11.19.1, after npm install --ignore-scripts in a new copy (the repository has no lockfile). npm ls --depth=0 gave @animalabs/context-manager 0.24.2, @animalabs/membrane 0.8.0, @animalabs/chronicle 0.5.1 and @animalabs/mcpl-core 0.4.0. The probe's server is a copy of the repository's test/fixtures/tune-out-mcpl-server.mjs with one more command, which sends channels/changed with its channel in removed. The probe:

  • tuned the resident out of disc:guild:noisy with no durationSeconds, and let one message arrive and be held;
  • had the server remove the channel;
  • called the resident's tune_out through the framework's bridge for a script's tool calls, and the subconscious's cancel_tuneout through TuneOutCoordinator.handleSubconsciousTool.

It printed:

[probe] enter, no durationSeconds: ok: {"tunedOut":"disc:guild:noisy","cadenceSeconds":3600,"backlogCap":200,"maxWakes":5}
[probe] check before the removal: ok: {"allExcept":null,"channels":[{"serverId":"disc","channelId":"disc:guild:noisy","epochId":"9fe53fbb-9f75-42a5-b863-a9c4f510b238","held":1}]}
[probe] check after the removal: ok: {"allExcept":null,"channels":[]}
[probe] check, peek by id: refused: unknown channel: disc:guild:noisy
[probe] cancel by id: refused: unknown channel: disc:guild:noisy
[probe] cancel by id with serverId: refused: unknown channel: disc:guild:noisy
[probe] cancel by server-qualified name: refused: unknown channel: disc:disc:guild:noisy
[probe] subconscious cancel_tuneout by id: {"success":false,"error":"Nothing was cancelled: no connected channel is tuned out now. 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 the resident then.","isError":true}
[probe] after all of these: tune-out on record true, messages held 1, backlog delivered false
[probe] subconscious cancel_tuneout by id with serverId, which its schema doesn't declare: {"success":true,"data":{"cancelled":true}}
[probe] then: tune-out on record false, backlog delivered true

The one call that ended the tune-out gave cancel_tuneout a serverId. cancel_tuneout's published input schema declares only channelId, scope and text (dist/src/tune-out/tools.js:39–46 in 0.31.0).

The lines behind this at a64cfc010f:

  • handleChanged deletes a removed channel's entry: this.channels.delete(destinationKey(ref)) (src/mcpl/channel-registry.ts:1720).
  • check() builds its list from listActive() (src/tune-out/coordinator.ts:1248), which reads this.channelRegistry.listChannelsRaw() (:549).
  • tune_out resolves peek and its channel through resolveChannel (src/framework.ts:36841, :36860), which returns the entry resolveToolChannelEntry finds, or an error (src/mcpl/channel-registry.ts:2363–2366).
  • The subconscious's refusal is notTunedOut's (src/tune-out/coordinator.ts:2106), which lists what listActive() returns.

A connector removes channels this way. In discord-mcpl 0.6.0 (4bde04b), forgetChannels (src/server.ts:3298) calls this.conn.sendNotification(method.CHANNELS_CHANGED, { removed }) (:3302). Its callers include if (thread.archived) this.forgetChannels([id]); (:3335), the onChannelDelete and onThreadDelete handlers (:4146–4147, through channelDeleted, :3482) and the onGuildDelete handler (:4124–4125). We read these lines and haven't run them.

The probe: test/fixtures/zz-remove-server.mjs and test/zz-removed-tune-out.probe.test.ts

We built it with npm run build and npm run pretest, which copies test/fixtures/*.mjs into dist/test/fixtures, and ran node --test --test-reporter=spec dist/test/zz-removed-tune-out.probe.test.js.

test/fixtures/zz-remove-server.mjs is test/fixtures/tune-out-mcpl-server.mjs with this added:

--- a/test/fixtures/tune-out-mcpl-server.mjs
+++ b/test/fixtures/zz-remove-server.mjs
@@ -35,6 +35,10 @@
   for (const line of lines.slice(processedCommands)) {
     processedCommands++;
     const [kind, messageId, ...rest] = line.split(' ');
+    if (kind === 'remove') {
+      send({ jsonrpc: '2.0', id: nextId++, method: 'channels/changed', params: { removed: [CHANNEL_ID] } });
+      continue;
+    }
     const text = rest.join(' ');
     const tags = kind === 'addressed'
       ? ['chat:mention', 'chat:from-human']

test/zz-removed-tune-out.probe.test.ts:

// Probe (not a change): a tune-out with no end time on a channel its server
// then removes by channels/changed, as a connector does when a channel is
// deleted or a thread archives. Prints what the resident's tune_out and the
// subconscious's cancel_tuneout answer about it afterwards, and whether it is
// still held.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { AgentFramework } from '../src/index.js';
import { MockMembrane } from './helpers/mock-membrane.js';
import { answerText, scriptCall } from './helpers/script-call.js';

const FIXTURE = join(import.meta.dirname, 'fixtures/zz-remove-server.mjs');
const CHANNEL = 'disc:guild:noisy';

async function waitFor(cond: () => boolean, what: string): Promise<void> {
  const deadline = Date.now() + 15_000;
  while (!cond()) {
    if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`);
    await new Promise((r) => setTimeout(r, 20));
  }
}

test('probe: a tune-out with no end time on a channel its server removes', async (t) => {
  const tempDir = mkdtempSync(join(tmpdir(), 'zz-removed-tune-out-'));
  const commandPath = join(tempDir, 'commands.txt');
  writeFileSync(commandPath, '');
  const fw = await AgentFramework.create({
    storePath: join(tempDir, 'test.chronicle'),
    membrane: new MockMembrane().asMembrane(),
    agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }],
    subconscious: { enabled: true, systemPrompt: 'You are the Subconscious.' },
    mcplServers: [{ id: 'disc', command: process.execPath, args: [FIXTURE], env: { COMMAND_PATH: commandPath } }],
    modules: [],
  });
  t.after(async () => { await fw.stop(); rmSync(tempDir, { recursive: true, force: true }); });
  const i = fw as unknown as {
    channelRegistry: { listChannelsRaw(): unknown[]; getTuneOutState(s: string, c: string): unknown };
    tuneOutCoordinator: { handleSubconsciousTool(name: string, input: Record<string, unknown>): Promise<unknown> };
    agents: Map<string, { getContextManager(): { getAllMessages(): Array<{ content: Array<{ type: string; text?: string }>; metadata?: Record<string, unknown> }> } }>;
  };
  await fw.start();
  await waitFor(() => i.channelRegistry.listChannelsRaw().length === 1, 'registration');
  const call = async (input: Record<string, unknown>) => {
    const answer = await scriptCall(fw, 'scout', 'tune_out', input);
    return `${answer.refused || answer.isError ? 'refused' : 'ok'}: ${answerText(answer)}`;
  };
  const scout = i.agents.get('scout')!.getContextManager();
  const held = () => scout.getAllMessages().filter((m) => (m.metadata as { tuneOut?: unknown } | undefined)?.tuneOut).length;
  const backlogDelivered = () => scout.getAllMessages().some((m) => m.content.some((b) => b.type === 'text' && (b.text ?? '').includes('<tuned-out-backlog')));

  console.log(`[probe] enter, no durationSeconds: ${await call({ channelId: CHANNEL, cadenceSeconds: 3600 })}`);
  appendFileSync(commandPath, 'ambient m1 held before the removal\n');
  await waitFor(() => held() === 1, 'the held message');
  console.log(`[probe] check before the removal: ${await call({ mode: 'check' })}`);
  appendFileSync(commandPath, 'remove\n');
  await waitFor(() => i.channelRegistry.listChannelsRaw().length === 0, 'the removal');
  console.log(`[probe] check after the removal: ${await call({ mode: 'check' })}`);
  console.log(`[probe] check, peek by id: ${await call({ mode: 'check', peek: CHANNEL })}`);
  console.log(`[probe] cancel by id: ${await call({ mode: 'cancel', channelId: CHANNEL })}`);
  console.log(`[probe] cancel by id with serverId: ${await call({ mode: 'cancel', channelId: CHANNEL, serverId: 'disc' })}`);
  console.log(`[probe] cancel by server-qualified name: ${await call({ mode: 'cancel', channelId: `disc:${CHANNEL}` })}`);
  console.log(`[probe] subconscious cancel_tuneout by id: ${JSON.stringify(await i.tuneOutCoordinator.handleSubconsciousTool('cancel_tuneout', { channelId: CHANNEL }))}`);
  console.log(`[probe] after all of these: tune-out on record ${i.channelRegistry.getTuneOutState('disc', CHANNEL) !== null}, messages held ${held()}, backlog delivered ${backlogDelivered()}`);
  console.log(`[probe] subconscious cancel_tuneout by id with serverId, which its schema doesn't declare: ${JSON.stringify(await i.tuneOutCoordinator.handleSubconsciousTool('cancel_tuneout', { channelId: CHANNEL, serverId: 'disc' }))}`);
  console.log(`[probe] then: tune-out on record ${i.channelRegistry.getTuneOutState('disc', CHANNEL) !== null}, backlog delivered ${backlogDelivered()}`);
  assert.ok(true);
});

The published statements on each side

What the tools tell an agent

In @animalabs/agent-framework 0.31.0 as npm has it:

  • tune_out's durationSeconds: "Optional bounded duration: the tune-out auto-cancels (backlog delivered) after this many seconds; floors at 60. Unset = until cancelled." (dist/src/framework.js:11217–11218)
  • tune_out's description: "mode "check" reports what is held without releasing it (peek shows one channel's newest held lines)." (dist/src/framework.js:11191–11192)
  • focus's description: "mode "check" reports what is held." (dist/src/framework.js:11231)
  • TuneOutCoordinator.check(): "What is held right now — per-channel epochs and the scope — without releasing." (dist/src/tune-out/coordinator.d.ts:384)

What the changelog says

In CHANGELOG.md at v0.31.0:

  1. 0.22.3, under Fixed (:6862): "A cancel of a channel that isn't tuned out, through tune_out or the subconscious's cancel_tuneout, names the connected channels that are and how a tune-out ends by itself, and says whose window its backlog goes to: "you" to the resident, "the resident" to the subconscious."
  2. 0.30.0, under Changed, in the entry "Merged main at 0.22.3" (:920–923). For tune_out and focus, "a channel the registry doesn't know is refused as unknown channel: <id> (an id on several servers keeps the registry's own words)."
  3. 0.30.0, under Changed, in the entry "Merged main at a4a74b48 (0.26.0 and after)" (:937–943): "tune_out and focus resolve their channel as main's channel tools do: with a serverId, that server's channel; inside the calling turn, the turn's own channel by its id; otherwise the one registered channel a name answers to, by its id or as serverId:channelId, and a name two channels answer to is refused with them as candidates; check's peek takes the same names and refuses what they refuse, a channel no server registers now included."

For tune_out and cancel_tuneout, the run shows each of the three for a tune-out whose channel its server removed, and there the tools' text doesn't hold: check didn't report that tune-out's held message, and peek refused its channel. Nor did any cancel the run made with the tools' declared inputs end that tune-out, which, entered with no durationSeconds, lasts "until cancelled".

The rules we propose

We propose that the changelog's rules yield to what the tools tell an agent, with these rules in their place:

  • check, of tune_out and of focus, lists every channel tune-out on record. One whose channel no server registers now is marked connected: false, with the label its channel id was last seen with, where one was seen.

  • peek, tune_out's cancel and cancel_tuneout, given a name no registered channel answers to, reach the tune-out on record that the name picks:

    • with a serverId, that server's channel;
    • inside the calling turn, the turn's own channel by its id;
    • otherwise the one such tune-out whose channel id, or serverId:channelId, is the name.

    A name that several such tune-outs answer to is refused, with how to name each alone. A label isn't a name.

  • A name a registered channel answers to still means that channel, even where a removed channel's tune-out shares the name.

  • Entering a tune-out still needs a registered channel.

  • A cancel's refusal, for a name that picks no tune-out, lists every tune-out on record and marks those whose channel isn't connected now. Today it names only the connected channels' tune-outs.

  • No tool's description or input schema changes, so cancel_tuneout still declares no serverId.

Why the tools' side. The tool descriptions are what an agent reads when it decides to tune a channel out with no end time. They tell it the tune-out lasts until it's cancelled, and that check shows what's held. At main, once a server removes the channel, check no longer shows the tune-out, peek and tune_out's cancel refuse its channel, and the run ended it only through an input that cancel_tuneout's schema doesn't declare. Until then its held message stayed undelivered. The statements could also be made to agree by changing the tools' text, or by ending a tune-out when its channel is removed. We haven't built or run either.

#916, opened from @tel-0s's account and open at 9704133265, adds changelog.d/tune-out-resume-late-channels.fixed.md, whose last sentence keeps today's rule: "tune_out's check and a cancel's refusal still name only the connected channels' tune-outs." A decision here bears on that sentence.

What the run doesn't show

  • A real connector. The probe's fixture server sent the removal, and no run used discord-mcpl.
  • focus. The run didn't call it, though the second and third rules, and the check we propose, are stated for focus too.
  • Whether the tune-out ends by itself later. The run ended once the calls above returned.
  • A model's own tool calls. The resident's calls went through the framework's bridge for a script's tool calls, and the subconscious's went to the coordinator's handler directly. So the run doesn't show what a model's call to cancel_tuneout with the undeclared serverId gets.
  • Other platforms and Node versions. The run was on Linux with Node 24.22.2.

Activity

  1. self-assigned this
    on Nov 2, 2026
  2. removed their assignment
    on Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions