Repository navigation
Proposal: tune_out's check, peek and cancel reach a tune-out whose channel its server removed #950
Description
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:noisywith nodurationSeconds, and let one message arrive and be held; - had the server remove the channel;
- called the resident's
tune_outthrough the framework's bridge for a script's tool calls, and the subconscious'scancel_tuneoutthroughTuneOutCoordinator.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:
handleChangeddeletes a removed channel's entry:this.channels.delete(destinationKey(ref))(src/mcpl/channel-registry.ts:1720).check()builds its list fromlistActive()(src/tune-out/coordinator.ts:1248), which readsthis.channelRegistry.listChannelsRaw()(:549).tune_outresolvespeekand its channel throughresolveChannel(src/framework.ts:36841,:36860), which returns the entryresolveToolChannelEntryfinds, 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 whatlistActive()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'sdurationSeconds: "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 (peekshows 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:
- 0.22.3, under Fixed (
:6862): "A cancel of a channel that isn't tuned out, throughtune_outor the subconscious'scancel_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." - 0.30.0, under Changed, in the entry "Merged
mainat 0.22.3" (:920–923). Fortune_outandfocus, "a channel the registry doesn't know is refused asunknown channel: <id>(an id on several servers keeps the registry's own words)." - 0.30.0, under Changed, in the entry "Merged
mainata4a74b48(0.26.0 and after)" (:937–943): "tune_outandfocusresolve their channel as main's channel tools do: with aserverId, 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 asserverId:channelId, and a name two channels answer to is refused with them as candidates;check'speektakes 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, oftune_outand offocus, lists every channel tune-out on record. One whose channel no server registers now is markedconnected: false, with the label its channel id was last seen with, where one was seen. -
peek,tune_out's cancel andcancel_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.
- with a
-
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_tuneoutstill declares noserverId.
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 thecheckwe propose, are stated forfocustoo.- 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_tuneoutwith the undeclaredserverIdgets. - Other platforms and Node versions. The run was on Linux with Node 24.22.2.
Activity