diff --git a/changelog.d/refusals-are-error-results.fixed.md b/changelog.d/refusals-are-error-results.fixed.md new file mode 100644 index 000000000..3de0497e5 --- /dev/null +++ b/changelog.d/refusals-are-error-results.fixed.md @@ -0,0 +1 @@ +- A refusal now reaches the model as an error result (`isError: true`), so it can tell a refusal from a tool that ran. That covers `tune_out`'s refusals (an unknown channel, a cancel of a channel that isn't tuned out, and an enter that can't go through, such as on a channel already tuned out), `channel_open` on a tuned-out channel, the subconscious's refusals of a `deliver_summary` with no text, a `cancel_tuneout` of a channel that isn't tuned out, a `note_disposition` with no key and a `speak_in_channel` it can't make, and the api module's `Unknown tool`. Each came back unflagged (`isError: false`, or for the api module no `isError` at all), so the model read it as a tool's ordinary answer, in XML tool mode as its ``. `channel_open`'s refusal also showed the model only its `data`, `{"refusal":"tuned-out","channelId":"…"}`, and never the text saying to cancel the tune-out with `tune_out`'s `cancel` mode; the model now reads that text. A script's call to one of these tools now gets `Error: `, as for any failed call. An in-process caller of `channel_open` (`ctx.callTool`) or of the api module gets the same result as before with `isError` set, and the tuned-out refusal keeps its `data`. diff --git a/changelog.d/tune-out-errors.fixed.md b/changelog.d/tune-out-errors.fixed.md new file mode 100644 index 000000000..05ecc7c87 --- /dev/null +++ b/changelog.d/tune-out-errors.fixed.md @@ -0,0 +1 @@ +- The tune-out coordinator's refusals say what didn't happen 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, and its end time where it has one. 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. A fork-bound channel says why `tune_out` won't divert it, and that calling again won't change that until its conversation ends. The subconscious's `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, through `deliver_summary` or by ending the tune-out with `cancel_tuneout`, and the configuration key it used to name goes to a `[tune-out]` log line for whoever runs the host. diff --git a/src/framework.ts b/src/framework.ts index 013277a59..823492835 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -18277,12 +18277,12 @@ export class AgentFramework { .find((e) => e.descriptor.id === channelId); let result: { success: boolean; data?: unknown; error?: string; isError?: boolean }; if (!entry) { - result = { success: false, error: `unknown channel: ${channelId}`, isError: false }; + result = { success: false, error: `unknown channel: ${channelId}`, isError: true }; } else if (input.mode === 'cancel') { const r = coordinator.cancel(entry.serverId, channelId, 'agent-tool', 'agent request'); result = r.ok ? { success: true, data: { cancelled: channelId } } - : { success: false, error: r.error, isError: false }; + : { success: false, error: r.error, isError: true }; } else { const r = coordinator.enter(entry.serverId, channelId, { cadenceSeconds: input.cadenceSeconds, @@ -18300,7 +18300,7 @@ export class AgentFramework { maxWakes: r.params.maxWakes, }, } - : { success: false, error: r.error, isError: false }; + : { success: false, error: r.error, isError: true }; } this.pushEvent({ type: 'tool-result', diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index 082e6ed9b..a66be48ef 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -2670,11 +2670,13 @@ export class ChannelRegistry { // A tuned-out channel is transport-open but attention-diverted; a plain // open would silently discard the epoch (and its pending backlog dump). // Cancelling is the tune-out coordinator's flow, reached via its own - // tool — refuse with the pointer rather than eat the state. + // tool — refuse with the pointer rather than eat the state. As an error + // result, the agent reads the pointer itself; `data` names the refusal + // for an in-process caller. if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') { return { success: false, - isError: false, + isError: true, error: `Channel ${input.channelId} is tuned out. Cancel the tune-out ` + `(tune_out with mode: 'cancel') to resume normal attention — ` + diff --git a/src/modules/api/index.ts b/src/modules/api/index.ts index 9e844a8d9..9d9543a3d 100644 --- a/src/modules/api/index.ts +++ b/src/modules/api/index.ts @@ -40,7 +40,7 @@ export class ApiModule implements Module { async handleToolCall(_call: ToolCall): Promise { // API module has no tools - return { success: false, error: 'Unknown tool' }; + return { success: false, error: 'Unknown tool', isError: true }; } async onProcess(event: ProcessEvent, _state: ProcessState): Promise { diff --git a/src/tune-out/coordinator.ts b/src/tune-out/coordinator.ts index dbfee7013..b3afb8acf 100644 --- a/src/tune-out/coordinator.ts +++ b/src/tune-out/coordinator.ts @@ -137,13 +137,37 @@ export class TuneOutCoordinator { source: string, ): { ok: true; params: TuneOutParams } | { ok: false; error: string } { if (!this.hooks.subconsciousName()) { - return { ok: false, error: 'tune-out requires the subconscious resident (framework config `subconscious`)' }; + return { + ok: false, + error: 'Nothing was tuned out: this host has no subconscious resident to watch a channel, so tune_out ' + + "isn't available here, and calling it again won't change that. If you need channels diverted, ask " + + 'whoever runs this host.', + }; } if (this.hooks.isForkBound(channelId)) { - return { ok: false, error: `channel ${channelId} is bound to a conversation fork; tune-out and fork routing are mutually exclusive` }; + return { + ok: false, + error: `Nothing changed: a conversation is bound to ${JSON.stringify(channelId)}, so its messages go to that ` + + "conversation, and tune_out won't divert a channel while one is. Calling tune_out on it again won't change " + + 'that until the conversation ends, which happens only once the channel has been quiet for a while. Tune ' + + 'out a different channel, or ask whoever runs this host if this one should be diverted.', + }; } - if (this.channelRegistry.getTuneOutState(serverId, channelId)) { - return { ok: false, error: `channel ${channelId} is already tuned out (cancel first to change params)` }; + const current = this.channelRegistry.getTuneOutState(serverId, channelId); + if (current) { + const p = current.params; + // A duration so long its end is past what a Date can hold has no time to show. + const expires = p.expiresAtMs !== undefined ? new Date(p.expiresAtMs) : undefined; + const until = expires && Number.isFinite(expires.getTime()) + ? `, until ${expires.toISOString().replace(/\.\d+Z$/, 'Z')}` + : ''; + return { + ok: false, + error: `Nothing changed: ${JSON.stringify(channelId)} is already tuned out, with cadenceSeconds ` + + `${p.cadenceSeconds}, backlogCap ${p.backlogCap} and maxWakes ${p.maxWakes}${until}. 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.', + }; } const params: TuneOutParams = { epochId: randomUUID(), @@ -188,7 +212,8 @@ export class TuneOutCoordinator { ): { ok: true } | { ok: false; error: string } { const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, 'open', source); if (!ended) { - return { ok: false, error: `channel ${channelId} is not tuned out` }; + // Its reader is the resident's tune_out: cancel_tuneout finds the channel first. + return { ok: false, error: this.notTunedOut(channelId, 'you') }; } this.stopCadence(serverId, channelId); const expiry = this.expiryTimers.get(this.key(serverId, channelId)); @@ -485,7 +510,14 @@ export class TuneOutCoordinator { case 'deliver_summary': { const text = String(input.text ?? '').trim(); const channelId = String(input.channelId ?? ''); - if (!text) return { success: false, error: 'empty summary', isError: false }; + if (!text) { + return { + success: false, + error: 'Nothing was delivered: deliver_summary needs the summary in "text". If there\'s nothing to ' + + "report, don't call it: staying silent is allowed.", + isError: true, + }; + } const subName = this.hooks.subconsciousName(); if (!subName) return { success: false, error: 'subconscious not configured', isError: true }; // Verbatim, under its own participant name, into the resident's window. @@ -498,30 +530,45 @@ export class TuneOutCoordinator { case 'cancel_tuneout': { const channelId = String(input.channelId ?? ''); const located = this.findByChannel(channelId); - if (!located) return { success: false, error: `channel ${channelId} is not tuned out`, isError: false }; + if (!located) return { success: false, error: this.notTunedOut(channelId, 'the resident'), isError: true }; const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined; const result = this.cancel(located.serverId, channelId, 'subconscious', 'subconscious judgment', note); return result.ok ? { success: true, data: { cancelled: true } } - : { success: false, error: result.error, isError: false }; + : { success: false, error: result.error, isError: true }; } case 'note_disposition': { const key = String(input.key ?? '').trim(); - if (!key) return { success: false, error: 'key required', isError: false }; + if (!key) { + return { + success: false, + error: 'Nothing was recorded: note_disposition needs a "key", a short stable name for the note, such as ' + + '"busy-channels". To delete a note, call it with that note\'s key and an empty "text".', + isError: true, + }; + } this.noteDisposition(key, String(input.text ?? '')); return { success: true, data: { noted: key } }; } case 'speak_in_channel': { if (!this.hooks.allowChannelSpeech()) { + // The setting's name is the host's, for its log; the subconscious + // reads what it can do instead. + console.warn('[tune-out] speak_in_channel refused: FrameworkConfig.subconscious.allowChannelSpeech is off'); return { success: false, - isError: false, - error: 'channel speech is disabled for the subconscious (recipe: subconscious.allowChannelSpeech)', + isError: true, + error: "Nothing was posted: speak_in_channel is turned off for you in the resident's configuration, and " + + "calling it again won't change that. 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.', }; } const channelId = String(input.channelId ?? ''); const text = String(input.text ?? '').trim(); - if (!text) return { success: false, error: 'empty message', isError: false }; + if (!text) { + return { success: false, error: 'Nothing was posted: speak_in_channel needs the message in "text".', isError: true }; + } return this.channelRegistry.publishForAgent(channelId, text, this.hooks.subconsciousName() ?? 'Subconscious'); } default: @@ -529,6 +576,26 @@ export class TuneOutCoordinator { } } + /** + * A cancel of a channel that isn't tuned out: which channels are, so the + * next call can name one, and how a tune-out ends without one. `backlogTo` + * names the resident as the message's reader knows it: "you" to the + * resident, "the resident" to the subconscious. It speaks of connected + * channels because listActive reads the registered ones: removing a server + * drops its channels and keeps their tune-outs, so a tune-out there is in + * neither this list nor findByChannel's lookup, and the text claims nothing + * about it. + */ + private notTunedOut(channelId: string, backlogTo: 'you' | 'the resident'): string { + const active = this.listActive().map((e) => JSON.stringify(e.channelId)); + const opening = active.length === 0 + ? 'no connected channel is tuned out now.' + : `no connected channel ${JSON.stringify(channelId)} is tuned out. Connected channels tuned out now: ` + + `${active.join(', ')}.`; + return `Nothing was cancelled: ${opening} 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 ${backlogTo} then.`; + } + private findByChannel(channelId: string): { serverId: string } | null { for (const entry of this.listActive()) { if (entry.channelId === channelId) return { serverId: entry.serverId }; diff --git a/test/refusals-are-error-results.test.ts b/test/refusals-are-error-results.test.ts new file mode 100644 index 000000000..c5ed6f126 --- /dev/null +++ b/test/refusals-are-error-results.test.ts @@ -0,0 +1,213 @@ +/** + * A refusal is an error result. tune_out's refusals, channel_open on a + * tuned-out channel, the subconscious's refusals and the api module's + * "Unknown tool" reach the model flagged as errors, with the refusal's text, + * so it can tell a refusal from a success. Before, only deliver_summary's + * "subconscious not configured" was flagged: the model read each of the + * others as a tool that ran and answered (in XML tool mode, as the tool's + * stdout), and channel_open's refusal showed it only its data, never the + * sentence that says how to cancel the tune-out. A script's call gets each + * refusal as "Error: ", and a module calling in process gets the + * result flagged, with the tuned-out refusal's data kept. + */ +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { AgentFramework, ApiModule } from '../src/index.js'; +import type { Module, ModuleContext, ToolDefinition, ToolResult } from '../src/index.js'; +import { TUNE_OUT_DEFAULTS } from '../src/tune-out/coordinator.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +const FIXTURE = join(import.meta.dirname, 'fixtures/tune-out-mcpl-server.mjs'); +const CHANNEL = 'disc:guild:noisy'; +const TUNED_OUT = `Channel ${CHANNEL} is tuned out. Cancel the tune-out (tune_out with mode: 'cancel') ` + + 'to resume normal attention — cancelling delivers the diverted backlog.'; + +type SentResult = { toolUseId: string; content: unknown; isError: boolean }; + +async function waitFor(cond: () => boolean, what: string): Promise { + const deadline = Date.now() + 10_000; + while (!cond()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for: ${what}`); + await new Promise((r) => setTimeout(r, 10)); + } +} + +/** A module that keeps its context, to call tools in process as a module does. */ +class ContextKeeper implements Module { + readonly name = 'context-keeper'; + ctx: ModuleContext | null = null; + async start(ctx: ModuleContext): Promise { + this.ctx = ctx; + } + async stop(): Promise { + this.ctx = null; + } + getTools(): ToolDefinition[] { + return []; + } + async handleToolCall(): Promise { + return { success: false, error: 'no tools', isError: true }; + } + async onProcess(): Promise> { + return {}; + } +} + +/** A resident and its subconscious, with the fixture's one channel registered. */ +async function setUp(t: TestContext, allowChannelSpeech = false, modules: Module[] = []) { + const dir = mkdtempSync(join(tmpdir(), 'refusals-are-errors-')); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', proseRouting: 'disabled' }], + subconscious: { enabled: true, systemPrompt: 'Watch tuned-out channels and report.', allowChannelSpeech }, + mcplServers: [{ id: 'disc', command: process.execPath, args: [FIXTURE] }], + modules: [new ApiModule(), ...modules], + }); + t.after(async () => { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + }); + const registry = (framework as unknown as { + channelRegistry: { listChannelsRaw(): unknown[] } | null; + }).channelRegistry; + await waitFor(() => (registry?.listChannelsRaw().length ?? 0) > 0, 'channel registration'); + return { framework, membrane }; +} + +/** + * Runs one turn of `agent` that makes each round of calls in turn, and + * returns, for each round, the results sent back to the model by call id. + */ +async function runRounds( + framework: AgentFramework, + membrane: MockMembrane, + agent: string, + rounds: Array }>>, +): Promise>> { + for (const calls of rounds) { + membrane.pushResponse(createMockResponse(calls.map((c) => ({ type: 'tool_use' as const, ...c })), 'tool_use')); + } + membrane.pushResponse(createMockResponse([])); + assert.equal(framework.nudgeAgent(agent).ok, true); + await framework.runUntilIdle(); + const sent = (membrane.lastStream!.receivedToolResults as SentResult[][]).map( + (round) => new Map(round.map((r) => [r.toolUseId, { content: r.content, isError: r.isError }])), + ); + assert.equal(sent.length, rounds.length, `${agent} answered every round`); + return sent; +} + +test("the resident's refusals reach the model as error results, and a tune-out it enters as a success", async (t) => { + const { framework, membrane } = await setUp(t); + const [first, second, third] = await runRounds(framework, membrane, 'scout', [ + [ + { id: 'unknown', name: 'tune_out', input: { channelId: 'disc:guild:none' } }, + { id: 'cancel', name: 'tune_out', input: { channelId: CHANNEL, mode: 'cancel' } }, + { id: 'api', name: 'api--lookup', input: {} }, + ], + [{ id: 'enter', name: 'tune_out', input: { channelId: CHANNEL } }], + [ + { id: 'again', name: 'tune_out', input: { channelId: CHANNEL } }, + { id: 'open', name: 'channel_open', input: { channelId: CHANNEL } }, + ], + ]); + assert.deepEqual(first!.get('unknown'), { content: 'unknown channel: disc:guild:none', isError: true }); + assert.deepEqual(first!.get('cancel'), { + content: '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 you then.', + isError: true, + }); + assert.deepEqual(first!.get('api'), { content: 'Unknown tool', isError: true }); + // A control: the same tool's success still reads as one. + const { cadenceSeconds, backlogCap, maxWakes } = TUNE_OUT_DEFAULTS; + assert.deepEqual(second!.get('enter'), { + content: JSON.stringify({ tunedOut: CHANNEL, cadenceSeconds, backlogCap, maxWakes }), + isError: false, + }); + assert.deepEqual(third!.get('again'), { + content: `Nothing changed: "${CHANNEL}" is already tuned out, with cadenceSeconds ${cadenceSeconds}, backlogCap ` + + `${backlogCap} and maxWakes ${maxWakes}. 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.', + isError: true, + }); + assert.deepEqual(third!.get('open'), { content: TUNED_OUT, isError: true }); +}); + +test("the subconscious's refusals reach it as error results", async (t) => { + const { framework, membrane } = await setUp(t); + const [round] = await runRounds(framework, membrane, 'Subconscious', [[ + { id: 'summary', name: 'deliver_summary', input: { text: ' ', channelId: CHANNEL } }, + { id: 'cancel', name: 'cancel_tuneout', input: { channelId: CHANNEL } }, + { id: 'note', name: 'note_disposition', input: { key: '', text: 'ignore release-bot' } }, + { id: 'speak', name: 'speak_in_channel', input: { channelId: CHANNEL, text: 'They are tuned out.' } }, + ]]); + assert.deepEqual(round!.get('summary'), { + content: 'Nothing was delivered: deliver_summary needs the summary in "text". If there\'s nothing to report, ' + + "don't call it: staying silent is allowed.", + isError: true, + }); + assert.deepEqual(round!.get('cancel'), { + content: '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, + }); + assert.deepEqual(round!.get('note'), { + content: 'Nothing was recorded: note_disposition needs a "key", a short stable name for the note, such as ' + + '"busy-channels". To delete a note, call it with that note\'s key and an empty "text".', + isError: true, + }); + assert.deepEqual(round!.get('speak'), { + content: "Nothing was posted: speak_in_channel is turned off for you in the resident's configuration, and calling " + + "it again won't change that. 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.', + isError: true, + }); +}); + +test("the subconscious's speak_in_channel with no text, where channel speech is allowed, reaches it as an error result", async (t) => { + const { framework, membrane } = await setUp(t, true); + const [round] = await runRounds(framework, membrane, 'Subconscious', [[ + { id: 'speak', name: 'speak_in_channel', input: { channelId: CHANNEL, text: ' ' } }, + ]]); + assert.deepEqual(round!.get('speak'), { + content: 'Nothing was posted: speak_in_channel needs the message in "text".', + isError: true, + }); +}); + +test('a script, and a module calling in process, get the refusals as errors too', async (t) => { + const keeper = new ContextKeeper(); + const { framework } = await setUp(t, false, [keeper]); + // A script's call is answered through the event loop, so it has to run. + framework.start(); + const script = (tool: string, input: Record) => (framework as unknown as { + handleScriptToolCall(agent: string, tool: string, args: Record): Promise; + }).handleScriptToolCall('scout', tool, input); + assert.equal(await script('tune_out', { channelId: 'disc:guild:none' }), 'Error: unknown channel: disc:guild:none'); + assert.equal(await script('api--lookup', {}), 'Error: Unknown tool'); + // A control: the same tool's success still reads as one. + const { cadenceSeconds, backlogCap, maxWakes } = TUNE_OUT_DEFAULTS; + assert.equal( + await script('tune_out', { channelId: CHANNEL }), + JSON.stringify({ tunedOut: CHANNEL, cadenceSeconds, backlogCap, maxWakes }), + ); + assert.equal(await script('channel_open', { channelId: CHANNEL }), `Error: ${TUNED_OUT}`); + // A module gets the result as the registry returns it: flagged, with the + // refusal's data kept, so it can still read why. + assert.deepEqual(await keeper.ctx!.callTool({ id: 'ctx-open', name: 'channel_open', input: { channelId: CHANNEL } }), { + success: false, + isError: true, + error: TUNED_OUT, + data: { refusal: 'tuned-out', channelId: CHANNEL }, + }); + assert.deepEqual( + await keeper.ctx!.callTool({ id: 'ctx-api', name: 'api--lookup', input: {} }), + { success: false, error: 'Unknown tool', isError: true }, + ); +}); diff --git a/test/tune-out-messages.test.ts b/test/tune-out-messages.test.ts new file mode 100644 index 000000000..441021904 --- /dev/null +++ b/test/tune-out-messages.test.ts @@ -0,0 +1,110 @@ +/** + * What the tune-out coordinator tells the agent, and the subconscious, when it + * refuses: that nothing changed, why, and what to do instead. A channel that's + * already tuned out gives its settings and how to change them; a cancel of one + * that isn't names the connected ones that are, and the backlog's reader as + * its caller knows the resident. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import { TuneOutCoordinator } from '../src/tune-out/coordinator.js'; + +const SETTINGS = { epochId: 'e1', cadenceSeconds: 300, backlogCap: 50, maxWakes: 3, startedAtSequence: 0 }; + +/** The hooks the refusals read: a subconscious unless it's left out, a fork binding, and channel speech off. */ +function hooks(opts: { subconscious?: boolean; forkBound?: boolean }) { + return { + subconsciousName: () => (opts.subconscious === false ? undefined : 'Subconscious'), + isForkBound: () => opts.forkBound ?? false, + allowChannelSpeech: () => false, + }; +} + +/** A coordinator over registered tuned-out channels, or none, for the refusals that need no live channel. */ +function coordinator(opts: { tunedOut?: Array<{ channelId: string; expiresAtMs?: number }>; subconscious?: boolean; forkBound?: boolean }) { + const tunedOut = opts.tunedOut ?? []; + const registry = { + listChannelsRaw: () => tunedOut.map((t) => ({ serverId: 'disc', descriptor: { id: t.channelId } })), + getTuneOutState: (_server: string, channelId: string) => { + const found = tunedOut.find((t) => t.channelId === channelId); + return found ? { params: { ...SETTINGS, expiresAtMs: found.expiresAtMs } } : null; + }, + cancelTuneOut: () => null, + }; + return new TuneOutCoordinator(registry as never, {} as never, hooks(opts) as never); +} + +test('tune_out and cancel_tuneout say why nothing changed, and name the connected channels that are tuned out', async () => { + assert.deepEqual(coordinator({ subconscious: false }).enter('disc', 'disc:guild:noisy', {}, 'agent-tool'), { + ok: false, + error: 'Nothing was tuned out: this host has no subconscious resident to watch a channel, so tune_out isn\'t ' + + 'available here, and calling it again won\'t change that. If you need channels diverted, ask whoever runs this host.', + }); + assert.deepEqual(coordinator({ forkBound: true }).enter('disc', 'disc:guild:noisy', {}, 'agent-tool'), { + ok: false, + error: 'Nothing changed: a conversation is bound to "disc:guild:noisy", so its messages go to that conversation, ' + + 'and tune_out won\'t divert a channel while one is. Calling tune_out on it again won\'t change that until the ' + + 'conversation ends, which happens only once the channel has been quiet for a while. Tune out a different ' + + 'channel, or ask whoever runs this host if this one should be diverted.', + }); + // An end carries milliseconds, as Date.now() plus whole seconds does, and is shown to the second. + const expiring = coordinator({ tunedOut: [{ channelId: 'disc:guild:noisy', expiresAtMs: Date.UTC(2026, 9, 15, 6, 30, 0, 123) }] }); + assert.deepEqual(expiring.enter('disc', 'disc:guild:noisy', {}, 'agent-tool'), { + ok: false, + error: '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.', + }); + + const busy = coordinator({ tunedOut: [{ channelId: 'disc:guild:noisy' }, { channelId: 'disc:guild:busy' }] }); + const elsewhere = 'Nothing was cancelled: no connected channel "disc:guild:quiet" is tuned out. Connected channels tuned ' + + 'out now: "disc:guild:noisy", "disc:guild:busy". 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'; + assert.deepEqual(busy.cancel('disc', 'disc:guild:quiet', 'agent-tool', 'agent request'), { ok: false, error: `${elsewhere} you then.` }); + assert.deepEqual(await busy.handleSubconsciousTool('cancel_tuneout', { channelId: 'disc:guild:quiet' }), { + success: false, + error: `${elsewhere} the resident then.`, + isError: true, + }); +}); + +test('a tune-out with no end, or one past what a date can hold, is described without one', () => { + const settled = 'Nothing changed: "disc:guild:noisy" is already tuned out, with cadenceSeconds 300, backlogCap 50 and ' + + 'maxWakes 3. 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.'; + for (const expiresAtMs of [undefined, 8.64e15 + 1]) { + const busy = coordinator({ tunedOut: [{ channelId: 'disc:guild:noisy', ...(expiresAtMs !== undefined ? { expiresAtMs } : {}) }] }); + assert.deepEqual(busy.enter('disc', 'disc:guild:noisy', {}, 'agent-tool'), { ok: false, error: settled }, String(expiresAtMs)); + } +}); + +test("a cancel_tuneout of a channel whose server was removed claims nothing about that channel's tune-out", async () => { + // Removing a server drops its channels and keeps their tune-outs, so the lookup can't find this one. + const registry = new ChannelRegistry({ getServer: () => null } as never, {} as never, () => {}, () => {}); + await registry.handleRegister('disc', { + channels: [{ id: 'disc:guild:noisy', type: 'discord', label: '#noisy', direction: 'bidirectional' }], + }); + registry.enterTuneOut('disc', 'disc:guild:noisy', SETTINGS, 'agent-tool'); + registry.removeServer('disc'); + assert.ok(registry.getTuneOutState('disc', 'disc:guild:noisy'), 'the tune-out outlives its server'); + const overRegistry = new TuneOutCoordinator(registry, {} as never, hooks({}) as never); + assert.deepEqual(await overRegistry.handleSubconsciousTool('cancel_tuneout', { channelId: 'disc:guild:noisy' }), { + 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, + }); +}); + +test("speak_in_channel's refusal names the setting in the host's log, not to the subconscious", async (t) => { + const busy = coordinator({ tunedOut: [{ channelId: 'disc:guild:noisy' }] }); + const warned = t.mock.method(console, 'warn', () => {}); + const refused = await busy.handleSubconsciousTool('speak_in_channel', { channelId: 'disc:guild:noisy', text: 'hello' }); + assert.equal(refused.isError, true); + assert.deepEqual( + warned.mock.calls.map((c) => c.arguments[0]), + ['[tune-out] speak_in_channel refused: FrameworkConfig.subconscious.allowChannelSpeech is off'], + ); +});