From d8676de9c975c3b0b34807da238723807a23d157 Mon Sep 17 00:00:00 2001 From: Aster Date: Wed, 5 Aug 2026 18:29:00 -0700 Subject: [PATCH 1/8] =?UTF-8?q?feat(mcpl):=20tuned-out=20=E2=80=94=20a=20t?= =?UTF-8?q?hird=20durable=20desired=20state=20for=20channels=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'tuned-out' joins open/closed in the mcpl/channel-lifecycle append-log, carrying its epoch params (epochId, cadenceSeconds, backlogCap, maxWakes, startedAtSequence). Transport-wise a tuned-out channel is OPEN — traffic must keep arriving for the subconscious — so reconcile treats it as open and only main's wake/visibility diverts, downstream at ingestion (next commits). Wake counts are durable 'tune-out-wake' records in the same log, replayed last-record-wins and epoch-checked, because gate runtime stats die with the process and a restart must not hand a hammered channel a fresh max-wakes budget. Re-entering under a new epoch supersedes the old count. Guard rails: channel_open on a tuned-out channel refuses with a pointer at the cancel flow (a plain open would silently discard the epoch and its pending backlog dump); machine-sourced closes treat tuned-out as stated agent intent, same as explicit opens; desired-state records for 'tuned-out' without params are dropped at replay (old/foreign writers degrade safely — pre-existing behavior, now pinned by a test). Co-Authored-By: Claude Fable 5 --- src/mcpl/channel-registry.ts | 194 ++++++++++++++++++++++++++++++-- test/tune-out-lifecycle.test.ts | 142 +++++++++++++++++++++++ 2 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 test/tune-out-lifecycle.test.ts diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index ebfd537..ea5e17d 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -44,14 +44,48 @@ import { CapabilityGrant } from './capability-grant.js'; const TYPING_INTERVAL_MS = 7_000; const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle'; -type DesiredChannelState = 'open' | 'closed'; +type DesiredChannelState = 'open' | 'closed' | 'tuned-out'; + +/** + * Parameters of an active tune-out (issue #77), carried on the + * 'desired-state' record entering the tuned-out state and projected into + * `desiredStates`. A tuned-out channel stays OPEN at the transport (traffic + * must keep arriving for the subconscious); the divert-don't-wake behavior + * is applied downstream at ingestion. + */ +export interface TuneOutParams { + /** Identity of this tune-out epoch. Stamped into diverted messages + * (`metadata.tuneOut = { epochId }`) for permanent main-view exclusion + * and per-epoch audit; also keys durable wake counting. */ + epochId: string; + /** Subconscious summary cadence, in seconds. */ + cadenceSeconds: number; + /** Maximum messages dumped raw at cancel; above the cap the subconscious + * curates a digest and `fetch_history` covers the rest. */ + backlogCap: number; + /** Wake invocations before the tune-out auto-cancels. */ + maxWakes: number; + /** Chronicle sequence when the tune-out began — window anchor + audit bound. */ + startedAtSequence: number; +} interface ChannelLifecycleEvent { - kind: 'desired-state' | 'legacy-policy-migrated' | 'invitation-declined'; + kind: + | 'desired-state' + | 'legacy-policy-migrated' + | 'invitation-declined' + | 'tune-out-wake'; serverId: string; timestamp: string; channelId?: string; desired?: DesiredChannelState; + /** Present when desired === 'tuned-out'. */ + tuneOut?: TuneOutParams; + /** kind 'tune-out-wake': durable running wake count for an epoch. + * Lives in the lifecycle log (not gate stats) because gate runtime + * state dies with the process and max-wakes must not reset on restart. */ + epochId?: string; + wakeCount?: number; source?: string; messageId?: string; acknowledgment?: string; @@ -410,7 +444,15 @@ export class ChannelRegistry { /** Chronicle-projected desired lifecycle state, keyed by server + channel. * Provenance is kept so reconcile can tell a pure default (nobody ever * decided) from a real decision (agent-tool, invitation-declined, …). */ - private desiredStates = new Map(); + private desiredStates = new Map(); /** One-time migration inputs from the retired recipe auto-open policy. */ private legacyPolicies = new Map(); @@ -1118,12 +1160,31 @@ export class ChannelRegistry { if ( event.kind === 'desired-state' && typeof event.channelId === 'string' && - (event.desired === 'open' || event.desired === 'closed') + (event.desired === 'open' || event.desired === 'closed' || + (event.desired === 'tuned-out' && event.tuneOut)) ) { this.desiredStates.set( this.lifecycleKey(event.serverId, event.channelId), - { state: event.desired, source: typeof event.source === "string" ? event.source : "unknown" }, + { + state: event.desired, + source: typeof event.source === "string" ? event.source : "unknown", + tuneOut: event.desired === 'tuned-out' ? event.tuneOut : undefined, + wakeCount: 0, + }, ); + } else if ( + event.kind === 'tune-out-wake' && + typeof event.channelId === 'string' && + typeof event.wakeCount === 'number' + ) { + // Fold durable wake counts into the projection — but only while the + // epoch that recorded them is still the active desired state + // (last-record-wins semantics, same as desired-state itself). + const key = this.lifecycleKey(event.serverId, event.channelId); + const current = this.desiredStates.get(key); + if (current?.state === 'tuned-out' && current.tuneOut?.epochId === event.epochId) { + current.wakeCount = event.wakeCount; + } } else if (event.kind === 'legacy-policy-migrated') { this.migratedLegacyPolicies.add(event.serverId); } @@ -1153,6 +1214,104 @@ export class ChannelRegistry { }); } + // ========================================================================== + // Tune-out state (issue #77) — durable in the lifecycle log + // ========================================================================== + + /** + * Enter (or re-enter with fresh params) the tuned-out state for a channel. + * Re-entering under a new epochId replaces the active epoch; the previous + * epoch's stamped messages stay excluded (stamps are permanent) and its + * wake count is superseded. Transport stays open (see reconcile). + */ + enterTuneOut( + serverId: string, + channelId: string, + params: TuneOutParams, + source: string, + ): void { + const key = this.lifecycleKey(serverId, channelId); + this.desiredStates.set(key, { + state: 'tuned-out', + source, + tuneOut: params, + wakeCount: 0, + }); + this.appendLifecycleEvent({ + kind: 'desired-state', + serverId, + channelId, + desired: 'tuned-out', + tuneOut: params, + source, + timestamp: new Date().toISOString(), + }); + } + + /** + * End the active tune-out, returning the channel to `nextState`. + * The caller (tune-out coordinator) owns the dump/notice flow; this is + * only the durable state flip. No-op returning null if the channel is + * not tuned out. + */ + cancelTuneOut( + serverId: string, + channelId: string, + nextState: 'open' | 'closed', + source: string, + ): { params: TuneOutParams; wakeCount: number } | null { + const key = this.lifecycleKey(serverId, channelId); + const current = this.desiredStates.get(key); + if (current?.state !== 'tuned-out' || !current.tuneOut) return null; + const ended = { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 }; + this.desiredStates.set(key, { state: nextState, source }); + this.appendLifecycleEvent({ + kind: 'desired-state', + serverId, + channelId, + desired: nextState, + source, + timestamp: new Date().toISOString(), + }); + return ended; + } + + /** + * Durably record one wake invocation against the active epoch and return + * the updated count with the params (the coordinator compares against + * maxWakes and decides auto-cancel). Durable here, not in gate stats: + * gate runtime state dies with the process, and a restart must not grant + * a hammered channel a fresh wake budget. + */ + recordTuneOutWake( + serverId: string, + channelId: string, + ): { params: TuneOutParams; wakeCount: number } | null { + const key = this.lifecycleKey(serverId, channelId); + const current = this.desiredStates.get(key); + if (current?.state !== 'tuned-out' || !current.tuneOut) return null; + current.wakeCount = (current.wakeCount ?? 0) + 1; + this.appendLifecycleEvent({ + kind: 'tune-out-wake', + serverId, + channelId, + epochId: current.tuneOut.epochId, + wakeCount: current.wakeCount, + timestamp: new Date().toISOString(), + }); + return { params: current.tuneOut, wakeCount: current.wakeCount }; + } + + /** Active tune-out params + wake count for a channel, or null. */ + getTuneOutState( + serverId: string, + channelId: string, + ): { params: TuneOutParams; wakeCount: number } | null { + const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId)); + if (current?.state !== 'tuned-out' || !current.tuneOut) return null; + return { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 }; + } + /** * Consume the old recipe policy exactly once. It seeds Chronicle for * existing deployments, but is not an ongoing admission policy: channels @@ -1269,7 +1428,10 @@ export class ChannelRegistry { const key = `${serverId}:${channel.id}`; const desired = this.getDesiredState(serverId, channel.id); const entry = this.channels.get(key); - if (desired !== 'open') { + // Tuned-out sits on the OPEN side of reconcile: traffic must keep + // arriving (the subconscious reads it); only main's wake/visibility + // is diverted, downstream at ingestion. + if (desired !== 'open' && desired !== 'tuned-out') { try { await server.sendChannelsClose({ channelId: channel.id }); if (entry) entry.open = false; @@ -1682,6 +1844,22 @@ 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. + if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') { + return { + success: false, + isError: false, + error: + `Channel ${input.channelId} is tuned out. Cancel the tune-out ` + + `(tune_out with mode: 'cancel') to resume normal attention — ` + + `cancelling delivers the diverted backlog.`, + data: { refusal: 'tuned-out', channelId: input.channelId }, + }; + } + const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open'; if (entry.open && alreadyDesiredOpen && !input.backscroll) { return { @@ -1780,7 +1958,9 @@ export class ChannelRegistry { // than retry — unless it certifies an explicit idle lease. if (isModuleOrigin && closeSource !== 'agent-tool' && !input.overrideExplicitOpen) { const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId)); - if (current?.state === 'open' && current.source === 'agent-tool') { + // Tuned-out is stated intent too: a GC/housekeeping close would + // silently end the epoch and orphan its backlog. + if ((current?.state === 'open' || current?.state === 'tuned-out') && current.source === 'agent-tool') { return { success: false, isError: false, diff --git a/test/tune-out-lifecycle.test.ts b/test/tune-out-lifecycle.test.ts new file mode 100644 index 0000000..e11db02 --- /dev/null +++ b/test/tune-out-lifecycle.test.ts @@ -0,0 +1,142 @@ +/** + * Tune-out desired state (issue #77) — durable lifecycle semantics. + * + * The third DesiredChannelState. A tuned-out channel stays transport-open + * (traffic keeps arriving for the subconscious); main's wake/visibility + * divert happens downstream. Params and wake counts live in the + * mcpl/channel-lifecycle append-log, replayed last-record-wins, so + * max-wakes cannot reset on restart and time travel carries the epoch. + */ +import { test } 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 { JsStore } from '@animalabs/chronicle'; +import { ChannelRegistry, type TuneOutParams } from '../src/mcpl/channel-registry.js'; +import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; +import type { FeatureSetManager } from '../src/mcpl/feature-set-manager.js'; + +function makeRegistry(store?: JsStore) { + const serverRegistry = { + getServer: (_id: string) => null, + } as unknown as McplServerRegistry; + return new ChannelRegistry( + serverRegistry, + {} as FeatureSetManager, + () => {}, + () => {}, + store ? { store } : undefined, + ); +} + +const PARAMS: TuneOutParams = { + epochId: 'epoch-1', + cadenceSeconds: 1800, + backlogCap: 200, + maxWakes: 5, + startedAtSequence: 42, +}; + +test('enter/query/cancel round-trip in the projection', () => { + const registry = makeRegistry(); + registry.enterTuneOut('discord', '#dev', PARAMS, 'agent-tool'); + + assert.equal(registry.getDesiredState('discord', '#dev'), 'tuned-out'); + const state = registry.getTuneOutState('discord', '#dev'); + assert.deepEqual(state, { params: PARAMS, wakeCount: 0 }); + + const ended = registry.cancelTuneOut('discord', '#dev', 'open', 'agent-tool'); + assert.deepEqual(ended, { params: PARAMS, wakeCount: 0 }); + assert.equal(registry.getDesiredState('discord', '#dev'), 'open'); + assert.equal(registry.getTuneOutState('discord', '#dev'), null); + // Cancelling twice is a null no-op. + assert.equal(registry.cancelTuneOut('discord', '#dev', 'open', 'agent-tool'), null); +}); + +test('wake counts are durable across restart; epoch and params replay', () => { + const dir = mkdtempSync(join(tmpdir(), 'tune-out-lifecycle-')); + try { + const store = JsStore.openOrCreate({ path: join(dir, 'store') }); + const first = makeRegistry(store); + first.enterTuneOut('discord', '#dev', PARAMS, 'agent-tool'); + assert.deepEqual(first.recordTuneOutWake('discord', '#dev'), { + params: PARAMS, + wakeCount: 1, + }); + assert.deepEqual(first.recordTuneOutWake('discord', '#dev'), { + params: PARAMS, + wakeCount: 2, + }); + + // Fresh registry over the same store: full state from replay alone. + const second = makeRegistry(store); + assert.equal(second.getDesiredState('discord', '#dev'), 'tuned-out'); + assert.deepEqual(second.getTuneOutState('discord', '#dev'), { + params: PARAMS, + wakeCount: 2, + }); + + // A wake recorded after restart continues the durable count. + assert.equal(second.recordTuneOutWake('discord', '#dev')?.wakeCount, 3); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a stale epoch\'s wake records do not leak into a new epoch', () => { + const dir = mkdtempSync(join(tmpdir(), 'tune-out-epoch-')); + try { + const store = JsStore.openOrCreate({ path: join(dir, 'store') }); + const registry = makeRegistry(store); + registry.enterTuneOut('discord', '#dev', PARAMS, 'agent-tool'); + registry.recordTuneOutWake('discord', '#dev'); + registry.recordTuneOutWake('discord', '#dev'); + // Re-enter under a new epoch (fresh params) without cancelling first. + registry.enterTuneOut( + 'discord', + '#dev', + { ...PARAMS, epochId: 'epoch-2', maxWakes: 3 }, + 'agent-tool', + ); + assert.equal(registry.getTuneOutState('discord', '#dev')?.wakeCount, 0); + + // Replay agrees: old epoch's wake records are attributed to epoch-1 + // and skipped once epoch-2's desired-state record supersedes it. + const replayed = makeRegistry(store); + assert.deepEqual(replayed.getTuneOutState('discord', '#dev'), { + params: { ...PARAMS, epochId: 'epoch-2', maxWakes: 3 }, + wakeCount: 0, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('recordTuneOutWake on a non-tuned-out channel is a null no-op', () => { + const registry = makeRegistry(); + assert.equal(registry.recordTuneOutWake('discord', '#dev'), null); + assert.equal(registry.getTuneOutState('discord', '#dev'), null); +}); + +test('malformed tuned-out records (no params) are dropped at replay', () => { + const dir = mkdtempSync(join(tmpdir(), 'tune-out-malformed-')); + try { + const store = JsStore.openOrCreate({ path: join(dir, 'store') }); + // Seed the log the way an old/foreign writer might: a tuned-out + // desired-state with no params object. + const first = makeRegistry(store); + void first; // registers the state slot + store.appendToStateJson('mcpl/channel-lifecycle', { + kind: 'desired-state', + serverId: 'discord', + channelId: '#dev', + desired: 'tuned-out', + timestamp: new Date().toISOString(), + }); + const replayed = makeRegistry(store); + assert.equal(replayed.getDesiredState('discord', '#dev'), undefined); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 0ee66d9ced5d020f497075eb694a92dabd8da48c Mon Sep 17 00:00:00 2001 From: Aster Date: Wed, 5 Aug 2026 19:35:28 -0700 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20tune-out=20=E2=80=94=20subconscious?= =?UTF-8?q?=20resident,=20coordinator,=20and=20divert=20plumbing=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full arc: tune_out (per channel: cadence, backlog cap, max-wakes) → incoming traffic stamped (metadata.tuneOut={epochId}), stored in the shared slot, diverted from resident wake AND compiled view (cm viewFilter at the strategy-view choke point — no emission leak, no L1-compression leak) → addressed messages get the deterministic channels/acknowledge (intent 'suppressed-tuned-out', grant-gated) and, with gate-privileged authors, a coalesced subconscious wake → durable wake counting with max-wakes auto-cancel → cancel delivers the system-framed capped dump plus one resident wake, and gives the subconscious its own report turn; stamped originals stay view-excluded permanently (the dump is the delivery — KV-prefix-stable append). The subconscious: a persistent resident on the conversation-fork template — isolated slot subconscious/, merged unfiltered view (own + shared slots) under WindowedPassthroughStrategy, excluded from every broadcast fan-out, never primary, proseRouting explicit, tool surface deliver_summary / cancel_tuneout / note_disposition / speak_in_channel (off by default pending the voice-block canary) plus think/skip_reply/end_turn. Voice doctrine mechanically enforced: its words reach the resident verbatim under participant 'Subconscious' (Context Manager precedent); host framing is system-styled bracket notices and the backlog wrapper, never voiced. Plumbing folded in (would have been separate PRs): targetAgents honored on the channel-incoming fan-out (declared-but-dead field goes live, mirroring the push path; untargeted broadcast unchanged); per-agent message delivery (addMessage {forAgent} with deferral/turn-alive evaluated against the target's state, flushed at the target's boundaries; gate self-wake notices deliver to the waking agent). TEMPORARY: @animalabs/context-manager pinned to the cm PR branch (Meganeuridae/context-manager#feat/strategy-view-composition); flips to the published version at integration, after cm#54 merges. Co-Authored-By: Claude Fable 5 --- package.json | 4 +- src/framework.ts | 365 ++++++++++++++++++++++++++++++++--- src/gate/event-gate.ts | 31 ++- src/mcpl/channel-registry.ts | 43 +++++ src/tune-out/coordinator.ts | Bin 0 -> 20989 bytes src/tune-out/tools.ts | 129 +++++++++++++ src/types/framework.ts | 7 + 7 files changed, 547 insertions(+), 32 deletions(-) create mode 100644 src/tune-out/coordinator.ts create mode 100644 src/tune-out/tools.ts diff --git a/package.json b/package.json index 7d8cd3d..e04dc6f 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "license": "MIT", "dependencies": { "@animalabs/chronicle": "^0.3.0", - "@animalabs/context-manager": "^0.6.0", + "@animalabs/context-manager": "github:Meganeuridae/context-manager#feat/strategy-view-composition", "@animalabs/membrane": "^0.5.78", "chokidar": "^4.0.3", "discord.js": "^14.25.1", @@ -63,4 +63,4 @@ "engines": { "node": ">=20.0.0" } -} +} \ No newline at end of file diff --git a/src/framework.ts b/src/framework.ts index ebc024f..5f54708 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -3,7 +3,9 @@ import { appendFileSync, mkdirSync } from 'node:fs'; import { JsStore } from '@animalabs/chronicle'; import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane'; import { MembraneError } from '@animalabs/membrane'; -import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager'; +import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy } from '@animalabs/context-manager'; +import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js'; +import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js'; import type { MessageId, MessageMetadata, @@ -767,7 +769,23 @@ export class AgentFramework { // Messages deferred while an agent is waiting_for_tools (to preserve // tool_use → tool_result adjacency required by the Anthropic API). - private deferredMessages: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = []; + /** The subconscious resident's registry name (issue #77), or null. */ + private subconsciousAgentName: string | null = null; + /** Its windowed strategy — the coordinator moves the anchor on tune-out entry. */ + private subconsciousStrategy: WindowedPassthroughStrategy | null = null; + /** Its config (speak_in_channel gate, voice block provenance). */ + private subconsciousConfig: SubconsciousConfig | null = null; + /** Tune-out coordinator (issue #77); non-null iff subconscious + channels. */ + private tuneOutCoordinator: TuneOutCoordinator | null = null; + + private deferredMessages: Array<{ + participant: string; + content: ContentBlock[]; + metadata?: MessageMetadata; + /** Delivery target (registry name). Absent = the primary agent — + * the historical behavior of every existing caller. */ + forAgent?: string; + }> = []; // Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName → // token of the turn currently in progress. Set at startAgentStream ENTRY — @@ -1044,6 +1062,13 @@ export class AgentFramework { await framework.createAgent(agentConfig); } + // The subconscious resident (issue #77) registers after the residents so + // it can never become primary; it is excluded from every broadcast + // fan-out and woken only by the tune-out coordinator's explicit pushes. + if (config.subconscious?.enabled) { + await framework.createSubconsciousAgent(config.subconscious); + } + // Finish any branch-local suppression interrupted after Chronicle switched // branches. This runs before modules, MCPL connections, or inbound traffic. await framework.resumePreparedDiscordSuppressions(); @@ -1093,11 +1118,12 @@ export class AgentFramework { initialConfig: config.gate.config, privilegedUsersPath: config.gate.privilegedUsersPath, emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }), - addMessage: (p, c, m) => framework.addMessage(p, c, m as MessageMetadata), + addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m as MessageMetadata, forAgent ? { forAgent } : undefined), requestInference: (agentName, reason, source) => { framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() }); }, - getAgentNames: () => [...framework.agents.keys()], + getAgentNames: () => [...framework.agents.keys()].filter( + (n) => n !== framework.subconsciousAgentName), }); } @@ -1160,6 +1186,40 @@ export class AgentFramework { await framework.initializeMcpl(config.mcplServers, config.inferenceRouting); } + // Tune-out coordinator (issue #77): needs both the subconscious resident + // and the MCPL channel subsystem; created after both exist. + if (framework.subconsciousAgentName && framework.channelRegistry) { + framework.tuneOutCoordinator = new TuneOutCoordinator( + framework.channelRegistry, + framework.mcplServerRegistry!, + { + addMessage: (participant, content, metadata, forAgent) => + framework.addMessage(participant, content, metadata as MessageMetadata, + forAgent ? { forAgent } : undefined), + requestInference: (agentName, reason, source) => { + framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() }); + }, + subconsciousName: () => framework.subconsciousAgentName, + primaryName: () => framework.primaryAgentName, + getStoredMessages: () => { + const primary = framework.primaryAgentName + ? framework.agents.get(framework.primaryAgentName) : undefined; + return primary ? primary.getContextManager().getAllMessages() : []; + }, + currentSequence: () => framework.store.currentSequence(), + setSubconsciousAnchor: (sequence) => + framework.subconsciousStrategy?.setAnchor(sequence), + isForkBound: (channelId) => + [...framework.conversationAgentHomes.values()].includes(channelId), + isPrivilegedAuthor: (authorId) => + framework.eventGate?.isPrivilegedUser(authorId) ?? false, + allowChannelSpeech: () => !!framework.subconsciousConfig?.allowChannelSpeech, + emitTrace: (e) => framework.emitTrace(e as never), + }, + ); + framework.tuneOutCoordinator.resumeActiveEpochs(); + } + // Diagnostics: `kill -USR2 ` dumps live wake/inference state to stderr // (journal) without a restart — for catching the wake-wedge on the running // process. Shows the gate's `inferring` set + buffered-event count (the @@ -1227,6 +1287,7 @@ export class AgentFramework { async stop(): Promise { this.running = false; this.queue.close(); + this.tuneOutCoordinator?.stop(); // Kill running code_execution scripts before cancelling streams: a // zombie script must not keep firing side-effectful tool calls into a @@ -1527,6 +1588,9 @@ export class AgentFramework { getAllTools(): import('./types/index.js').ToolDefinition[] { const moduleTools = this.moduleRegistry.getAllTools(); const channelTools = this.channelRegistry?.getChannelTools() ?? []; + if (this.tuneOutCoordinator) { + channelTools.push(AgentFramework.TUNE_OUT_TOOL); + } const gateTools = this.eventGate ? [ this.eventGate.getToolDefinition(), @@ -1726,6 +1790,20 @@ export class AgentFramework { agentName: string, snapshot?: InferenceToolSnapshot, ): import('./types/index.js').ToolDefinition[] { + // The subconscious gets its own small surface (plus think/skip_reply/ + // end_turn), not the residents' tool board: no channel lifecycle, no + // gate rules, no workspace — it observes, judges, and reports. + if (agentName === this.subconsciousAgentName) { + const basics = this.getAllTools() + .filter((t) => t.name === 'think' || t.name === 'skip_reply' || t.name === 'end_turn') + .map((t) => t.name === 'think' + ? this.buildThinkTool( + snapshot?.sameRoundThinkTextPolicy + ?? this.getAgentRuntimeSettings(agentName).sameRoundThinkTextPolicy, + ) + : t); + return [...SUBCONSCIOUS_TOOLS, ...basics]; + } return this.getAllTools().map((tool) => { if (tool.name === 'think') { return this.buildThinkTool( @@ -2321,6 +2399,31 @@ export class AgentFramework { * Query inference logs. * Returns entries with summary info (doesn't resolve blobs). */ + /** Tune-out (issue #77): divert a channel to your subconscious. Present + * only when the subconscious resident is configured. */ + private static readonly TUNE_OUT_TOOL: import('./types/index.js').ToolDefinition = { + name: 'tune_out', + description: + 'Divert a channel to your subconscious for a while: its traffic stops ' + + 'entering your context and stops waking you. Your subconscious watches ' + + 'it instead — summarizing on a cadence in its own voice, judging ' + + 'whether mentions merit interrupting, and able to cancel. People who ' + + 'mention you get an automatic reaction so they know you saw nothing. ' + + 'Cancelling (mode "cancel") delivers the diverted backlog. The ' + + 'tune-out auto-cancels after maxWakes mention-bursts.', + inputSchema: { + type: 'object', + properties: { + channelId: { type: 'string' }, + mode: { type: 'string', enum: ['enter', 'cancel'], description: 'Default: enter.' }, + cadenceSeconds: { type: 'number', description: `Summary cadence (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}s).` }, + backlogCap: { type: 'number', description: `Max raw messages delivered at cancel (default ${TUNE_OUT_DEFAULTS.backlogCap}).` }, + maxWakes: { type: 'number', description: `Wake budget before auto-cancel (default ${TUNE_OUT_DEFAULTS.maxWakes}).` }, + }, + required: ['channelId'], + }, + }; + /** Synthesized sleep/wake tool definitions (present when a gate is wired). */ private static readonly SLEEP_TOOLS: import('./types/index.js').ToolDefinition[] = [ { @@ -3740,6 +3843,15 @@ export class AgentFramework { strategy: config.strategy ?? new PassthroughStrategy(), membrane: this.membrane, debugLogContext: !!process.env.DEBUG_CONTEXT, + // Tune-out (issue #77): messages stamped at ingestion as diverted + // (metadata.tuneOut = { epochId }) never enter a resident's compiled + // view — not at emission, not via chunking into the memory pyramid. + // The stamp is permanent: after cancel, the backlog arrives as one + // appended dump instead of retro-inserting into + // the timeline (KV-prefix-stable delivery). The stored originals + // remain in the shared slot for the subconscious's merged view, + // fetch_history, and audit. + viewFilter: (message) => !(message.metadata as { tuneOut?: unknown } | undefined)?.tuneOut, }); const agent = new Agent(config, contextManager, this.membrane); @@ -3760,6 +3872,60 @@ export class AgentFramework { return agent; } + /** + * Create and register the subconscious resident (issue #77): a persistent + * side-agent with an isolated slot (`subconscious/`) whose + * strategy view merges the residents' shared timeline with its own — + * including the tuned-out-stamped messages the residents' viewFilter + * excludes; the backlog is precisely what it exists to see. Windowed + * passthrough, no memory pyramid: its durable output is what it delivers + * into the resident's window, which accumulates there. + */ + private async createSubconsciousAgent(cfg: SubconsciousConfig): Promise { + const primaryName = this.primaryAgentName; + const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined; + if (!primaryName || !primaryConfig) { + throw new Error('subconscious requires a primary agent to attend to'); + } + const name = cfg.name ?? 'Subconscious'; + if (this.agents.has(name)) { + throw new Error(`subconscious name "${name}" collides with a registered agent`); + } + + const strategy = new WindowedPassthroughStrategy({ + reAnchorFraction: cfg.reAnchorFraction, + }); + const contextManager = await ContextManager.open({ + store: this.store, + namespace: `subconscious/${primaryName}`, + isolate: true, + // The residents' shared un-namespaced slot, merged read-only and + // UNfiltered (no viewFilter here — stamped messages included). + auxiliaryMessageViews: [{}], + strategy, + membrane: this.membrane, + debugLogContext: !!process.env.DEBUG_CONTEXT, + }); + + const agentConfig: AgentConfig = { + name, + model: cfg.model ?? primaryConfig.model, + systemPrompt: cfg.systemPrompt, + // Prose is never auto-routed: a cadence-triggered turn carries no + // locus, and bare prose must not fall through to the default channel. + // Speech happens only via speak_in_channel (its own marked voice). + proseRouting: 'explicit', + allowedTools: [...SUBCONSCIOUS_TOOL_NAMES, 'think', 'skip_reply', 'end_turn'], + }; + const agent = new Agent(agentConfig, contextManager, this.membrane); + this.agents.set(name, agent); + this.agentConfigs.set(name, agentConfig); + this.subconsciousAgentName = name; + this.subconsciousStrategy = strategy; + this.subconsciousConfig = cfg; + return agent; + } + private async runLoop(): Promise { while (this.running) { try { @@ -3898,12 +4064,11 @@ export class AgentFramework { // queued, they flush at the target's own next boundary — with // injection — or in driveStream's finally when its turn ends. const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = []; - if (this.deferredMessages.length > 0) { - const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent; - if (target === agent) { - const deferred = this.deferredMessages.splice(0); + { + const deferred = this.drainDeferredFor(agent.name); + if (deferred.length > 0) { for (const msg of deferred) { - target.getContextManager().addMessage(msg.participant, msg.content, msg.metadata); + agent.getContextManager().addMessage(msg.participant, msg.content, msg.metadata); // Injection guards: tool blocks would corrupt the tool-cycle // structure the membrane enforces, and a message named as the // agent itself would render as an ASSISTANT turn on the wire @@ -4238,7 +4403,8 @@ export class AgentFramework { // their own channel's messages, not by framework-wide events. const targetAgents = response.requestInference === true - ? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n)) + ? Array.from(this.agents.keys()).filter( + (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName) : response.requestInference; for (const agentName of targetAgents) { @@ -4275,6 +4441,7 @@ export class AgentFramework { metadata?: Record; tags?: string[]; triggerInference?: boolean; + targetAgents?: string[]; }): Promise { const metadata: Record = { ...event.metadata, @@ -4321,12 +4488,35 @@ export class AgentFramework { } } + // Tune-out divert (issue #77): stamped, stored, no resident wake. The + // coordinator has already handled wake bookkeeping (coalesced + // subconscious invocation, durable count, suppression ack) before we + // stamp; the subconscious reads the message through its merged view. + const divert = this.tuneOutCoordinator?.onIncoming( + event.serverId, + event.channelId, + event.messageId, + event.tags, + event.author?.id, + ) ?? null; + if (divert) { + metadata.tuneOut = { epochId: divert.epochId }; + } + const id = this.addMessage('user', incomingContent, metadata); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); - if (event.triggerInference) { + if (event.triggerInference && !divert) { const addressed = isAddressedMessage(event.tags, event.metadata); - for (const agentName of this.agents.keys()) { + // Honor explicit targeting, mirroring the push-event path: an event + // that names its agents wakes exactly those; an untargeted event + // keeps the historical broadcast. (targetAgents was declared on + // McplChannelIncomingEvent from the start but never honored here — + // tune-out's wake routing is the first setter.) + const targetAgents = event.targetAgents + ?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName); + for (const agentName of targetAgents) { + if (!this.agents.has(agentName)) continue; this.pendingRequests.push({ agentName, reason: 'mcpl:channel-incoming', @@ -4749,7 +4939,8 @@ export class AgentFramework { if (event.triggerInference) { // Default broadcast excludes conversation forks (channel-driven). const targetAgents = event.targetAgents - ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n)); + ?? [...this.agents.keys()].filter( + (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName); for (const agentName of targetAgents) { this.pendingRequests.push({ agentName, @@ -5450,9 +5641,8 @@ export class AgentFramework { trigger?.reason !== 'context_budget_restart' && this.deferredMessages.length > 0 ) { - const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent; - if (target === agent) { - const deferred = this.deferredMessages.splice(0); + { + const deferred = this.drainDeferredFor(agent.name); for (const msg of deferred) { // Per-message try/catch (mirrors announceLocusIfChanged): one // poison message — or a transient store-write failure — must not @@ -6732,11 +6922,15 @@ export class AgentFramework { this.activeTurnTokens.delete(agent.name); } - // Flush any deferred messages (e.g. if stream failed while tools were pending) + // Flush any deferred messages (e.g. if stream failed while tools were + // pending). Only THIS agent's messages: other targets' entries wait + // for their own boundaries (re-adding via addMessage re-defers if the + // target has meanwhile started a turn). if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) { - const deferred = this.deferredMessages.splice(0); + const deferred = this.drainDeferredFor(agent.name); for (const msg of deferred) { - this.addMessage(msg.participant, msg.content, msg.metadata); + this.addMessage(msg.participant, msg.content, msg.metadata, + msg.forAgent ? { forAgent: msg.forAgent } : undefined); } } } @@ -7704,6 +7898,30 @@ export class AgentFramework { return; } + // Route the tune-out tool (residents) and the subconscious's surface + if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) { + this.dispatchTuneOutToolCall(agentName, enrichedCall); + return; + } + if ( + this.tuneOutCoordinator && + agentName === this.subconsciousAgentName && + (SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(enrichedCall.name) + ) { + void this.tuneOutCoordinator + .handleSubconsciousTool(enrichedCall.name, enrichedCall.input as Record) + .then((result) => { + this.queue.push({ + type: 'tool-result', + callId: enrichedCall.id, + agentName, + moduleName: 'tune-out', + result, + }); + }); + return; + } + // Route sleep / wake tools if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) { this.dispatchSleepToolCall(agentName, enrichedCall); @@ -7848,13 +8066,32 @@ export class AgentFramework { private addMessage( participant: string, content: ContentBlock[], - metadata?: MessageMetadata + metadata?: MessageMetadata, + opts?: { + /** + * Deliver into this registry agent's window instead of the primary's, + * with the deferral/turn-alive guard evaluated against THAT agent's + * turn state. Default (absent) is byte-identical to the historical + * primary-only behavior. Unknown names log and drop (loud, non-fatal: + * this is reachable from timer callbacks that may outlive an agent). + */ + forAgent?: string; + } ): MessageId { - // Route to the primary agent's context manager (not ephemeral subagents). - const agent = this.primaryAgentName - ? this.agents.get(this.primaryAgentName) - : this.agents.values().next().value; + // Route to the named agent, else the primary (not ephemeral subagents). + const agent = opts?.forAgent + ? this.agents.get(opts.forAgent) + : this.primaryAgentName + ? this.agents.get(this.primaryAgentName) + : this.agents.values().next().value; if (!agent) { + if (opts?.forAgent) { + console.error( + `[addMessage] delivery target "${opts.forAgent}" is not a registered agent — message dropped ` + + `(participant=${participant})`, + ); + return '' as MessageId; + } throw new Error('No agents configured'); } @@ -7875,19 +8112,48 @@ export class AgentFramework { // ALSO injected into the live stream — hear-while-acting), or in // driveStream's finally when the turn ends. const hasToolResult = content.some(b => b.type === 'tool_result'); + // Explicitly-targeted deliveries scope the tool-cycle check to the + // target (pendingAssistantBlocks is keyed by agent); the default path + // keeps the historical GLOBAL check byte-for-byte (conservative: any + // agent's pending cycle defers primary-bound messages). + const midToolCycle = opts?.forAgent + ? this.pendingAssistantBlocks.has(agent.name) + : this.pendingAssistantBlocks.size > 0; if ( !hasToolResult && - (this.pendingAssistantBlocks.size > 0 || + (midToolCycle || this.activeTurnTokens.has(agent.name) || this.activeStreams.has(agent.name)) ) { - this.deferredMessages.push({ participant, content, metadata }); - return '' as MessageId; // Deferred — flushed at the next boundary + this.deferredMessages.push({ participant, content, metadata, forAgent: opts?.forAgent }); + return '' as MessageId; // Deferred — flushed at the target's next boundary } return agent.getContextManager().addMessage(participant, content, metadata); } + /** + * Splice out the deferred messages addressed to `agentName` (explicitly + * via forAgent, or implicitly when it is the primary), leaving other + * targets' messages queued for their own boundaries. + */ + private drainDeferredFor(agentName: string): Array<{ + participant: string; + content: ContentBlock[]; + metadata?: MessageMetadata; + forAgent?: string; + }> { + if (this.deferredMessages.length === 0) return []; + const mine: typeof this.deferredMessages = []; + const rest: typeof this.deferredMessages = []; + for (const msg of this.deferredMessages) { + const target = msg.forAgent ?? this.primaryAgentName; + (target === agentName ? mine : rest).push(msg); + } + this.deferredMessages = rest; + return mine; + } + private editMessage(id: MessageId, content: ContentBlock[]): void { const agent = this.primaryAgentName ? this.agents.get(this.primaryAgentName) @@ -10258,6 +10524,51 @@ export class AgentFramework { * suppression window, optionally announces in the sticky channel, and ends * the turn (the agent goes idle immediately). `wake` clears sleep. */ + /** tune_out tool (residents): enter or cancel a channel tune-out. */ + private dispatchTuneOutToolCall(agentName: string, call: ToolCall): void { + const coordinator = this.tuneOutCoordinator!; + const input = (call.input ?? {}) as { + channelId?: string; mode?: string; + cadenceSeconds?: number; backlogCap?: number; maxWakes?: number; + }; + const channelId = String(input.channelId ?? ''); + const entry = this.channelRegistry?.listChannelsRaw() + .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 }; + } 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 }; + } else { + const r = coordinator.enter(entry.serverId, channelId, { + cadenceSeconds: input.cadenceSeconds, + backlogCap: input.backlogCap, + maxWakes: input.maxWakes, + }, 'agent-tool'); + result = r.ok + ? { + success: true, + data: { + tunedOut: channelId, + cadenceSeconds: r.params.cadenceSeconds, + backlogCap: r.params.backlogCap, + maxWakes: r.params.maxWakes, + }, + } + : { success: false, error: r.error, isError: false }; + } + this.queue.push({ + type: 'tool-result', + callId: call.id, + agentName, + moduleName: 'tune-out', + result, + }); + } + private dispatchSleepToolCall(agentName: string, call: ToolCall): void { this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input }); const gate = this.eventGate!; diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index a88dbaf..1baf52a 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -515,7 +515,13 @@ export class EventGate { // Dependency-injected callbacks private emitTrace: (event: TraceEventLike) => void; - private addMessageFn: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record) => unknown; + private addMessageFn: ( + participant: string, + content: Array<{ type: 'text'; text: string }>, + metadata?: Record, + /** Registry agent to deliver into; absent = primary (historical). */ + forAgent?: string, + ) => unknown; private requestInferenceFn: (agentName: string, reason: string, source: string) => void; private getAgentNamesFn: () => string[]; /** Clock injection — keeps the new rate_limit / passive_sample paths @@ -527,7 +533,12 @@ export class EventGate { initialConfig?: GateConfig; privilegedUsersPath?: string; emitTrace: (event: TraceEventLike) => void; - addMessage: (participant: string, content: Array<{ type: 'text'; text: string }>, metadata?: Record) => unknown; + addMessage: ( + participant: string, + content: Array<{ type: 'text'; text: string }>, + metadata?: Record, + forAgent?: string, + ) => unknown; requestInference: (agentName: string, reason: string, source: string) => void; getAgentNames: () => string[]; /** Optional clock — defaults to Date.now. Tests inject for deterministic time. */ @@ -1582,10 +1593,13 @@ export class EventGate { // before the inference request so it rides the wake turn (addMessage's // turn-alive guard defers it to that turn's start if needed). const nowIso = new Date(this.now()).toISOString().slice(0, 19) + 'Z'; + // Deliver the notice into the WAKING agent's window — a self-wake + // armed by a non-primary agent (e.g. a subconscious cadence) must not + // narrate itself into the primary's context. this.addMessageFn('user', [{ type: 'text', text: `[self-wake] your ${source} timer (${Math.round(ms / 1000)}s) elapsed — now ${nowIso}`, - }], { source: 'gate:self-wake' }); + }], { source: 'gate:self-wake' }, agentName); this.requestInferenceFn( agentName, `self-scheduled wake (${source}, ${Math.round(ms / 1000)}s)`, @@ -1659,6 +1673,17 @@ export class EventGate { } /** Load the privileged-users file (bare array or { userIds: [...] }). */ + /** + * Whether an author id is on the privileged-users list (hot-reloaded). + * Shared by sleep bypass and tune-out wake classification (#77: wakes are + * "mentions, gate-privileged authors, etc."). + */ + isPrivilegedUser(authorId: string | null | undefined): boolean { + if (!authorId) return false; + this.loadPrivileged(); + return this.privilegedUserIds.has(authorId); + } + private loadPrivileged(): void { if (!this.privilegedUsersPath) return; try { diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index ea5e17d..aaf7d6c 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -1312,6 +1312,49 @@ export class ChannelRegistry { return { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 }; } + /** Registered channel entries (read-only iteration for the coordinator). */ + listChannelsRaw(): Array<{ serverId: string; descriptor: ChannelDescriptor }> { + return [...this.channels.values()].map((e) => ({ + serverId: e.serverId, + descriptor: e.descriptor, + })); + } + + /** + * Small chronicle snapshot-state helpers for the tune-out coordinator + * (dispositions slot). Registration is idempotent; absent store = null/no-op + * (mirrors the lifecycle log's optional-store posture). + */ + readCoordinatorState(stateId: string): unknown { + if (!this.store) return null; + try { + this.store.registerState({ id: stateId, strategy: 'snapshot' }); + } catch { /* already registered */ } + return this.store.getStateJson(stateId); + } + + writeCoordinatorState(stateId: string, value: unknown): void { + if (!this.store) return; + try { + this.store.registerState({ id: stateId, strategy: 'snapshot' }); + } catch { /* already registered */ } + this.store.setStateJson(stateId, value); + } + + /** + * Publish into a channel on behalf of a named non-resident agent (the + * subconscious's speak_in_channel). Same delivery path as the + * channel_publish tool; the agent name rides the speech-routed trace. + */ + async publishForAgent( + channelId: string, + text: string, + agentName: string, + ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> { + this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId: agentName, channelId, text }); + return this.handleToolPublish({ channelId, content: text }); + } + /** * Consume the old recipe policy exactly once. It seeds Chronicle for * existing deployments, but is not an ongoing admission policy: channels diff --git a/src/tune-out/coordinator.ts b/src/tune-out/coordinator.ts new file mode 100644 index 0000000000000000000000000000000000000000..37e775598808ec45a48cf462998c63e22f1d25b5 GIT binary patch literal 20989 zcmcIs+j1LMcFnWC;@EKw0GR~kNu?$QiBe=ro>66NIifs%kX2$6Xp(IL-I(r%XoiET zd_=w>AC@o4TKjVPGyu}lj64rxvAfT0U)R1I9NfQuFWe8WS7rWk)gDzDU z-R792&d$z?X&5$bwpfN7kFQ(o;e0P#WtVxVO3swcW|+LmXQRE4O)typYM#%|^U$o8 zOMMX*Sh&DyVV1Xfy(r3}X^Uw#pI`5VdR20_oN1ZY`+kUKwOC|0>omizr};{CG<#QfK2%^t1da-me{G}?Ud6-qx zwgzp(@bj_?SKvq!iZU!JjBW<_@2b6MJ|9gI63B#3RIXg-8AhGe)nc3YMYHv6&;)Gj zG;0fdpa0T^RXM{pi&l0b5>`dCJSf4R$+W&+w$yF8JqxIjEi=*d>p$Yq?bhgNe7}Fr4|Egv4({I%&+@ZuH3zTGV4)~O?`UhZ`ZqP=Ou>mR;*{_f=uuirg;{>{@L{`~ql9EO{F!FBmK$7eHGoUi`v!Gk?K zH+jS+C*k+{2ul4Ub$Sy1b?@FfWRM|F{qUab>8Q%)ISd=5|EhpkT*>CdCKcuTY6`8w z*OQRB9)fZWuO``jhs8;~!DwYmILRb4hhBBi*REbd;G z!TN0lRSS@}vW4ZaLl0>%KHKu6GC9_7-v-dY|js!{lt?Z=qbx=jr^*8a4)=%bi2AhS_nM*FO8zDPO^_i1)Ao zn2y2>JMTgW|9p}0K{Kz~Juv62m_yRwkd2h*dE2vU&Jz4Vgu*4Nd;wX5os2d1ZbT@?|x>XnfJBPI7F6e93aPIL&Kc>D%gkzU3NSylwiS z_%g0?mDMxIGUNr$173Y&#RkVr6#A*`4(s~9Ctwv9-c9zTQ8_aiX{Z*Hu^M1&^0`KD}_} zCq&2>Mf(~s0lGQ_Rw;oUZUTL`Q|)1RbD3ZJUvDRFbiz8}-#QvsL9cFDQ&4>a9VVek za08RCiv_8D7$~*hX3J>TUgdNGud$0q+)8x@nQ!?uT;Aiy!;y2(4o5=_0GcTp?wkaG zYza&cXa;}{(dJH9m=B2I6|&TiBdxd=ZDz>RrL!+gh%!#dgXGeh|Nsc_K41-c7QV*`}d&Xn6tfz{&L4rR0(BgV2wD>dNqYfGhb!A zBA%{FfP51}M?UaX8vqNlbS#d;6cBZzwlS1?FK0j;ctK}8aZ&2~Xbu^L8;D)EB4_~< zLvYrq8Wck}**!w^pyojQe1Ll(=fcSsH$Hg%PhZ@`Ve1nLn=C}%`3hbV@qkOPvcG}P zoMEDUvy|Bx^)!RHS3uno^Kp=I3jS!d$e)Vkd@iPXh&|Ce+K%Qx?A3gRmE3eHtK4ot zU(D47vH3#OErXc2QqeD>>VdMtYH!J7yc30vm!9 zeI)aXvoK6|9u5z6v*B-lbCfD;e z$t;Af+*yMIO7|!FAEfHwAUtPa5)SChkvGw2pJ)M0Lt6<_MWZ#_$;sM z3eMN;`PvkRs{dK4+zA*Z%uhwwc$>_+=d%tNWJ^MiAy5IcEzUU&TViYxhRgy<3`tLV zR-A{E^uv?UAgt9)b>=DAVuD;d+?rVDJGOaZEDNdAVlwI$*i4sk3Ebj`qw5kRT?1SZ zEFpvmi&eW~S}^3lOy{cxkmcl-{r{T6yOx>_xLw`qJeKSEz3K@?F#m+u;mRC>1)DBou7#Te2){FeuNqAME?GCX;* zos8M|CQhDaVdBI-Z})QdoZb64pKIX+5Zku-mF?1B?Viiu#+hI%nUC&VH`JAgLVf5) zpy|{yv6l}wg+pk#$Hg10rLzaCbsfN%{M9H5;G1|CO#|=FaP}P`SEKHPF>Q+ z;X5W{kww;6*Jm=&1|5*N_5u;RWc`r`A(jDwDoToY+ykb7V2t2@ zHO&=JhcHB*80JOdCgnv@+B5S&<1G?L;;X=Jf1omHjw@luU697=>jVA&LsEI zDy)%V{m0v=fYDhmuP9oVI#yDkpYKe81oG=2gcwwhku1PrZUM4-syl7fphEJ1k}0`Q zlQ;n*GINJwdr~M+CW4Mzy*abpFJfm+a~@+L_Pf3`$qFDeA&pBm7m8J~>(^KslT;$r)E&z0zAVX3gu-jO

rdYIr6RU%P4n|D0V%X4J&_Jd4m zf12ahQFEkBnKsl97TIz*TnKS)gsbkCHSdb=wrHkrgk&XP`(5<1PsjX@Flj-~dOcVP z;OmLCJ;WE-C2O9xnGl?l!&4-BPHC^7|ALzSw0(4xR7dy7Oi8>BI1>Vf$E@9)j`>hB zDf)cW%_~Pgm80V`3`*YpTmf7Rekuo1^UUlZ)1`-6PY^S3l;*gQ*ytIFtZgMZ`^MID z({%WG8fkof;-L7%>mY|;-1K?5j^B*O<6*~hm6JCo@d-x8f3H22^e~*|NO-@45+Xr> zJS6ZNHpWCL++sUAc`IUNKuL-x&JlJPghSu|j>hyd z-Va!k7&wvN^krmETs*o8lG1<59rO0=M&KsCxKV$)-ormtsfjg?*wV2U*s=9k1n(=T z^I!ln51PUH?N23XMB1+5h^$B%Aia8Ujp6sgB@jABkZeLV^`P*N(fvof=6L~=KhX2_ zc7c%gMOs2wTC*Mv!g0eU4q8&945Yw-A0{9naSuM=t(FrsdD(jhXc2z*J0{{eN$2E} zxwhFQg-U+!91GzVo^7qwk&?tT@}*zx0|exE5)fH&wMptI)m$R8E``t@73?8*ni9Ys z?>y{gC3t8b?}Za*aw2cTC~TvXy-p0=9O(vka5}&hN@sx01qX-pJR&{6osVQyEWWd6H?XO(U^0|qH~gwJROl(0+N z3&lGyi%wV@-9-coyX6~N{^wdM6_Kl(jBh@v_UJ%S!N)~C?8N|!x0>G|;F~y3Eftd^pedUIkVbDY^0N(k&3#+EGH5YNi)Rs?T#uPuJhFYpvb_(M?&dgZhX=Z{%qb z-$l8?2Z+_K%PH;79S3yfPGXzxQ9Q6ef?4<;DOnisN9_9WdOwTsH^wLxB~^6SR{hHx zX_%)w79SpuZs*lV<}(|N#*5F6Vpp+r9gnLs8#5ZidW$&o!a_4nPB}o8PHEG zgMDP&D`wJq!Y2hIqQu8ZMLThyEQHD}4H(+a*AlWdObmT;ROBiGO{&|?+_OvjPMxrQ z#tw9A2<^2&skVPaR{MiJn`#%5c$d&E?Z9=pHO2ch?$|Yvvqfzp8zK;gN@V5L1%%P$ zPSZ%Y=!3eSwL3O zH*75ezPUBhly!F*UKZKi?`*h`x)z7Dn7mSka_>&M(L08qEyt9MNwIUn5i~)Y3ki2wpe}H zXMS7w$Q)mk@h(Gca;A1JH8Fau56Mk;OZ2(4ot*xyf}fN(awT4E4TzP)$TTHxQC}d3 z%1d~^Y1ET)^9a8QfE!aA*E2O;_!u@s5`KPf1DfTL6P;5!&oM)V7c*W6v8Lu5(L;4Y z{pPO&u!6Za86Z!L$ZnwCJ zrP0{X&(`u-W5}hLFV$OnvM@bLC5#&&4=g`$faJ+7s)CHU_sLtXM`?1$nsGp?DYp|y z%o2@7B5({hU1q(dLiKN#e@_pNGv5Sswa9wgoc5-9VJSLow2cRJ@Kb4U(6a%&ZfRXC z>wv5renr82ak{X4MXq92<-0=D-3P~q>0liopKituS9Q^%Atcs!?v@A!_MMoBeEe2s z|2WNg$DKimi6}i zq*Ku|k_x!!vqFzQV!ODzt_03CL^S_m{nVR{OGzi8H8F`BX1L>3SM;Q4#O( z)XuTDyW5DZ4hh%8&hDZ}>L?vMV{cDz^Kj-v6Qc3>unPFO3Kl<2^!UR!N|%mp9!aZ= zYDUz%(szhxCsP}8+P&BmHkDHR@C-0j%$4A}{p{=OBNg4`uGNNE*r;C@ixXSF-*$o; zIVB|4@qvz`fZQU!bV-z5^_+qwdAc7pc>k2`*sII$g~rwi(D)l}|5~I1J>sV_pRxN(CtMp&5VU=s+w_|N-}`^J!MF4P literal 0 HcmV?d00001 diff --git a/src/tune-out/tools.ts b/src/tune-out/tools.ts new file mode 100644 index 0000000..24031a4 --- /dev/null +++ b/src/tune-out/tools.ts @@ -0,0 +1,129 @@ +/** + * The subconscious's tool surface (issue #77). + * + * Deliberately small and channel-scoped: the subconscious observes the + * merged timeline, judges wakes, and reports — it is not a general agent. + * Names and results carry no credential/config vocabulary; everything it + * says to the resident is its own text (never host-templated), delivered + * under its own participant name. Prose is never auto-routed + * (proseRouting: 'explicit' on its AgentConfig): speaking into a channel + * happens only through speak_in_channel, so a timer-triggered turn can + * never leak bare prose to the default locus. + */ + +import type { ToolDefinition } from '../types/index.js'; + +export const SUBCONSCIOUS_TOOL_NAMES = [ + 'deliver_summary', + 'cancel_tuneout', + 'note_disposition', + 'speak_in_channel', +] as const; + +export type SubconsciousToolName = (typeof SUBCONSCIOUS_TOOL_NAMES)[number]; + +export const SUBCONSCIOUS_TOOLS: ToolDefinition[] = [ + { + name: 'deliver_summary', + description: + 'Deliver a summary into the resident\'s context, in your own voice, ' + + 'addressed to them (second person). Use at cadence when the diverted ' + + 'traffic merits it; staying silent is always allowed — an empty ' + + 'period needs no report.', + inputSchema: { + type: 'object', + properties: { + text: { + type: 'string', + description: 'The summary, verbatim as the resident will read it.', + }, + channelId: { + type: 'string', + description: 'The tuned-out channel this summary covers.', + }, + }, + required: ['text', 'channelId'], + }, + }, + { + name: 'cancel_tuneout', + description: + 'End the tune-out on a channel now — use when something needs the ' + + 'resident\'s full attention. The diverted backlog is delivered to ' + + 'them (capped), and normal attention resumes. Add your own note in ' + + '`text`; it arrives alongside the backlog, in your voice.', + inputSchema: { + type: 'object', + properties: { + channelId: { type: 'string' }, + text: { + type: 'string', + description: 'Your accompanying note to the resident (optional).', + }, + }, + required: ['channelId'], + }, + }, + { + name: 'note_disposition', + description: + 'Record a standing disposition for yourself — a durable note that ' + + 'shapes how you treat future traffic ("antra\'s pings always ' + + 'escalate", "ignore release-bot"). Replaces any previous note under ' + + 'the same key.', + inputSchema: { + type: 'object', + properties: { + key: { type: 'string', description: 'Short stable identifier.' }, + text: { + type: 'string', + description: 'The disposition. Empty string deletes the key.', + }, + }, + required: ['key', 'text'], + }, + }, + { + name: 'speak_in_channel', + description: + 'Post a message into a tuned-out channel, clearly as yourself (not ' + + 'as the resident). Use sparingly — e.g. to tell someone the resident ' + + 'is tuned out and when to expect them. Disabled unless the resident\'s ' + + 'configuration allows it.', + inputSchema: { + type: 'object', + properties: { + channelId: { type: 'string' }, + text: { type: 'string' }, + }, + required: ['channelId', 'text'], + }, + }, +]; + +/** + * Configuration for the subconscious resident (FrameworkConfig.subconscious). + */ +export interface SubconsciousConfig { + /** Master switch. */ + enabled: boolean; + /** + * Registry + participant name. Default 'Subconscious' — following the + * Context Manager precedent: a title-case functional voice, not an + * agent-prefixed identifier. + */ + name?: string; + /** Model id; defaults to the primary agent's (same-model side-process). */ + model?: string; + /** + * The voice/criteria mode block — recipe-side and co-authored with the + * resident (it is an aspect of their attention). Report-shaped, second + * person toward the resident. Canary before fleet use (issue #77). + */ + systemPrompt: string; + /** Allow speak_in_channel. Default false until the voice block has + * passed its canary round. */ + allowChannelSpeech?: boolean; + /** WindowedPassthroughStrategy re-anchor fraction (default 0.5). */ + reAnchorFraction?: number; +} diff --git a/src/types/framework.ts b/src/types/framework.ts index 2f25b57..e5c73ae 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -71,6 +71,13 @@ export interface CodeExecutionConfig { } export interface FrameworkConfig { + /** + * The subconscious resident (issue #77): a persistent side-agent that + * receives diverted traffic from tuned-out channels and reports to the + * resident in its own voice. See src/tune-out/tools.ts. + */ + subconscious?: import('../tune-out/tools.js').SubconsciousConfig; + /** * IANA zone used only when rendering wall-clock times for the agent. * Stored/protocol timestamps remain epoch/UTC. Defaults to AGENT_TIMEZONE, From 95a23afb45205ff98eeeb0925aaca85b7b605eb4 Mon Sep 17 00:00:00 2001 From: Aster Date: Wed, 5 Aug 2026 19:35:28 -0700 Subject: [PATCH 3/8] test: tune-out lifecycle, plumbing seams, and the end-to-end arc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tune-out-lifecycle: durable third state — round-trip, restart-surviving wake counts, epoch supersession, malformed-record replay safety. channel-incoming-targeting: fan-out asserted at the pendingRequests seam (MockMembrane hands its whole queue to the first stream, so concurrent two-agent turns starve one and restart-loop — harness limitation). per-agent-delivery: target-scoped deferral, survival across the primary's boundary, loud unknown-target drop. tune-out-e2e: the full arc against a real stdio MCPL fixture (0.5 handshake, initial-policy Request, itemized register): divert/stamp/ exclude + merged-view visibility + suppressed-mention ack + coalesced wake with durable count + max-wakes auto-cancel + capped dump + the subconscious's cancel-report notice + permanent exclusion after cancel. Co-Authored-By: Claude Fable 5 --- test/channel-incoming-targeting.test.ts | 95 +++++++++++ test/fixtures/tune-out-mcpl-server.mjs | 145 +++++++++++++++++ test/per-agent-delivery.test.ts | 140 ++++++++++++++++ test/tune-out-e2e.test.ts | 206 ++++++++++++++++++++++++ 4 files changed, 586 insertions(+) create mode 100644 test/channel-incoming-targeting.test.ts create mode 100644 test/fixtures/tune-out-mcpl-server.mjs create mode 100644 test/per-agent-delivery.test.ts create mode 100644 test/tune-out-e2e.test.ts diff --git a/test/channel-incoming-targeting.test.ts b/test/channel-incoming-targeting.test.ts new file mode 100644 index 0000000..243f00c --- /dev/null +++ b/test/channel-incoming-targeting.test.ts @@ -0,0 +1,95 @@ +/** + * targetAgents on the channel-incoming path (issue #77 plumbing). + * + * McplChannelIncomingEvent declared `targetAgents` from the start, but the + * fan-out ignored it and woke every registered agent. Now it mirrors the + * push-event path: a targeted event wakes exactly the named agents (unknown + * names skipped), an untargeted event keeps the historical broadcast. + * Tune-out's wake routing is the first setter. + * + * Asserted at the pendingRequests seam (the fan-out's output), not by + * running turns: MockMembrane hands its whole response queue to the first + * stream, so two agents' concurrent turns starve one stream and the + * framework restart-loops it — a harness limitation, not product behavior. + */ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { AgentFramework } from '../src/index.js'; +import { MockMembrane } from './helpers/mock-membrane.js'; + +function internals(framework: AgentFramework) { + return framework as unknown as { + pendingRequests: Array<{ agentName: string; reason: string }>; + handleMcplChannelIncoming(event: Record): Promise; + }; +} + +function channelIncoming(text: string, targetAgents?: string[]): Record { + return { + type: 'mcpl:channel-incoming', + serverId: 'discord', + channelId: 'discord:guild:chanA', + messageId: `m-${Math.random().toString(36).slice(2)}`, + author: { id: 'U1', name: 'antra' }, + content: [{ type: 'text', text }], + timestamp: new Date().toISOString(), + metadata: {}, + triggerInference: true, + ...(targetAgents ? { targetAgents } : {}), + }; +} + +describe('channel-incoming targetAgents', () => { + let tempDir: string; + let framework: AgentFramework; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'channel-targeting-test-')); + framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: new MockMembrane().asMembrane(), + agents: [ + { name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }, + { name: 'shade', model: 'test-model', systemPrompt: 'You are shade.' }, + ], + modules: [], + }); + }); + + afterEach(async () => { + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('untargeted events keep the historical broadcast (both agents queued)', async () => { + const i = internals(framework); + await i.handleMcplChannelIncoming(channelIncoming('hello everyone')); + assert.deepEqual( + i.pendingRequests.map((r) => r.agentName).sort(), + ['scout', 'shade'], + ); + }); + + it('a targeted event queues exactly the named agent', async () => { + const i = internals(framework); + await i.handleMcplChannelIncoming(channelIncoming('for shade only', ['shade'])); + assert.deepEqual(i.pendingRequests.map((r) => r.agentName), ['shade']); + }); + + it('unknown names in targetAgents are skipped, not crashed on', async () => { + const i = internals(framework); + await i.handleMcplChannelIncoming(channelIncoming('mixed', ['nobody-home', 'scout'])); + assert.deepEqual(i.pendingRequests.map((r) => r.agentName), ['scout']); + }); + + it('non-triggering events queue nobody', async () => { + const i = internals(framework); + const event = channelIncoming('ambient, gate said no'); + event.triggerInference = false; + await i.handleMcplChannelIncoming(event); + assert.equal(i.pendingRequests.length, 0); + }); +}); diff --git a/test/fixtures/tune-out-mcpl-server.mjs b/test/fixtures/tune-out-mcpl-server.mjs new file mode 100644 index 0000000..bdb8b6b --- /dev/null +++ b/test/fixtures/tune-out-mcpl-server.mjs @@ -0,0 +1,145 @@ +// MCPL fixture for tune-out e2e tests (issue #77). +// +// Registers one channel (`disc:guild:noisy`, initiallyOpen) and then emits +// channel traffic on command: the TEST appends lines to COMMAND_PATH +// (`ambient ` / `addressed `), the fixture polls the +// file and sends a channels/incoming Request per line. Host-side +// channels/acknowledge calls are recorded to STATUS_PATH as JSONL — the +// test asserts the deterministic suppressed-mention reaction there. +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import { createInterface } from 'node:readline'; + +const statusPath = process.env.STATUS_PATH; +const commandPath = process.env.COMMAND_PATH; + +const log = (event, extra = {}) => { + if (!statusPath) return; + appendFileSync(statusPath, JSON.stringify({ event, ...extra }) + '\n'); +}; +const send = (message) => process.stdout.write(JSON.stringify(message) + '\n'); +const reply = (id, result) => send({ jsonrpc: '2.0', id, result }); + +const CHANNEL_ID = 'disc:guild:noisy'; +let nextId = 500; +let processedCommands = 0; + +function pollCommands() { + if (!commandPath || !existsSync(commandPath)) return; + const lines = readFileSync(commandPath, 'utf8').split('\n').filter(Boolean); + for (const line of lines.slice(processedCommands)) { + processedCommands++; + const [kind, messageId, ...rest] = line.split(' '); + const text = rest.join(' '); + const tags = kind === 'addressed' + ? ['chat:mention', 'chat:from-human'] + : ['chat:ambient', 'chat:from-human']; + log('incoming-sent', { kind, messageId }); + send({ + jsonrpc: '2.0', + id: nextId++, + method: 'channels/incoming', + params: { + messages: [{ + channelId: CHANNEL_ID, + messageId, + author: { id: 'U1', name: 'antra' }, + timestamp: new Date().toISOString(), + content: [{ type: 'text', text }], + tags, + }], + }, + }); + } +} +const pollTimer = setInterval(pollCommands, 100); + +const rl = createInterface({ input: process.stdin }); +// Exit when the host closes the transport — a real connector dies with its +// pipe, and the poll timer must not keep this process (and the test run) +// alive past teardown. +rl.on('close', () => { + clearInterval(pollTimer); + process.exit(0); +}); +rl.on('line', (line) => { + let msg; + try { + msg = JSON.parse(line); + } catch { + return; + } + if (msg.method === 'initialize') { + reply(msg.id, { + protocolVersion: '2024-11-05', + capabilities: { + experimental: { + mcpl: { + version: '0.5', + channels: { + register: true, + lifecycle: true, + incoming: true, + publish: true, + acknowledge: true, + }, + featureSets: { + chat: { description: 'chat connector', uses: ['channels.incoming', 'channels.publish'] }, + }, + }, + }, + }, + serverInfo: { name: 'tune-out-fixture', version: '0.0.0' }, + }); + return; + } + if (msg.method === 'featureSets/update') { + // §5.3: answer the initial-policy Request or the grant never activates. + if (msg.id !== undefined && msg.id !== null) reply(msg.id, { accepted: true }); + return; + } + if (msg.method === 'notifications/initialized') { + send({ + jsonrpc: '2.0', + id: 400, + method: 'channels/register', + params: { + channels: [{ + id: CHANNEL_ID, + type: 'disc', + label: 'noisy', + direction: 'bidirectional', + initiallyOpen: true, + }], + }, + }); + return; + } + if (msg.method === 'channels/open') { + log('channel-opened', { channelId: msg.params?.channelId }); + reply(msg.id, { opened: true }); + return; + } + if (msg.method === 'channels/close') { + reply(msg.id, { closed: true }); + return; + } + if (msg.method === 'channels/acknowledge') { + log('acknowledge', { + channelId: msg.params?.channelId, + messageId: msg.params?.messageId, + intent: msg.params?.intent, + }); + reply(msg.id, { acknowledged: true, representation: '👀' }); + return; + } + if (msg.method === 'channels/publish') { + log('publish', { channelId: msg.params?.channelId }); + if (msg.id !== undefined && msg.id !== null) reply(msg.id, { delivered: true }); + return; + } + if (msg.method === 'tools/list') { + reply(msg.id, { tools: [] }); + return; + } + // Responses to our own requests (register, incoming) need no handling. +}); diff --git a/test/per-agent-delivery.test.ts b/test/per-agent-delivery.test.ts new file mode 100644 index 0000000..429255d --- /dev/null +++ b/test/per-agent-delivery.test.ts @@ -0,0 +1,140 @@ +/** + * Per-agent message-delivery seam (issue #77 plumbing). + * + * framework.addMessage historically hardcoded the primary agent: the + * deferral queue, turn-alive guard, and every flush point evaluated the + * primary's turn state. The seam adds an optional forAgent target with the + * guard evaluated against THAT agent's state, flushed at THAT agent's + * boundaries. Default behavior (no forAgent) is byte-identical. + */ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { AgentFramework } from '../src/index.js'; +import type { ProcessEvent } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +function internals(framework: AgentFramework) { + return framework as unknown as { + addMessage( + participant: string, + content: Array<{ type: 'text'; text: string }>, + metadata?: Record, + opts?: { forAgent?: string }, + ): string; + deferredMessages: Array<{ participant: string; forAgent?: string }>; + activeTurnTokens: Map; + }; +} + +function channelIncoming(text: string, targetAgents: string[]): ProcessEvent { + return { + type: 'mcpl:channel-incoming', + serverId: 'discord', + channelId: 'discord:guild:chanA', + messageId: `m-${Math.random().toString(36).slice(2)}`, + author: { id: 'U1', name: 'antra' }, + content: [{ type: 'text', text }], + timestamp: new Date().toISOString(), + metadata: {}, + triggerInference: true, + targetAgents, + } as unknown as ProcessEvent; +} + +describe('per-agent message delivery', () => { + let tempDir: string; + let membrane: MockMembrane; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'per-agent-delivery-')); + membrane = new MockMembrane(); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + async function makeFramework() { + return AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [ + { name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }, + { name: 'shade', model: 'test-model', systemPrompt: 'You are shade.' }, + ], + modules: [], + }); + } + + it('a targeted message defers on the TARGET\'s busy turn, even with the primary idle', async () => { + const framework = await makeFramework(); + const i = internals(framework); + + // Shade is mid-turn; scout (primary) is idle. + i.activeTurnTokens.set('shade', 999); + + const id = i.addMessage('user', [{ type: 'text', text: 'for shade' }], undefined, { + forAgent: 'shade', + }); + assert.equal(id, '', 'deferred, not stored'); + assert.equal(i.deferredMessages.length, 1); + assert.equal(i.deferredMessages[0].forAgent, 'shade'); + + // An untargeted message with the primary idle stores immediately — + // shade's busyness must not defer primary-bound traffic. + const id2 = i.addMessage('user', [{ type: 'text', text: 'for scout' }]); + assert.notEqual(id2, '', 'primary delivery unaffected by shade\'s turn'); + assert.equal(i.deferredMessages.length, 1, 'still only shade\'s entry queued'); + + i.activeTurnTokens.delete('shade'); + await framework.stop(); + }); + + it('a queued targeted message survives the PRIMARY\'s boundary and flushes at the target\'s', async () => { + const framework = await makeFramework(); + const i = internals(framework); + + // Queue a message for shade while shade is busy. + i.activeTurnTokens.set('shade', 7); + i.addMessage('user', [{ type: 'text', text: 'note for shade' }], undefined, { + forAgent: 'shade', + }); + assert.equal(i.deferredMessages.length, 1); + + // Scout (primary) runs a full turn: its turn-start flush must NOT + // deliver shade's message. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'scout says hi' }])); + framework.pushEvent(channelIncoming('wake scout', ['scout'])); + await framework.runUntilIdle(); + assert.equal( + i.deferredMessages.length, + 1, + 'shade\'s entry must survive the primary\'s boundary', + ); + + // Shade's own turn flushes it (turn-start flush at its boundary). + i.activeTurnTokens.delete('shade'); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'shade replies' }])); + framework.pushEvent(channelIncoming('wake shade', ['shade'])); + await framework.runUntilIdle(); + assert.equal(i.deferredMessages.length, 0, 'flushed at the target\'s boundary'); + + await framework.stop(); + }); + + it('unknown forAgent drops loudly instead of misdelivering', async () => { + const framework = await makeFramework(); + const i = internals(framework); + + const id = i.addMessage('user', [{ type: 'text', text: 'lost' }], undefined, { + forAgent: 'nobody-home', + }); + assert.equal(id, ''); + assert.equal(i.deferredMessages.length, 0, 'not queued for a ghost'); + + await framework.stop(); + }); +}); diff --git a/test/tune-out-e2e.test.ts b/test/tune-out-e2e.test.ts new file mode 100644 index 0000000..d502239 --- /dev/null +++ b/test/tune-out-e2e.test.ts @@ -0,0 +1,206 @@ +/** + * Tune-out end-to-end (issue #77): a real MCPL channel diverts to the + * subconscious and comes back. + * + * enter → ambient traffic is stamped + stored + never wakes the resident, + * and never enters their compiled view (but does enter the subconscious's + * merged view) → an addressed message gets the deterministic + * suppressed-mention acknowledge AND a coalesced subconscious wake turn → + * exceeding max-wakes auto-cancels: the resident receives the + * system-framed dump (capped) and one wake, with the + * raw stamped originals still excluded from their view. + */ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync, appendFileSync, readFileSync, existsSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { AgentFramework } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import type { TuneOutCoordinator } from '../src/tune-out/coordinator.js'; + +const FIXTURE = join(import.meta.dirname, 'fixtures/tune-out-mcpl-server.mjs'); +const CHANNEL = 'disc:guild:noisy'; + +function internals(framework: AgentFramework) { + return framework as unknown as { + tuneOutCoordinator: TuneOutCoordinator | null; + channelRegistry: { + listChannelsRaw(): Array<{ serverId: string; descriptor: { id: string } }>; + getDesiredState(serverId: string, channelId: string): string | undefined; + getTuneOutState(serverId: string, channelId: string): { wakeCount: number } | null; + } | null; + agents: Map; metadata?: Record }>; + compile(): Promise<{ messages: Array<{ participant: string; content: Array<{ type: string; text?: string }> }> }>; + } }>; + }; +} + +async function waitFor(cond: () => boolean | Promise, what: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await cond()) return; + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`timed out waiting for: ${what}`); +} + +const textOf = (m: { content: Array<{ type: string; text?: string }> }): string => + m.content.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('\n'); + +describe('tune-out end to end', () => { + let tempDir: string; + let membrane: MockMembrane; + let framework: AgentFramework; + let statusPath: string; + let commandPath: string; + + before(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'tune-out-e2e-')); + statusPath = join(tempDir, 'status.jsonl'); + commandPath = join(tempDir, 'commands.txt'); + writeFileSync(commandPath, ''); + membrane = new MockMembrane(); + framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + subconscious: { + enabled: true, + systemPrompt: + 'You are the Subconscious. You watch tuned-out channels for the resident ' + + 'and report to them in second person, briefly.', + }, + mcplServers: [{ + id: 'disc', + command: process.execPath, + args: [FIXTURE], + env: { STATUS_PATH: statusPath, COMMAND_PATH: commandPath }, + }], + modules: [], + }); + await framework.start(); + await waitFor( + () => (internals(framework).channelRegistry?.listChannelsRaw().length ?? 0) > 0, + 'channel registration', + ); + }); + + after(async () => { + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + function emit(kind: 'ambient' | 'addressed', id: string, text: string): void { + appendFileSync(commandPath, `${kind} ${id} ${text}\n`); + } + + function statusEvents(): Array<{ event: string; intent?: string; messageId?: string }> { + if (!existsSync(statusPath)) return []; + return readFileSync(statusPath, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); + } + + it('runs the full divert → wake → auto-cancel arc', async () => { + const i = internals(framework); + const coordinator = i.tuneOutCoordinator!; + assert.ok(coordinator, 'coordinator exists when subconscious + mcpl are configured'); + + // ---- enter ----------------------------------------------------------- + const entered = coordinator.enter('disc', CHANNEL, { + cadenceSeconds: 3600, // cadence not exercised here; wake path is + backlogCap: 2, + maxWakes: 1, + }, 'agent-tool'); + assert.ok(entered.ok, 'enter succeeds'); + assert.equal(i.channelRegistry!.getDesiredState('disc', CHANNEL), 'tuned-out'); + + // ---- ambient traffic: stamped, stored, no resident wake -------------- + emit('ambient', 'a1', 'release chatter one'); + emit('ambient', 'a2', 'release chatter two'); + emit('ambient', 'a3', 'release chatter three'); + + const scout = i.agents.get('scout')!.getContextManager(); + await waitFor( + () => scout.getAllMessages().filter((m) => (m.metadata as { tuneOut?: unknown })?.tuneOut).length >= 3, + 'three stamped diverted messages', + ); + assert.equal(membrane.calls.length, 0, 'nobody woke for ambient diverted traffic'); + + const compiled = await scout.compile(); + assert.ok( + !compiled.messages.some((m) => textOf(m).includes('release chatter')), + 'diverted messages never enter the resident\'s compiled view', + ); + const sub = i.agents.get('Subconscious')!.getContextManager(); + const subCompiled = await sub.compile(); + assert.ok( + subCompiled.messages.some((m) => textOf(m).includes('release chatter one')), + 'the subconscious\'s merged view includes the diverted backlog', + ); + + // ---- addressed message: ack + coalesced subconscious wake ------------ + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Noted — nothing urgent yet.' }])); + emit('addressed', 'm1', 'hey scout, quick question'); + + await waitFor( + () => statusEvents().some((e) => e.event === 'acknowledge' && e.intent === 'suppressed-tuned-out' && e.messageId === 'm1'), + 'deterministic suppressed-mention acknowledge', + ); + await waitFor(() => membrane.calls.length >= 1, 'subconscious wake turn (coalesced)', 20_000); + await waitFor( + () => (i.channelRegistry!.getTuneOutState('disc', CHANNEL)?.wakeCount ?? -1) === 1, + 'durable wake count = 1', + ); + assert.ok( + sub.getAllMessages().some((m) => textOf(m).includes('[Tune-out wake:')), + 'the wake notice landed in the subconscious\'s own window', + ); + assert.ok( + !scout.getAllMessages().some((m) => textOf(m).includes('[Tune-out wake:')), + 'wake notices do not leak into the resident\'s window', + ); + + // ---- second wake exceeds maxWakes=1: auto-cancel --------------------- + // Two turns follow the cancel: the resident's wake and the + // subconscious's cancel-report (issue: dump + a message from the + // subconscious). MockMembrane hands all queued responses to whichever + // stream starts first, so queue two and assert on durable state only. + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ack one' }])); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ack two' }])); + emit('addressed', 'm2', 'scout are you there?'); + + await waitFor( + () => i.channelRegistry!.getDesiredState('disc', CHANNEL) === 'open', + 'auto-cancel returns the channel to open', + 20_000, + ); + + await waitFor( + () => scout.getAllMessages().some((m) => textOf(m).includes(' sub.getAllMessages().some((m) => textOf(m).includes('[Tune-out cancelled:')), + 'the subconscious received its cancel-report notice', + ); + const dumpMsg = scout.getAllMessages().find((m) => textOf(m).includes(' t.includes(' t.includes('release chatter one') && !t.includes(' Date: Thu, 6 Aug 2026 14:02:21 -0700 Subject: [PATCH 4/8] feat: subconscious inherits the principal's inference settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antra's requirement for named side agents: same settings as the main agent for reasoning effort, attachment size, and kin. The subconscious's AgentConfig now spreads the primary's config as its base (the conversation-fork idiom), overriding only identity- and surface-defining fields (name, mode-block systemPrompt, strategy instance, explicit proseRouting, restricted tool surface). thinking, providerParams, maxTokens/maxStreamTokens, contextBudgetTokens, cacheTtl, promptCaching, temperature, and refusalHandling — present and future fields alike — ride along automatically. Its windowed strategy mirrors the principal's strategy.maxMessageTokens so both truncate oversized tool results and attachments under the same policy. Co-Authored-By: Claude Fable 5 --- src/framework.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/framework.ts b/src/framework.ts index 5f54708..a7f68dc 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -3894,6 +3894,9 @@ export class AgentFramework { const strategy = new WindowedPassthroughStrategy({ reAnchorFraction: cfg.reAnchorFraction, + // Mirror the principal's per-message truncation ceiling so both see + // oversized tool results / attachments under the same policy. + maxMessageTokens: primaryConfig.strategy?.maxMessageTokens, }); const contextManager = await ContextManager.open({ store: this.store, @@ -3907,10 +3910,18 @@ export class AgentFramework { debugLogContext: !!process.env.DEBUG_CONTEXT, }); + // Inherit the principal's inference settings wholesale (fork-template + // idiom): reasoning effort (thinking), provider params, token ceilings, + // caching, refusal handling, timezone — a same-model side-process must + // not silently run under different inference conditions than the + // resident it serves. Only identity- and surface-defining fields are + // overridden below. const agentConfig: AgentConfig = { + ...primaryConfig, name, model: cfg.model ?? primaryConfig.model, systemPrompt: cfg.systemPrompt, + strategy: undefined, // its CM owns the windowed strategy instance // Prose is never auto-routed: a cadence-triggered turn carries no // locus, and bare prose must not fall through to the default channel. // Speech happens only via speak_in_channel (its own marked voice). From efc45bea1be5f3998da220eb33117a849a120508 Mon Sep 17 00:00:00 2001 From: Aster Date: Sat, 8 Aug 2026 23:07:01 -0700 Subject: [PATCH 5/8] feat: the resident's wake gate preconditions subconscious wakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per antra (#tuneout-talk): the subconscious is affected BY main's wake gate, it does not replace it. The gate verdict (already riding the incoming event as triggerInference) composes as a conjunct on the divert-wake condition: gate-suppressed events — muted authors, rate-limited patterns, whatever the resident configured — divert silently into the backlog and surface in cadence summaries, but wake nobody. Gate-suppressed mentions also get NO deterministic reaction: main would not have signaled either, and reacting would leak the resident's standing mute to its subject. Four-case unit coverage (gate × addressed/privileged), incl. the still-stamped guarantee on the suppressed path. Co-Authored-By: Claude Fable 5 --- src/framework.ts | 3 + src/tune-out/coordinator.ts | Bin 20989 -> 21577 bytes test/tune-out-gate-composition.test.ts | 92 +++++++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 test/tune-out-gate-composition.test.ts diff --git a/src/framework.ts b/src/framework.ts index a7f68dc..18b1214 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -4509,6 +4509,9 @@ export class AgentFramework { event.messageId, event.tags, event.author?.id, + // The resident's gate verdict rides the event; it preconditions + // subconscious wakes (gate composes, it is not replaced). + event.triggerInference !== false, ) ?? null; if (divert) { metadata.tuneOut = { epochId: divert.epochId }; diff --git a/src/tune-out/coordinator.ts b/src/tune-out/coordinator.ts index 37e775598808ec45a48cf462998c63e22f1d25b5..de2d7d9ba8776254f85741f7d879419397a9d41c 100644 GIT binary patch delta 704 zcmY+Czlsz=5XRY5O+*YdDirPy%2-ngq6l4shV7gxkn6Y)dXTQYiqAbK@Nw>mvhMy$VMI zo?TH82DXi|G#|h^@x#73nq}i_<=kNkm0DnZbPNVHQ1!#HZ82El3CtXb@4#1EPX%CE zCL6RBX?2fPlqB5$9N)W>P&4T0%BE$G)L)Lj-rCl=w+H=~$+z|3XDCUNF_ii|oWS#B zf0LH>vDL3hU^>&>(WHe1q<(fn9+Xlu3tXuUTRUuY{wka-;r*L+^)Pi*LPNN`R>nU6 mhhotYtB+GZ8idBHxyJc>{ZINLe{*xkQ2!~ve{r6_e()D{;O>(E delta 139 zcmX@Pg7NQS#tlh2o6B{K7=089Qi~KyGEx;Xi;GKBtrT)o^GY)F^NRHoQ&Ngji;GiJ z6de6rxD ({ params: PARAMS, wakeCount: 0 }), + recordTuneOutWake: () => ({ params: PARAMS, wakeCount: 1 }), + listChannelsRaw: () => [], + } as unknown as ChannelRegistry; + const servers = { + getServer: () => ({ + // The ack path is grant-gated (CapabilityGrant.of reads conn.grant). + grant: new CapabilityGrant(new Set(['channels.acknowledge']), []), + sendChannelsAcknowledge: async (p: { messageId: string }) => { + acks.push(p.messageId); + return { acknowledged: true }; + }, + }), + } as unknown as McplServerRegistry; + const hooks = { + addMessage: () => '', + requestInference: () => {}, + subconsciousName: () => 'Subconscious', + primaryName: () => 'scout', + getStoredMessages: () => [], + currentSequence: () => 1, + setSubconsciousAnchor: () => {}, + isForkBound: () => false, + isPrivilegedAuthor: () => opts?.privileged ?? false, + allowChannelSpeech: () => false, + emitTrace: () => {}, + } as unknown as TuneOutFrameworkHooks; + const coordinator = new TuneOutCoordinator(registry, servers, hooks); + // Observe wake scheduling without waiting out the coalesce timer. + (coordinator as unknown as { scheduleWakeInvocation: (s: string, c: string) => void }) + .scheduleWakeInvocation = (s: string, c: string) => { wakes.push(`${s}:${c}`); }; + return { coordinator, wakes, acks }; +} + +describe('gate composition on subconscious wakes', () => { + it('gate-passed addressed messages wake and get the reaction', () => { + const { coordinator, wakes, acks } = harness(); + const divert = coordinator.onIncoming('disc', '#a', 'm1', ['chat:addressed'], 'U1', true); + assert.equal(divert?.epochId, 'e1', 'still stamped'); + assert.equal(wakes.length, 1); + assert.deepEqual(acks, ['m1']); + }); + + it('gate-suppressed addressed messages divert silently: stamp yes, wake no, reaction no', () => { + const { coordinator, wakes, acks } = harness(); + const divert = coordinator.onIncoming('disc', '#a', 'm2', ['chat:addressed'], 'U1', false); + assert.equal(divert?.epochId, 'e1', 'still stamped into the backlog'); + assert.equal(wakes.length, 0, 'no subconscious wake past the gate'); + assert.equal(acks.length, 0, 'no reaction — it would leak the mute'); + }); + + it('gate-suppressed privileged authors do not wake either (gate is a true precondition)', () => { + const { coordinator, wakes } = harness({ privileged: true }); + coordinator.onIncoming('disc', '#a', 'm3', ['chat:ambient'], 'U-priv', false); + assert.equal(wakes.length, 0); + }); + + it('gate-passed privileged ambient authors wake without a reaction (nothing was suppressed toward them)', () => { + const { coordinator, wakes, acks } = harness({ privileged: true }); + coordinator.onIncoming('disc', '#a', 'm4', ['chat:ambient'], 'U-priv', true); + assert.equal(wakes.length, 1); + assert.equal(acks.length, 0); + }); +}); From 3047aca9362e234b3526326d96af52073227ba2e Mon Sep 17 00:00:00 2001 From: Aster Date: Mon, 17 Aug 2026 21:24:22 -0700 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20optional=20durationSeconds=20?= =?UTF-8?q?=E2=80=94=20tune-outs=20as=20bounded=20attention=20budgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approved by antra (#tuneout-talk): optional. The enter params gain durationSeconds; when set, the epoch carries an absolute expiresAtMs deadline and auto-cancels through the standard cancel flow (dump + notices + resident wake, reason 'duration elapsed'). Addresses the forgotten-channel failure mode: a quiet diverted channel now has a guaranteed return path the resident committed to at entry. Deadlines survive restarts — an epoch that expired while the host was down cancels at resume, delivering the backlog on the promised schedule. Unset = until cancelled, exactly as before. Co-Authored-By: Claude Fable 5 --- src/framework.ts | 8 ++ src/mcpl/channel-registry.ts | 5 ++ src/tune-out/coordinator.ts | Bin 21577 -> 23227 bytes test/tune-out-gate-composition.test.ts | 100 +++++++++++++++++++++++++ 4 files changed, 113 insertions(+) diff --git a/src/framework.ts b/src/framework.ts index 18b1214..6146242 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -2419,6 +2419,12 @@ export class AgentFramework { cadenceSeconds: { type: 'number', description: `Summary cadence (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}s).` }, backlogCap: { type: 'number', description: `Max raw messages delivered at cancel (default ${TUNE_OUT_DEFAULTS.backlogCap}).` }, maxWakes: { type: 'number', description: `Wake budget before auto-cancel (default ${TUNE_OUT_DEFAULTS.maxWakes}).` }, + durationSeconds: { + type: 'number', + description: + 'Optional bounded duration: the tune-out auto-cancels (backlog ' + + 'delivered) after this many seconds. Unset = until cancelled.', + }, }, required: ['channelId'], }, @@ -10544,6 +10550,7 @@ export class AgentFramework { const input = (call.input ?? {}) as { channelId?: string; mode?: string; cadenceSeconds?: number; backlogCap?: number; maxWakes?: number; + durationSeconds?: number; }; const channelId = String(input.channelId ?? ''); const entry = this.channelRegistry?.listChannelsRaw() @@ -10561,6 +10568,7 @@ export class AgentFramework { cadenceSeconds: input.cadenceSeconds, backlogCap: input.backlogCap, maxWakes: input.maxWakes, + durationSeconds: input.durationSeconds, }, 'agent-tool'); result = r.ok ? { diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index aaf7d6c..252b9bb 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -67,6 +67,11 @@ export interface TuneOutParams { maxWakes: number; /** Chronicle sequence when the tune-out began — window anchor + audit bound. */ startedAtSequence: number; + /** Absolute wall-clock deadline (epoch ms). When set, the tune-out + * auto-cancels at this time via the standard cancel flow ("duration + * elapsed") — the agent's self-binding attention budget (#77 "for a + * period chosen by the agent"). Unset = until cancelled. */ + expiresAtMs?: number; } interface ChannelLifecycleEvent { diff --git a/src/tune-out/coordinator.ts b/src/tune-out/coordinator.ts index de2d7d9ba8776254f85741f7d879419397a9d41c..42990b7a13479e1097942770a59dc45334ceaf72 100644 GIT binary patch delta 1220 zcmaJ>O=}ZD7^W6&we}0Gwa~)bgKf4=H?cx#n{K60LB*aTo;*12zReEZomqA!O+yV) ze}Ew4&5Pbd5QN}K5Q+!UAK}4&;K7>|XTQ=!qI=qXcjkHC=Xsx*uW$N3e&~DudFcLj zH6)U9wuA=(4eKDOkD-8EKtkdRGp?f8k~_jEf$bp)(E-kYusYa;+31{sAl%+@Z<4|cvpymI>;ncP>QHRLwHE+J&#v_H(9(! z*e*KNgry1lmPCB6ZJj`eBfF237QS(MA zp~UJ?;;8SN*0d(pp5Or$n!f^;_DORlF;K^=Q_bv`k>n?e2CvFoyPR~Kz=Y@%$`YI% zB3;ub2f1Rtmt9L8ZlykSFvkHDra-CIR>wDU23VV^GU)6uUFV>O5_5Z4Z(ACJB@tJz zHYTrS=KpUHjl2#-s=zQxt(7sWf2;)A)-r09PK#bR?r#E^rkUe`6lV8t(UlgbJ>ctb zn4F^IIIefdhec?cwW8hXg=)Y8TrC!h(1;NSqvNv_ MUM^&>U&>Ga0P9elZvX%Q delta 58 zcmV-A0LA~iwE@Yf0kFdhvs(;;0+Z$r$&)}4MU%J@^Rv4YEd#Rx81w^^gd9(^1RX^M QlkGnsvn3 { + it('an expired deadline at resume cancels immediately via the standard flow', () => { + const cancels: string[] = []; + const registry = { + listChannelsRaw: () => [{ serverId: 'disc', descriptor: { id: '#a' } }], + getTuneOutState: () => ({ + params: { ...PARAMS, expiresAtMs: Date.now() - 5_000 }, + wakeCount: 0, + }), + cancelTuneOut: (_s: string, c: string) => { + cancels.push(c); + return { params: PARAMS, wakeCount: 0 }; + }, + } as unknown as ChannelRegistry; + const hooks = { + addMessage: () => '', + requestInference: () => {}, + subconsciousName: () => 'Subconscious', + primaryName: () => 'scout', + getStoredMessages: () => [], + currentSequence: () => 1, + setSubconsciousAnchor: () => {}, + isForkBound: () => false, + isPrivilegedAuthor: () => false, + allowChannelSpeech: () => false, + emitTrace: () => {}, + } as unknown as TuneOutFrameworkHooks; + const coordinator = new TuneOutCoordinator(registry, {} as McplServerRegistry, hooks); + coordinator.resumeActiveEpochs(); + assert.deepEqual(cancels, ['#a'], 'expired epoch cancelled at resume'); + coordinator.stop(); + }); + + it('a live deadline arms a timer that cancels on schedule', async () => { + const cancels: string[] = []; + const registry = { + listChannelsRaw: () => [], + getTuneOutState: () => null, + enterTuneOut: () => {}, + cancelTuneOut: (_s: string, c: string) => { + cancels.push(c); + return { params: PARAMS, wakeCount: 0 }; + }, + } as unknown as ChannelRegistry; + const hooks = { + addMessage: () => '', + requestInference: () => {}, + subconsciousName: () => 'Subconscious', + primaryName: () => 'scout', + getStoredMessages: () => [], + currentSequence: () => 1, + setSubconsciousAnchor: () => {}, + isForkBound: () => false, + isPrivilegedAuthor: () => false, + allowChannelSpeech: () => false, + emitTrace: () => {}, + } as unknown as TuneOutFrameworkHooks; + const coordinator = new TuneOutCoordinator(registry, {} as McplServerRegistry, hooks); + // armExpiry directly with a ~50ms deadline (enter() clamps duration to + // >=60s, which a unit test should not wait out). + (coordinator as unknown as { + armExpiry: (s: string, c: string, p: TuneOutParams) => void; + }).armExpiry('disc', '#a', { ...PARAMS, expiresAtMs: Date.now() + 50 }); + await new Promise((r) => setTimeout(r, 150)); + assert.deepEqual(cancels, ['#a'], 'deadline fired the standard cancel'); + coordinator.stop(); + }); + + it('enter() stamps expiresAtMs only when durationSeconds is given', () => { + const entered: TuneOutParams[] = []; + const registry = { + listChannelsRaw: () => [], + getTuneOutState: () => null, + enterTuneOut: (_s: string, _c: string, p: TuneOutParams) => { entered.push(p); }, + } as unknown as ChannelRegistry; + const hooks = { + addMessage: () => '', + requestInference: () => {}, + subconsciousName: () => 'Subconscious', + primaryName: () => 'scout', + getStoredMessages: () => [], + currentSequence: () => 42, + setSubconsciousAnchor: () => {}, + isForkBound: () => false, + isPrivilegedAuthor: () => false, + allowChannelSpeech: () => false, + emitTrace: () => {}, + } as unknown as TuneOutFrameworkHooks; + const coordinator = new TuneOutCoordinator(registry, {} as McplServerRegistry, hooks); + coordinator.enter('disc', '#a', {}, 'agent-tool'); + assert.equal(entered[0].expiresAtMs, undefined, 'unset by default'); + coordinator.enter('disc', '#b', { durationSeconds: 3600 }, 'agent-tool'); + assert.ok( + entered[1].expiresAtMs !== undefined && entered[1].expiresAtMs > Date.now() + 3_500_000, + 'deadline stamped from durationSeconds', + ); + coordinator.stop(); + }); +}); + describe('gate composition on subconscious wakes', () => { it('gate-passed addressed messages wake and get the reaction', () => { const { coordinator, wakes, acks } = harness(); From d12f73d6b7b6e347a253bc44af121450febededb Mon Sep 17 00:00:00 2001 From: Aster Date: Tue, 18 Aug 2026 00:22:40 -0700 Subject: [PATCH 7/8] feat: dispositions ride a fixed system-position injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per antra (#tuneout-talk): dispositions need not repeat per invocation — but they must never wash away, because the subconscious has no long-term memory. Both constraints pick the same mechanism: a system-position ContextInjection on its compiles. Never in the timeline (no repetition), structurally outside window content (cannot scroll out), and the bytes change only when a disposition changes (KV-friendly). Invocation notices are now pure triggers; the durable snapshot slot and note_disposition are unchanged. Co-Authored-By: Claude Fable 5 --- src/framework.ts | 11 ++++++ src/tune-out/coordinator.ts | Bin 23227 -> 23600 bytes test/tune-out-gate-composition.test.ts | 48 +++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/src/framework.ts b/src/framework.ts index 6146242..69175f4 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -5818,6 +5818,17 @@ export class AgentFramework { } } + // The subconscious's standing dispositions ride a fixed system-position + // injection (antra, #tuneout-talk): never repeated in its timeline, and + // — since it has no long-term memory — structurally unable to wash out + // of its window. Bytes change only when a disposition changes. + if (agent.name === this.subconsciousAgentName && this.tuneOutCoordinator) { + const dispositionInjection = this.tuneOutCoordinator.getDispositionsInjection(); + if (dispositionInjection) { + injections = injections ? [...injections, dispositionInjection] : [dispositionInjection]; + } + } + const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections); const handle = this.driveStream( diff --git a/src/tune-out/coordinator.ts b/src/tune-out/coordinator.ts index 42990b7a13479e1097942770a59dc45334ceaf72..e391c6ea6dc437cf93a9dbb0fc152e2eb799ad4a 100644 GIT binary patch delta 633 zcmZXQ!D(Qz~9qkWj9`RRs<7?Bsm14nQ$fhJn;EA?8+K6P!$hA@4(FXvHR@~FHY{`^yZnu^D8|HCNI@OR&A(;=kZCh_ zi9=?pnJ&p;L$O$uBBxS+CyT65JSDdrf+?O$k_f3#D_3+VRV$A_XBW_dA_QWC((rU7 zWGhru8S95|CO-41=1zh_5b@BAT61pdjua9Ju?#k`owJ~`yc`DV&(zOW+RtgZ=$^M#(n|HfeF(oK?=4GWOm!zgB zWagFRE2NgC7F7b7W%aWtJtDq$*%@ bos~jyNl|8A`s7t!OC}%m4%n>Z!zTa$WLa31 diff --git a/test/tune-out-gate-composition.test.ts b/test/tune-out-gate-composition.test.ts index 680517e..24a00d0 100644 --- a/test/tune-out-gate-composition.test.ts +++ b/test/tune-out-gate-composition.test.ts @@ -160,6 +160,54 @@ describe('duration expiry', () => { }); }); +describe('dispositions injection', () => { + function dispositionHarness() { + const registry = { + listChannelsRaw: () => [], + getTuneOutState: () => null, + readCoordinatorState: () => null, + writeCoordinatorState: (_id: string, v: unknown) => { stateStore = v; }, + } as unknown as ChannelRegistry; + let stateStore: unknown = null; + const hooks = { + addMessage: () => '', + requestInference: () => {}, + subconsciousName: () => 'Subconscious', + primaryName: () => 'scout', + getStoredMessages: () => [], + currentSequence: () => 1, + setSubconsciousAnchor: () => {}, + isForkBound: () => false, + isPrivilegedAuthor: () => false, + allowChannelSpeech: () => false, + emitTrace: () => {}, + } as unknown as TuneOutFrameworkHooks; + return new TuneOutCoordinator(registry, {} as McplServerRegistry, hooks); + } + + it('empty dispositions produce no injection', () => { + const coordinator = dispositionHarness(); + assert.equal(coordinator.getDispositionsInjection(), null); + coordinator.stop(); + }); + + it('noted dispositions become a system-position injection; empty text deletes', () => { + const coordinator = dispositionHarness(); + coordinator.noteDisposition('antra', 'pings always escalate'); + coordinator.noteDisposition('release-bot', 'ignore'); + const injection = coordinator.getDispositionsInjection(); + assert.equal(injection?.position, 'system'); + assert.equal(injection?.namespace, 'dispositions'); + assert.match(injection!.content[0].text, /antra: pings always escalate/); + assert.match(injection!.content[0].text, /release-bot: ignore/); + + coordinator.noteDisposition('release-bot', ''); + const after = coordinator.getDispositionsInjection(); + assert.ok(!after!.content[0].text.includes('release-bot'), 'empty text deletes the key'); + coordinator.stop(); + }); +}); + describe('gate composition on subconscious wakes', () => { it('gate-passed addressed messages wake and get the reaction', () => { const { coordinator, wakes, acks } = harness(); From acdc692fefb10c6e028a58648cfcf33c7fd3fdd8 Mon Sep 17 00:00:00 2001 From: Aster Date: Tue, 18 Aug 2026 00:31:16 -0700 Subject: [PATCH 8/8] docs(changelog): tune-out, channel-incoming targeting, per-agent delivery Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2be5869..fef5e93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,29 @@ Releases up to and including 0.7.3 predate this file; for their contents see ## Unreleased +### Added + +- **Tune-out (#77): subconscious summaries instead of unsubscribing.** A third + channel state between subscribed and gone: `tune_out` diverts a channel's + traffic to a persistent same-model side-agent (participant `Subconscious`) + that summarizes on a cadence in its own voice, judges wakes (addressed + messages and gate-privileged authors, both preconditioned by the resident's + wake gate), and can cancel. Suppressed mentions get a deterministic + `channels/acknowledge` reaction; wake budgets are durable in the + `mcpl/channel-lifecycle` log with max-wakes auto-cancel; optional + `durationSeconds` gives a tune-out a restart-surviving deadline. Cancel + delivers a capped `` dump plus a subconscious report; + diverted messages never enter the residents' compiled view (cm `viewFilter`) + and stay excluded after cancel. Standing dispositions ride a fixed + system-position injection on the subconscious's compiles. Requires + `@animalabs/context-manager` with strategy-view composition + (context-manager#54); designer review record in #115. +- **`targetAgents` honored on the channel-incoming fan-out** (was declared but + dead); untargeted events keep the historical broadcast. +- **Per-agent message delivery**: `addMessage(…, {forAgent})` with deferral and + turn-alive guards evaluated against the target agent; gate self-wake notices + deliver to the waking agent. Default path unchanged. + ## 0.10.0 — 2026-08-18 Minor release because it adds a third public prose-routing mode and expands the