diff --git a/bench/dendrite/cache-acceptance.mjs b/bench/dendrite/cache-acceptance.mjs new file mode 100644 index 000000000..ab8fa9ece --- /dev/null +++ b/bench/dendrite/cache-acceptance.mjs @@ -0,0 +1,180 @@ +/** + * Live acceptance run for the Dendrite rendering contract. + * + * The fork tests prove provider-formatted content identity up to the fork + * boundary. This script asks the real provider the question that test + * cannot: does a fork's FIRST request read the resident's cache? + * + * phase 1 resident turn: writes the cache (cache_creation ≈ prefix) + * phase 2 resident turn: own reuse, the control (cache_read ≈ prefix) + * phase 3 fork at the boundary, first request (cache_read ≈ prefix?) + * phase 4 resident blocks in a tool call; a fork + * derived mid-round, first request (cache_read ≈ prefix?) + * + * Needs the local build (`npm run build`) and either ANTHROPIC_API_KEY, or + * GATE_TOKEN + GATE_URL for an Anthropic-compatible inference gateway. + * node bench/dendrite/cache-acceptance.mjs [model] + * Nothing here prints the environment. + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Membrane, AnthropicAdapter, NativeFormatter } from '@animalabs/membrane'; +import { AgentFramework, AutobiographicalStrategy } from '../../dist/src/index.js'; +import { GateModule, until } from '../../dist/test/helpers/dendrite.js'; + +const model = process.argv[2] ?? 'claude-sonnet-5-5'; +const viaGate = !!process.env.GATE_TOKEN; +if (!viaGate && !process.env.ANTHROPIC_API_KEY) { + console.error('set ANTHROPIC_API_KEY, or GATE_TOKEN (+ GATE_URL) for a gateway'); + process.exit(2); +} +const adapterConfig = viaGate + ? { apiKey: process.env.GATE_TOKEN, baseURL: process.env.GATE_URL ?? 'https://gate.animalabs.ai/anthropic' } + : {}; + +const dir = mkdtempSync(join(tmpdir(), 'dendrite-cache-acceptance-')); +const gates = new GateModule(); +// Native tools and roles, as residents run: the XML default is a prefill formatter, which Sonnet 5.5 rejects. +const inner = new Membrane(new AnthropicAdapter(adapterConfig), { assistantParticipant: 'mira', formatter: new NativeFormatter() }); + +// Record each request's shape next to the usage the provider reports for it. +let phase = 'setup'; +const requests = []; +const membrane = new Proxy(inner, { + get(target, prop, receiver) { + if (prop === 'streamYielding') { + return (request, options) => { + const built = new NativeFormatter().buildMessages(request.messages, { + participantMode: 'multiuser', assistantParticipant: request.assistantParticipant ?? 'mira', tools: request.tools, + systemPrompt: request.system, promptCaching: request.promptCaching ?? true, + cacheMarkers: request.cacheMarkers ?? 'membrane-system', cacheTtl: request.cacheTtl, + }); + const marked = []; + built.messages.forEach((m, i) => { + const blocks = Array.isArray(m.content) ? m.content : []; + if (blocks.some((b) => b.cache_control)) marked.push(`${i}:${m.role}`); + }); + const systemMarked = Array.isArray(built.system) && built.system.some((b) => b.cache_control); + requests.push({ phase, messages: request.messages.length, provider: built.messages.length, markers: `${systemMarked ? 'sys,' : ''}${marked.join(',') || 'none'}`, cacheMarkers: request.cacheMarkers ?? 'membrane-system', breakpoints: request.messages.filter((m) => m.cacheBreakpoint).length }); + return target.streamYielding(request, options); + }; + } + const value = Reflect.get(target, prop, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, +}); + +// The production shape: a folding resident. Message cache breakpoints exist +// only once there is a recall ladder (summaries) in the compiled view, so the +// windows are small enough that this history folds; summaries are written by +// the same model through the same route. +const makeStrategy = () => new AutobiographicalStrategy({ + compressionModel: model, + adaptiveResolution: true, + targetChunkTokens: 400, + recentWindowTokens: 1500, +}); + +const framework = await AgentFramework.create({ + storePath: join(dir, 'store.chronicle'), + membrane, + agents: [{ + name: 'mira', + model, + strategy: makeStrategy(), + systemPrompt: 'You are mira, a terse assistant used in an automated acceptance run. Follow instructions literally.', + }], + modules: [gates], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, +}); + +const usage = []; // { phase, agent, input, cacheCreation, cacheRead } +let completed = 0; +framework.onTrace((event) => { + if (event.type === 'inference:usage') { + const u = event.tokenUsage; + usage.push({ phase, agent: event.agentName, input: u.input, cacheCreation: u.cacheCreation ?? 0, cacheRead: u.cacheRead ?? 0, output: u.output }); + } + if (event.type === 'inference:completed') completed++; + if (event.type === 'inference:error') console.error('[inference:error]', JSON.stringify(event).slice(0, 400)); +}); + +const cm = framework.getAgent('mira').getContextManager(); +// ~6k tokens of history: well past every model's minimum cacheable prefix. +for (let i = 0; i < 40; i++) { + cm.addMessage(i % 2 ? 'mira' : 'user', [{ type: 'text', text: `Turn ${i}. ` + `The quick brown fox jumps over the lazy dog number ${i}. `.repeat(10) }]); +} +let ticks = 0; +while (!cm.isReady()) { await cm.tick(); ticks++; } +console.error(`history seeded; ${ticks} ticks to ready`); + +framework.start(); +const internals = framework; // pendingRequests is the same door the framework's own tests use +async function residentTurn(text, label) { + phase = label; + cm.addMessage('user', [{ type: 'text', text }]); + const before = completed; + internals.pendingRequests.push({ agentName: 'mira', reason: 'acceptance', source: 'acceptance', timestamp: Date.now() }); + await until(() => completed > before, `${label} to complete`, 60_000); +} + +try { + await residentTurn('Reply with exactly one word: ready.', 'p1-resident-write'); + await residentTurn('Reply with exactly one word: again.', 'p2-resident-reuse'); + + phase = 'p3-fork-at-boundary'; + const forkA = await framework.deriveAgent({ + name: 'fork-a', + from: 'mira', + strategy: makeStrategy(), + framing: [{ participant: 'user', content: [{ type: 'text', text: 'You are a fork of mira. Reply with exactly one word: forked.' }] }], + }); + await framework.runEphemeralToCompletion(forkA.agent, forkA.contextManager); + + // Mid-round: ask the resident for a tool call that blocks, derive while it waits. + phase = 'p4-resident-tool-call'; + cm.addMessage('user', [{ type: 'text', text: 'Call the tool test--wait once with input {"gate": "g1"}. Say nothing else.' }]); + internals.pendingRequests.push({ agentName: 'mira', reason: 'acceptance', source: 'acceptance', timestamp: Date.now() }); + let midRound = true; + try { + await until(() => gates.entered.has('g1'), 'mira to block in test--wait', 60_000); + } catch { + midRound = false; + console.error('mira did not call the tool; phase 4 skipped'); + } + if (midRound) { + phase = 'p4-fork-mid-round'; + const forkB = await framework.deriveAgent({ + name: 'fork-b', + from: 'mira', + strategy: makeStrategy(), + framing: [{ participant: 'user', content: [{ type: 'text', text: 'Reply with exactly one word: forked.' }] }], + }); + await framework.runEphemeralToCompletion(forkB.agent, forkB.contextManager); + phase = 'p4-resident-finish'; + const before = completed; + gates.release('g1'); + await until(() => completed > before, 'mira to finish her turn', 60_000); + } +} finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); +} + +console.log(`model: ${model} route: ${viaGate ? 'gateway' : 'direct'}`); +console.log('phase agent msgs input cache_creation cache_read markers'); +for (const u of usage) { + const r = requests.find((q) => q.phase === u.phase); + if (!r) continue; // a tool-result continuation reports cumulative stream usage; not a request of its own + console.log(`${u.phase.padEnd(26)} ${u.agent.padEnd(7)} ${String(r?.messages ?? '?').padStart(4)} ${String(u.input).padStart(6)} ${String(u.cacheCreation).padStart(15)} ${String(u.cacheRead).padStart(11)} ${r ? `${r.cacheMarkers} bp=${r.breakpoints} ${r.markers}` : ''}`); +} +const by = (p) => usage.find((u) => u.phase === p); +const control = by('p2-resident-reuse')?.cacheRead ?? 0; +for (const p of ['p3-fork-at-boundary', 'p4-fork-mid-round']) { + const u = by(p); + if (!u) continue; + const ratio = control ? (u.cacheRead / control) : 0; + console.log(`${p}: cache_read is ${(ratio * 100).toFixed(1)}% of the resident's own reuse (${u.cacheRead} / ${control})`); +} diff --git a/changelog.d/author-named.fixed.md b/changelog.d/author-named.fixed.md new file mode 100644 index 000000000..5413cba9b --- /dev/null +++ b/changelog.d/author-named.fixed.md @@ -0,0 +1,13 @@ +- A channel message is stored as the model reads it: `author: body`. MCPL's + `channels/incoming` carries the author as a field and the body alone as + content; the framework stored the body under the anonymous participant + `user` with the author in metadata, and nothing between the store and the + wire wrote the name, so a spec-shaped server's message reached the model + as `user: ` with the author nowhere (Weft's rig, 2026-10-13). A + server that renders `Name: body` into the text itself (portal-mcpl) is + left as it is: the rule is idempotent, so the name appears once whichever + shape a server sends. The author stays in `metadata.author` as the + structured record. Exported as `presentAuthor` / `authorLine`. +- A tune-out release line and the reader fork's backlog name an author + once. Both read `metadata.author`, so a message whose text a server had + already prefixed was shown as `Name: Name: body`. diff --git a/changelog.d/dendrite-admission.added.md b/changelog.d/dendrite-admission.added.md new file mode 100644 index 000000000..60c2ef3bf --- /dev/null +++ b/changelog.d/dendrite-admission.added.md @@ -0,0 +1,19 @@ +- The admission stage (Dendrite §D, issue #232 §3.5): every inbound channel + message and push event passes one decision — captured, recipients, body or + hold, wake, locus — before anything acts on it (`src/admission/admit.ts`, + traced as `admission:decided`). The wake gate runs after the hold, so a + held message never forms a debounce batch; for a held message it is only + probed, and that verdict preconditions the reader's wake. Gate batches + re-check the hold at delivery (`gate:held-dropped`). +- `speech.locus` (`'delivery'` | `'latest-inbound'`): where plain prose goes + when nothing names a destination. The default `'delivery'` is the §3 + carve-out: only the waking delivery, a fork's home or a channel the agent + opened this turn sets the locus; incoming traffic never moves it, between + turns or mid-turn; a turn without one keeps its prose private with a + `[routing]` notice. `'latest-inbound'` restores the previous rule. +- Tune-out without a reader (#232 §3.3): the coordinator exists whenever + MCPL channels do, a persisted epoch is always resumed and released, and a + tune-out without a subconscious is a silent hold. `tuneOut.enabled` offers + `tune_out` to residents when no reader is configured. +- `tune_out` and `cancel_tuneout` take `serverId`; a channel id that exists + on several servers is refused as ambiguous. diff --git a/changelog.d/dendrite-admission.changed.md b/changelog.d/dendrite-admission.changed.md new file mode 100644 index 000000000..84a3e5c8c --- /dev/null +++ b/changelog.d/dendrite-admission.changed.md @@ -0,0 +1,13 @@ +- `ChannelRegistry` no longer evaluates the wake gate or sets the publish + locus at ingestion: `shouldTriggerInference` is the per-server veto only, + and the framework reports delivered messages through `noteDelivered` after + admission. `channel_publish` without a `channelId` goes to the calling turn's + locus, else a conversation fork's home channel, else the turn's trigger + channel, else the registry's default, which an ephemeral agent (one + `createEphemeralAgent` or `resumeAgent` made, derivations included) or a + turn with no channel by design (`noChannel`, or a heartbeat) doesn't + take; the call is refused when none of these names a channel. +- `TuneOutCoordinator.onIncoming` is split into `holdFor` (the pure lookup + admission uses) and `recordHeld` (the bookkeeping that follows a hold); + `cancel` takes the lifecycle state to return to; `enter` no longer + requires a reader. diff --git a/changelog.d/dendrite-admission.fixed.md b/changelog.d/dendrite-admission.fixed.md new file mode 100644 index 000000000..0d2b1fd22 --- /dev/null +++ b/changelog.d/dendrite-admission.fixed.md @@ -0,0 +1,13 @@ +- Issue #232 §2 tune-out gaps, each with a regression test (gaps 1 to 10 on + the loopback MCPL server in `test/admission-e2e.test.ts`, gap 11 in + `test/tune-out-dump-fit.test.ts`): tuning out a DM now holds it + (1); held traffic never moves the speech locus (2); a debounced mention in + a tuned-out channel wakes the reader, not the resident (3); a release names + non-text blocks instead of dropping them (4); a held message that arrives + mid-turn is stored, never injected into the live turn (5); a release left + in the deferred queue is durable and re-wakes the resident at boot (6); + held messages still deferred at cancel are in the release (7); channel ids + resolve server-qualified (8); `channel_close` on a tuned-out channel is + refused, and the tune-out's cancel releases (9); the hold machinery runs + without a reader (10); the truncation note names a history tool the + resident has, or none (11). diff --git a/changelog.d/dendrite-all-except.added.md b/changelog.d/dendrite-all-except.added.md new file mode 100644 index 000000000..64efdd821 --- /dev/null +++ b/changelog.d/dendrite-all-except.added.md @@ -0,0 +1,21 @@ +- The all-except scope (issue #232 §3.2; focus folded into tune-out): + `tune_out` with `scope: 'all-except'`, or its alias `focus`, holds every + channel except one — DMs and channels registered later included — for a + mandatory `durationSeconds`. A turn with no delivery of its own speaks into + the excepted channel. `mode: 'check'` reports held counts per channel and + peeks one channel without releasing; release is per channel, each capped. + A per-channel epoch takes precedence; the reader may end the scope; + exhausting the reader's wake budget pauses reader wakes rather than ending + the hold (`tune-out:wake-budget-paused`); closing the excepted channel ends + it. Listed by `listPolicyEpochs()` as a tune-out epoch with + `scope: { kind: 'all-except', except }`. + Entry and release are announced in the excepted channel as one bracketed + line each under the resident's name (`tuneOut.announce`, default true; + traced `tune-out:announced` / `tune-out:announce-failed`). + Its end time and cadence hold any length: both run on timers no longer + than one holds, as a channel's tune-out does since 0.22.3, so a hold or a + cadence past 24.8 days ends or ticks when it should rather than at once. + `focus` reads its options as `tune_out` does: a value that gives no finite + number is refused, naming it, before anything changes; a numeric string is + read as its number; a `null` option counts as left out; and the duration + is still required. diff --git a/changelog.d/dendrite-cache-acceptance.added.md b/changelog.d/dendrite-cache-acceptance.added.md new file mode 100644 index 000000000..60683a068 --- /dev/null +++ b/changelog.d/dendrite-cache-acceptance.added.md @@ -0,0 +1,9 @@ +- `bench/dendrite/cache-acceptance.mjs`: a live acceptance run for the Dendrite + rendering contract — a folding resident, a fork at the boundary and a fork + derived mid-tool-call, each request's `cache_creation`/`cache_read` against the + resident's own reuse. Measured 2026-10-10 on Claude Sonnet 5.5 (one model, one + route, a synthetic 40-message history): the fork at the boundary read 10,193 + cached tokens and wrote 29; the fork derived mid-tool-call read 10,230 and + wrote 152; the resident's own reuse read 10,173. Reuse follows the cache markers + the strategy emits (autobiographical: messages; passthrough: system and + tools only). diff --git a/changelog.d/dendrite-derivation-notice.added.md b/changelog.d/dendrite-derivation-notice.added.md new file mode 100644 index 000000000..7f4228c40 --- /dev/null +++ b/changelog.d/dendrite-derivation-notice.added.md @@ -0,0 +1,13 @@ +- A derived agent's source is told of it (#270, for Linn): by default every + derivation puts one host-framed `[Agent lifecycle: … was derived from you as a …]` + line in the context of the agent it was made from — the fork's name and + kind, whose weights run it, what it inherits, where its result goes and + what ends it; a resumption says so. Stamped `kind: 'agent-lifecycle'` + with the record's fields under `dendrite`. No wake; a host's + `dendrite.derivationNotice` can replace the line or send none. The fork + never inherits the notice about itself; an ended source is told nothing. + After `dendrite.collapseDerivationNoticesAfter` derivations of one kind + from one agent (default 10; 0 never collapses), the source hears a count + line every so many instead, with the latest described and + `dendrite.collapsed: { count, sinceLastNote }`; every derivation still + emits its `dendrite:agent-created` trace. diff --git a/changelog.d/dendrite-derive.added.md b/changelog.d/dendrite-derive.added.md new file mode 100644 index 000000000..d9114cdc3 --- /dev/null +++ b/changelog.d/dendrite-derive.added.md @@ -0,0 +1,7 @@ +- `deriveAgent({ name, from, mode?, solve?, strategy?, config?, framing?, resultTo?, ... })` derives a bounded agent from a live one: the parent's context at a checkpoint, a private continuation, and — unless overridden — the parent's whole configuration (model, thinking, cache TTL, provider params, system prompt, tools, live context budget). The parent keeps running. `mode: 'shared'` (default) gives the child the parent's messages and fold state through shared storage on a Chronicle branch of its own: nothing is copied, the child folds with the parent's memory tree, and neither can alter what the other reads. It requires `@animalabs/chronicle` with `JsStore.view` and `@animalabs/context-manager` with `ContextManager.derive`, and is refused with a clear error, never silently downgraded, where they are missing. `mode: 'copy'` is the historical compiled-context copy and works with the published packages. `solve: 'fresh'` deliberately re-solves the inherited context instead of reusing the parent's rendering. See `docs/dendrite.md`. +- `AgentConfig.selfParticipants` and `AgentConfig.presentAs`: model-facing role assignment at request assembly. Stored participants named in `selfParticipants` are presented as the agent's own assistant turns, under `presentAs` (default: its name). Stored authorship is never rewritten. A shared-inheritance fork uses these so its parent's turns read as its own history and its request shares the parent's provider prefix. +- `AgentConfig.denyToolsAtDispatch`: tools that stay advertised but are refused when the agent calls them, so a derived agent can be restricted without changing the tool block at the front of its request. +- `resumeAgent(name, config)` brings an ended bounded job back as a new incarnation of the same identity on the context it left (its namespace, or a fork's own branch) — after a stop, a failure, or a restart. `inspectAgentContext(name, { atSequence? })` returns a snapshot of a live agent's context at the head or an earlier sequence without affecting the agent, or the context an ended agent left. +- `deriveAgent({ inheritRefusals: true })` also inherits the parent's compression-refusal ledger (default off); the choice is recorded as `inherit.refusals` on the agent record and in `dendrite:agent-created`. +- `getPendingAssistantRound(name)` returns the assistant turn an agent has produced but not yet stored (its tool calls in flight), for hosts that derive an agent from inside a tool call. +- A derived agent that inherits its parent's tools shows the parent's tool presentation (resident-edited visibility and descriptions); only the owner can edit it. diff --git a/changelog.d/dendrite-derive.changed.md b/changelog.d/dendrite-derive.changed.md new file mode 100644 index 000000000..db4723fbd --- /dev/null +++ b/changelog.d/dendrite-derive.changed.md @@ -0,0 +1,2 @@ +- `createEphemeralAgent(config, spec)` with `spec.inherit.mode: 'shared'` opens the agent's context by derivation from the named agent instead of a fresh isolated namespace. +- A mid-turn injected message whose participant is one of the receiving agent's `selfParticipants` is no longer injected into the live stream (it would render as an assistant turn); it stays window-only, as a message under the agent's own name already did. diff --git a/changelog.d/dendrite-durability.fixed.md b/changelog.d/dendrite-durability.fixed.md new file mode 100644 index 000000000..47f080e83 --- /dev/null +++ b/changelog.d/dendrite-durability.fixed.md @@ -0,0 +1,47 @@ +- The agent registry's durable state keeps `main`'s rules for the framework's + own state (nissa's review of #546, findings 1 to 5). A registry that can't + be read, or that reads as another version, refuses the start and names the + state, where before it was taken as empty and overwritten by the first + registration. A failed registry write, ended-log append or sync fails the + call that made it, and a result whose hold isn't durable is not delivered + (it stays held for the next attempt); a hold whose write fails leaves + memory too, so a retry holds it once. The registry and its ended log are + recovery files beside the store (`recovery/dendrite-registry.json`, + `recovery/dendrite-ended.json`), which win over the branch-local state, so + an undo or an operator's surgery can't erase a held result or the record + of work that ran; the branch-local copies stay for inspection and for + stores that predate the files. At boot, interrupted work is settled and + held mail redelivered after host mode and the deferred-write queue are + restored, so a quiesced host holds them as it holds any write, and mail an + entry of the restored queue carries is left to that entry's landing rather + than delivered twice under one id. Every agent that ends — by `stopAgent`, + by the idle reap, with its parent — is released through one path, which + releases its window as the reap did: a failure notice it never read + reaches the primary, and a write queued for it lands in its window. +- The second round of the same review: a change a caller asks for — a + registration, an end, a reparent, a hold — is put back when its write + fails, so the call fails and a retry does it once (a stop whose write + failed had left the agent recorded as stopped while it ran). The put-back + rewrites the registry's recovery files to match memory (the branch-local + snapshot, written last, never holds the change); only an end already + appended to the branch-local ended log stays, and a boot reads that log + only where no ended recovery file exists. + A write that records something that has already happened — a + release, a held mark, a message path — is not failed: it alerts, stays in + memory, and the next write carries it. A result's hold is durable as it is + written, so no store sync decides the delivery; the release is written + only once the store has synced the landed message, so a hard exit between + the two leaves the mail held and the next boot's check of the recipient's + store settles it, never a release on disk for a message that isn't. A + version-1 registry whose records or mail are not lists is refused, not + taken as empty. A reader of the residents' shared slot that ends — a + resident dropped from the configuration — is reopened on the slot it + read, through the residents' view, where before an isolated namespace + showed none of its messages. +- The split between a requested change and a record is the caller's: an end + `stopAgent` asks for is put back when its write fails, while a run's own + end and an idle reap record what has already happened — their writes + alert and the run still settles. An end that was put back comes off the + ended log's recovery file too, and a registry refused for its content + names the state it read, as one refused for its version does. + diff --git a/changelog.d/dendrite-first-live-run.fixed.md b/changelog.d/dendrite-first-live-run.fixed.md new file mode 100644 index 000000000..a35826d9a --- /dev/null +++ b/changelog.d/dendrite-first-live-run.fixed.md @@ -0,0 +1,13 @@ +- From the first live run of tune-out mk2 (Weft, 2026-10-12): a reader + fork that ends in prose without `deliver_summary` now has its final prose + delivered as its report (`tune-out:reader-prose-delivered`) instead of + losing it to `proseRouting: 'disabled'`; under `speech.locus: 'delivery'` + an addressed message injected mid-turn from another channel is announced + (who, from where, where the prose still lands); `tune_out check`'s `peek` + shows only the epochs holding the channel now, never a released one; + `stop()` leaves bounded work it cuts off live for the next boot to end as + `host-restart` and report (`dendrite:agent-interrupted` at stop), and + create()-time traces replay to a listener that asks (`onTrace(fn, { replay: true })`); + `channel_publish` without a `channelId` goes to the turn's locus (the + waking delivery, a channel opened this turn) before being refused. + `isAddressedMessage` is tag-only and says so; its unread parameter is gone. diff --git a/changelog.d/dendrite-floors.changed.md b/changelog.d/dendrite-floors.changed.md new file mode 100644 index 000000000..828c40869 --- /dev/null +++ b/changelog.d/dendrite-floors.changed.md @@ -0,0 +1 @@ +- **agent-framework now depends on `@animalabs/context-manager` `^0.24.0` and `@animalabs/chronicle` `^0.5.0`**, where it depended on `^0.23.1` and `^0.4.0`. Derived agents and reader forks need context-manager 0.24.0's derived contexts and chronicle 0.5.0's branch views, so a fresh install now takes both. diff --git a/changelog.d/dendrite-hooks.added.md b/changelog.d/dendrite-hooks.added.md new file mode 100644 index 000000000..ecb9fd2f8 --- /dev/null +++ b/changelog.d/dendrite-hooks.added.md @@ -0,0 +1,16 @@ +- Two hooks a host decides derivations with, free of semantics: + `dendrite.beforeDerive(spec)` says yes or no, with a reason, to each agent + `createEphemeralAgent` makes, derivations included, before the agent + exists, and to each `resumeAgent` before its new incarnation exists; + `dendrite.derivationNotice( + record, from, defaultText)` decides what, if anything, the source is told. + Consent, budgets, a mind's standing instructions, or uses not yet + imagined are a host's or a module's to build on them, with a record's + `metadata`, `denyToolsAtDispatch` and `ForkReaderConfig.framing`; the + framework carries no consent model (architecture channel, 2026-10-19). +- A reader fork can post nowhere with `allowChannelSpeech` off: every tool + but `deliver_summary`, `cancel_tuneout` and `note_disposition` is refused + at dispatch, while the block it is shown stays the resident's for the + shared prefix. `speak_in_channel` posts through the resident's publish + path, under the resident's account, in the fork's words; the docs said + otherwise and now say this. diff --git a/changelog.d/dendrite-main-merge.changed.md b/changelog.d/dendrite-main-merge.changed.md new file mode 100644 index 000000000..b9416fd38 --- /dev/null +++ b/changelog.d/dendrite-main-merge.changed.md @@ -0,0 +1,121 @@ +- Merged `main` at 0.22.1. The turn-routing rework (#314) is kept whole, and + `speech.locus` decides what its addressed re-pin does: under + `'latest-inbound'` a mid-turn addressed message or an *arrival* (one that + landed between the turn's streams) moves the locus as 0.22.1 does; under + `'delivery'` (the default) neither moves it, and an arrival that addressed + the agent from another channel is announced at the turn's next start with + the same `[routing] … addressed you from …` notice a tool boundary gives, + naming which start it is: a provider hold, a re-inference on tool + results, a budget restart or a retry. + The per-agent routing maps the Dendrite seam read (`turnLocusPins`, + `turnEngagedChannels`) are gone with the rework; the seam reads the turn's + `TurnRouting`. `conversationAgentHomes` and `sharedSlotAgents` are answered + by the agent registry (`homeChannel`, `readsSharedSlot`). +- Merged `main` at 0.22.2. Where main's deferred-write rework (#286) meets + the Dendrite seam: at a tool boundary, a message held from the residents + (`tuneOut.heldFromResidents`) is stored with the boundary's other drained + writes, as before, and left out of the injection, since the next compile + hides it; a message that can't join the stream waits for the turn's end, + as main has it, and "the agent's own voice" there is every participant + the agent presents as, a derived agent's inherited voice included, not + its name alone. Held agent mail (`sendAgentMessage`, `deliverAgentResult`) + is released at each of the three sites that now store a deferred write: + the tool boundary, the turn-start flush and `addMessage`. An arrival's + record carries `addressed`, so main's new arrival tests read it. The + all-except hold's timers and option reading follow #444 (see the + all-except entry). `ProviderWaitNotSleptError` (main) is exported beside + `AgentStoppedError` and `DeriveAgentOptions` (the branch). +- Merged `main` at 0.22.3. `isAddressedMessage` stays tag-only: main's call + sites pass the message's metadata as a second argument it ignores, and + here they pass the tags alone. The channel registry still decides nothing + about the locus at ingestion: main's acceptance step keeps only its + confirmation that the transport is open (a lifecycle-directed server's + traffic confirms the current target alone), and the latest-inbound locus + is set by admission, as before. `tune_out` and `focus` still dispatch + through the queue, and `enter` without a reader is still a silent hold; + main's words for a channel already tuned out, one bound to a conversation, + a cancel of a channel that isn't tuned out and an empty `speak_in_channel` + are adopted. A coalesced wake's timer reports what its work throws + (`tune-out:timer-failed`, main) around the all-except hold's own wake + path. The `drafts` tool (main) sits beside `tune_out` and `focus`; a + derived agent's tool block is still its surface owner's, with main's + `utils` filter applied to it. The deferred-writes restore is main's (a + queue that can't be read refuses to start; `heldAt`), with the branch's + re-wake for a tune-out release it restores. A request's per-agent + settings come from main's `requestSettings()` with the branch's + `assistantParticipant`; main's request-only rows are rendered under the + agent's presentation name. `endEphemeralRunRefused` is gone with main's + refusal of a start during `stop()` (#532). `QuiesceBlockedError` (main) + is exported beside the branch's names. + Main's rule that a refusal is an error result (#506) now covers + `tune_out` and `focus`: every refusal they return is `isError: true`, and + a channel the registry doesn't know is refused as `unknown channel: ` + (an id on several servers keeps the registry's own words). A tune-out + with no reader is a silent hold only where the host offers `tune_out` + without one (`tuneOut.enabled`); the coordinator's new + `holdWithoutReader` hook says so, and without it a hold with no reader is + refused in main's words. Under the author rule a placeholder for content + the host can't read is stored under its author too, `User: [Content not + shown: …]`, which main's parity test now counts as such. +- Merged `main` at `8ec1de5a` (0.26.2's changelog and #720). At a tool + boundary the branch still releases each stored message's landed mail and + skips one held from the residents, and what it injects is then #720's: a + message the render gives null goes in as stored. +- Merged `main` at `a4a74b48` (0.26.0 and after), where #710's + server-qualified channels meet the branch. A conversation fork's home + stays the agent registry's `homeChannel`, which the framework reads + wherever main read its own map of homes. `tune_out` and `focus` resolve + their channel as main's channel tools do: with a `serverId`, that server's + channel; inside the calling turn, the turn's own channel by its id; + otherwise the one registered channel a name answers to, by its id or as + `serverId:channelId`, and a name two channels answer to is refused with + them as candidates; `check`'s `peek` takes the same names and refuses what + they refuse, a channel no server registers now included. Their answers, + and the all-except announcement, name and reach the channel on its server. + The subconscious's `deliver_summary`, `cancel_tuneout` and + `speak_in_channel` resolve a channel as main's do, from a reader fork too. + A `cancel_tuneout` that names the all-except scope ends that hold, as + before, whatever channel its `channelId` picks or fails to pick (a name two + servers' channels answer to, or a turn's own channel its server no longer + registers); only a channel with a tune-out of its own is cancelled instead. + The latest delivered + inbound message, which admission records, keeps its server, and a message + whose server no longer registers its channel sets none. A + `channel_publish` naming no channel takes the turn's locus only when the + call is made inside the turn; outside it, a conversation fork's goes to + its home, an ephemeral agent's to none, and anyone else's takes the + registry's default. So main's + shared-channel-ids entry, which says such a publish posts to the latest + message's channel, holds here only under `speech.locus: + 'latest-inbound'`, while no all-except tune-out is open, for a call by + an agent that is neither ephemeral nor a conversation fork, made + outside a turn or in a turn with neither a locus nor a trigger channel + that isn't a heartbeat's or a `noChannel` turn. Every way a live non-ephemeral agent ends drops a continuation + of its turn that is on its way (#722), and a conversation agent stopped by + a listener of the failure its hold after the compile reports is held by + nothing. + `AgentSpec.homeChannel` and the registry record's `homeChannel` are a + channel with its server, `{ serverId, channelId }`, and + `conversationForkSpec` takes it as `homeChannel` in place of `channelId`. + A record that keeps a bare id, as one written before does, is read as a + wake that names its channel without its server is: the one registered + channel with that id, or, where none or several are, a home on no + server. +- Merged `main` at 0.28.0. main's view rule (#853: every check of whether + an agent sees a row asks the predicate its store was opened with) reads + this branch's views: a resident's (held rows and another agent's stored + context injections out), a derived agent's (its source's, which + context-manager's `derive` keeps) and a context reopened on the shared + slot (the residents'). A tool boundary, an arrival and the unread check + ask it, and a shared-slot message's readers are the registry's. main's + heartbeat (#827) wakes the agents whose spec takes untargeted inbound, as + an untargeted push does. An ended agent's re-arm count, exhausted-turn + record and run of stated waits (#846, #840) go with it. A `chat:system` + notice (#830) leaves the latest delivered inbound where it was, as a + marker does. Supplied text (#852): a channel message's author, which this + branch writes into the stored text and a held line, is kept to one line + with its notice openings marked, as is the author an addressed-elsewhere + notice names; `[Agent lifecycle: …]` and `[Focus: …]` are notice tags, so + a participant's text that opens with one is marked; tune-out's texts quote + a channel's name as `tune_out` takes it back, and name one no name picks + alone by its id and server. diff --git a/changelog.d/dendrite-pending-round.added.md b/changelog.d/dendrite-pending-round.added.md new file mode 100644 index 000000000..24d5b811d --- /dev/null +++ b/changelog.d/dendrite-pending-round.added.md @@ -0,0 +1,7 @@ +- A fork made from inside a tool call now sees the call that made it: by default + `deriveAgent` reproduces the parent's in-flight tool round on the child's + branch — the pending assistant turn verbatim, then a tool-result message with + the results already received, a note (or `pendingRound.madeBy.result`) for the + call that derived the child, and a note for any call still running — so the + child never meets a dangling `tool_use`. `pendingRound: { include: false }` + leaves it out. diff --git a/changelog.d/dendrite-reader-forks.added.md b/changelog.d/dendrite-reader-forks.added.md new file mode 100644 index 000000000..5955f7117 --- /dev/null +++ b/changelog.d/dendrite-reader-forks.added.md @@ -0,0 +1,27 @@ +- `subconscious.reader: 'forks'` (Dendrite): the tune-out reader as a succession + of forks of the resident instead of one persistent side-agent. Each cadence + tick, coalesced wake and cancel derives a short-lived `subconscious-fork` at + the resident's head — its history as the fork's own turns, its refusal ledger, + its tools and system prompt, ending with it — hands it the traffic held since + the last look as ordinary framing (each held message to exactly one fork, media + preserved), including messages still queued behind a resident turn that is + running, which come last in `ReaderFramingContext.backlog` with held handles + for ids (`queuedCount` says how many), and lets it report through + `deliver_summary` as attributed mail + under the fork's name and incarnation. In this mode `subconscious.model` is + required (whose weights read the resident's prefix is never defaulted; the + resident's own model makes the fork a copy, another model by name is allowed) + and `subconscious.voice` is the reader's framing block in place of + `systemPrompt`. `subconscious.strategyFactory` supplies a fresh strategy + instance for each fork (required for a folding resident); + `subconscious.forkIdleTimeoutMs` bounds one fork (default 10 minutes); + `subconscious.framing(context)` decides how a fork is prompted, with + `defaultReaderFraming` exported as the default. A cadence period with nothing + new derives no fork. A reader's own cancel is followed by one more fork only + for messages that arrived after the reader was handed its own, and a + reader's own release of the all-except hold by one fork for each channel + with messages no reader was handed. A fork that throws before it reports + gives its messages back to the channel's next reader while the epoch still + holds the channel. New traces `tune-out:reader-fork` (`held`, of which + `queued`) and `tune-out:reader-fork-failed` (`returned`). + `reader: 'persistent'` remains the default and is unchanged. diff --git a/changelog.d/dendrite-reader-forks.changed.md b/changelog.d/dendrite-reader-forks.changed.md new file mode 100644 index 000000000..253995982 --- /dev/null +++ b/changelog.d/dendrite-reader-forks.changed.md @@ -0,0 +1,14 @@ +- With `subconscious.reader: 'forks'`, residents see the reader's four tools + (`deliver_summary`, `cancel_tuneout`, `speak_in_channel`, `note_disposition`) + in their own tool block so a reader fork's request shares their provider + prefix; a resident that calls one gets a tool-result refusal naming the + reader. A resident whose `allowedTools` leaves them out isn't shown them, + and neither are its reader forks, which can still report through their + final prose. A reader fork may call its own tools whatever its resident's + `allowedTools` says, unless a `denyToolsAtDispatch` entry it takes from + its resident names one. Persistent mode advertises nothing extra. +- A reader fork's `cancel_tuneout` ends only the hold of the epoch it was + derived to read, and its `speak_in_channel` posts only into a channel that + hold still holds; calls made after it ends are refused. A fork whose + record carries no epoch is refused both. `deliver_summary` and + `note_disposition` stay open to every reader. diff --git a/changelog.d/dendrite-registry.added.md b/changelog.d/dendrite-registry.added.md new file mode 100644 index 000000000..626f96ab7 --- /dev/null +++ b/changelog.d/dendrite-registry.added.md @@ -0,0 +1,9 @@ +- Dendrite agent registry: one registry now describes every agent the framework runs — residents, the subconscious, conversation forks and ephemeral agents — with declared roles, lifetime and relationships (spawn, observe, message, result route). The four creation paths register as presets (`residentSpec`, `subconsciousSpec`, `conversationForkSpec`, `workerSpec`, `taskForkSpec`), exported with the registry types from the package root. +- Discovery: `listAgents({ includeEnded })`, `getAgentRecord(name)`, `getPrimaryAgentName()`, `listActivations()` (which agent owns each in-flight run) and `listPolicyEpochs()` (tune-out holds, read from the coordinator's own state). Ended agents stay inspectable. +- Lifecycle, with no permission checks inside the one host trust domain: `cancelActivation(name)`, `stopAgent(name)` and `reparentAgent(name, newParent)`. When an agent ends, the agents it spawned as attention tenants (`onParentEnd: 'end'`) end with it; task work it spawned is orphaned, keeps running, is told in its own context, and is offered to its nearest live ancestor or the primary. `stopAgent` refuses the default-delivery owner. +- `reparentAgent(name, newParent)` also takes an agent that has ended, for the mail it holds: what it produced for a recipient that is gone, results and messages alike, goes to the new parent, and its record stays as it ended. When an ended agent's result is first held for a recipient that is gone, its first adoption candidate is told. +- Attributed communication: `sendAgentMessage(from, to, content)` and `deliverAgentResult(from, content)` place the sender's verbatim content in the recipient's context under the sender's own name, with the sender's identity, incarnation and causal chain in `metadata.dendrite`. A result is recorded durably before delivery; if its recipient is gone it is held (`listHeldMail()`) until the agent is reparented, and a result held across a restart is delivered exactly once. +- `createEphemeralAgent(config, spec?)` accepts an optional registry spec declaring who spawned the agent, where its result goes and what bounds it. A `task` lifetime's `deadlineMs` is enforced by `runEphemeralToCompletion`, which rejects with the new `AgentStoppedError` when a run is stopped, cancelled or timed out from outside. A task spec with neither a deadline nor an idle timeout is refused. +- Restart behaviour follows lifetime: persistent agents are restored as a new incarnation of the same identity; bounded work that was live is recorded as ended (`host-restart`), and the recipient of a `message` result route is told once. Persistent agents the configuration no longer declares are ended (`not-configured`). +- `agentRegistryPath` and `agentRegistryEndedPath` place the registry's recovery files for a host that gives the framework its own `store`; each defaults to its file under `/recovery/`, and with neither and no `storePath` the framework warns at create. +- New traces: `dendrite:agent-created` (names the kind, the model, the context source, what ends the agent and what happens when its spawner ends), `dendrite:agent-ended`, `dendrite:agent-orphaned`, `dendrite:agent-reparented`, `dendrite:activation-cancelled`, `dendrite:mail-deferred` (queued for the recipient's next boundary), `dendrite:mail-delivered` (entered the recipient's store), `dendrite:mail-held`. Delivered mail carries `metadata.dendrite.producedAt`. New config: `dendrite.announceOrphans` (default true). diff --git a/changelog.d/dendrite-registry.changed.md b/changelog.d/dendrite-registry.changed.md new file mode 100644 index 000000000..1be5784dc --- /dev/null +++ b/changelog.d/dendrite-registry.changed.md @@ -0,0 +1,2 @@ +- Agent selection reads declared roles from the registry instead of excluding agent kinds at each site. Residents, the subconscious and conversation forks are selected exactly as before. One difference: an ephemeral agent that is mid-run is no longer queued for untargeted broadcasts (a module's `requestInference: true`, an untargeted channel message or push event, coalescing's default readers) or for host-wide gate wakes. Such requests previously sat in the queue until the run ended and were then dropped with an `agent_not_found` trace; they are now never queued. +- Two Chronicle states are added to every store: `framework/dendrite` (snapshot) and `framework/dendrite/ended` (append log). Existing stores need no migration. diff --git a/changelog.d/dendrite-speech-locus.breaking.md b/changelog.d/dendrite-speech-locus.breaking.md new file mode 100644 index 000000000..c43bdcd82 --- /dev/null +++ b/changelog.d/dendrite-speech-locus.breaking.md @@ -0,0 +1,44 @@ +- **Hosts:** `speech.locus` defaults to `'delivery'`, and a `channel_publish` + with no `channelId` goes where the calling turn's plain speech would. A + host that relied on its agents' prose or publishes reaching the channel + of the latest message needs to act. + - **Before, plain prose** (under prose routing by locus, `'locus'` or + `'hybrid'`): a turn no channel message woke fell back to the channel + of the most recent inbound message (a reaction, an edit, a deletion or + a system notice aside), unless it was a conversation fork's, a + heartbeat's, a `noChannel` turn or an ephemeral agent's, as now. An + addressed message injected mid-turn from another channel moved the + turn's speech to that channel, and so did one that arrived between the + turn's streams, at its next start. + - **Before, `channel_publish` with no `channelId`:** it went to the + channel of the most recent inbound message (the same markers aside), + whichever turn called it or whether or not one did. A conversation + fork's own call went to its home, as now, but one a host made for it + through `executeToolCall` took the latest message's channel, and now + takes the home. It was refused when there was none or the caller was + an ephemeral agent. + - **Now, plain prose** (under prose routing by locus): only the delivery + that woke a turn, a conversation fork's home channel or a channel the + agent opened during the turn sets its locus. A turn with no locus falls + back only to an open all-except tune-out's excepted channel, and + otherwise keeps its prose private; a heartbeat's turn, a `noChannel` + turn and an ephemeral agent's take no fallback. An addressed message + from another channel moves nothing, and the agent is told, once per + channel per turn, in a `[routing]` notice. + - **Now, `channel_publish` with no `channelId`:** from a turn, it goes to + the turn's locus, else a conversation fork's home channel, else the + channel of the message that woke the turn, else the fallback. Made + outside any turn, as a script left running after its turn makes, it + goes to a conversation fork's home channel, else the fallback. The + fallback is only an open all-except tune-out's excepted channel, and a + heartbeat's turn, a `noChannel` turn and an ephemeral agent don't take + it. The call is refused when none of these names a channel. + - **Migration:** `speech: { locus: 'latest-inbound' }` brings back the + re-pins on an addressed injection and on an arrival, and makes the most + recent delivered inbound message's channel the fallback again, after + an open all-except tune-out's, for prose and for a publish. No setting + sends a publish from a turn there ahead of the turn's own channel, or + from a turn that takes no fallback: a publish meant for it names its + `channelId`. + - **Unchanged:** a `channel_publish` that names its `channelId` goes to + that channel. diff --git a/docs/dendrite.md b/docs/dendrite.md new file mode 100644 index 000000000..0127e7a88 --- /dev/null +++ b/docs/dendrite.md @@ -0,0 +1,420 @@ +# Dendrite: one registry, lifecycle and fork primitive for agents + +Dendrite replaces four separate ways of making an agent (resident, subconscious, conversation fork, ephemeral subagent) with common machinery. Each of those is now a preset over one registry, one lifecycle and one way to derive a context. + +This page describes what the framework implements. It follows the Dendrite proposal (revision 2, 6 October 2026) and [issue #232](https://github.com/anima-research/agent-framework/issues/232). The proposal is not an approved specification; [What is not implemented](#what-is-not-implemented) and [Choices made here](#choices-made-here) say where this implementation stops and where it had to decide. + +## The model + +Three things are kept apart: + +- **Identity and lifetime.** What persists across runs: the name, the context the agent owns, its relationships. An identity has an *incarnation* number, which goes up each time it is instantiated. +- **Activation.** One bounded run. `listActivations()` reports each run in flight and the agent that owns it. +- **Policy epoch.** A tune-out interval. Ending an epoch ends neither an agent nor a task. `listPolicyEpochs()` reads epochs from the tune-out coordinator's own state; the registry does not keep a copy. + +### Roles + +These roles, not `kind`, decide where a message with no named recipient goes, which agents untargeted traffic and the host-wide gate wake, and who holds the provider gate. `kind` selects behaviour for two of its values, and derivation notices are counted per kind, below. + +| Role | Meaning | +|---|---| +| `defaultDelivery` | A message with no named recipient goes to this agent. Exactly one agent has it. | +| `receivesUntargeted` | Woken by a module's `requestInference: true`, by a channel message or push event that names no agents, and as a default reader of a coalesced occurrence. | +| `receivesGateWakes` | Woken by the host-wide gate (debounce batches, sleep expiry). | +| `ownsProviderScheduling` | Holds the provider gate and owns rate-limit cooldowns. | + +`kind` (`resident`, `subconscious`, `subconscious-fork`, `conversation-fork`, `task-fork`, `worker`, or any string) labels an agent for discovery, and the framework also selects by two of its values. An agent registered as `subconscious-fork` is a reader fork, which may call only its reader's tools (`deliver_summary`, `cancel_tuneout`, `note_disposition`), and `speak_in_channel` where `subconscious.allowChannelSpeech` allows it, whatever its `allowedTools` says. A `denyToolsAtDispatch` entry it takes from its resident's configuration still refuses the tool it names. The agents registered as `conversation-fork` are the ones `stop()` unbinds from their home channels and disposes, and the ones the cleanup after a context manager reinitializes disposes when their context is left empty. Whatever its value, `kind` also groups derivation notices for collapsing ("The source is told", below). + +### Lifetime + +| Lifetime | Ends | After a restart | +|---|---|---| +| `persistent` | Never; declared in configuration | Re-created as a new incarnation | +| `idle` | After `idleTtlMs` without traffic | Recorded as ended (`host-restart`) | +| `task` | When its run ends, at `deadlineMs`, or after `idleTimeoutMs` of silence | Recorded as ended (`host-restart`); can be resumed | + +A `task` lifetime with neither a deadline nor an idle timeout is refused. + +### Relationships + +Four relationships, each separate: + +- **Spawn** (`spawnedBy`): who created the agent. Optional; an independent agent has no parent. +- **Observe** (`observes`): whose stored messages the agent reads. +- **Message** (`messagePeers`): explicit paths the agent has used. +- **Result route** (`resultTo`): where a bounded job's result goes. + +Spawning an agent does not make you its only observer or the recipient of its result. + +## Presets + +| Preset | Lifetime | Roles | Relationships | +|---|---|---|---| +| `residentSpec` | persistent | untargeted, gate, provider; the first one also has default delivery | none | +| `subconsciousSpec` | persistent | provider only | spawned by the resident it serves; ends with it; observes the shared slot including held traffic; result to the resident | +| `conversationForkSpec` | idle | gate only | spawned by the template; home channel; inherits by copy | +| `workerSpec` | task | none | optional spawner and result route | +| `taskForkSpec` | task | none | spawned by its parent; inherits its context | + +With `subconscious.reader: 'forks'` the subconscious preset is not registered at all; the reader is a succession of `subconscious-fork` agents derived from the resident (see [The subconscious as forks](#the-subconscious-as-forks)). + +The four existing creation paths register these presets. Their behaviour is unchanged, with one exception: an ephemeral agent that is mid-run is no longer queued for untargeted broadcasts or gate wakes. Those requests used to wait in the queue and were dropped when the run ended. + +## Discovery + +```ts +framework.listAgents({ includeEnded: true }); // live, and the newest 256 ended +framework.getAgentRecord('fork-1'); // live, else the most recent ended one +framework.getPrimaryAgentName(); +framework.listActivations(); // runs in flight, with owners +framework.listPolicyEpochs(); // tune-out holds in force +framework.listHeldMail(); // results not yet delivered, and why +``` + +Ended agents stay inspectable. The registry keeps live records in the Chronicle state `framework/dendrite` and ended records in the append log `framework/dendrite/ended`. + +## Lifecycle + +There are no permission checks. The framework runs in one host trust domain, and any caller may stop, cancel or reparent any agent. + +**Context isolation is not process or filesystem isolation.** Agents that share tools, a shell or a workspace can change the same external state. A fork that edits a file edits the file its parent sees. + +```ts +framework.cancelActivation('worker-1', { by: 'mira', reason: 'changed plans' }); +framework.stopAgent('worker-1', { by: 'mira' }); +framework.reparentAgent('job', 'mira'); +``` + +- `cancelActivation` aborts the run in flight and leaves the agent registered. A bounded job lives for one run, so cancelling its run ends it. +- `stopAgent` ends the agent. The caller that is awaiting its run receives an `AgentStoppedError`. The agent with `defaultDelivery` cannot be stopped; cancel its activation instead. +- A `task` lifetime's `deadlineMs` is enforced: the run is rejected with `AgentStoppedError` when it passes. + +### When a parent ends + +Each agent declares `onParentEnd`: + +- `'end'` (attention tenancy): the agent exists to serve its parent and ends with it. +- `'orphan'` (bounded task work, the default): the agent keeps running. It receives a notice in its own context. Its nearest live ancestor, or else the primary, receives a notice that it can adopt the agent. `dendrite:agent-orphaned` carries the candidates. Set `dendrite.announceOrphans: false` if the host decides adoption itself. + +`reparentAgent` clears the orphan state. If the orphan's result recipient is gone, the route moves to the new parent and results already held are delivered there. + +The `dendrite:agent-created` trace names the kind, the model (whose weights), the context source (whose context), the lifetime and the `onParentEnd` policy at creation. The framework attaches no semantics to a derivation beyond its mechanics — no consent model, no rule about what a mind must be asked or told. What it offers a host that wants one is generic: `dendrite.beforeDerive(spec)` says yes or no, with a reason, to each agent `createEphemeralAgent` makes, derivations included, before the agent exists, and to each `resumeAgent` before its new incarnation exists; `dendrite.derivationNotice(record, from, defaultText)` decides what, if anything, the source is told; a record's `metadata` carries whatever the host records; `AgentConfig.denyToolsAtDispatch` narrows what a derived agent can do while its tool block stays the parent's; `ForkReaderConfig.framing` decides what a reader fork is told; and a module can offer tools. A consent flow — a recorded yes, a check that the configuration is the one agreed, a way to take the yes back — is a module built on these, not the platform's (architecture channel, 2026-10-19: semantics live outside the platform, so it can serve uses not yet imagined). + +**The source is told.** Every derivation puts one host-framed line in the context of the agent it was derived from, built from the same facts as the trace — `[Agent lifecycle: fork-1 was derived from you as a task-fork on your weights (claude-…), with a copy of your context; its result returns to you as a message; it ends when its task does (idle 15 min), and is orphaned if you end.]` — stamped `kind: 'agent-lifecycle'` with the record's fields under `dendrite`. A resumption says so (`was resumed (incarnation 2)`); a conversation fork says which channel it speaks in; a fork on another model names it instead of "your weights". It requests no wake. Being told when the body changes is a property, not a courtesy (Linn, 2026-10-09; #270), so the framework has no setting that turns it off; a host's `dendrite.derivationNotice` (above) can replace the line, or return `null` to send none, and the `dendrite:agent-created` trace is emitted either way. Both derivation paths announce after the fork's branch or copy exists, so a fork never inherits the notice about itself; a source that has ended is told nothing. For `subconscious.reader: 'forks'` that would be one line per invocation, and a reader loop can fork a hundred times in a session; so after `dendrite.collapseDerivationNoticesAfter` derivations of one kind from one agent (default 10; 0 never collapses) the source hears a count line every so many instead — `[Agent lifecycle: 20 subconscious-forks have been derived from you since 14:02 UTC (10 since the last note); the latest, reader-20, is a subconscious-fork on your weights …]`, stamped `dendrite.collapsed: { count, sinceLastNote }` — while every derivation still emits its `dendrite:agent-created` trace, and `getAgentRecord(name)` finds any agent that ended, from the ended log (`listAgents({ includeEnded: true })` lists the newest 256). + +### After a restart + +A host's `stop()` does not end bounded work it cuts off: a fork or job still running when the host stops is left live in the registry snapshot (traced `dendrite:agent-interrupted`, reason `host-stop`), so the next boot ends it as `host-restart` and tells its result recipient — the same as a crash. Before this, `stop()` ended the record as finished or failed and the restart had nothing to report (first live run, 2026-10-12, finding D). Traces emitted during `create()` (restore, interrupted work, resumed epochs) are replayed to a listener that asks for them — `onTrace(listener, { replay: true })` — since every host attaches its sinks after `create()` returns. The buffer keeps the first 1,000; when there were more, the replay ends with one `dendrite:early-traces-dropped` event naming how many it didn't keep. The buffer is dropped at `start()`. + +- Persistent agents are re-created by configuration, as a new incarnation. +- Every other agent that was live is recorded as ended with reason `host-restart`. If its result route is a message, the recipient is told once that it ended without a result. +- A persistent agent that the configuration no longer declares is ended with reason `not-configured`. +- Results held from before the restart are delivered, once. + +## Messages and results + +```ts +framework.sendAgentMessage('oren', 'mira', [{ type: 'text', text: 'the build is green' }]); +framework.deliverAgentResult('fork-1', [{ type: 'text', text: result.speech }], { causedBy: ['task-7'] }); +``` + +Both put the sender's content in the recipient's context **under the sender's own name**. The one exception is a recipient that counts the sender as its own voice without being the sender, as a shared fork counts its parent: there the mail is stored under ` (mail)`, such as `mira (mail)`, so no request presents it as the recipient's own turn. `metadata.dendrite` carries the sender's name and incarnation, the mail id, the causal chain, and `producedAt`, when the sender produced it, so a result that arrives late reads as mail from then rather than as the present. Nothing is inserted into the recipient's own voice. + +Published is not delivered. `dendrite:mail-deferred` fires when a message is queued for a recipient's next boundary; `dendrite:mail-delivered` fires only when it has entered the recipient's store. Neither says the model has seen it; that is a separate event the framework does not yet record. + +A result is written to the registry and synced to disk before delivery is attempted. It is released only when its message has entered the recipient's store. Consequences: + +- If the recipient is mid-turn, the result waits for its next boundary and stays held until then. +- If the process stops in between, the next boot delivers it, after checking the recipient's store for the mail id so it is never delivered twice. +- If the recipient no longer exists, the result is held with reason `recipient-gone` until the sender is reparented. + +`deliverAgentResult` works after the agent's run has ended. It refuses a `tool-result` route, because there the caller awaiting the run already has the result. + +## Deriving an agent + +```ts +const fork = await framework.deriveAgent({ + name: 'fork-1', + from: 'mira', + strategy: makeStrategy(), // a fresh instance; same class and config as the parent's + resultTo: { to: 'mira', as: 'message' }, + framing: [{ participant: 'user', content: [{ type: 'text', text: 'You are the fork. Task: …' }] }], +}); +const { speech } = await framework.runEphemeralToCompletion(fork.agent, fork.contextManager); +``` + +The child gets the parent's context at a checkpoint and continues privately. The parent keeps running. Unless you override them, the child also gets the parent's whole configuration: model, thinking, cache TTL, provider parameters, system prompt, tools, and the parent's live context budget. + +### Shared and copied inheritance + +| | `mode: 'shared'` (default) | `mode: 'copy'` | +|---|---|---| +| What the child reads | The parent's messages and fold state at the checkpoint, through shared storage | The parent's compiled context, re-added as the child's own messages | +| Memory tree | Inherited, with its alternative resolutions | Lost; the child refolds a rendered copy | +| Written to the store per fork | Only what the child adds | A full copy of the compiled context | +| Parent's turns | Kept under the parent's name; mapped at request assembly | Renamed to the child at copy time | +| Requires | Chronicle with `JsStore.view`; context-manager with `ContextManager.derive` | Nothing extra | + +With shared inheritance, neither side can change what the other reads. The parent's later folding does not alter the child's inherited past, and nothing the child does, background compression included, reaches the parent. + +Shared inheritance is refused with a clear error when the installed packages cannot provide it. It is never silently replaced by a copy. + +The child's writes go to a Chronicle branch of its own, which its record names as `inherit.ownBranch`: `dendrite/`, or `dendrite/~2`, `~3` and so on when the store already has a branch by that name, as one an earlier process's child of the same name left. That branch outlives the child. The store's branch cursor does not move. + +### The refusal ledger + +By default a fork does not inherit its parent's compression-refusal ledger (what the parent declined to compress, and why): a task fork is free of its parent's refusals. With shared inheritance, `inheritRefusals: true` brings it along, for a fork that stands in for the parent's attention and must not be asked the thing the parent said no to without knowing. Either way the creation record and event state it (`inherit.refusals`), so nobody discovers the difference by being asked. A copy brings no ledger, so a copy derivation refuses `inheritRefusals: true` before anything is made. Inherited entries are the parent's; the child's own refusals append after them on its branch. + +The ledger is **consultable, never rendered**: it steers what the child's strategy will and will not compress, and nothing in it is placed in a compiled request. Refusal records are the most classifier-sensitive content in the pipeline, so this is part of the declaration, not something that happens to be true. + +### Reusing or redoing the solve + +- `solve: 'reuse'` (default): the child keeps the parent's rendering. Fold state arrives through the store. The part that lives only in memory (the previous compile's cache identities, an in-flight budget transition, a hot-tuned tail) is handed from the parent's strategy to the child's. +- `solve: 'fresh'`: the child discards the inherited frontier and solves from scratch, for example at a different budget. The rendering changes and the provider cache misses. This is a supported choice. + +Any override that changes what is rendered (model, system prompt, budget, tools) has the same effect as a fresh solve on the provider cache. `solve` is for shared inheritance: a copy derivation refuses it before anything is made. + +### Framing and the fork point + +Framing is ordinary messages added to the child before it runs. How a child is prompted is the host's: `deriveAgent` fixes nothing about it beyond appending what the caller supplies after the fork point. + +While a tool call is executing, the assistant turn that made the call is not in the parent's store yet; the framework stores it when all its tool results are in. A fork made from inside a tool call is therefore derived at **the last stored message before the turn that made it** — and, by default, gets that turn back as the first thing on its own branch, so it sees the call that made it and never meets a dangling `tool_use`: + +1. the parent's pending assistant turn, verbatim (text and `tool_use` blocks), stored under the parent's name like the rest of a shared inheritance (renamed in a copy), with `metadata.kind = 'dendrite-pending-round'`; +2. a tool-result message answering every call in it: results the parent has already received, as stored; the call that derived the child with `pendingRound.madeBy.result` or, by default, a note saying who it is, what kind and how long — *"This call derived fork-1. You are fork-1, a task-fork, continuing from here on your own branch. You finish your task and then end; if mira ends first you keep running, as an orphan with the same return address. mira receives this call's result, not you."* (for `onParentEnd: 'end'`: *"You end when mira does; your report returns to mira as mail under your own name; nothing you do speaks as mira."*) — the same fields the creation event names, so a fork never infers its own lifetime from context; any other call still running with a note that its result was not available when the child was made. `madeBy` defaults to the only open call when there is exactly one. + +Then the caller's framing. `pendingRound: { include: false }` leaves the round out. The parent's own store is untouched; the round lands there when its results are in. `getPendingAssistantRound(name)` still returns the pending turn for hosts that want to word it themselves. + +### Role assignment + +Stored authorship and the role a model sees are different things. `AgentConfig` has two fields for this, both applied at request assembly only: + +- `selfParticipants`: stored participants that are this agent's own voice. Their turns are presented as its assistant turns. +- `presentAs`: the participant name its turns are presented under. Default: its own name. + +`deriveAgent` with shared inheritance sets `selfParticipants` to the parent (and the parent's own ancestors) and `presentAs` to the name the parent's requests use. The child's new turns are still stored under the child's name. + +### The author's name + +A channel message is stored as the model reads it: `author: body`. MCPL's `channels/incoming` carries the author as a field — `author: {id, name}` — and the body alone as content; the framework stores it under the anonymous participant `user` with the author in `metadata.author`. The wire formatter writes the participant, and the compiled view carries no metadata, so a body stored bare reached the model as `user: ` with the author nowhere, while a release's backlog lines, which read the metadata, named it (first live run, Weft's isolation, 2026-10-13). The rule, `presentAuthor` (exported): a message stored under `user` that names an author has the name written before its first text block — or, with no text block, as a leading `Name:` line before its media — unless the text already begins with `Name:`. A server that renders the name into the text itself (portal-mcpl does) is left as it is, so the name appears once whichever shape a server sends. Backlog lines follow the same rule (`authorLine`), so such a server's messages are no longer shown as `Name: Name: body`. + +### Restricting a fork without losing the prefix + +Removing a tool from `allowedTools` changes the tool block at the front of the request, which changes the prefix. `AgentConfig.denyToolsAtDispatch` leaves the tool advertised and refuses the call when it is made. + +## The subconscious as forks + +`subconscious.reader: 'forks'` replaces the persistent reader with a succession of forks of the resident. Nothing persists between invocations except what the tune-out coordinator already keeps: the epoch, its held traffic, the wake count, and the standing dispositions. + +```ts +await AgentFramework.create({ + agents: [{ name: 'scout', strategy: new AutobiographicalStrategy(opts), ... }], + subconscious: { + enabled: true, + reader: 'forks', + model: 'claude-...', // whose weights; required, never defaulted + voice: 'You are reading for scout. Report in second person, briefly.', + strategyFactory: () => new AutobiographicalStrategy(opts), + forkIdleTimeoutMs: 10 * 60_000, + }, +}); +``` + +Each cadence tick, each coalesced wake and the cancel derive one fork at the resident's head: + +```ts +deriveAgent({ + name: 'reader/scout//', + from: 'scout', kind: 'subconscious-fork', + inheritRefusals: true, onParentEnd: 'end', + resultTo: { to: 'scout', as: 'message' }, + config: { model, proseRouting: 'disabled' }, + metadata: { epochId, channelId, trigger }, + framing: [/* the notice, the reader's voice block, the dispositions, the held traffic */], +}) +``` + +The fork has the resident's history as its own turns, the resident's refusal ledger, the resident's tool block and system prompt, and runs on the model the configuration names. It ends when its turn ends (`runEphemeralToCompletion`), or with the resident. It reports through `deliver_summary`, which becomes attributed mail: the report lands in the resident's context under the fork's name and incarnation, with `producedAt`. The report is the fork's whole job, so a fork that ends in plain prose without calling the tool has still reported: its final prose is delivered as its report by the same route, traced `tune-out:reader-prose-delivered` (first live run, 2026-10-12, finding A — a fork that judged correctly and answered in prose lost the whole report to `proseRouting: 'disabled'`). A fork that did call the tool does not report its trailing prose again. `cancel_tuneout` works as before (the resident gets the dump; the fork's note arrives as its own mail). `speak_in_channel` posts through the resident's own publish path: the channel sees the post under the resident's account, in the fork's words; nothing in the `channels/publish` request names the fork. Both act only through the hold of the epoch the fork was derived to read, the `epochId` on its record: a fork's cancel of another hold is refused, and so is its post into a channel that hold doesn't hold, once that hold has ended included; a fork whose record has no epoch is refused both. The check is made when the call is made, so a post admitted before the hold ends may finish after it, with its ordinary delivery result. Its reports, `deliver_summary` and `note_disposition`, stay open to it. With `allowChannelSpeech` off, a reader fork can post nowhere — every tool but its own three is refused at dispatch, while the block it is shown stays the resident's for the shared prefix (scout's condition for a future yes, 2026-10-19). `note_disposition` records a standing note under a key, stored for the resident rather than the epoch (as coordinator state `subconscious//dispositions`); later forks see the text in their framing. + +**What the fork is handed.** The held traffic is delivered as ordinary framing, each held message to exactly one fork: the coordinator keeps a per-channel cursor over the diverted backlog and each invocation gets what arrived since the last look. That includes what is still queued behind a resident turn that is running: while the resident's turn runs, what arrives for it waits in the deferred queue rather than its store, and that is when a reader is most useful. A queued message is handed with its deferred write's id; the coordinator records the ids it hands out, and once such a write lands, its stored row (which carries the id as `metadata.deferredWriteId`) is not handed again. A fork that never reads what it was handed (its framing, derive or run throws before it reports) gives it back while the epoch still holds the channel, and the next invocation for the channel is handed it (`tune-out:reader-fork-failed`). A channel here is its server and id, since two servers can register the same id. How that is worded is configurable: `framing(context)` receives the resident and fork names, the `voice`, the channel, epoch and trigger, the coordinator's notice, the standing dispositions and the held messages (`backlog`, the queued ones last, with held handles for ids and provisional sequences; `queuedCount` says how many), and returns the content of the one message the fork receives after the fork point. The default, `defaultReaderFraming` (exported), writes a header, the notice (`[Tune-out wake: …]`, `[Tune-out cadence: …]`, `[Tune-out cancelled: …]`), the `voice` block, `[Standing dispositions]`, then `[Held in since your last look: n messages]` with one `author: text` line per message and media blocks preserved as the source supplied them, the queued ones after a note that they arrived while the resident's turn was running and are not stored yet — or `[Nothing new has been held since your last look.]`. Whatever is returned is stored on the fork's own branch with `metadata.kind = 'tune-out-reader-framing'` and the held message ids (a queued message's is its held handle, `heldHandle(writeId)`, as module rows name it). If the resident was mid-tool-call when the invocation fired, the pending round precedes it as for any fork. The resident never sees any of it; the held originals stay out of its compiled view as before. + +**Sharing the prefix.** For the fork's request to share the resident's provider-cached prefix, nothing ahead of the framing may differ. Hence: the reader's `voice` is framing rather than a system prompt, and residents are shown the reader's four tools (`deliver_summary`, `cancel_tuneout`, `speak_in_channel`, `note_disposition`) in their own tool block, unless their `allowedTools` leaves them out; then the resident's reader forks aren't shown them either, and can still report through their final prose. A resident that calls one is refused at dispatch (`… is available to the resident's reader, not to `), in the tune-out module's voice, so the refusal is a tool result and not a provider error. The persistent mode advertises nothing extra. + +**Whose weights.** A fork holds the resident's whole prefix, so which model reads it is the one fact that must never be defaulted: `model` is required in this mode and `create` refuses its omission. The resident's own model makes the fork a copy of the resident — the shape the subconscious was built on, a reader that knows what is good for the resident because it is them. Another model, stated by name, makes it a stranger holding the resident's history; the framework allows that when the configuration says so, since other residents elsewhere may want it, and refuses only the silent case. Either way this mode is fork-grade under the architecture channel's 10-05 ruling, which for Connectome's residents asks the resident's named consent to the preset (kind `subconscious-fork`, the model, shared inheritance with refusals, `onParentEnd: 'end'`, `resultTo` as mail) once per configuration. Whether a deployment asks that, and how, is its own: the framework records every one of those fields in `dendrite:agent-created`, offers `dendrite.beforeDerive` to a host that gates on them, and checks none of them against a consent itself; the registry is not a permission check, and the yes belongs on the operator's row or in a consent module's record. The persistent reader, with its own model and no prefix, remains the reader-grade option. + +**Strategy.** A fork reuses the resident's fold state and rendering (shared inheritance), which needs a strategy instance of the same class and configuration. `strategyFactory` supplies it; `create` refuses `reader: 'forks'` for a non-passthrough resident without one rather than letting a fork fold with the wrong strategy. + +**Trace.** `tune-out:reader-fork` fires per invocation with the fork's name, the channel, the epoch, the trigger (`cadence` | `wake` | `cancel`) and how many held messages it was given (`held`, of which `queued` were still queued). `tune-out:reader-fork-failed` fires for a fork that threw, with whether it had reported and how many messages it gave back (`returned`). `dendrite:agent-created` and `dendrite:agent-ended` fire as for any derived agent; `listAgents({ includeEnded: true })` lists the forks. + +Costs are those of [Cost](#cost): one Chronicle branch and one context manager per invocation, nothing copied. A fork over a 200k-message resident derives in under a millisecond and reads its first context in about ten. + +## Admission + +Every inbound channel event — a `channels/incoming` message or a `push/event` — passes one admission decision before anything acts on it (`src/admission/admit.ts`, called from both lanes in the framework). The decision answers the proposal's five questions once: + +| Question | Decided as | +|---|---| +| Is it captured? | Always. It is stored where it arrived, in the shared slot. | +| Who may receive it? | The event's explicit targets, else the untargeted recipients. | +| Body or hold? | A hold if a policy epoch (today: tune-out) is in force over the channel. A held message carries the record on itself, `metadata.tuneOut = { epochId }`, and is excluded from every resident's view by that record. | +| May it wake them? | Not when held or vetoed by the server. Otherwise the wake gate decides, and it is consulted here, after the hold — so a held message never forms a debounce batch, never advances a counter. For a held message the gate is only *probed* (side-effect free), and that verdict is handed to the hold's reader as a precondition: a message the gate would have muted does not wake the reader either. | +| Where may speech go? | A held message never counts as "the latest delivered inbound". Under the default `speech.locus: 'delivery'` nothing inbound sets a locus for a later turn at all (below). | + +Everything downstream reads the decision: the two lanes store and wake from it, the compiled view and the mid-turn injection use the same `heldFromResidents` predicate, and the channel registry learns of a delivered message through `noteDelivered` instead of deciding at ingestion. A tool close of a tuned-out channel is refused and leaves the tune-out in place, since its cancel is what releases the held traffic; a tool close of the channel an all-except hold excepts ends that hold through the cancel flow. The gate's batch delivery re-checks the hold, since an epoch can begin after an event was batched. The decision is traced as `admission:decided`. + +What it closes from issue #232 §2: gaps 1 (a DM can be tuned out: the push lane is admitted too), 2 (held traffic never moves the locus), 3 (no debounce wake for held mentions), 4 (a release names non-text blocks), 5 (no mid-turn injection of held messages), 6 (a release left in the deferred queue is durable and re-wakes the resident at boot), 7 (deferred held messages are in the release), 8 (server-qualified channel resolution; ambiguity is an error), 9 (`channel_close` on a tuned-out channel is refused, and the tune-out's cancel releases), 10 (the hold machinery runs without a reader) and 11 (the truncation note names a history tool the resident has, or none). Gaps 1 to 10 each have a regression test in `test/admission-e2e.test.ts`, on the loopback MCPL server, and gap 11's is in `test/tune-out-dump-fit.test.ts`. + +### The speech locus + +`FrameworkConfig.speech.locus` chooses where a turn's plain prose goes when nothing names a destination. + +- `'delivery'` (the default) is the §3 carve-out the residents accepted in the architecture channel on 2026-10-06: the locus comes only from the delivery that woke the turn, a conversation fork's home channel, or a channel the agent opened during the turn. Incoming traffic never moves it — not between turns, and not mid-turn either. An addressed message injected during a turn from another channel is announced in the same injection batch — `[routing] addressed you from while you were busy. Your plain speech still lands in this turn; reaching them takes an explicit send.` — once per channel per turn, because a neutral message carries nothing that names its channel (first live run, 2026-10-12, finding C). A turn with no such locus keeps its prose private. When the locus the agent was last told of was a channel, a `[routing]` notice tells it its speech now has none; an agent never told of a channel isn't told this. The same holds for a message that lands between a turn's streams — during a provider-capacity hold, before a re-inference on tool results, or drained at an over-budget boundary (an *arrival*, in the turn-routing sense of 0.22.1): it moves nothing, and if it addressed the agent from another channel the next start of the turn announces it with the same notice, naming which start it is — `while your turn waited on a provider hold`, `before this re-inference on your tool results`, `at your budget restart`, `while your turn was retried` — since from inside they look alike and the reason says whether the earlier prose landed (Linn, 2026-10-14). `channel_publish` without a `channelId`, made inside the turn, goes where the turn's plain speech would — the frozen turn locus, so the waking delivery's channel or one opened this turn — and is refused when its server no longer registers it. A `serverId` given with no `channelId` names the locus's id on that server, as the registry reads one given beside its own default. A turn with no locus takes its home channel if it's a conversation fork, else its trigger channel where it has one, as a turn under explicit or disabled prose routing does, since it never resolves a locus. Past those, a turn with no channel by design (`noChannel`, or a heartbeat) takes nothing, and so does an ephemeral agent's turn (one `createEphemeralAgent` or `resumeAgent` made, derivations included); any other turn takes the registry's default, as a call outside the turn does. One made outside the turn, as a script left running after it makes, takes none of the turn's channels: a conversation fork's goes to its home channel, an ephemeral agent's to none, and anyone else's takes the registry's default, which under this rule is the all-except hold's excepted channel while one is open, and none otherwise. +- `'latest-inbound'` restores the previous rule: the most recent delivered inbound message is a global fallback locus, an addressed mid-turn injection re-pins the turn, and so does an arrival at the turn's next start (the 0.22.1 rule, kept whole under this flag). + +Under both rules a held message is never "the latest inbound". + +### Tune-out without a reader + +The tune-out coordinator exists whenever MCPL channels do. With a reader (the persistent subconscious or reader forks) it summarizes on a cadence and wakes the reader on addressed traffic. Without one a tune-out is a *silent hold*: nothing summarizes, nothing wakes, suppressed mentions are still acknowledged, and the backlog is released on cancel; the tool result says so. A persisted epoch is therefore always resumed and released, whatever the configuration (gap 10). Residents are offered `tune_out` when a reader is configured or `tuneOut.enabled` is set. + +### What a release is + +A release is a system-framed notice with the count, the channel and the epoch, then the held messages up to `backlogCap` as `author: text [image] …` lines in block order, then the reader's report under the reader's own name. Messages still in the deferred queue (held while a turn was alive) are included. Above the cap the note names the history tool the resident actually has, or says the rest was not delivered. The release is made durable before the cancel returns. + +### The all-except scope + +`tune_out` with `scope: 'all-except'` — or its alias `focus` — holds every channel except `channelId`, for `durationSeconds` (mandatory: the scope always ends on its own). It is a resident-level policy epoch, not a per-channel state, so it covers DMs and channels registered after entry: admission's hold lookup asks "is this the exception?", not "is this channel marked?". While it holds, a turn with no delivery of its own speaks into the excepted channel (nissa's reply order: explicit target, waking delivery, focus destination, otherwise private). `mode: 'check'` reports the held count per channel and, with `peek`, one channel's newest held lines without releasing anything — only lines of the epochs holding that channel now; a released epoch's messages were delivered and are not shown as held (finding B of the first live run: peek is the resident's own gauge, and a gauge that shows released traffic as held is a false declaration). Release is one notice with a per-channel backlog, each capped by `backlogCap`. + +The excepted channel hears the door shut. On entry one bracketed line is posted there under the resident's name — `[Focus: mira is here for 30 min, until 19:30 UTC; other channels are held meanwhile.]` — and one on release — `[Focus: mira released — duration elapsed.]`. The held channels hear nothing; a suppressed mention there is acknowledged as before. This is on by default (`tuneOut.announce: false` turns it off): a hold the room cannot see is the failure the scope exists to prevent (Linn, 2026-10-12, on having built the same thing by hand and been rightly caught). The tool result names the channel as `announcedIn`; the outcome is traced as `tune-out:announced` or `tune-out:announce-failed`, never awaited by the epoch flip. The wording is fixed and is a notice, not the resident's speech. + +Decisions carried from issue #232 and the discussion: a per-channel epoch takes precedence over the scope (its own cadence, budget and stamp); the excepted channel cannot be tuned out, and a tuned-out channel cannot be the exception; the reader keeps its latitude and may end the scope (`cancel_tuneout` on a held channel, or `scope: 'all-except'`); exhausting the reader's wake budget *pauses* reader wakes and leaves the hold in force (traced as `tune-out:wake-budget-paused`); wakes already queued before entry still run; without a reader it is a silent hold; `focus` stays as the alias; closing the excepted channel ends the scope. The record lives in the coordinator's snapshot slot (`tune-out//all-except`) and appears in `listPolicyEpochs()` as a tune-out epoch with `scope: { kind: 'all-except', except }`. A scope whose deadline passed while the process was down is released at resume. + +### Not here + +The per-recipient exposure model of nissa's household's proposal (an inbox outside recipient slots, `attention`/`inline` per channel per recipient, overlays) and the mention autoreply (#232 §3.4) are not built. Admission here decides per *slot*: residents share one message slot, so a hold applies to all of them, and a reader of the slot sees held traffic through its own view. The structure is the one those designs need — one decision, consumed downstream — so they are additions to the policy inputs, not another path. + +## The rendering contract + +For a child with shared inheritance, reused solve and no overrides, the test `test/dendrite-derive.test.ts` compares **provider-formatted** requests (membrane's native formatter) up to the fork boundary, which is the end of the parent's last request: + +- The system prompt and the tool block are identical. +- Every provider message of the parent's request appears unchanged in the child's request. In the boundary message, every block appears unchanged. +- The boundary block carries a cache marker in both requests, with the same TTL. +- The child stays within four cache breakpoints. + +"Unchanged" covers the content the provider hashes. The positions of *interior* cache markers are not compared, because they differ between any two consecutive requests of one agent: each request marks the end of the previous request and its own end, and drops the marker before that. The parent's own next request would not repeat its earlier interior marker either. Issue #232 asked for byte identity including markers; that cannot hold under this marker scheme, so the contract here is content identity plus a marker on the boundary. + +A second test in context-manager (`test/derive.test.ts`) checks the stronger statement where it does hold: the child's first compile is identical, markers included, to the compile the parent would have made next. + +Both tests fail when the mechanism they check is disabled. + +### Live acceptance + +The contract was checked against the provider on 10 October 2026 with `bench/dendrite/cache-acceptance.mjs` (Claude Sonnet 5.5 through the house inference gateway; a folding resident with ~10k tokens of history, summaries written by the same model; native formatter). Uncached input was 4 tokens on every request. + +| Request | cache_creation | cache_read | +|---|---|---| +| resident, first turn (writes the cache) | 10,173 | 0 | +| resident, next turn (its own reuse, the control) | 20 | 10,173 | +| **fork at the boundary, first request** | 29 | **10,193** | +| resident, a turn that blocks in a tool call | 0 | 10,230 | +| **fork derived mid-tool-call, first request** | 152 | **10,230** | + +A fork's first request reads everything its parent had cached, including what the parent's latest request added, and writes only its own framing (and, mid-round, the pending round). The read counts grow between requests because the prefix grows, not because more than was cached is reused. Reuse follows the cache markers the strategy actually emits: of the two strategies tested, the autobiographical strategy marks messages (through its recall ladder) and the passthrough strategy marks none, so a passthrough resident caches only its system prompt and tools — for resident and fork alike; the same script shows that (1,306 / 1,306) when run without a folding strategy. This is builder-measured evidence for that model, route and setup; larger stores and other models are separate acceptance cases. The script needs `ANTHROPIC_API_KEY`, or `GATE_TOKEN` and `GATE_URL` for a gateway, and prints neither. + +## Resuming and inspecting + +```ts +const resumed = await framework.resumeAgent('fork-1', { model, systemPrompt, strategy: makeStrategy() }); +const past = await framework.inspectAgentContext('mira', { atSequence }); +const left = await framework.inspectAgentContext('fork-1'); +``` + +- `resumeAgent` brings an ended bounded job back as a new incarnation, on the context it left: its own namespace, or its own branch. Nothing is derived again. It continues from the end of its last complete round. Configuration is not stored with an agent, so the caller supplies it; a fork's role mapping is restored from its record. Use it for a job that was stopped, failed, or was interrupted by a restart. +- `inspectAgentContext` on a live agent returns a snapshot on its own branch, at the head or at an earlier sequence. The agent is not affected by anything done to the snapshot. +- `inspectAgentContext` on an ended agent returns the context it left. This is the real context: treat it as read-only unless you intend to change what a resume starts from. + +**External effects are not replayed and not undone.** A tool that ran before an agent ended has had its effect. A call that never received its result may be made again by the model after a resume. + +**On record for when replay exists:** a child's stored turns, if ever read back by its parent — merged, replayed or mailed — arrive as the child's, under its name and incarnation. Mail already does this; replay must too. + +## Cost + +Measured with `bench/derive/derive-bench.mjs` in context-manager, on one synthetic store with messages of about 470 bytes, on an Apple-silicon laptop. These numbers compare sizes; they are not a guarantee for a mature resident store. + +| | 20,000 messages | 200,000 messages | +|---|---|---| +| Create a child (`ContextManager.derive`), median of 10 | 0.8 ms | 0.5 ms | +| Child's first full read | 4.4 ms | 9.9 ms | +| Child's first append | 0.6 ms | 0.7 ms | +| Heap per additional child | 0.2 MB | 2.1 MB | +| Parent's append plus full read, with 10 children alive | 10 ms | 13 ms | +| For comparison: one cold full read of the history | about 600 ms | about 800 ms | + +Creating a child does not get slower as the history grows. Its first full read does, linearly, at the same rate the parent already pays on each of its own turns: both walk the message list once. + +Creating a child copies one array of references to the parent's message objects, at about 8 bytes per message. The messages themselves, their resolved media and their caller-facing views are shared. + +The store-level costs underneath, from `cargo run --release --example fork_bench` in chronicle. The store has one message-like state of N items of about 500 bytes and 1,000 small states. The 0.4.0 column is the same store on the published build. + +| | 20,000 items | 20,000 on 0.4.0 | 200,000 items | 200,000 on 0.4.0 | +|---|---|---|---|---| +| Fork at the head, inheriting 12 states | 3.9 µs | not available | 3.3 µs | not available | +| Fork at the head, inheriting all 1,001 states | 212 µs | 408 µs | 219 µs | 613 µs | +| Child's first read, parent warm | 2.8 µs | 33 ms | 2.8 µs | 343 ms | +| Resident memory per child that has read | 4.8 KB | 23.9 MB | 6.4 KB | 113 MB | +| Child's first append | 42 µs | 1.0 ms | 41 µs | 47 ms | +| Read right after that append | 2.4 µs | 32 ms | 2.5 µs | 372 ms | + +All figures in this section are single runs on a machine that was also running builds. Treat them as orders of magnitude. + +Store-level costs that still grow with the size of the state: + +- A fork at an *earlier* point (`atSequence`) when a full snapshot has been written since: about 10 ms at 20,000 items and 110 to 140 ms at 200,000. +- The first read on such a historical branch: one materialization, about 25 ms and 240 ms. Forks at the head avoid both. +- A field index built for a child that has already written: about 160 ms at 200,000 items. A child that never queries history by time or channel never builds one. + +Two costs are not removed: + +- A child's strategy loads its fold state from the store as any strategy does at start. The parent's summaries are parsed again for the child and held a second time in memory. +- Each fork leaves its branch behind, so that it can be inspected or resumed. The framework does not delete it. + +## Requirements on other packages + +| Package | Needed for | Without it | +|---|---|---| +| `@animalabs/chronicle` with `JsStore.view` and `createBranchWithStates` | Shared inheritance, live snapshots | `deriveAgent` shared mode and live `inspectAgentContext` throw; everything else works | +| `@animalabs/context-manager` with `ContextManager.derive` and `reopenDerived` | The same, plus resuming a fork | The same | + +The framework detects these at run time. + +## What is not implemented + +- **Per-recipient exposure.** Admission decides per shared slot, not per recipient (see [Admission](#admission)); the inbox, the `attention`/`inline` settings and overlays of nissa's household's proposal are not built. +- **The mention autoreply** (#232 §3.4). The tune-out coordinator still owns epochs and the wake budget; Dendrite supplies the admission decision, the reader and the result route. +- **Live-follow inheritance.** Shared inheritance takes a snapshot. The persistent reader (`reader: 'persistent'`, the default) keeps its live view of the shared slot; the fork reader does not need one, since each fork is derived at the head and handed what arrived since the last look. +- **Conversation forks on shared inheritance.** They still copy. Moving them needs a decision about their existing stores. +- **Retiring multi-residency.** Residents still share one message slot. +- **Diagnostic replay** in an isolated environment. +- **Per-activation bounds** (`maxTurns`, `maxInputTokens`) are recorded and not enforced. +- **Model-facing tools** for any of this. The framework provides the API; tools belong to the host. +- **Resource accounting and write-conflict handling** between agents that share external state. +- **A loud miss for off-branch sequences in Chronicle.** `iterFrom`/`query` on a handle still scan the log from the start when the branch has no record at the requested sequence. Nothing in the fork path reads records by sequence, so a fork cannot reach it, but a direct caller on a view can. + +## Choices made here + +The proposal left these open. Each is a decision of this implementation and can be revisited. + +1. **A fork is a Chronicle branch read through a branch-bound handle.** The alternative, an overlay in the context manager, would have re-implemented copy-on-write for eleven strategy states with in-place edits. +2. **A fork inherits the states its parent's context manager owns** (message slot, auxiliary slots, context log, the strategy's declared manifest, the mint-preimage index) and nothing else in the store. +3. **The inherited-state manifest is the strategy's to declare.** The autobiographical strategy inherits summaries, chunk records, the id counter, pins, resolutions, locks, calibration, the kv-unified receipt and the merge quarantine; it leaves the merge queue (rebuilt from inherited memory on initialize) empty on the child's branch, and its compression-refusal ledger too unless the derivation passes `inheritRefusals: true` (above). That is the list Linn quoted from the earlier design doc's §7 in the architecture channel. Compression holds and compression work in flight are not inherited either. +4. **Running bounded jobs no longer receive untargeted broadcasts or gate wakes.** +5. **Orphans keep running by default**, and the adoption offer is a notice to the nearest live ancestor, then the primary. +6. **The default-delivery owner cannot be stopped.** +7. **The rendering contract compares content and the boundary marker, not interior marker positions** (see above). +8. **A fork presents its turns under its parent's name**, so formatters that render participant names produce the same prefix as formatters that only assign roles. +9. **Activations and policy epochs are derived from live state**, not recorded a second time. +10. **A reader fork is handed the held traffic as framing, not as a view.** Delivering each held message to exactly one fork keeps the fork's request a pure extension of the resident's and leaves the coordinator as the only holder of the backlog; the alternative, a per-fork view filter over the shared slot, would have put tune-out state into the context manager. The reader's tools sit in the resident's tool block, refused at dispatch, for the same prefix. +11. **The hold record lives on the message.** A held message is stamped at admission (`metadata.tuneOut`) and excluded from residents' views by that stamp; no second corpus and no separate delivery flag mirrors it (antra, architecture channel: recipient-state membership records delivery). Nothing is retro-inserted on release. +12. **A debounce verdict counts as "would wake"** when the gate is probed for a held message, so a mention in a debounced, tuned-out channel still reaches the reader. The old order (gate before hold) returned false for it, which is what gap 3 observed. +13. **`speech.locus` defaults to `'delivery'`**, the §3 carve-out, with `'latest-inbound'` to restore the previous rule — including the mid-turn addressed re-pin, which was ratified separately and is kept under that rule only. diff --git a/package.json b/package.json index 56118915a..4352c3107 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "author": "Anima Research", "license": "MIT", "dependencies": { - "@animalabs/chronicle": "^0.4.0", - "@animalabs/context-manager": "^0.23.1", + "@animalabs/chronicle": "^0.5.0", + "@animalabs/context-manager": "^0.24.0", "@animalabs/membrane": "^0.8.0", "@modelcontextprotocol/client": "^2.3.1", "chokidar": "^4.0.3", diff --git a/src/admission/admit.ts b/src/admission/admit.ts new file mode 100644 index 000000000..d26c74e82 --- /dev/null +++ b/src/admission/admit.ts @@ -0,0 +1,113 @@ +/** + * The admission stage (Dendrite §D; issue #232 §3.5). + * + * One decision per inbound event, taken before any side effect. Everything + * downstream — storage, the recipient's view, the wake, the speech locus, + * the gate's batching — consumes this decision instead of rediscovering the + * policy at its own stage. The function is pure over the policy inputs it is + * handed; the framework supplies them and applies the result. + * + * What is decided, in the proposal's words: + * 1. captured — always; an admitted event is stored where it arrived + * 2. recipients — who is eligible (explicit targets, else the untargeted set) + * 3. delivery — a body in the recipients' view, or a hold + * 4. wake — may it start a turn + * 5. locus — may it count as "the latest delivered inbound" (never + * under a hold); the channel registry records it under + * either `speech.locus` setting, and makes it the fallback + * locus only under `'latest-inbound'` + * + * A hold is a policy epoch over a channel (today: tune-out). Residents share + * one message slot, so a hold applies to the slot: every resident recipient + * gets the same disposition. A reader of the slot (the subconscious, or a + * reader fork) sees held traffic through its own view; that is not a + * delivery decision of this stage. + */ + +export interface InboundEnvelope { + /** Which MCPL lane carried it. */ + lane: 'channel' | 'push'; + serverId: string; + /** Endpoint-qualified channel id when the event has one (a push event + * without channel provenance — heartbeat, timer — has none). */ + channelId?: string; + /** Platform message id, or the push event id. */ + messageId: string; + authorId?: string; + tags?: string[]; + /** `chat:addressed` (mention / reply / DM) or the server's equivalent. */ + addressed: boolean; + /** + * The server-side veto: a per-server `shouldTriggerInference` said no, or + * the event is a materialized coalesced delivery whose wake already + * happened (`triggerInference: false`). A vetoed event wakes nobody and is + * not shown to the gate as a fresh event. + */ + vetoed: boolean; + /** Explicit recipients named by the event, if any. */ + targetAgents?: string[]; + /** What the gate sees: the text and the metadata shape it has always matched on. */ + gateContent: string; + gateMetadata: Record; +} + +export interface HoldRecord { + kind: 'tune-out'; + epochId: string; + serverId: string; + channelId: string; +} + +export interface AdmissionPolicy { + /** The recipients an untargeted event reaches. */ + untargetedRecipients(): string[]; + /** The hold in force over this channel, if any. */ + holdFor(serverId: string, channelId: string): HoldRecord | null; + /** + * The wake gate. `evaluate` may have side effects (debounce batching, + * counters, traces) and is called only for an event that may reach the + * recipients; `probe` is side-effect-free and answers "would the gate have + * woken them" for a held event, which the hold's own policy needs. + */ + gate?: { + evaluate(env: InboundEnvelope): boolean; + probe(env: InboundEnvelope): boolean; + }; +} + +export interface AdmissionDecision { + recipients: string[]; + /** Null = delivered into the recipients' view; otherwise held, with the record to stamp. */ + held: HoldRecord | null; + /** Whether the recipients may be woken by this event. */ + wake: boolean; + /** + * The gate's verdict as a precondition for whatever reads the hold: true + * when the gate would (or did) wake the recipients. For a held event this + * comes from the side-effect-free probe; for a vetoed event it is false. + */ + gatePass: boolean; + /** Whether this arrival may count as the latest delivered inbound message. */ + locus: boolean; +} + +export function admit(env: InboundEnvelope, policy: AdmissionPolicy): AdmissionDecision { + const recipients = env.targetAgents ?? policy.untargetedRecipients(); + const held = env.channelId ? policy.holdFor(env.serverId, env.channelId) : null; + + if (held) { + // Held: nothing downstream may act for the recipients. The gate is only + // asked what it would have done — no batch forms, no counter moves. + const gatePass = env.vetoed ? false : (policy.gate ? policy.gate.probe(env) : true); + return { recipients, held, wake: false, gatePass, locus: false }; + } + + if (env.vetoed) { + return { recipients, held: null, wake: false, gatePass: false, locus: true }; + } + + // Deliverable: now the gate may take its side effects (a debounce batch + // is the gate's own deferred wake and is consumed later by the same rule). + const gatePass = policy.gate ? policy.gate.evaluate(env) : true; + return { recipients, held: null, wake: gatePass, gatePass, locus: true }; +} diff --git a/src/agent.ts b/src/agent.ts index 08b1304a1..10a9d3f94 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -321,6 +321,11 @@ export class Agent { /** Provider-specific request parameters forwarded unchanged by Membrane. */ readonly providerParams?: Record; readonly sameRoundThinkTextPolicy: AgentConfig['sameRoundThinkTextPolicy']; + /** The participant name this agent's turns are presented under (see AgentConfig.presentAs). */ + readonly presentAs: string; + /** Stored participants presented as this agent's own turns: its name plus AgentConfig.selfParticipants. */ + private readonly ownParticipants: ReadonlySet; + private readonly deniedAtDispatch: ReadonlySet; private _state: AgentState = { status: 'idle' }; private _inferenceStartedAt = 0; @@ -450,6 +455,9 @@ export class Agent { this.prefillUserMessage = config.prefillUserMessage; this.providerParams = config.providerParams; this.sameRoundThinkTextPolicy = config.sameRoundThinkTextPolicy; + this.presentAs = config.presentAs ?? config.name; + this.ownParticipants = new Set([config.name, ...(config.selfParticipants ?? [])]); + this.deniedAtDispatch = new Set(config.denyToolsAtDispatch ?? []); this.maxStreamTokens = config.maxStreamTokens ?? 150_000; // The window size converted as the projection's comparison (`projected > window`) converts it, so a string such // as "200000" from a JSON or YAML config is its number, for every rule that reads it. A value whose number isn't @@ -509,6 +517,36 @@ export class Agent { return alias !== undefined && this.allowedTools.includes(alias); } + /** + * Whether a call to an advertised tool is refused at dispatch + * (see AgentConfig.denyToolsAtDispatch). + */ + deniesAtDispatch(toolName: string): boolean { + return this.deniedAtDispatch.has(toolName); + } + + /** Whether a stored participant is this agent's own voice. */ + isOwnParticipant(participant: string): boolean { + return this.ownParticipants.has(participant); + } + + /** + * Role assignment for a request: every participant that is this agent's + * own voice — its name, and for a derived agent the agent it inherited + * from — is presented under one name, which the formatter maps to the + * assistant role. Stored authorship is untouched; this shapes the request + * only. For a plain agent (no selfParticipants, presentAs = name) the + * input is returned as is. + */ + private presentOwnTurns(messages: NormalizedMessage[]): NormalizedMessage[] { + if (this.ownParticipants.size === 1 && this.presentAs === this.name) return messages; + return messages.map((m) => + this.ownParticipants.has(m.participant) && m.participant !== this.presentAs + ? { ...m, participant: this.presentAs } + : m, + ); + } + /** * Check if a source can trigger inference for this agent. */ @@ -1643,8 +1681,9 @@ export class Agent { // What it holds of the stored injections is what the agent last saw. this.noteRenderedInjections(compiled); const { systemInjections } = compiled; - // The framework's own pricing stamp never goes out (thinking-token-stamp.ts). - const messages = withoutTokenEstimates(compiled.messages); + // The framework's own pricing stamp never goes out (thinking-token-stamp.ts); + // the agent's own turns are then presented under its name (presentOwnTurns). + const messages = this.presentOwnTurns(withoutTokenEstimates(compiled.messages)); // Read once, after the first compile, since results may have become // ready while it ran; the refusal retry, assembled while the agent is // inferring, sends the same ones. @@ -1656,6 +1695,7 @@ export class Agent { system: this.buildSystemPrompt(systemInjections), tools: tools.length > 0 ? tools : undefined, ...this.requestSettings(), + assistantParticipant: this.presentAs, }; }; const request = await assemble(); @@ -1891,6 +1931,8 @@ export class Agent { // The framework's own pricing stamp never goes out (thinking-token-stamp.ts). messages = withoutTokenEstimates(messages); + messages = this.presentOwnTurns(messages); + // Silent heartbeat ticks and replies to a [Continue] turn store no // prompt row. Render each request-only turn before its response's first // surviving row, so the wire formatter cannot merge the response into the @@ -1901,7 +1943,7 @@ export class Agent { // renders. "First" is per agent: a broadcast tick shares its eventId with // every resident. const index = this.indexRequestOnlyRows(); - const rendered = renderRequestOnlyRows(messages, index, this.name); + const rendered = renderRequestOnlyRows(messages, index, this.presentAs); messages = rendered.messages; // A tick opens wherever its separator isn't already rendered: on its first // request, and on a restart whose compile left none of the tick's rows, @@ -1920,7 +1962,7 @@ export class Agent { let continuePrompt: RequestOnlyPrompt | undefined; if (openingTick) { messages = [...messages, silentHeartbeatSeparatorTurn()]; - } else if (endsOnOwnMessage(messages, this.name, injectionTail)) { + } else if (endsOnOwnMessage(messages, this.presentAs, injectionTail)) { // Safety: ensure messages don't end with an assistant message. // Some models reject trailing assistant messages ("prefill not supported"), // and after context compression a stale assistant turn can end up last. @@ -1970,7 +2012,7 @@ export class Agent { cacheTtl: this.cacheTtl, ...(this.prefillUserMessage && { prefillUserMessage: this.prefillUserMessage }), ...(this.providerParams && { providerParams: this.providerParams }), - assistantParticipant: this.name, + assistantParticipant: this.presentAs, }; } @@ -2063,7 +2105,7 @@ export class Agent { try { const stored = cm.getAllMessages() as unknown as readonly StoredRowLike[]; if (this.requestOnlyIndex?.source === stored) return this.requestOnlyIndex.index; - const index = indexRequestOnlyRows(stored, this.name); + const index = indexRequestOnlyRows(stored, this.ownParticipants); this.requestOnlyIndex = { source: stored, index }; return index; } catch (error) { diff --git a/src/author-presentation.ts b/src/author-presentation.ts new file mode 100644 index 000000000..8c5dfbfac --- /dev/null +++ b/src/author-presentation.ts @@ -0,0 +1,81 @@ +/** + * The author of a channel message, presented to the model. + * + * MCPL `channels/incoming` carries the author as a field, `author: {id, + * name}`, and the body alone as content (SPEC §14); the framework stores + * the message under the anonymous participant `user` with the author in + * `metadata.author`. The wire formatter writes the participant, so without + * this step the model reads `user: ` and who spoke is lost. Servers + * that pre-render `Name: body` into the text themselves (portal-mcpl) are + * left as they are: the rule is idempotent, so the name appears once + * whichever shape a server sends. + * + * Applied where the framework stores a `channels/incoming` message — the + * compiled view carries no metadata, so the name has to be in the stored + * text to reach the wire — and to a tune-out backlog line, which reads the + * metadata and must not name an author twice. The author stays in + * `metadata.author` as the structured record. + */ +import type { ContentBlock, MessageMetadata } from '@animalabs/membrane'; +import { suppliedValue } from './supplied-text.js'; + +/** The participant the framework stores channel traffic under: a role, not a name. */ +export const ANONYMOUS_PARTICIPANT = 'user'; + +type TextBlock = Extract; + +/** The author a stored message names, when it names one. */ +export function authorNamed(metadata: Record | MessageMetadata | undefined): string | undefined { + const name = (metadata?.author as { name?: unknown } | undefined)?.name; + return typeof name === 'string' && name.length > 0 ? name : undefined; +} + +/** Whether `text` already begins with the author's own line: `Name:` then a space or the end. */ +export function beginsWithAuthor(author: string, text: string): boolean { + if (!text.startsWith(author) || text.charAt(author.length) !== ':') return false; + const after = text.charAt(author.length + 1); + return after === '' || after === ' ' || after === '\n'; +} + +/** + * The author's name as a line of the framework's writes it: a value someone + * supplied, kept to one line with its notice openings marked (main's #852, + * suppliedValue), so a name can't open a line that reads as the framework's + * own notice. + */ +export function authorShown(author: string): string { + return suppliedValue(author); +} + +/** Whether `text` begins with the author's line, as the author wrote their + * name or as the framework shows it (authorShown). */ +export function beginsWithAuthorLine(author: string, text: string): boolean { + return beginsWithAuthor(author, text) || beginsWithAuthor(authorShown(author), text); +} + +/** `author: text`, unless `text` begins that way already. */ +export function authorLine(author: string, text: string): string { + return beginsWithAuthorLine(author, text) ? text : `${authorShown(author)}: ${text}`; +} + +/** + * A message stored under the anonymous participant that names an author is + * presented with the author before its first text block — or, with no text + * block, as a leading `Name:` line before its media. Any other message, and + * one whose text already begins with the author's line, is returned as is. + * Never mutates its input. + */ +export function presentAuthor }>(message: M): M { + if (message.participant !== ANONYMOUS_PARTICIPANT) return message; + const author = authorNamed(message.metadata); + if (author === undefined) return message; + const index = message.content.findIndex((b) => b.type === 'text'); + if (index === -1) { + return { ...message, content: [{ type: 'text', text: `${authorShown(author)}:` } as TextBlock, ...message.content] }; + } + const block = message.content[index] as TextBlock; + if (typeof block.text !== 'string' || beginsWithAuthorLine(author, block.text)) return message; + const content = [...message.content]; + content[index] = { ...block, text: `${authorShown(author)}: ${block.text}` }; + return { ...message, content }; +} diff --git a/src/dendrite/index.ts b/src/dendrite/index.ts new file mode 100644 index 000000000..7fd4898e8 --- /dev/null +++ b/src/dendrite/index.ts @@ -0,0 +1,32 @@ +export { AgentRegistry, AgentSpecError } from './registry.js'; +export type { AgentRegistryOptions, RestoreOutcome, ReconcileOutcome } from './registry.js'; +export { + residentSpec, + subconsciousSpec, + conversationForkSpec, + workerSpec, + taskForkSpec, + DEFAULT_TASK_IDLE_TIMEOUT_MS, +} from './presets.js'; +export type { BoundedJobOptions } from './presets.js'; +export type { + AgentName, + AgentKind, + AgentRoles, + AgentLifetime, + ActivationBounds, + ParentEndPolicy, + ResultRoute, + ObserveEdge, + AgentRelationships, + ContextInheritance, + AgentSpec, + AgentEndReason, + AgentRef, + ActivationRecord, + AgentRecord, + HeldMail, + RegistrySnapshot, + EndOutcome, + PolicyEpoch, +} from './types.js'; diff --git a/src/dendrite/presets.ts b/src/dendrite/presets.ts new file mode 100644 index 000000000..974e8ba65 --- /dev/null +++ b/src/dendrite/presets.ts @@ -0,0 +1,149 @@ +/** + * Presets — the historical agent kinds expressed as {@link AgentSpec} + * values. They are configurations of the common machinery, not engines: + * everything a preset decides is visible in the spec it returns, and a host + * may register a spec that matches none of them. + */ + +import type { AgentName, AgentSpec, ContextInheritance, ResultRoute } from './types.js'; + +/** Default idle bound for a bounded job with no explicit deadline (matches the run watchdog). */ +export const DEFAULT_TASK_IDLE_TIMEOUT_MS = 15 * 60_000; + +/** + * A resident: persistent, declared in configuration, reads the residents' + * shared message slot. The first one declared owns default delivery. + */ +export function residentSpec(name: AgentName, options: { primary: boolean }): AgentSpec { + return { + name, + kind: 'resident', + roles: { + defaultDelivery: options.primary, + receivesUntargeted: true, + receivesGateWakes: true, + ownsProviderScheduling: true, + }, + lifetime: { kind: 'persistent' }, + readsSharedSlot: true, + namespace: `agents/${name}`, + }; +} + +/** + * The subconscious: a persistent attention tenant of the resident it + * serves. It persists across policy epochs (cancelling a tune-out ends a + * channel's cadence, not this agent), observes the residents' slot with + * held traffic included, is never a broadcast recipient, and ends with its + * resident. + */ +export function subconsciousSpec(name: AgentName, serves: AgentName): AgentSpec { + return { + name, + kind: 'subconscious', + roles: { + defaultDelivery: false, + receivesUntargeted: false, + receivesGateWakes: false, + ownsProviderScheduling: true, + }, + lifetime: { kind: 'persistent' }, + spawnedBy: serves, + onParentEnd: 'end', + observes: [{ includeHeld: true }], + resultTo: { to: serves, as: 'message' }, + readsSharedSlot: true, + namespace: `subconscious/${serves}`, + }; +} + +/** + * A conversation fork: derived from a template agent's context at bind + * time, bound to one channel, independent continuation, ends on idle. + */ +export function conversationForkSpec( + name: AgentName, + options: { + template: AgentName; + /** The channel it's bound to, on its server. */ + homeChannel: { serverId: string; channelId: string }; + idleTtlMs: number; + inherit?: Partial; + }, +): AgentSpec { + return { + name, + kind: 'conversation-fork', + roles: { + defaultDelivery: false, + receivesUntargeted: false, + // Woken by its own channel's debounced traffic through the host-wide gate. + receivesGateWakes: true, + ownsProviderScheduling: false, + }, + lifetime: { kind: 'idle', idleTtlMs: options.idleTtlMs }, + spawnedBy: options.template, + onParentEnd: 'orphan', + inherit: { from: options.template, mode: 'copy', ...options.inherit }, + // A copy renames the template's turns as it copies them. A shared + // inheritance has no copy step, so the mapping is declared instead. + ...(options.inherit?.mode === 'shared' ? { selfParticipants: [options.template] } : {}), + homeChannel: { serverId: options.homeChannel.serverId, channelId: options.homeChannel.channelId }, + namespace: `conversations/${name}`, + }; +} + +export interface BoundedJobOptions { + /** Who created it. Absent for an independent job driven by the host. */ + spawnedBy?: AgentName; + /** Where its completion returns. */ + resultTo?: ResultRoute; + deadlineMs?: number; + idleTimeoutMs?: number; + maxTurns?: number; + namespace?: string; + metadata?: Record; +} + +function boundedJob(name: AgentName, options: BoundedJobOptions): Omit { + return { + name, + roles: { + defaultDelivery: false, + receivesUntargeted: false, + receivesGateWakes: false, + ownsProviderScheduling: false, + }, + lifetime: { + kind: 'task', + ...(options.deadlineMs !== undefined ? { deadlineMs: options.deadlineMs } : {}), + idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_TASK_IDLE_TIMEOUT_MS, + ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}), + }, + ...(options.spawnedBy ? { spawnedBy: options.spawnedBy, onParentEnd: 'orphan' as const } : {}), + ...(options.resultTo ? { resultTo: options.resultTo } : {}), + namespace: options.namespace ?? `subagent/${name}`, + ...(options.metadata ? { metadata: options.metadata } : {}), + }; +} + +/** A fresh worker: new identity and context, one bounded job, a result route. */ +export function workerSpec(name: AgentName, options: BoundedJobOptions = {}): AgentSpec { + return { ...boundedJob(name, options), kind: 'worker' }; +} + +/** + * A task fork: the parent's context at a checkpoint, a private + * continuation, one bounded job, a return. + */ +export function taskForkSpec( + name: AgentName, + options: BoundedJobOptions & { parent: AgentName; inherit?: Partial }, +): AgentSpec { + return { + ...boundedJob(name, { ...options, spawnedBy: options.spawnedBy ?? options.parent }), + kind: 'task-fork', + inherit: { from: options.parent, mode: 'copy', ...options.inherit }, + ...(options.inherit?.mode === 'shared' ? { selfParticipants: [options.parent] } : {}), + }; +} diff --git a/src/dendrite/registry.ts b/src/dendrite/registry.ts new file mode 100644 index 000000000..0e7198ff9 --- /dev/null +++ b/src/dendrite/registry.ts @@ -0,0 +1,808 @@ +/** + * AgentRegistry — the one place that knows which agents exist, what they + * are for, how they relate and what ends them. + * + * Pure bookkeeping: it never touches a stream, a context manager or a + * channel. Lifecycle methods return what changed (who ended, who was + * orphaned, which mail became deliverable) and the framework performs the + * side effects. That split keeps every rule here testable without a store + * and keeps the framework from re-deriving policy at each call site. + */ + +import { randomUUID } from 'node:crypto'; +import type { + AgentEndReason, + AgentName, + AgentRecord, + AgentRef, + AgentSpec, + EndOutcome, + HeldMail, + RegistrySnapshot, +} from './types.js'; + +/** Thrown for a spec the registry refuses to load. */ +export class AgentSpecError extends Error { + constructor(agent: string, problem: string) { + super(`agent "${agent}": ${problem}`); + this.name = 'AgentSpecError'; + } +} + +export interface AgentRegistryOptions { + now?: () => number; + /** Called after every durable mutation with the state to persist. */ + persist?: (snapshot: RegistrySnapshot) => void; + /** + * Called once per ended record. Ended records are append-only history, + * kept out of the snapshot so a mutation never rewrites them. + */ + appendEnded?: (record: AgentRecord) => void; + /** How many ended records stay inspectable in memory (oldest dropped first). */ + endedRetention?: number; + /** + * Told when a write that records something that has already happened — a + * release, a held mark, a message path — fails. The change stays in memory + * and the next write carries it; the call that made it is not failed, since + * what it records is done (nissa's #546 review, round 2). Absent, the + * failure is logged. + */ + onRecordWriteFailed?: (what: string, error: unknown) => void; + /** Told after a change that failed was put back in memory (a requested change, or a hold): the host rewrites what it wrote alongside to match. */ + onRollback?: () => void; + /** + * Asked for an ended record no longer in memory (past `endedRetention`): + * the host reads its durable ended log, so an agent that ended long ago + * can still be inspected, resumed and named. + */ + findEnded?: (name: AgentName) => AgentRecord | undefined; +} + +/** What a restore found that the previous process left unfinished. */ +export interface RestoreOutcome { + /** Non-persistent agents that were live when the previous process stopped. */ + interrupted: AgentRecord[]; +} + +/** What boot reconciliation concluded once configuration was applied. */ +export interface ReconcileOutcome { + /** Persistent agents the previous process ran that this configuration no longer declares. */ + unconfigured: EndOutcome[]; +} + +const DEFAULT_ENDED_RETENTION = 256; + +function clone(value: T): T { + return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); +} + +export class AgentRegistry { + private readonly live = new Map(); + /** Ended records, oldest first. */ + private ended: AgentRecord[] = []; + /** Persistent records from the previous process, not yet re-declared. */ + private readonly awaiting = new Map(); + /** + * Bounded work the previous process left live, ended by this restart in + * memory but kept in the snapshot as it was until the host has told each + * recipient (settleInterrupted), so a crash before then owes the same + * notices at the next boot rather than none. + */ + private readonly interruptedPending = new Map(); + private readonly incarnations = new Map(); + private mail: HeldMail[] = []; + private readonly now: () => number; + private readonly persistFn?: (snapshot: RegistrySnapshot) => void; + private readonly appendEndedFn?: (record: AgentRecord) => void; + private readonly endedRetention: number; + private readonly onRecordWriteFailed: (what: string, error: unknown) => void; + private readonly onRollback?: () => void; + private readonly findEnded?: (name: AgentName) => AgentRecord | undefined; + /** Above zero while a record of what happened is being written: its writes alert rather than throw. */ + private recordingDepth = 0; + + constructor(options: AgentRegistryOptions = {}) { + this.now = options.now ?? Date.now; + this.persistFn = options.persist; + this.appendEndedFn = options.appendEnded; + this.onRollback = options.onRollback; + this.findEnded = options.findEnded; + this.onRecordWriteFailed = options.onRecordWriteFailed + ?? ((what, error) => console.error(`[dendrite] the registry could not record ${what}; it stays in memory and the next write carries it:`, error)); + this.endedRetention = options.endedRetention ?? DEFAULT_ENDED_RETENTION; + } + + // ========================================================================== + // Registration + // ========================================================================== + + /** + * Admit an agent. Refuses a spec that breaks a registry invariant — an + * unbounded task, a second default-delivery owner, a relationship that + * names nobody — so a malformed agent fails at load, not mid-run. + * + * `resume` admits a new incarnation of an identity that ended: where its + * context came from is then history, so the context source no longer has + * to be live. Who it reports to still does. A spawner that has ended, and + * a result route to it, are admitted: the agent registers as that + * spawner's orphan, as if it had been live when the spawner ended. + * `after` is the incarnation a resumption continues from: the new one is + * past it, since the counter of a name that aged out of memory isn't kept. + */ + register(spec: AgentSpec, options: { resume?: boolean; after?: number } = {}): AgentRecord { + return this.requested(() => this.registerUnguarded(spec, options)); + } + + private registerUnguarded(spec: AgentSpec, options: { resume?: boolean; after?: number } = {}): AgentRecord { + this.validate(spec, options.resume === true); + const previous = this.awaiting.get(spec.name); + this.awaiting.delete(spec.name); + + const incarnation = Math.max(this.incarnations.get(spec.name) ?? 0, options.after ?? 0) + 1; + this.incarnations.set(spec.name, incarnation); + + const record: AgentRecord = { + name: spec.name, + kind: spec.kind, + ...(spec.model ? { model: spec.model } : {}), + incarnation, + roles: { ...spec.roles }, + lifetime: { ...spec.lifetime }, + ...(spec.activation ? { activation: { ...spec.activation } } : {}), + onParentEnd: spec.onParentEnd ?? 'orphan', + relationships: { + ...(spec.spawnedBy ? { spawnedBy: spec.spawnedBy } : {}), + observes: (spec.observes ?? []).map((edge) => ({ ...edge })), + // Explicit message paths accumulate over an identity's life. + messagePeers: previous ? [...previous.relationships.messagePeers] : [], + ...(spec.resultTo ? { resultTo: { ...spec.resultTo } } : {}), + }, + ...(spec.inherit ? { inherit: clone(spec.inherit) } : {}), + ...(spec.selfParticipants?.length ? { selfParticipants: [...spec.selfParticipants] } : {}), + ...(spec.presentAs ? { presentAs: spec.presentAs } : {}), + ...(spec.homeChannel ? { homeChannel: { serverId: spec.homeChannel.serverId, channelId: spec.homeChannel.channelId } } : {}), + readsSharedSlot: spec.readsSharedSlot ?? false, + ...(spec.namespace ? { namespace: spec.namespace } : {}), + ...(spec.metadata ? { metadata: clone(spec.metadata) } : {}), + createdAt: previous?.createdAt ?? this.now(), + }; + const spawner = record.relationships.spawnedBy; + if (spawner !== undefined && !this.live.has(spawner)) { + const former = this.inspect(spawner)!; + const above = former.relationships.spawnedBy; + const candidates = above !== undefined && this.live.has(above) ? [above, ...this.ancestors(above)] : []; + const primary = this.primary(); + if (primary && primary !== record.name && !candidates.includes(primary)) candidates.push(primary); + record.orphaned = { + since: this.now(), + formerParent: { agent: former.name, incarnation: former.incarnation }, + reason: former.ended?.reason ?? 'stopped', + candidates, + }; + } + this.live.set(spec.name, record); + this.persist(); + return record; + } + + private validate(spec: AgentSpec, resume = false): void { + const fail = (problem: string): never => { + throw new AgentSpecError(spec.name || '(unnamed)', problem); + }; + if (!spec.name) fail('a name is required'); + if (this.live.has(spec.name)) fail('is already registered'); + + if (spec.roles.defaultDelivery) { + const owner = this.primary(); + if (owner) fail(`default delivery already belongs to "${owner}"; exactly one agent owns it`); + } + + const lifetime = spec.lifetime; + if (lifetime.kind === 'task') { + const bounded = + (lifetime.deadlineMs !== undefined && lifetime.deadlineMs > 0) || + (lifetime.idleTimeoutMs !== undefined && lifetime.idleTimeoutMs > 0); + if (!bounded) fail('a task lifetime needs a deadline or an idle timeout; every task agent must end'); + } else if (lifetime.kind === 'idle') { + if (!(lifetime.idleTtlMs > 0)) fail('an idle lifetime needs a positive idleTtlMs'); + } + + // A spawner that ended before its child registered (between creating the + // child and running it): the child is its orphan from the start, as one + // live when the spawner ended would be, with its route to the spawner + // held; one that ends with its parent would have ended with it. + const endedSpawner = spec.spawnedBy !== undefined && !this.live.has(spec.spawnedBy) + ? this.inspect(spec.spawnedBy) + : undefined; + if (spec.spawnedBy !== undefined) { + if (spec.spawnedBy === spec.name) fail('cannot be spawned by itself'); + if (!this.live.has(spec.spawnedBy)) { + if (!endedSpawner?.ended) fail(`spawner "${spec.spawnedBy}" is not a registered agent`); + if (spec.onParentEnd === 'end') { + fail(`spawner "${spec.spawnedBy}" has ended, and an agent that ends with its parent would have ended with it`); + } + } + } else if (spec.onParentEnd === 'end') { + fail('onParentEnd "end" needs a spawnedBy relationship'); + } + if (spec.resultTo && !this.live.has(spec.resultTo.to) + && !(endedSpawner?.ended && spec.resultTo.to === spec.spawnedBy)) { + fail(`result recipient "${spec.resultTo.to}" is not a registered agent`); + } + if (spec.inherit && !resume && !this.live.has(spec.inherit.from)) { + fail(`context source "${spec.inherit.from}" is not a registered agent`); + } + for (const edge of spec.observes ?? []) { + if (edge.agent !== undefined && !this.live.has(edge.agent)) { + fail(`observed agent "${edge.agent}" is not a registered agent`); + } + } + } + + // ========================================================================== + // Discovery + // ========================================================================== + + /** A live agent's record. */ + get(name: AgentName): AgentRecord | undefined { + return this.live.get(name); + } + + has(name: AgentName): boolean { + return this.live.has(name); + } + + /** The live record, else the most recent ended one with that name, from memory or the host's ended log. */ + inspect(name: AgentName): AgentRecord | undefined { + const live = this.live.get(name); + if (live) return live; + for (let i = this.ended.length - 1; i >= 0; i--) { + if (this.ended[i]!.name === name) return this.ended[i]; + } + // An interrupted record trimmed from memory before its end is written: + // the newest incarnation, where two of one name are pending. + let newest: AgentRecord | undefined; + for (const { ended } of this.interruptedPending.values()) { + if (ended.name === name && (!newest || ended.incarnation > newest.incarnation)) newest = ended; + } + return newest ?? this.findEnded?.(name); + } + + /** Flat list of agents — the discovery surface. Ended ones on request. */ + list(options: { includeEnded?: boolean } = {}): AgentRecord[] { + const live = [...this.live.values()]; + return options.includeEnded ? [...this.ended, ...live] : live; + } + + /** + * An agent other than `except` whose own context a namespace holds, among + * the records in memory: live, awaiting re-declaration, or ended. A shared + * fork's record names its parent's namespace, where it owns only a branch, + * so it holds none. + * @internal For the framework's namespace check in + * `createEphemeralAgent()`; not a host API. + */ + namespaceHolder(namespace: string, except: AgentName): AgentName | undefined { + const pending = [...this.interruptedPending.values()].map((entry) => entry.asWas); + for (const record of [...this.live.values(), ...this.awaiting.values(), ...pending, ...this.ended]) { + if (record.name !== except && record.namespace === namespace && record.inherit?.mode !== 'shared') return record.name; + } + return undefined; + } + + /** Provenance stamp for a live agent (or its last incarnation). */ + ref(name: AgentName): AgentRef { + const record = this.inspect(name); + return { agent: name, incarnation: record?.incarnation ?? this.incarnations.get(name) ?? 0 }; + } + + // ========================================================================== + // Role queries — the positive replacements for exclusion filters + // ========================================================================== + + /** The agent that owns default delivery, if any. */ + primary(): AgentName | null { + for (const record of this.live.values()) { + if (record.roles.defaultDelivery) return record.name; + } + return null; + } + + /** Recipients of untargeted inbound, in registration order. */ + untargetedRecipients(): AgentName[] { + return this.namesWhere((r) => r.roles.receivesUntargeted); + } + + /** Recipients of host-wide gate fan-out, in registration order. */ + gateRecipients(): AgentName[] { + return this.namesWhere((r) => r.roles.receivesGateWakes); + } + + ownsProviderScheduling(name: AgentName): boolean { + return this.live.get(name)?.roles.ownsProviderScheduling === true; + } + + /** Agents reading the residents' shared message slot. */ + sharedSlotReaders(): AgentName[] { + return this.namesWhere((r) => r.readsSharedSlot); + } + + readsSharedSlot(name: AgentName): boolean { + return this.live.get(name)?.readsSharedSlot === true; + } + + /** The channel an agent is bound to, if it has one. */ + homeChannel(name: AgentName): AgentRecord['homeChannel'] { + return this.live.get(name)?.homeChannel; + } + + /** Live agents of one preset label. Discovery only — see {@link AgentKind}. */ + ofKind(kind: string): AgentRecord[] { + return [...this.live.values()].filter((r) => r.kind === kind); + } + + private namesWhere(predicate: (record: AgentRecord) => boolean): AgentName[] { + const names: AgentName[] = []; + for (const record of this.live.values()) if (predicate(record)) names.push(record.name); + return names; + } + + // ========================================================================== + // Relationships + // ========================================================================== + + /** Live agents this one spawned. */ + children(name: AgentName): AgentRecord[] { + return [...this.live.values()].filter((r) => r.relationships.spawnedBy === name && !r.orphaned); + } + + /** Live spawn ancestors, nearest first. */ + ancestors(name: AgentName): AgentName[] { + const chain: AgentName[] = []; + const seen = new Set([name]); + let cursor = this.live.get(name)?.relationships.spawnedBy; + while (cursor && !seen.has(cursor) && this.live.has(cursor)) { + chain.push(cursor); + seen.add(cursor); + cursor = this.live.get(cursor)?.relationships.spawnedBy; + } + return chain; + } + + /** Live agents whose completed work returns to `name`. */ + resultSources(name: AgentName): AgentRecord[] { + return [...this.live.values()].filter((r) => r.relationships.resultTo?.to === name); + } + + /** Record that `from` used an explicit message path to `to`. */ + noteMessagePath(from: AgentName, to: AgentName): void { + const record = this.live.get(from); + if (!record || record.relationships.messagePeers.includes(to)) return; + record.relationships.messagePeers.push(to); + this.persistRecord(`the message path ${from} -> ${to}`); + } + + // ========================================================================== + // Lifecycle + // ========================================================================== + + /** + * End an agent identity. Attention tenants it spawned end with it; task + * work it spawned is orphaned, with reparenting candidates. Result routes + * that pointed at it are left in place — delivery holds the mail instead + * of dropping it, so a later adoption can still receive the result. + */ + /** + * End an agent. A stop a caller asks for is a requested change: a write that + * fails puts memory back and fails the call. A run's own end or an idle + * reap (`record: true`) records what has already happened: a write that + * fails alerts, and the next write carries it (nissa's #546 review, round 3). + */ + end(name: AgentName, reason: AgentEndReason, by?: string, options: { record?: boolean } = {}): EndOutcome | null { + return options.record + ? this.recording(() => this.endUnguarded(name, reason, by)) + : this.requested(() => this.endUnguarded(name, reason, by)); + } + + private endUnguarded(name: AgentName, reason: AgentEndReason, by?: string): EndOutcome | null { + const record = this.live.get(name); + if (!record) return null; + const outcome = this.endRecursive(record, reason, by); + this.persist(); + return outcome; + } + + private endRecursive(record: AgentRecord, reason: AgentEndReason, by?: string): EndOutcome { + // Candidates are computed before removal so the chain is still walkable. + const adopters = this.adoptionCandidates(record); + + this.live.delete(record.name); + this.recordEnded(record, reason, by); + + const cascaded: AgentRecord[] = []; + const orphaned: EndOutcome['orphaned'] = []; + const formerParent: AgentRef = { agent: record.name, incarnation: record.incarnation }; + for (const child of [...this.live.values()]) { + if (child.relationships.spawnedBy !== record.name || child.orphaned) continue; + if (!this.live.has(child.name)) continue; // ended by an earlier cascade + if (child.onParentEnd === 'end') { + const inner = this.endRecursive(child, 'parent-ended', record.name); + cascaded.push(...inner.cascaded, inner.ended); + orphaned.push(...inner.orphaned); + } else { + const candidates = adopters.filter((candidate) => candidate !== child.name); + child.orphaned = { since: this.now(), formerParent, reason, candidates }; + orphaned.push({ record: child, candidates }); + } + } + return { ended: record, cascaded, orphaned }; + } + + /** Who could take over an ending agent's children: nearest live ancestor, then the primary. */ + private adoptionCandidates(record: AgentRecord): AgentName[] { + const candidates = this.ancestors(record.name); + const primary = this.primary(); + if (primary && primary !== record.name && !candidates.includes(primary)) candidates.push(primary); + return candidates; + } + + /** + * Give an agent a new spawner. Clears orphan state. A result route whose + * recipient is gone follows the new parent, and mail the agent already + * produced for the lost recipient is re-addressed and returned so the + * caller can deliver it. + */ + reparent(name: AgentName, newParent: AgentName): { record: AgentRecord; deliverable: HeldMail[] } { + return this.requested(() => this.reparentUnguarded(name, newParent)); + } + + private reparentUnguarded(name: AgentName, newParent: AgentName): { record: AgentRecord; deliverable: HeldMail[] } { + const record = this.live.get(name); + if (!record) throw new AgentSpecError(name, 'is not a registered agent'); + if (!this.live.has(newParent)) throw new AgentSpecError(name, `new parent "${newParent}" is not a registered agent`); + if (newParent === name) throw new AgentSpecError(name, 'cannot be its own parent'); + if (this.ancestors(newParent).includes(name)) { + throw new AgentSpecError(name, `"${newParent}" descends from it; reparenting would form a cycle`); + } + record.relationships.spawnedBy = newParent; + delete record.orphaned; + + const route = record.relationships.resultTo; + if (route && !this.live.has(route.to)) route.to = newParent; + + const deliverable: HeldMail[] = []; + for (const item of this.mail) { + if (item.from.agent === name && !this.live.has(item.to)) { + item.to = newParent; + item.heldBecause = 'undelivered'; + deliverable.push(item); + } + } + this.persist(); + return { record, deliverable }; + } + + /** + * An ended agent's held mail finds a new recipient: every item it produced + * for a recipient that is gone (a result or a message, as `reparent` + * moves for a live agent) is re-addressed to `newParent` and returned for + * delivery. Its record stays as it ended, since history isn't reparented. + * @internal For the framework's `reparentAgent()` of an ended agent; not a host API. + */ + readdressHeldMail(name: AgentName, newParent: AgentName): { record: AgentRecord; deliverable: HeldMail[] } { + return this.requested(() => { + const record = this.inspect(name); + if (!record?.ended) throw new AgentSpecError(name, 'is not an ended agent'); + if (!this.live.has(newParent)) throw new AgentSpecError(name, `new parent "${newParent}" is not a registered agent`); + const deliverable: HeldMail[] = []; + for (const item of this.mail) { + if (item.from.agent === name && !this.live.has(item.to)) { + item.to = newParent; + item.heldBecause = 'undelivered'; + deliverable.push(item); + } + } + if (deliverable.length > 0) this.persist(); + return { record, deliverable }; + }); + } + + // ========================================================================== + // Held mail + // ========================================================================== + + /** Record a communication durably BEFORE attempting delivery. */ + holdMail(mail: Omit & { id?: string }): HeldMail { + const item: HeldMail = { + ...mail, + id: mail.id ?? randomUUID(), + createdAt: this.now(), + content: clone(mail.content), + }; + this.mail.push(item); + // A hold that isn't durable isn't held: the persist's failure fails this + // call, and the item leaves memory and every copy written before the + // failure too, so a retry holds it once. + try { + this.persist(); + } catch (error) { + this.mail.pop(); + this.putBack(); + throw error; + } + return item; + } + + /** Delivery confirmed: forget the held copy. */ + releaseMail(id: string): void { + const before = this.mail.length; + this.mail = this.mail.filter((item) => item.id !== id); + if (this.mail.length !== before) this.persistRecord(`the release of mail ${id}`); + } + + /** Mark why an item is still held (delivery attempted and not possible yet). */ + markMailHeld(id: string, because: NonNullable): void { + const item = this.mail.find((candidate) => candidate.id === id); + if (!item || item.heldBecause === because) return; + item.heldBecause = because; + this.persistRecord(`why mail ${id} is held (${because})`); + } + + listMail(filter: { to?: AgentName; from?: AgentName } = {}): HeldMail[] { + return this.mail.filter( + (item) => + (filter.to === undefined || item.to === filter.to) && + (filter.from === undefined || item.from.agent === filter.from), + ); + } + + // ========================================================================== + // Persistence and restart + // ========================================================================== + + snapshot(): RegistrySnapshot { + const pending = [...this.interruptedPending.values()].map((entry) => entry.asWas); + const records = [...this.awaiting.values(), ...pending, ...this.live.values()].map((record) => clone(record)); + // Counters are kept for identities that can still be referred to: live + // agents, persistent agents awaiting re-declaration, and ended agents + // still in retention. A single-use name that has aged out restarts at 1. + const referable = new Set([ + ...this.live.keys(), + ...this.awaiting.keys(), + ...pending.map((record) => record.name), + ...this.ended.map((record) => record.name), + ]); + const incarnations: Record = {}; + for (const [name, value] of this.incarnations) if (referable.has(name)) incarnations[name] = value; + return { version: 1, records, incarnations, mail: clone(this.mail) }; + } + + /** + * Load what the previous process persisted. Restart behaviour follows + * from lifetime: a persistent agent waits to be re-declared by + * configuration (see {@link reconcile}); every other agent that was live + * is ended here as interrupted, in memory, and returned so its result + * recipient can be told. It stays in the snapshot as it was, and its end + * is written, once the host has told the recipients (settleInterrupted). + * Held mail is kept for redelivery. + * + * A bounded record the ended history already holds, by name and + * incarnation, ended before the previous process could write the snapshot + * without it: it is dropped, not ended again. A persistent record waits to + * be re-declared whatever that history says, since the end there may be one + * a stop left that failed and was put back (an append can't be taken + * back); configuration then declares it again or ends it. + * + * `endedHistory` is the tail of the ended-record log, oldest first. + */ + restore(snapshot: RegistrySnapshot | null | undefined, endedHistory: AgentRecord[] = []): RestoreOutcome { + const interrupted: AgentRecord[] = []; + for (const raw of endedHistory.slice(-this.endedRetention)) { + if (raw && typeof raw === 'object' && raw.ended) this.ended.push(clone(raw)); + } + if (!snapshot) return { interrupted }; + if (snapshot.version !== 1) { + throw new Error(`the agent registry reads as version ${JSON.stringify(snapshot.version)}, which this code doesn't read; nothing was restored`); + } + + // Damaged in its content rather than its version: refused the same way, + // since taken as empty it would be overwritten, and without its counters + // every identity it names would start again at incarnation 1. + if (!Array.isArray(snapshot.mail) || !Array.isArray(snapshot.records)) { + throw new Error('the agent registry reads as version 1 but its records or mail are not lists; nothing was restored'); + } + const counters: unknown = snapshot.incarnations; + if (counters === null || typeof counters !== 'object' || Array.isArray(counters)) { + throw new Error('the agent registry reads as version 1 but has no map of incarnations; nothing was restored'); + } + for (const [name, value] of Object.entries(counters)) { + if (typeof value === 'number') this.incarnations.set(name, value); + } + this.mail = clone(snapshot.mail); + + const endedKeys = new Set(this.ended.map((record) => `${record.name}#${record.incarnation}`)); + let dropped = false; + for (const raw of snapshot.records) { + const record = clone(raw); + if (record.ended) continue; // history lives in the ended log + if (record.lifetime.kind === 'persistent') { + this.awaiting.set(record.name, record); + continue; + } + if (endedKeys.has(`${record.name}#${record.incarnation}`)) { + dropped = true; + continue; + } + const asWas = clone(record); + this.pushEnded(record, 'host-restart'); + this.interruptedPending.set(`${record.name}#${record.incarnation}`, { asWas, ended: clone(record) }); + interrupted.push(record); + } + if (dropped) this.persist(); + return { interrupted }; + } + + /** + * The host has told each interrupted job's recipient (restore's + * `interrupted`): their ends are written now, and the snapshot no longer + * lists them. Each pending entry carries its own end, as memory keeps only + * the newest `endedRetention`. An end whose write fails is reported and + * stays pending, so the snapshot still lists it and a later settlement (the + * next boot's) writes it; a failed snapshot write is reported too. + * @internal For the framework's boot, once the interrupted jobs' + * recipients are told; not a host API. + */ + settleInterrupted(): void { + if (this.interruptedPending.size === 0) return; + for (const [key, { ended }] of [...this.interruptedPending]) { + try { + this.appendEndedFn?.(clone(ended)); + } catch (error) { + this.onRecordWriteFailed(`the end of ${ended.name}`, error); + continue; + } + this.interruptedPending.delete(key); + } + this.recording(() => this.persist()); + } + + /** + * Call once configuration has declared its agents. A persistent agent the + * previous process ran but this configuration omits is ended, with the + * usual consequences for what it spawned. + */ + reconcile(): ReconcileOutcome { + const unconfigured: EndOutcome[] = []; + for (const record of [...this.awaiting.values()]) { + this.awaiting.delete(record.name); + this.recordEnded(record, 'not-configured'); + const formerParent: AgentRef = { agent: record.name, incarnation: record.incarnation }; + const orphaned: EndOutcome['orphaned'] = []; + const primary = this.primary(); + for (const child of this.live.values()) { + if (child.relationships.spawnedBy !== record.name || child.orphaned) continue; + const candidates = primary && primary !== child.name ? [primary] : []; + child.orphaned = { since: this.now(), formerParent, reason: 'not-configured', candidates }; + orphaned.push({ record: child, candidates }); + } + unconfigured.push({ ended: record, cascaded: [], orphaned }); + } + if (unconfigured.length > 0) this.persist(); + return { unconfigured }; + } + + private recordEnded(record: AgentRecord, reason: AgentEndReason, by?: string): void { + this.pushEnded(record, reason, by); + this.appendEndedRecord(record); + } + + /** Mark a record ended and keep it in memory, without writing the end. */ + private pushEnded(record: AgentRecord, reason: AgentEndReason, by?: string): void { + record.ended = { at: this.now(), reason, ...(by ? { by } : {}) }; + this.ended.push(record); + if (this.ended.length > this.endedRetention) { + this.ended.splice(0, this.ended.length - this.endedRetention); + } + } + + /** Write an ended record through the host's append hook. */ + private appendEndedRecord(record: AgentRecord): void { + if (!this.appendEndedFn) return; + // A failed append fails the end that made it, as a failed persist does — + // unless the end records a run that has ended, when it alerts instead. + if (this.recordingDepth > 0) { + try { + this.appendEndedFn(clone(record)); + } catch (error) { + this.onRecordWriteFailed(`the end of ${record.name}`, error); + } + return; + } + this.appendEndedFn(clone(record)); + } + + /** + * Run a change a caller asks for — a registration, an end, a reparent, a + * hold — so that a write failing on the way puts memory back as it was: + * the call fails and changes nothing, and a retry does it once (nissa's + * #546 review, round 2: a stop whose write failed left the agent recorded + * as stopped while it kept running). + */ + private requested(change: () => T): T { + const saved = { + live: new Map([...this.live].map(([k, v]) => [k, clone(v)])), + ended: this.ended.map(clone), + awaiting: new Map([...this.awaiting].map(([k, v]) => [k, clone(v)])), + incarnations: new Map(this.incarnations), + mail: this.mail.map(clone), + }; + try { + return change(); + } catch (error) { + this.live.clear(); for (const [k, v] of saved.live) this.live.set(k, v); + this.ended = saved.ended; + this.awaiting.clear(); for (const [k, v] of saved.awaiting) this.awaiting.set(k, v); + this.incarnations.clear(); for (const [k, v] of saved.incarnations) this.incarnations.set(k, v); + this.mail = saved.mail; + this.putBack(); + throw error; + } + } + + /** Memory is as it was before a change that failed: the host rewrites its copies to match. The caller hears why + * its change failed, not why the put-back did. */ + private putBack(): void { + try { + this.onRollback?.(); + } catch (rollbackError) { + this.onRecordWriteFailed('the put-back of a change that failed', rollbackError); + } + } + + /** Run a record of something that has already happened: its writes alert and stay in memory rather than throw. */ + private recording(change: () => T): T { + this.recordingDepth++; + try { + return change(); + } finally { + this.recordingDepth--; + } + } + + /** The ended records in memory, newest last. */ + endedHistory(): AgentRecord[] { + // An interrupted end not yet written isn't history yet: written into the + // ended file early, it would let a crash drop the record before its + // recipient is told (restore drops what the ended history holds). + return this.ended + .filter((record) => !this.interruptedPending.has(`${record.name}#${record.incarnation}`)) + .map(clone); + } + + /** Persist a record of something that has already happened: a failure + * alerts and leaves the change in memory for the next write to carry. */ + private persistRecord(what: string): void { + try { + this.persist(); + } catch (error) { + this.onRecordWriteFailed(what, error); + } + } + + /** Write the snapshot through the host's persist hook. A write that fails + * fails the change that made it (nissa's #546 finding 2): the change is in + * memory, and the caller is told it is not durable, rather than told it + * succeeded while a restart would lose it. */ + private persist(): void { + if (!this.persistFn) return; + if (this.recordingDepth > 0) { + try { + this.persistFn(this.snapshot()); + } catch (error) { + this.onRecordWriteFailed('a change already made', error); + } + return; + } + this.persistFn(this.snapshot()); + } +} diff --git a/src/dendrite/types.ts b/src/dendrite/types.ts new file mode 100644 index 000000000..2b303f0ee --- /dev/null +++ b/src/dendrite/types.ts @@ -0,0 +1,397 @@ +/** + * Dendrite — common agent / context / lifecycle machinery. + * + * One registry describes every agent the framework runs: residents, fresh + * workers and derived agents. The four historical creation paths (resident, + * subconscious, conversation fork, ephemeral subagent) are PRESETS over the + * types in this file, not separate engines: the framework never branches on + * what kind of agent something is, only on what its spec declares. + * + * Three things are kept apart on purpose: + * + * - **Identity / lifetime** — what persists across activations: the name, + * the context it owns, its configuration, its relationships. + * - **Activation** — one bounded run, with an owner for the in-flight work. + * - **Policy epoch** — a focus / tune-out interval. Ending an epoch is not + * ending an agent and not cancelling a task; epochs stay owned by the + * policy coordinator and are only *read* through the registry. + * + * Trust: one host trust domain. Context isolation here is NOT process or + * filesystem isolation — agents sharing a shell, tools or a workspace can + * affect shared external state. Nothing in this file is a permission check. + */ + +/** A registered agent's name. Unique among live agents. */ +export type AgentName = string; + +/** + * Preset label, for discovery and telemetry. {@link AgentRoles}, + * {@link AgentLifetime} and the relationship fields decide the framework's + * general behaviour. It also compares two values: 'subconscious-fork' + * decides a reader fork's tool calls, and 'conversation-fork' picks the + * agents that stop() and the cleanup after a context manager reinitializes + * dispose. And it counts derivation notices per kind, for collapsing them. + */ +export type AgentKind = + | 'resident' + | 'subconscious' + | 'conversation-fork' + | 'task-fork' + | 'worker' + | (string & {}); + +/** + * Positive capabilities. Each replaces a family of hand-written exclusion + * filters; a new kind of agent declares these instead of editing every + * selection site. + */ +export interface AgentRoles { + /** + * Owns default delivery: a framework message with no named recipient lands + * in this agent's context. Exactly one live agent holds it. + */ + defaultDelivery: boolean; + /** + * Woken by untargeted inbound: a module's `requestInference: true`, a + * channel message or push event that names no agents, and the default + * reader set of a coalesced occurrence. + */ + receivesUntargeted: boolean; + /** + * Woken by host-wide gate fan-out (debounce batches, sleep expiry). The + * gate is host-wide today; this stays a separate capability from + * `receivesUntargeted` until gating is per recipient, because a + * channel-bound agent is woken by its own channel's debounced traffic + * without being a broadcast recipient. + */ + receivesGateWakes: boolean; + /** + * Provider admission belongs to it: it holds the per-agent provider gate + * for its primary calls and owns rate-limit cooldowns and the requests + * held behind them. Agents without it run under the established provider + * policy and never leave a gate or cooldown behind when they end. + */ + ownsProviderScheduling: boolean; +} + +/** + * How long the IDENTITY lives. Every activation of every agent is bounded + * separately (see {@link ActivationBounds}); a persistent agent is exempt + * from ending, not from activation bounds. + */ +export type AgentLifetime = + /** Declared in configuration and re-created at boot. */ + | { kind: 'persistent' } + /** Ends after a period with no inbound on its binding. */ + | { kind: 'idle'; idleTtlMs: number } + /** + * A bounded job: must end. At least one of `deadlineMs` (wall clock from + * creation) and `idleTimeoutMs` (no stream activity) is required — a task + * with neither is a load-time error. + */ + | { kind: 'task'; deadlineMs?: number; idleTimeoutMs?: number; maxTurns?: number }; + +/** Bounds on one activation. Informational where the framework has no enforcement yet. */ +export interface ActivationBounds { + maxTurns?: number; + maxMs?: number; + maxInputTokens?: number; +} + +/** + * What ends this agent when the agent that spawned it ends. + * + * - `'end'` — attention tenancy: the agent exists to serve its parent and + * ends with it (a reader of held traffic, a subconscious). + * - `'orphan'` — bounded task work: it may finish after the parent is gone. + * It is notified, offered for reparenting, and its result is held as + * attributed mail until someone can receive it. + */ +export type ParentEndPolicy = 'end' | 'orphan'; + +/** Where the completion of a bounded job goes. */ +export interface ResultRoute { + /** Recipient agent. */ + to: AgentName; + /** + * - `'message'`: an attributed message in the recipient's context, in the + * child's own name, plus a wake. + * - `'tool-result'`: returned to the caller that is awaiting the run; the + * registry records the route but the caller delivers. + */ + as: 'message' | 'tool-result'; +} + +/** Read access to another slot, merged into this agent's view. */ +export interface ObserveEdge { + /** The agent whose slot is read, when the slot belongs to one. */ + agent?: AgentName; + /** Message-slot namespace; undefined = the shared un-namespaced slot. */ + slot?: string; + /** Only messages at or after this sequence. */ + fromSequence?: number; + /** Whether held (policy-diverted) traffic in that slot is visible. */ + includeHeld?: boolean; +} + +/** + * The four relationships, kept distinct even when one preset connects the + * same pair through all of them. Spawning does not imply being the sole + * observer; observing does not imply lifecycle authority. + */ +export interface AgentRelationships { + /** Spawn: who created whom. Absent for an independent agent. */ + spawnedBy?: AgentName; + /** Observe/read: whose streams or retained history this agent inspects. */ + observes: ObserveEdge[]; + /** Message: explicit communication paths this agent has used or was given. */ + messagePeers: AgentName[]; + /** Result route: where the completion of its bounded job returns. */ + resultTo?: ResultRoute; +} + +/** How a derived agent's context relates to its parent's. */ +export interface ContextInheritance { + /** The agent whose context is inherited. */ + from: AgentName; + /** + * - `'copy'`: the parent's compiled context re-added as the child's own + * messages (the historical mechanism). The child can fold what it + * inherited but loses the parent's memory tree. + * - `'shared'`: the child reads the parent's messages and fold state at + * the checkpoint through shared storage and writes only its own + * (requires store support; refused when unavailable). + */ + mode: 'copy' | 'shared'; + /** The parent checkpoint (sequence on the parent's branch). Filled at creation. */ + atSequence?: number; + /** The parent branch the checkpoint is on. Filled at creation. */ + branch?: string; + /** + * `'shared'` only: the Chronicle branch that holds everything the child + * writes. It outlives the child and is what inspection or a later resume + * opens. Filled at creation. + */ + ownBranch?: string; + /** + * `'shared'` only: the context manager's own record of the derivation + * (plain data). Kept so the child's context can be reopened after it + * ended or after a restart — to resume it or to inspect it. + */ + derivation?: unknown; + /** + * `'shared'` only: whether the parent's refusal ledger — what it declined + * to compress, and why — comes along. Default false: a task fork is free + * of its parent's refusals. A fork that stands in for the parent's + * attention is created with it, so it is not asked the thing the parent + * said no to without knowing. Named in the creation event either way. + * + * Consultable, never rendered: the ledger steers what the child's + * strategy will and will not compress; nothing in it is ever placed in a + * compiled request. Refusal records are the most classifier-sensitive + * content in the pipeline, so this is a stated property of the + * declaration, not an accident of the implementation. + */ + refusals?: boolean; + /** + * - `'reuse'` (default): keep the parent's rendering frontier — prefix, + * cache markers and solver state — for fast startup. + * - `'fresh'`: deliberately pay for a new solve, e.g. at another budget; + * presentation changes and the provider cache misses. An operating + * choice, not a failure. + */ + solve?: 'reuse' | 'fresh'; +} + +/** + * Declarative description of an agent. Inference configuration (model, + * prompt, strategy, tools) stays in `AgentConfig`; this is everything the + * registry and lifecycle need on top of it. + */ +export interface AgentSpec { + name: AgentName; + kind: AgentKind; + /** + * The model the agent runs on — "whose weights", next to `inherit.from` + * for "whose context". Together they are what a consent decision about a + * derived agent is made on. Filled by the framework from the agent's + * configuration. + */ + model?: string; + roles: AgentRoles; + lifetime: AgentLifetime; + activation?: ActivationBounds; + /** Applies when `spawnedBy` is set. Default `'orphan'`. */ + onParentEnd?: ParentEndPolicy; + spawnedBy?: AgentName; + observes?: ObserveEdge[]; + resultTo?: ResultRoute; + inherit?: ContextInheritance; + /** + * Stored participants that render as this agent's own (assistant) turns + * at request assembly. Stored authorship is never rewritten; this is a + * model-facing role assignment only. + */ + selfParticipants?: string[]; + /** The participant name its turns are presented under, when not its own name. */ + presentAs?: string; + /** + * A channel this agent is bound to (its speech home and inbound scope), on + * its server: channel ids are unique only within one server's connection, + * so two servers' channels with one id are two homes (the channel + * registry's `ChannelRef`). + */ + homeChannel?: { serverId: string; channelId: string }; + /** + * Whether the agent reads the residents' shared un-namespaced message + * slot (as its own slot or through an observe edge). + */ + readsSharedSlot?: boolean; + /** Chronicle namespace owning this agent's context. */ + namespace?: string; + /** Free-form metadata surfaced in discovery. */ + metadata?: Record; +} + +/** Why an agent identity ended. */ +export type AgentEndReason = + | 'completed' + | 'stopped' + | 'deadline' + | 'idle-ttl' + | 'parent-ended' + | 'host-restart' + | 'not-configured' + | 'failed' + | 'host-stop' + | 'context-emptied' + | (string & {}); + +/** Provenance stamp for anything one agent hands another. */ +export interface AgentRef { + agent: AgentName; + /** Which instantiation of that identity. */ + incarnation: number; +} + +/** + * One bounded run. The owner is the agent whose in-flight work this is. + * Derived from the framework's live turn state on request — never stored, + * so it cannot disagree with what is actually running. + */ +export interface ActivationRecord { + id: string; + owner: AgentRef; + /** When the triggering request was made (ms), when known. */ + requestedAt?: number; + reason?: string; + source?: string; + /** The channel whose traffic started it, if any. */ + channelId?: string; +} + +/** The registry's record of one agent. */ +export interface AgentRecord { + name: AgentName; + kind: AgentKind; + model?: string; + /** + * Bumped every time this identity is instantiated: a persistent agent + * restored at boot is a new incarnation of the same identity. + */ + incarnation: number; + roles: AgentRoles; + lifetime: AgentLifetime; + activation?: ActivationBounds; + onParentEnd: ParentEndPolicy; + relationships: AgentRelationships; + inherit?: ContextInheritance; + selfParticipants?: string[]; + presentAs?: string; + /** + * The spec's home, on its server. A bare channel id is a record written + * before homes carried their server: such a record is an ended one, since + * a conversation fork ends at a restart, and the framework reads its id + * as a wake that names a channel without its server is. + */ + homeChannel?: { serverId: string; channelId: string } | string; + readsSharedSlot: boolean; + namespace?: string; + metadata?: Record; + createdAt: number; + /** Set when the identity has ended. Ended records stay inspectable. */ + ended?: { at: number; reason: AgentEndReason; by?: string }; + /** + * Set while the agent's spawner is gone and nobody has adopted it. The + * spawn edge is kept as history in `formerParent`. + */ + orphaned?: { + since: number; + formerParent: AgentRef; + reason: AgentEndReason; + /** Agents offered as a new parent: nearest live ancestor first, then the primary. */ + candidates: AgentName[]; + }; +} + +/** + * A result (or other attributed communication) that could not yet be handed + * to its recipient. Held durably so a completed job is neither lost nor + * delivered twice across a restart. + */ +export interface HeldMail { + /** Stable id; stamped on the delivered message for de-duplication. */ + id: string; + kind: 'result' | 'message' | 'notice'; + from: AgentRef; + to: AgentName; + /** Message content blocks, verbatim. */ + content: unknown[]; + /** Message ids / activation ids this was caused by. */ + causedBy?: string[]; + createdAt: number; + /** Why it is still held. */ + heldBecause?: 'recipient-gone' | 'undelivered'; +} + +/** + * Persisted shape of the registry (Chronicle snapshot state). Ended records + * are history and live in a separate append-only log. + */ +export interface RegistrySnapshot { + version: 1; + /** Records of agents that have not ended. */ + records: AgentRecord[]; + /** Highest incarnation issued, for names that can still be referred to. */ + incarnations: Record; + mail: HeldMail[]; +} + +/** Outcome of ending an agent: who was affected. */ +export interface EndOutcome { + ended: AgentRecord; + /** Children that ended with it (attention tenancy), deepest first. */ + cascaded: AgentRecord[]; + /** Children now orphaned, with the candidates offered as new parents. */ + orphaned: Array<{ record: AgentRecord; candidates: AgentName[] }>; +} + +/** + * A policy interval in force (a tune-out hold, a focus). Read from the + * owning coordinator's durable state — the registry never mirrors it. + * Ending an epoch ends neither an agent nor a task. + */ +export interface PolicyEpoch { + id: string; + kind: 'tune-out' | (string & {}); + /** What the policy applies to: one channel, or every channel but one. */ + scope: + | { serverId: string; channelId: string } + | { kind: 'all-except'; except: { serverId: string; channelId: string } }; + /** The agent whose attention the policy governs. */ + owner?: AgentName; + /** The agent reading what the policy withholds, if any. */ + reader?: AgentName; + startedAtSequence?: number; + expiresAtMs?: number; +} diff --git a/src/framework.ts b/src/framework.ts index e683eccd2..ac5959e65 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1,4 +1,5 @@ import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogue, type PresentationSnapshot } from "./tool-presentation.js"; +import { presentAuthor } from './author-presentation.js'; import { constants as bufferConstants } from 'node:buffer'; import { dirname, join } from 'node:path'; import { EXPIRY_REASON, REFERENCE_LIMITS, claimedExpiry, classifyBlock, isReferenceExpired, referenceBlockForScript, referenceRegistry, sanitizeLabel, type RegistryJournal, type ResultClaims, type ScriptLine, type ServerAddress } from './mcpl/references.js'; @@ -14,8 +15,16 @@ import { classifyFailure, retryDecision, MEMBRANE_RETRY_SETTINGS, type RetrySett import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks, continuesBody, MalformedContentError, malformedContentPlaces, CompressionWithdrawn } from '@animalabs/context-manager'; import type { CacheWireReceipt } from './kv-unified-wire.js'; import { SILENT_HEARTBEAT_SEPARATOR, type RequestOnlyPrompt, type RequestOnlyStamps } from './silent-heartbeat.js'; -import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js'; -import { TuneOutCoordinator, TUNE_OUT_DEFAULTS, alreadyTunedOutText } from './tune-out/coordinator.js'; +import { + SUBCONSCIOUS_TOOLS, + SUBCONSCIOUS_TOOL_NAMES, + defaultReaderFraming, + type SubconsciousConfig, + type PersistentReaderConfig, + type ForkReaderConfig, +} from './tune-out/tools.js'; +import { TuneOutCoordinator, TUNE_OUT_DEFAULTS, alreadyTunedOutText, type QueuedHeldMessage, type ReaderInvocation } from './tune-out/coordinator.js'; +import { admit, type InboundEnvelope, type AdmissionDecision } from './admission/admit.js'; import type { MessageId, MessageMetadata, @@ -106,6 +115,24 @@ import { InferenceRouter } from './mcpl/inference-router.js'; import { ChannelRegistry, TUNE_OUT_REPLACED, connectorText, desiredStateInLog, destinationKey, journalToolDescription, NON_HOLDER_CHANNEL_DESCRIPTIONS, NO_CHANNEL_EXAMPLE, NO_PERSON_EXAMPLE, qualifiedChannelName, sameChannel, type ChannelRef, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SelectedForOpening } from './mcpl/channel-registry.js'; import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; +import { + AgentRegistry, + conversationForkSpec, + residentSpec, + subconsciousSpec, + taskForkSpec, + workerSpec, +} from './dendrite/index.js'; +import type { + ActivationRecord, + AgentEndReason, + AgentRecord, + AgentSpec, + EndOutcome, + HeldMail, + PolicyEpoch, + RegistrySnapshot, +} from './dendrite/index.js'; import { INBOUND_SOURCE_KEY, sourceBodyDigest, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js'; import { markNoticeOpenings, nameToTypeBack, quotedValue, showsAsItself, suppliedValue } from './supplied-text.js'; import { NOTICE_TAG } from './notice-tags.js'; @@ -173,7 +200,7 @@ import { splitThinkingDrops, ThinkingLossIncidents, type PlacedDrop, type Stream import { asNextRequestCarries, roundRows, type RoundRow, type SentRow } from './sent-rows.js'; import { ReasoningLossTeller } from './reasoning-loss.js'; import { contentAsStored, nameAsStored } from './text-as-stored.js'; -import { STORED_INJECTION_KEY, STORED_INJECTION_KIND, asStoredRow, hasRenderedContent, isAnotherAgentsInjection, isStoredKind, storedInjectionMarkOf, unitHash, type StoredInjectionMark } from './stored-injections.js'; +import { STORED_INJECTION_KEY, STORED_INJECTION_KIND, asStoredRow, hasRenderedContent, isAnotherAgentsInjection, isOwnInjection, isStoredKind, storedInjectionMarkOf, unitHash, type StoredInjectionMark } from './stored-injections.js'; /** An MCPL tool call refused because its server isn't in the registry: one * closed for good, one removed, or one whose connect failed without @@ -373,10 +400,11 @@ const PRIMARY_IF_UNREAD_KEY = 'primaryIfUnread'; * a deferred write, `external` a module's own row for an outside message, * `openaiResponsesItems` membrane's mark of an imported Responses * history, `contextInjection` an agent's stored context injection - * (stored-injections.ts), which every other agent's view leaves out, and + * (stored-injections.ts), which every other agent's view leaves out, * `primaryIfUnread` a module's write for an ephemeral run or a * conversation fork, which goes to the primary if its target never read it - * (passesToPrimary). A + * (passesToPrimary), and `dendrite` the stamp of delivered agent mail, whose + * `mailId` releases that held mail once the row lands (releaseLandedMail). A * server's metadata, a channel message's `metadata` or a * push event's `origin`, is stored without them, and the host writes its own * after it: MCPL SPEC §16.6 makes metadata the producer's untrusted claims, @@ -387,7 +415,7 @@ const PRIMARY_IF_UNREAD_KEY = 'primaryIfUnread'; */ const HOST_METADATA_KEYS = [ 'system', 'kind', 'draftOwner', 'draftIds', 'tuneOut', 'coalescingSubject', 'firstView', - 'deferredWriteId', 'external', 'openaiResponsesItems', STORED_INJECTION_KEY, PRIMARY_IF_UNREAD_KEY, + 'deferredWriteId', 'external', 'openaiResponsesItems', STORED_INJECTION_KEY, PRIMARY_IF_UNREAD_KEY, 'dendrite', ] as const; /** @@ -655,16 +683,18 @@ function mcplToolNoWordsText(serverId: string, callName: string): string { } /** - * True when an incoming channel message explicitly addressed the agent. - * Primary signal is the MCPL RFC-001 `chat:addressed` tag (mention / reply / - * DM, as classified by the server); the metadata booleans are the fallback - * for servers that predate tags. Addressed messages outrank ambient chatter + * True when an incoming channel message explicitly addressed the agent: the + * MCPL RFC-001 `chat:addressed` tag (mention / reply / DM, as classified by + * the server). Tag-only — there is no metadata fallback, so a server that + * does not tag never addresses. Addressed messages outrank ambient chatter * when picking a turn's frozen speech locus. */ -const isAddressedMessage = ( - tags?: string[], - _metadata?: Record, -): boolean => Array.isArray(tags) && tags.includes('chat:addressed'); +const isAddressedMessage = (tags?: string[]): boolean => + Array.isArray(tags) && tags.includes('chat:addressed'); + +/** The author a channel message names, for a notice about it. */ +const authorName = (metadata: Record | undefined): string => + (metadata?.author as { name?: string } | undefined)?.name ?? 'Someone'; /** A wake's `ambient` mark: only a message its server tagged `chat:ambient` * (RFC-001) is known to ask nothing of the agent. */ @@ -843,7 +873,7 @@ import { HostPresentation, BUILTIN_TOOLSET, type HostActTarget } from './host-pr import { isEmptyChange, type Presence } from './mcpl/tool-presence.js'; import { ToolsetsModule, resolveAct, withLookHint, type TakenChange } from './mcpl/toolsets-module.js'; import { ModernMcpConnection } from './mcpl/modern-connection.js'; -import { WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor, type CredentialPattern } from './mcpl/protocol-family.js'; +import { MAX_TIMER_MS, WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor, type CredentialPattern } from './mcpl/protocol-family.js'; import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js'; import { boundedForLog, describeCause } from './mcpl/errors.js'; import { assertServerConfigBooleans, buildWebSocketUrl, isWebSocketTransport } from './mcpl/transport.js'; @@ -860,7 +890,7 @@ import type { ChannelsChangedParams, ChannelsIncomingParams, } from './mcpl/types.js'; -import type { ContextInjection, ThinkingBinding } from '@animalabs/context-manager'; +import type { ContextInjection, ContextStrategy, ThinkingBinding } from '@animalabs/context-manager'; import { formatZonedDateTime, resolveTimeZone } from './timezone.js'; import { CLOCK_ROW_KIND, clockStampOf, planClock, takesClockBefore } from './clock-rows.js'; import { firstFreeBranchName } from './branch-names.js'; @@ -902,6 +932,12 @@ import { } from './operator-log.js'; const FRAMEWORK_STATE_ID = 'framework/state'; +/** Dendrite agent registry: live records, incarnation counters, held mail. */ +const AGENT_REGISTRY_STATE_ID = 'framework/dendrite'; +/** Dendrite agent registry: append-only history of ended agent records. */ +const AGENT_REGISTRY_ENDED_ID = 'framework/dendrite/ended'; +/** How many ended agent records are reloaded for inspection at boot. */ +const AGENT_REGISTRY_ENDED_RELOAD = 256; /** Snapshot slot mirroring context writes deferred while quiesced (#122). */ const DEFERRED_WRITES_ID = 'framework/deferred-writes'; /** What a write of the deferred-write queue did: wrote the record (the @@ -1086,11 +1122,12 @@ function describeDeferredWrite(msg: { id: string; participant: string; content: /** Whether a deferred message can join a live stream at a tool boundary: * no tool blocks (the membrane's tool cycle) and not named as the agent * itself (it would render as an assistant turn, a prefill). */ -function isInjectableMidTurn(msg: { participant: string; content: ContentBlock[] }, agentName: string): boolean { - // Exact, as membrane's role test is: only a participant equal to the - // request's assistantParticipant (Agent's name) renders as the assistant. - // It's the name as sent, which the boundary takes as the store keeps it. - return nameAsStored(msg.participant) !== agentName +function isInjectableMidTurn(msg: { participant: string; content: ContentBlock[] }, agent: Pick): boolean { + // Exact, as membrane's role test is: a participant the request assigns + // the assistant role (the agent's name, and for a derived agent the + // voice it inherited) renders as the assistant. It's the name as sent, + // which the boundary takes as the store keeps it. + return !agent.isOwnParticipant(nameAsStored(msg.participant)) && !msg.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result'); } @@ -1447,6 +1484,13 @@ interface TurnRouting { * retracted, isn't there for the agent to answer. */ arrivals: Arrival[]; + /** + * Under `speech.locus: 'delivery'`, the channels the turn has already told + * the agent it was addressed from while busy, by destinationKey (one + * notice per channel per turn; a retry or restart continues the turn and + * doesn't repeat it). + */ + addressedElsewhere: Set; heard?: Map; } @@ -1455,6 +1499,8 @@ interface Arrival { messageId: MessageId; /** The channel it re-pins the locus to. */ channel: ChannelRef; + /** It addressed the agent (`chat:addressed`), rather than following up in an engaged channel. */ + addressed: boolean; } /** The last TurnRouting.turnId given out: ids increase, and are unique within the process. */ @@ -1472,6 +1518,7 @@ function freshTurnRouting(): TurnRouting { engaged: new Set(), wasIn: [], continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], heard: new Map(), + addressedElsewhere: new Set(), }; } @@ -2633,6 +2680,136 @@ interface EphemeralRun { inferenceStarted: boolean; lastActivity: number; toolCallsCount: number; + /** Why the run ended, for the registry record. Unset = it failed. */ + endReason?: AgentEndReason; + /** Who ended it, when it was stopped from outside. */ + endedBy?: string; +} + +/** + * The admission record on a stored message: it was held from the residents + * by a policy epoch (`metadata.tuneOut = { epochId }`). ONE predicate for + * "held from the resident", read by the compiled-view filter and by the + * mid-turn injection alike (#232 H1), so the window and the wire agree. + */ +export const heldFromResidents = (metadata: Record | undefined): boolean => + !!(metadata as { tuneOut?: unknown } | undefined)?.tuneOut; + +/** + * What a reader of the residents' shared message slot does not see: held + * messages. Used by every resident, and by a context derived from one when + * it is reopened. + */ +const RESIDENT_VIEW_FILTER = (message: StoredMessage): boolean => + !heldFromResidents(message.metadata as Record | undefined); + +/** The context-manager surface shared inheritance needs; absent on versions without derivation. */ +interface DerivingContextManager { + derive(options: { + branch: string; + strategy: ContextStrategy; + membrane?: Membrane; + solve?: 'reuse' | 'fresh'; + inherit?: { refusals?: boolean }; + atSequence?: number; + viewFilter?: (message: StoredMessage) => boolean; + debugLogContext?: boolean; + }): Promise; + getDerivation(): ContextDerivationRecord | null; +} + +/** context-manager's plain-data record of a derivation (see its ContextDerivation). */ +interface ContextDerivationRecord { + parentBranch: string; + branch: string; + atSequence: number; + solve: 'reuse' | 'fresh'; + slots?: { messageNamespace: string | null; contextNamespace: string | null }; +} + +/** `ContextManager.reopenDerived`, absent on versions without derivation. */ +type ReopenDerived = (options: { + store: JsStore; + derivation: ContextDerivationRecord; + strategy?: ContextStrategy; + membrane?: Membrane; + viewFilter?: (message: StoredMessage) => boolean; + debugLogContext?: boolean; +}) => Promise; + +/** Options for {@link AgentFramework.deriveAgent}. */ +export interface DeriveAgentOptions { + /** The new agent's name. Single-use within this framework's lifetime. */ + name: string; + /** The live agent whose context and configuration are inherited. */ + from: string; + /** How context is inherited. Default `'shared'`. */ + mode?: 'shared' | 'copy'; + /** `'shared'` only. Default `'reuse'`. */ + solve?: 'reuse' | 'fresh'; + /** + * `'shared'` only. Also inherit the parent's refusal ledger (what it + * declined to compress). Default false; set for a fork that stands in + * for the parent's attention. Recorded as `inherit.refusals`. + */ + inheritRefusals?: boolean; + /** + * A fresh strategy instance for the child. Strategy instances hold state + * and are never shared; to reuse the parent's rendering it must be the + * same class with compatible configuration. Default: passthrough. + */ + strategy?: ContextStrategy; + /** Identity and settings overrides. Everything not named is the parent's. */ + config?: Partial>; + /** + * The parent's tool round in flight, if any. While a tool call runs, the + * assistant turn that made it is not in the parent's store yet. By + * default the child's branch gets that turn verbatim (as its own, like + * the rest of the inheritance) followed by a tool-result message: results + * the parent has already received as they are; the call that derived the + * child with `madeBy.result`, or a note saying it did; and any other call + * still running with a note that its result was not available. So a fork + * sees the call that made it and never meets a dangling `tool_use`. + * `madeBy` defaults to the only open call when there is exactly one. + * `include: false` leaves the round out, and the child starts from the + * last stored message before it. + */ + pendingRound?: { + include?: boolean; + madeBy?: { toolUseId: string; result: string }; + /** Results, from the child's side, for other calls still open, by tool-use id. */ + results?: Record; + }; + /** Ordinary messages appended to the child before it runs. */ + framing?: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }>; + /** Who spawned it. Default: `from`. */ + spawnedBy?: string; + /** Where its completion returns. */ + resultTo?: import('./dendrite/index.js').ResultRoute; + /** What happens to it when its spawner ends. Default `'orphan'`. */ + onParentEnd?: import('./dendrite/index.js').ParentEndPolicy; + deadlineMs?: number; + idleTimeoutMs?: number; + maxTurns?: number; + /** Preset label for discovery. Default `'task-fork'`. */ + kind?: string; + metadata?: Record; +} + +/** Rejection delivered to the caller awaiting a bounded run that was ended from outside. */ +export class AgentStoppedError extends Error { + constructor( + readonly agentName: string, + readonly reason: AgentEndReason, + readonly by?: string, + detail?: string, + ) { + super( + `Agent "${agentName}" was ended (${reason}${by ? ` by ${by}` : ''})` + + (detail ? `: ${detail}` : ''), + ); + this.name = 'AgentStoppedError'; + } } interface SyncMoveReinit { @@ -3370,6 +3547,13 @@ export class AgentFramework { private quiesceReason?: string; private quiescedAt?: number; private traceListeners: TraceRegistration[] = []; + /** Traces emitted before start() — registry restore, interrupted work, + * resumed epochs — replayed to listeners that attach afterwards, since a + * host attaches its sinks after create() returns. Dropped at start(). + * It keeps the first 1,000, the start of the stream, and counts the rest + * (earlyTracesDropped), which a replay ends by naming. */ + private earlyTraces: TraceEvent[] | null = []; + private earlyTracesDropped = 0; private syncIntervalMs: number; private syncTimer: ReturnType | null = null; private maintenanceIntervalMs: number; @@ -3431,8 +3615,9 @@ export class AgentFramework { private midTurnInputSignals: Set = new Set(); /** Last outbound locus announced to each agent as a durable `[routing]` * window message. Announce-on-change only: steady state emits nothing - * (KV-safe, no per-turn chatter). In-memory — after a process restart the - * first turn's locus is announced once to re-establish the baseline. */ + * (KV-safe, no per-turn chatter). Kept in the store as well + * (rememberAnnouncedLocus), so a restart compares with what the agent was + * last told rather than with nothing. */ private lastAnnouncedLocus: Map = new Map(); // ---- Held prose drafts (src/prose-drafts.ts) --------------------------- @@ -3519,7 +3704,22 @@ export class AgentFramework { * global fallback even after its release, or a conversation fork. */ private usedEphemeralAgentNames: Map = new Map(); /** One-shot generation tickets minted by createEphemeralAgent. */ - private ephemeralCandidates: WeakMap = new WeakMap(); + private ephemeralCandidates: WeakMap = new WeakMap(); + /** + * Derived agent → the agent whose tool presentation it shows. A derived + * agent that inherits its parent's tools must advertise the identical + * tool block (it is the front of the shared request prefix), including + * the parent's resident-edited visibility and descriptions. + */ + private toolSurfaceOwners: Map = new Map(); /** Every agent createEphemeralAgent made, for as long as the object lives: * its usage counts in the usage record's one shared ephemeral entry. Kept * by the object, not the run's record (ephemeralRuns), which its cleanup @@ -3596,8 +3796,25 @@ export class AgentFramework { 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. */ + /** Tune-out coordinator (issue #77); non-null iff a reader is configured + channels. */ private tuneOutCoordinator: TuneOutCoordinator | null = null; + /** Residents are offered `tune_out` (a reader is configured, or `tuneOut.enabled`). */ + private tuneOutOffered = false; + /** + * The reply anchor a coalesced delivery would set, parked until the + * acceptance is acknowledged to the server (handleCoalescedIncoming): an + * acceptance whose commit fails after its delivery moves no anchor, and the + * retry the receipt answers, which delivers nothing again, commits the one + * its first attempt parked. Keyed by server and event id; bounded. + */ + private parkedAnchors: Map = new Map(); + /** `tuneOut.announce`: the excepted channel hears the all-except scope open and close. */ + private tuneOutAnnounce = true; + /** `speech.locus`: where prose goes when nothing names a destination (see FrameworkConfig). */ + private speechLocus: 'delivery' | 'latest-inbound' = 'delivery'; + /** `subconscious.reader: 'forks'`: the reader is a succession of forks (Dendrite). */ + private readerForks = false; + private readerForkCounter = 0; /** Agents an abandon could not cancel (token held, no stream) — surfaced * in HostModeStatus until the next quiesce/resume. */ @@ -3616,6 +3833,24 @@ export class AgentFramework { /** True once the cap has been met in the current run, so its line is * logged once a run, whatever began the run. */ private deferredWritesCapLogged = false; + /** The agent registry and its ended log, as recovery files beside the store + * (nissa's #546 finding 3): Chronicle state follows the store's branch, and + * an undo or an operator's surgery must not erase a result held for + * delivery or the record of work that ran. The file wins where it exists; + * the branch-local state is read only for stores that predate it. */ + private agentRegistryPath: string | undefined; + private agentRegistryEndedPath: string | undefined; + /** The ended log as the recovery file keeps it: the newest records, bounded. */ + private endedRecovery: AgentRecord[] = []; + /** The branch-local ended log by name (its newest entry for each), and how far it is indexed (findEndedAgentRecord). */ + private readonly endedLogIndex = new Map(); + /** The ended log's entries by the namespace their context holds (the latest for each). */ + private readonly endedLogNamespaceIndex = new Map(); + /** Namespaces this process opened for an ephemeral agent's context, by owner: candidates not yet registered included. */ + private readonly namespaceOwners = new Map(); + private endedLogIndexed = 0; + /** The last indexed entry's mark, so a log that changed under the index is seen. */ + private endedLogTail = ''; /** Branch-independent recovery files (see FrameworkConfig.hostModePath / * deferredWritesPath). Undefined → branch-local slot fallback. */ private hostModePath: string | undefined; @@ -3777,12 +4012,32 @@ export class AgentFramework { private coalescingSnapshotDirty: (() => CoalescingSnapshot) | null = null; private coalescingRecentReceipts: CoalescingReceiptRecord[] = []; /** - * Agents that read the residents' shared un-namespaced message slot: every - * ordinary resident (their context managers are not isolated) and the - * subconscious (isolated, but merging that slot read-only). A message in - * that slot is unread only if NONE of them has compiled past it. Forks and - * ephemeral agents have isolated slots and are their own only readers. - */ + * Dendrite agent registry — every agent this framework runs, with its + * roles, lifetime and relationships. `agents` stays the map of live Agent + * OBJECTS (the generation identity streams are checked against); the + * registry is what says what each of them is for. Selection sites read + * roles from here instead of excluding kinds by hand. + */ + private readonly registry: AgentRegistry = new AgentRegistry({ + persist: (snapshot) => this.persistAgentRegistry(snapshot), + appendEnded: (record) => this.appendEndedAgentRecord(record), + onRollback: () => this.rewriteAgentRegistryAfterRollback(), + findEnded: (name) => this.findEndedAgentRecord(name), + }); + /** Tell the first adoption candidate when task work is orphaned (config.dendrite.announceOrphans). */ + private announceOrphans = true; + /** + * After this many derivations of one kind from one agent, that agent hears + * a count line every so many instead of a line per fork + * (config.dendrite.collapseDerivationNoticesAfter; 0 = never collapse). + */ + private collapseDerivationNoticesAfter = 10; + /** The host's say over each derivation, ephemeral spawn and resume (config.dendrite.beforeDerive). */ + private beforeDerive?: (spec: AgentSpec) => void | { refuse: string }; + /** What a source is told of a derivation (config.dendrite.derivationNotice); absent, the default line. */ + private derivationNoticeHook?: (record: AgentRecord, from: string, defaultText: string) => string | null; + /** Per source agent, per kind: how many derivations, since when, and at which count the last note went. */ + private derivationTallies: Map> = new Map(); private readonly sharedSlotAgents = new Set(); /** The batched-wake lines already written (placeWakeLines), so every agent a * wake was for, and every later start of it, writes each at most once. */ @@ -3791,6 +4046,7 @@ export class AgentFramework { * in the round counted by their share of the bound: such a round is * stored by its share whether or not it fits (buildStoredToolResultContent). */ private readonly toolRoundsByShare = new WeakSet(); + /** Group commit: one fsync per event-loop turn, shared by every waiter. */ /** The windows of forks and runs deregistered while the host was quiesced, * by agent name (releaseWindowNowOrAfterResume); resume() releases them * (runReleasesAfterResume), or stop() passes on what they left, unread or @@ -3799,6 +4055,7 @@ export class AgentFramework { private readonly releasesAfterResume = new Map(); private readonly managerReleases = new WeakMap(); private readonly releasedByCleanup = new WeakSet(); + private readonly resumesPending = new Map(); // context-manager 0.23.1: a strategy that keeps its context, as // AutobiographicalStrategy does (hostContext, autobiographical.ts:2897), // keeps its manager reachable while its work is pending, since the @@ -3819,12 +4076,10 @@ export class AgentFramework { private conversationRouter: ConversationRouter | null = null; /** Agent configs by name — fork agents are built from the template's config. */ private agentConfigs: Map = new Map(); - /** - * Fork agent → its home channel. Permanent (unlike router bindings, which - * expire): publish/injection scoping must survive unbinding so the closure - * turn still lands in the right channel. - */ - private conversationAgentHomes: Map = new Map(); + // A fork agent's home channel is its registry record's `homeChannel` + // (homeOf). Permanent (unlike router bindings, which expire): + // publish/injection scoping must survive unbinding so the closure turn + // still lands in the right channel. /** Forks being spawned, by channel id, each until its spawn settles (see * routeConversation). The promise never rejects. */ private readonly conversationSpawns = new Map>(); @@ -3832,7 +4087,7 @@ export class AgentFramework { private lastConversationSweep = 0; /** Forks whose TTL closure turn has been queued — disposed (removed from - * agents/agentConfigs/conversationAgentHomes) when their stream ends. */ + * agents/agentConfigs and ended in the registry) when their stream ends. */ private closingConversationAgents: Set = new Set(); // Client-side programmatic tool calling (`code_execution`). Null unless // config.codeExecution.enabled: its settings, and every agent's scripts, @@ -4170,7 +4425,7 @@ export class AgentFramework { // Register framework states ensureStateRegistered(store, { id: FRAMEWORK_STATE_ID, strategy: 'snapshot' }); ensureStateRegistered(store, { id: DEFERRED_WRITES_ID, strategy: 'snapshot' }); - for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID]) { + for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID, AGENT_REGISTRY_STATE_ID]) { ensureStateRegistered(store, { id, strategy: 'snapshot' }); } ensureStateRegistered(store, { @@ -4203,6 +4458,12 @@ export class AgentFramework { ); } } + ensureStateRegistered(store, { + id: AGENT_REGISTRY_ENDED_ID, + strategy: 'append_log', + deltaSnapshotEvery: 100, + fullSnapshotEvery: 20, + }); // Process logging config (default: disabled) const processLoggingPersist = config.processLogging?.persist === true; @@ -4287,9 +4548,41 @@ export class AgentFramework { // subconscious disabled now may have run and left its own entry. framework.restoreUsageState([ ...config.agents.map((agent) => String(agent.name)), - ...(config.subconscious ? [String(config.subconscious.name ?? 'Subconscious')] : []), + // A forks reader has no agent of its own; its forks are bounded jobs. + ...(config.subconscious && config.subconscious.reader !== 'forks' ? [String(config.subconscious.name ?? 'Subconscious')] : []), ]); + framework.announceOrphans = config.dendrite?.announceOrphans ?? true; + framework.collapseDerivationNoticesAfter = Math.max(0, Math.floor(config.dendrite?.collapseDerivationNoticesAfter ?? 10)); + framework.beforeDerive = config.dendrite?.beforeDerive; + framework.derivationNoticeHook = config.dendrite?.derivationNotice; + framework.speechLocus = config.speech?.locus ?? 'delivery'; + framework.tuneOutOffered = !!config.subconscious?.enabled || config.tuneOut?.enabled === true; + framework.tuneOutAnnounce = config.tuneOut?.announce !== false; + + // What the previous process left in the agent registry. Read before any + // agent is declared so a restored persistent agent becomes a new + // incarnation of the same identity rather than a stranger. The registry + // lives in recovery files beside the store (see agentRegistryPath). + framework.agentRegistryPath = config.agentRegistryPath + ?? (config.storePath ? join(config.storePath, 'recovery', 'dendrite-registry.json') : undefined); + framework.agentRegistryEndedPath = config.agentRegistryEndedPath + ?? (config.storePath ? join(config.storePath, 'recovery', 'dendrite-ended.json') : undefined); + if (!framework.agentRegistryPath || !framework.agentRegistryEndedPath) { + const missing = [ + ...(framework.agentRegistryPath ? [] : ['agentRegistryPath']), + ...(framework.agentRegistryEndedPath ? [] : ['agentRegistryEndedPath']), + ]; + console.error( + `[dendrite] no storePath / ${missing.join(' / ')}: the agent registry's ` + + (framework.agentRegistryPath + ? 'ended log persists only in the branch-local store, where a branch switch or historical rollback can erase it' + : 'records and held results persist only in the branch-local store, where a branch switch or historical rollback can erase them') + + ', and a crash can lose what wasn\'t synced; configure the recovery paths', + ); + } + const interruptedAgents = framework.restoreAgentRegistry(); + // Create agents for (const agentConfig of config.agents) { await framework.createAgent(agentConfig); @@ -4299,8 +4592,12 @@ export class AgentFramework { // 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 === true) { - await framework.createSubconsciousAgent(config.subconscious); + if (config.subconscious?.enabled) { + if (config.subconscious.reader === 'forks') { + framework.configureReaderForks(config.subconscious); + } else { + await framework.createSubconsciousAgent(config.subconscious); + } } // Finish any branch-local suppression interrupted after Chronicle switched @@ -4433,8 +4730,9 @@ export class AgentFramework { })(), }); }, - getAgentNames: () => [...framework.agents.keys()].filter( - (n) => n !== framework.subconsciousAgentName), + getAgentNames: () => framework.registry.gateRecipients(), + isHeld: (serverId, channelId) => + !!(serverId && channelId && framework.tuneOutCoordinator?.holdFor(serverId, channelId)), // Push events reach the gate with the adapter's raw channel id; map // them to the registered composite id the same way ingestion does // (handleMcplPushEvent registers that channel on arrival, before any @@ -4601,6 +4899,15 @@ export class AgentFramework { framework.hostPresentation = framework.makeHostPresentation(config.storePath); // Initialize MCPL subsystems if configured + // Configuration has now declared its agents, and host mode and the + // deferred-write queue are restored: settle what the previous process + // left unfinished (interrupted bounded work, persistent agents no longer + // configured, results still held for delivery). After the restore, so a + // quiesced host holds the restart notices and redelivered mail as it + // holds any write, and mail an entry of the restored queue carries is + // left to that entry's landing (nissa's #546 finding 4). + framework.reconcileAgentRegistry(interruptedAgents); + if (config.mcplServers && config.mcplServers.length > 0) { // Check every server's config before any is brought up: its boolean // settings, and its tool prefix, which mustn't collide with a module @@ -4653,9 +4960,14 @@ export class AgentFramework { await framework.ensureToolsetsModule(); } - // Tune-out coordinator (issue #77): needs both the subconscious resident - // and the MCPL channel subsystem; created after both exist. - if (framework.subconsciousAgentName && framework.channelRegistry) { + // Tune-out coordinator (issue #77, #232 §3.3): the hold machinery exists + // wherever tune_out is offered — with a reader (persistent subconscious + // or reader forks) it summarizes and wakes; with `tuneOut.enabled` alone + // it is a silent hold, and a persisted epoch is resumed and released + // (#232 gap 10). Where neither is configured nothing could ever cancel a + // tune-out on record, so each one ends at the start instead + // (endTuneOutsWithoutCoordinator), as main has it. + if (framework.channelRegistry && framework.tuneOutOffered) { framework.tuneOutCoordinator = new TuneOutCoordinator( framework.channelRegistry, framework.mcplServerRegistry!, @@ -4667,12 +4979,21 @@ export class AgentFramework { framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() }); }, subconsciousName: () => framework.subconsciousAgentName, + ...(framework.readerForks + ? { + invokeReader: (invocation: ReaderInvocation) => framework.invokeReaderFork(invocation), + deliverFromReader: (reader: string, content: ContentBlock[], metadata: Record) => + framework.deliverFromReader(reader, content, metadata), + } + : {}), primaryName: () => framework.primaryAgentName, getStoredMessages: () => { const primary = framework.primaryAgentName ? framework.agents.get(framework.primaryAgentName) : undefined; return primary ? primary.getContextManager().getAllMessages() : []; }, + commitDeliveries: () => framework.commitDeliveries(), + historyTool: () => framework.residentHistoryTool(), getDeferredMessages: () => { const forPrimary = (m: DeferredWrite): boolean => (m.forAgent ?? framework.primaryAgentName) === framework.primaryAgentName; // Settled first, so a write that landed is in the stored rows @@ -4680,7 +5001,7 @@ export class AgentFramework { framework.settleUnknownLandings(forPrimary); return framework.deferredMessages.filter(forPrimary) .sort(bySeq) - .map((m) => ({ participant: m.participant, content: m.content, metadata: m.metadata as Record | undefined })); + .map((m) => ({ participant: m.participant, content: m.content, metadata: m.metadata as Record | undefined, writeId: m.id, heldAt: m.heldAt })); }, // Every reader of the shared slot compiles the dump with its own // strategy, so the dump fits the smallest of their bounds. @@ -4696,10 +5017,22 @@ export class AgentFramework { setSubconsciousAnchor: (sequence) => framework.subconsciousStrategy?.setAnchor(sequence), isForkBound: (channel) => - [...framework.conversationAgentHomes.values()].some((home) => sameChannel(home, channel)), + framework.registry.list().some((record) => sameChannel(framework.homeOf(record.name), channel)), isPrivilegedAuthor: (authorId) => framework.eventGate?.isPrivilegedUser(authorId) ?? false, allowChannelSpeech: () => framework.subconsciousConfig?.allowChannelSpeech === true, + holdWithoutReader: () => framework.tuneOutOffered, + ...(framework.tuneOutAnnounce + ? { + // The excepted channel on its server: a bare id two servers + // register is refused (publishForAgent). + announce: async (serverId: string, channelId: string, text: string) => { + const r = await framework.channelRegistry!.publishForAgent( + { serverId, channelId }, text, framework.primaryAgentName ?? 'resident'); + return { success: r.success, ...(r.error ? { error: r.error } : {}) }; + }, + } + : {}), emitTrace: (e) => framework.emitTrace(e as never), }, ); @@ -4791,6 +5124,7 @@ export class AgentFramework { this.running = true; this.providerAdmissionClosed = false; + this.earlyTraces = null; // A new lifecycle: a turn compiled before an earlier stop() never drives, // and withheld spills, closed for the generation a stop ended, are open // for this one. @@ -5042,6 +5376,11 @@ export class AgentFramework { step(() => this.pushCoalescer?.suspend()); step(() => this.flushCoalescingSnapshot()); this.running = false; + // Bounded work this stop cuts off is neither finished nor failed. Its + // record stays live in the snapshot, so the next boot ends it as + // `host-restart` and tells its result recipient — the same as a crash + // (first live run, 2026-10-12, finding D: a reader fork ended silently). + for (const run of this.ephemeralRuns.values()) run.endReason ??= 'host-stop'; // Flushed-but-unsynced deferred writes: sync and ack now, while the // store is still open, rather than leaving them to a reboot replay. step(() => this.ackDeferredWrites()); @@ -5249,11 +5588,11 @@ export class AgentFramework { // since the name is never used again. These releases, and the pass after // them, come before the registry forgets its channels, since a notice // names its channel by the label the registry holds (channelForNotice). - for (const name of [...this.conversationAgentHomes.keys()]) { + // The registry names the conversation forks and their home channels. + for (const { name, home } of this.listAgents().filter((r) => r.kind === 'conversation-fork').map((r) => ({ name: r.name, home: this.homeOf(r.name) }))) { step(() => { - const home = this.conversationAgentHomes.get(name)!; - if (this.conversationRouter?.getBinding(home)?.agentName === name) this.conversationRouter.unbind(home); - this.disposeConversationAgent(name); + if (home !== undefined && this.conversationRouter?.getBinding(home)?.agentName === name) this.conversationRouter.unbind(home); + this.disposeConversationAgent(name, 'host-stop'); }); } // A window release the quiesce postponed (releasesAfterResume) never runs @@ -5682,10 +6021,12 @@ export class AgentFramework { } /** The agents the heartbeat wakes: the residents, not conversation forks, - * ephemeral runs or the subconscious, which keep windows of their own. */ + * ephemeral runs or the subconscious, which keep windows of their own. + * Read from the registry by role, as an untargeted push's recipients are + * (the heartbeat's wakes were heartbeat-mcpl's pushes): the agents whose + * spec takes untargeted inbound, in registration order. */ private heartbeatTargets(): string[] { - return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) - && !this.ephemeralRuns.has(n) && n !== this.subconsciousAgentName); + return this.registry.untargetedRecipients().filter((n) => this.agents.has(n)); } /** @@ -5783,7 +6124,7 @@ export class AgentFramework { private heartbeatRefusal(agentName: string, call: ToolCall): string | null { const tool = call.name.replace(/^(mcpl--)?heartbeat--/, ''); if (!HEARTBEAT_CHANGING_TOOLS.includes(tool) || this.heartbeatTargets().includes(agentName)) return null; - const one = this.conversationAgentHomes.has(agentName) ? 'conversation' + const one = this.registry.homeChannel(agentName) !== undefined ? 'conversation' : this.ephemeralRuns.has(agentName) ? 'run' : 'side agent'; return `Nothing was changed: the heartbeat and its reminders are the agent's, and wake its residents, so this ${one} can't ` + 'change them. heartbeat--heartbeat_status and heartbeat--reminder_list show them.'; @@ -6011,7 +6352,7 @@ export class AgentFramework { return true; } private holdProviderAcceleration(agent: Agent, error: Error, trigger?: InferenceRequest, auxiliary = false): boolean { - if (this.ephemeralRuns.has(agent.name) || this.conversationAgentHomes.has(agent.name)) return false; + if (!this.registry.ownsProviderScheduling(agent.name)) return false; // A provider's stated wait binds later calls to the primary model, across // restarts. (An auxiliary call records its own model's wait at the gate.) const wait = auxiliary ? undefined : this.recordProviderWait(agent.name, agent.model, error); @@ -6140,7 +6481,7 @@ export class AgentFramework { // From either copy of membrane (isMembraneError), a wait is stated only // as a number, as the default error policy reads it. if (!isMembraneError(error) || typeof error.retryAfterMs !== 'number') return undefined; - if (this.conversationAgentHomes.has(agent.name)) { + if (this.registry.homeChannel(agent.name) !== undefined) { const wait = this.recordProviderWait(agent.name, agent.model, error); return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true) ? 'held' @@ -6364,6 +6705,14 @@ export class AgentFramework { // failure or start reaching here after it holds nothing, so no retry // outlives the stop as a turn still alive. Nothing is started either. if (this.providerAdmissionClosed) return true; + // A listener of the failure reported here (`report`) may have ended the + // agent (stopAgent): its end dropped what of its turn was on its way, + // and nothing is held for an agent that's gone, so no retry outlives it + // as a turn still alive. (A caller that reports a failure before calling + // here checks the agent first: nonOwningProviderWait holds only for an + // agent whose registry record is still live, and the scheduler and the + // last boundary pass this.agents' own Agent.) + if (this.agents.get(agent.name) !== agent) return true; // A listener of the report may have released the wait // (releaseProviderWait) before anything was held: nothing binds now, so // the requests start, as the release of an installed hold starts them. @@ -6901,9 +7250,21 @@ export class AgentFramework { /** * Add a trace event listener for observability. */ - onTrace(listener: TraceEventListener): () => void { + onTrace(listener: TraceEventListener, options?: { replay?: boolean }): () => void { const registration: TraceRegistration = { listener, active: true }; this.traceListeners.push(registration); + // A host attaches its sinks after create() returns, so the traces + // create() emitted (registry restore, interrupted work, resumed epochs) + // are replayed to a listener that asks; dropped at start(). + if (options?.replay) { + const replayed: TraceEvent[] = [...(this.earlyTraces ?? [])]; + if (this.earlyTraces && this.earlyTracesDropped > 0) { + replayed.push({ type: 'dendrite:early-traces-dropped', dropped: this.earlyTracesDropped, timestamp: Date.now() }); + } + for (const event of replayed) { + try { registration.listener(event); } catch (error) { console.error('Trace listener error (replay):', error); } + } + } // Return an unsubscribe so callers with a bounded lifetime (e.g. modules // that get torn down and recreated on session switch) don't leak // listeners. Existing callers that ignore the return value are unaffected. @@ -7293,8 +7654,8 @@ export class AgentFramework { if (this.channelRegistry) { channelTools.push(AgentFramework.DRAFTS_TOOL); } - if (this.tuneOutCoordinator) { - channelTools.push(AgentFramework.TUNE_OUT_TOOL); + if (this.tuneOutCoordinator && this.tuneOutOffered) { + channelTools.push(AgentFramework.TUNE_OUT_TOOL, AgentFramework.FOCUS_TOOL); } const gateTools = this.eventGate ? [ @@ -7689,7 +8050,7 @@ export class AgentFramework { private tunedOutSendNote(agentName: string, channel: ChannelRef): string | undefined { try { const registry = this.channelRegistry; - if (!registry || this.conversationAgentHomes.has(agentName)) return undefined; + if (!registry || this.registry.homeChannel(agentName) !== undefined) return undefined; if (registry.getDesiredState(channel.serverId, channel.channelId) !== 'tuned-out') return undefined; if (!this.closedChannelNoteOn(agentName)) return undefined; // How it ends is tune_out's own refusals' sentence, which names the @@ -7722,7 +8083,7 @@ export class AgentFramework { * delivered, so that's logged and nothing is added. */ private closedChannelNotes(agentName: string, channels: Iterable): string[] { try { - if (this.conversationAgentHomes.has(agentName)) return []; + if (this.registry.homeChannel(agentName) !== undefined) return []; const notes = [...channels].flatMap((channel) => this.closedChannelNoteFor(agentName, channel) ?? []); // The setting is read only when there's a note to give. return notes.length > 0 && this.closedChannelNoteOn(agentName) ? notes : []; @@ -7963,7 +8324,13 @@ export class AgentFramework { : t); return [...SUBCONSCIOUS_TOOLS, ...basics]; } - return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName)!.config.cataloguePath) : [])] + const surfaceOwner = this.toolSurfaceOwners.get(agentName) ?? agentName; + // Reader forks (subconscious.reader: 'forks') share the resident's + // provider prefix, and the tool block is the front of it: the reader's + // four tools are therefore in every agent's block and refused at + // dispatch for everyone but a reader (see dispatchToolCall). + const readerTools = this.readerForks ? SUBCONSCIOUS_TOOLS : []; + return [...this.getAllTools(), ...readerTools, ...(this.toolPresentations.has(surfaceOwner) ? presentationTools(this.toolPresentations.get(surfaceOwner)!.config.cataloguePath) : [])] .map((tool) => { if (tool.name === 'think') { return this.buildThinkTool( @@ -7991,7 +8358,9 @@ export class AgentFramework { } inspectToolPresentation(agentName: string, snapshot?: InferenceToolSnapshot): PresentationSnapshot | null { - const presentation = this.toolPresentations.get(agentName); + // A derived agent shows its parent's presentation (see toolSurfaceOwners); + // only the owner can edit it (editToolPresentation looks up by own name). + const presentation = this.toolPresentations.get(this.toolSurfaceOwners.get(agentName) ?? agentName); if (!presentation) return null; const tools = this.availableToolsForPresentation(agentName, snapshot); const sources = new Map(); @@ -9349,6 +9718,15 @@ export class AgentFramework { } this.deferredMessages.push(...restored); this.deferredWritesPersisted = true; + // A tune-out release that was still deferred at the crash owes the + // resident the wake its cancel requested (#232 gap 6). + for (const m of restored) { + if ((m.metadata as { kind?: unknown } | undefined)?.kind !== 'tune-out-cancel') continue; + const target = m.forAgent ?? this.primaryAgentName; + if (target) { + this.pendingRequests.push({ agentName: target, reason: 'tune-out released (restored)', source: 'tune-out', timestamp: Date.now() }); + } + } // Rewrite the durable queue without the already-landed entries so a // second interrupted boot does not re-scan them, without the malformed // and repeated ones, and with a seq for each entry that came without a @@ -10249,7 +10627,18 @@ export class AgentFramework { * ContextManager, before the store. Until then the ContextManager stays * open. */ - async createEphemeralAgent(config: AgentConfig): Promise<{ + async createEphemeralAgent( + config: AgentConfig, + /** + * How the registry should describe this agent. Omitted: an independent + * fresh worker with no spawner and no result route, which is what every + * caller of this method got before the registry existed. Supplying a + * spec (see the presets in `dendrite/`) declares who spawned it, where + * its result goes and what it inherited, so lifecycle rules — orphan + * notice, reparenting, held results — apply to it. + */ + spec?: Omit, + ): Promise<{ agent: Agent; contextManager: ContextManager; cleanup: () => void; @@ -10266,24 +10655,79 @@ export class AgentFramework { } this.usedEphemeralAgentNames.set(config.name, 'run'); const release: ManagerRelease = { released: false, calls: new Set() }; + let opened: string | undefined; try { - const namespace = `subagent/${config.name}`; + // A namespace the spec names is where the context opens, as it is what + // the record says and what inspection and resumption open later. + let namespace = spec?.namespace ?? `subagent/${config.name}`; + let contextManager: ContextManager; + // The host's say, on the spec of what is about to exist. + if (this.beforeDerive) { + const effective: AgentSpec = spec + ? { ...spec, name: config.name, model: config.model } + : { ...workerSpec(config.name, { namespace }), model: config.model }; + const verdict = this.beforeDerive(effective); + if (verdict && typeof verdict === 'object' && 'refuse' in verdict) { + throw new Error(`${config.name} was not created: ${verdict.refuse}`); + } + } + let inherit = spec?.inherit; - const contextManager = await ContextManager.open({ - store: this.store, - namespace, - isolate: true, - strategy: config.strategy ?? new PassthroughStrategy(), - membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release), - debugLogContext: !!process.env.DEBUG_CONTEXT, - }); + if (inherit?.mode === 'shared') { + // Shared reads, separate writes: the child's context is the parent's + // at a checkpoint, on a branch of its own. Nothing is copied. + const derived = await this.deriveSharedContext(config, inherit, release); + contextManager = derived.contextManager; + inherit = derived.inherit; + namespace = derived.namespace; + } else { + // A namespace belongs to the agent that opened it: another agent's + // turns there would be read as its own by inspection and by its + // resumption, which reopens "its own namespace". So one another + // agent's context holds (live, a candidate, awaiting re-declaration + // or ended) is refused; an agent's own reopens in a new incarnation. + const holder = this.namespaceHolder(namespace, config.name); + if (holder) { + // The default is offered only where it isn't the namespace refused. + const fallback = `subagent/${config.name}`; + throw new Error( + `Ephemeral agent "${config.name}" refused: namespace "${namespace}" holds the context of agent ` + + `"${holder}"; name another${namespace === fallback ? '' : `, or none for ${fallback}`}`, + ); + } + this.namespaceOwners.set(namespace, config.name); + opened = namespace; + contextManager = await ContextManager.open({ + store: this.store, + namespace, + isolate: true, + strategy: config.strategy ?? new PassthroughStrategy(), + membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release), + debugLogContext: !!process.env.DEBUG_CONTEXT, + }); + } + // Either manager is the run's to release (main's manager release), the derived one included. this.trackManager(contextManager); release.manager = contextManager; const agent = new Agent(config, contextManager, this.membrane); this.adoptToolResultGuard(agent); agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable - this.ephemeralCandidates.set(agent, contextManager); + this.ephemeralCandidates.set(agent, { + contextManager, + config, + spec: spec + ? { + ...spec, + name: config.name, + model: config.model, + // A shared fork's context lives under its parent's namespace, + // on the fork's own branch — not in a namespace of its own. + namespace: inherit?.mode === 'shared' ? namespace : spec.namespace ?? namespace, + ...(inherit ? { inherit } : {}), + } + : { ...workerSpec(config.name, { namespace }), model: config.model }, + }); this.ephemeralAgents.add(agent); this.managerReleases.set(agent, release); @@ -10296,10 +10740,1447 @@ export class AgentFramework { } catch (error) { if (release.manager) this.closeManager(release.manager); this.usedEphemeralAgentNames.delete(config.name); + if (opened !== undefined) this.namespaceOwners.delete(opened); throw error; } } + /** + * Derive a new agent from a live one: the parent's context at a + * checkpoint, a private continuation, and — unless overridden — the + * parent's whole configuration (model, thinking, cache TTL, provider + * params, prompt, tools). The parent keeps running. + * + * The returned agent is a bounded job, driven with + * `runEphemeralToCompletion` exactly like one from `createEphemeralAgent`. + * + * **Inheritance.** `mode: 'shared'` (the default) gives the child the + * parent's messages and fold state through shared storage: nothing is + * copied, the child folds with the parent's memory tree, and neither can + * alter what the other reads. It needs branch-bound store handles and is + * refused, not silently downgraded, where they are missing. `mode: 'copy'` + * is the historical mechanism — the parent's compiled context re-added as + * the child's own messages — and works everywhere. + * + * **Rendering.** With `solve: 'reuse'` (the default) and nothing + * overridden, the child's first request is the parent's request plus the + * child's framing: the same system prompt and tool block, the parent's + * turns presented as the child's own, and a cache marker on the end of + * the parent's request so the prefix it wrote is read back. `solve: + * 'fresh'` deliberately re-solves, e.g. at another budget; so does any + * override that changes what is rendered. Those are operating choices — + * the presentation changes and the provider cache misses. + * + * **Framing** is ordinary messages appended to the child before it runs: + * the task, and whatever tells it which stream it is. The parent's turn + * in flight is not in its store yet (see `getPendingAssistantRound`), so + * a fork made from inside a tool call starts from the message before the + * turn that made it. + * + * Context isolation is not process or filesystem isolation: parent and + * child share tools, any shell and the workspace. + */ + async deriveAgent(options: DeriveAgentOptions): Promise<{ + agent: Agent; + contextManager: ContextManager; + cleanup: () => void; + }> { + const parent = this.agents.get(options.from); + const parentConfig = this.agentConfigs.get(options.from); + if (!parent || !parentConfig) { + throw new Error(`deriveAgent: "${options.from}" is not a registered agent`); + } + const mode = options.mode ?? 'shared'; + const shared = mode === 'shared'; + if (!shared && options.inheritRefusals === true) { + throw new Error(`deriveAgent: "${options.name}" was not created: inheritRefusals is for mode 'shared', and a copy brings no refusal ledger`); + } + if (!shared && options.solve !== undefined) { + throw new Error(`deriveAgent: "${options.name}" was not created: solve is for mode 'shared', and a copy is not solved`); + } + + const config: AgentConfig = { + ...parentConfig, + // The parent's LIVE settings, not just its recipe: a runtime budget or + // think-policy override changes what it renders. + ...(parent.contextBudgetTokens !== undefined ? { contextBudgetTokens: parent.contextBudgetTokens } : {}), + sameRoundThinkTextPolicy: parent.getEffectiveSameRoundThinkTextPolicy(), + ...options.config, + name: options.name, + strategy: options.strategy, + // A shared inheritance has no copy step to rename the parent's turns, + // so the mapping is declared: the parent's voice is the child's own, + // presented under the name the parent's own requests use. + ...(shared + ? { + selfParticipants: [...new Set([ + parent.name, + ...(parentConfig.selfParticipants ?? []), + ...(options.config?.selfParticipants ?? []), + ])], + presentAs: options.config?.presentAs ?? parent.presentAs, + } + : { selfParticipants: options.config?.selfParticipants, presentAs: options.config?.presentAs }), + }; + + const spec = taskForkSpec(options.name, { + parent: options.from, + spawnedBy: options.spawnedBy ?? options.from, + ...(options.resultTo ? { resultTo: options.resultTo } : {}), + ...(options.deadlineMs !== undefined ? { deadlineMs: options.deadlineMs } : {}), + ...(options.idleTimeoutMs !== undefined ? { idleTimeoutMs: options.idleTimeoutMs } : {}), + ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}), + ...(options.metadata ? { metadata: options.metadata } : {}), + inherit: { + mode, + ...(options.solve ? { solve: options.solve } : {}), + ...(options.inheritRefusals !== undefined ? { refusals: options.inheritRefusals } : {}), + }, + }); + const { name: _name, ...specBody } = { + ...spec, + // The record states the mapping the agent's requests actually use. + ...(config.selfParticipants?.length ? { selfParticipants: config.selfParticipants } : {}), + ...(config.presentAs && config.presentAs !== config.name ? { presentAs: config.presentAs } : {}), + ...(options.kind ? { kind: options.kind } : {}), + ...(options.onParentEnd ? { onParentEnd: options.onParentEnd } : {}), + }; + + const created = await this.createEphemeralAgent(config, specBody); + try { + if (this.agents.get(options.from) !== parent) { + throw new Error(`deriveAgent: "${options.from}" ended while "${options.name}" was being derived from it`); + } + if (options.config?.allowedTools === undefined) { + this.toolSurfaceOwners.set(options.name, this.toolSurfaceOwners.get(options.from) ?? options.from); + } + + if (!shared) { + // The historical copy: the parent's compiled view, its turns renamed + // so the child reads its inheritance as its own history. + const { messages } = await parent.getContextManager().compile(); + for (const message of messages) { + created.contextManager.addMessage( + parent.isOwnParticipant(message.participant) ? options.name : message.participant, + message.content, + ); + } + created.agent.markContextConsumed(); + } + await this.framePendingRound(parent, created.contextManager, options, shared); + for (const message of options.framing ?? []) { + created.contextManager.addMessage(message.participant, message.content, message.metadata); + } + return created; + } catch (error) { + this.discardEphemeralCandidate(created.agent, created.contextManager); + throw error; + } + } + + private discardEphemeralCandidate(agent: Agent, contextManager: ContextManager): void { + this.ephemeralCandidates.delete(agent); + this.ephemeralAgents.delete(agent); + if (this.toolSurfaceOwners.has(agent.name) && !this.agents.has(agent.name)) this.toolSurfaceOwners.delete(agent.name); + this.releaseManager(agent); + this.managerReleases.delete(agent); + this.closeManager(contextManager); + } + + /** + * The default wording for the call that derived a child: who it is, what + * kind, and how long — the fields the consent event names, so the fork + * never has to infer its own lifetime from context (Linn, 10-09). + */ + private derivedNote(parentName: string, options: DeriveAgentOptions): string { + const record = this.registry.inspect(options.name); + const kind = record?.kind ?? options.kind ?? 'task-fork'; + const onParentEnd = record?.onParentEnd ?? options.onParentEnd ?? 'orphan'; + const route = record?.relationships.resultTo ?? options.resultTo; + const returns = route + ? route.as === 'message' + ? `your report returns to ${route.to} as mail under your own name` + : `your result returns to ${route.to} as this call's result` + : null; + const lifetime = + onParentEnd === 'end' + ? `You end when ${parentName} does${returns ? `; ${returns}` : ''}; nothing you do speaks as ${parentName}.` + : `You finish your task and then end${returns ? `; ${returns}` : ''}; if ${parentName} ends first you keep running, as an orphan with the same return address.`; + return ( + `[This call derived ${options.name}. You are ${options.name}, a ${kind}, continuing from here on your own branch. ` + + `${lifetime} ${parentName} receives this call's result, not you.]` + ); + } + + /** + * The parent's in-flight tool round, reproduced on the child's branch so + * the child sees the call that made it (see `DeriveAgentOptions.pendingRound`). + */ + private async framePendingRound( + parent: Agent, + child: ContextManager, + options: DeriveAgentOptions, + shared: boolean, + ): Promise { + const round = options.pendingRound ?? {}; + if (round.include === false) return; + const pending = this.pendingAssistantBlocks.get(parent.name); + if (!pending) return; + const calls = pending.filter((b): b is Extract => b.type === 'tool_use'); + if (calls.length === 0) return; + const state = parent.state; + const completed = state.status === 'waiting_for_tools' ? state.completed : []; + const cap = this.resolveToolResultInlineCap(parent).cap; + const { blocks: received } = await this.buildStoredToolResultContent(options.name, completed, cap); + const byId = new Map(received.map((b) => [(b as { toolUseId: string }).toolUseId, b])); + const open = calls.filter((c) => !byId.has(c.id)); + const madeBy = round.madeBy?.toolUseId ?? (open.length === 1 ? open[0]!.id : undefined); + const results: ContentBlock[] = calls.map((call) => { + const stored = byId.get(call.id); + if (stored) return stored; + const content = + call.id === madeBy + ? round.madeBy?.result ?? this.derivedNote(parent.name, options) + : round.results?.[call.id] ?? + `[No result yet: this call was still running in ${parent.name} when ${options.name} was derived.]`; + return { type: 'tool_result' as const, toolUseId: call.id, toolName: call.name, content, isError: false }; + }); + const metadata = { kind: 'dendrite-pending-round', from: parent.name } as unknown as MessageMetadata; + child.addMessage(shared ? parent.name : options.name, structuredClone(pending), metadata); + child.addMessage('user', results, metadata); + } + + /** + * The assistant turn an agent has produced but not yet stored: its tool + * calls and any text before them, held until every tool result is in so + * the pair lands adjacently. A host deriving an agent from inside one of + * those tool calls can reproduce the turn in the child's framing. + */ + getPendingAssistantRound(agentName: string): ContentBlock[] | undefined { + const blocks = this.pendingAssistantBlocks.get(agentName); + return blocks ? structuredClone(blocks) : undefined; + } + + /** + * Bring an ended bounded job back as a new incarnation of the same + * identity, on the context it left: an isolated agent's own namespace, or + * a derived agent's own branch. Nothing is re-derived and nothing is + * re-seeded — the agent continues from its last stored message, which is + * the end of its last complete round (a round whose tool results never + * all arrived was never stored). + * + * Use it for a job that was stopped, failed, or was interrupted by a + * restart. Configuration is not persisted with an agent, so the caller + * supplies it again; the role mapping a derived agent used is restored + * from its record unless the config sets one. Relationships are kept + * where the other agent still exists; a job whose spawner or result + * recipient is gone resumes without that edge and can be reparented. + * + * External effects are not replayed and not undone: a tool the previous + * incarnation ran before it ended has had its effect, and a call it never + * received a result for may be made again by the model. Drive the result + * with `runEphemeralToCompletion`. + */ + async resumeAgent(name: string, config: Omit): Promise<{ + agent: Agent; + contextManager: ContextManager; + cleanup: () => void; + }> { + if (this.resumesPending.has(name)) { + throw new Error(`resumeAgent: "${name}" is already being resumed`); + } + if (this.registry.has(name) || this.agents.has(name) || this.ephemeralRuns.has(name)) { + throw new Error(`resumeAgent: "${name}" is still registered; only an ended agent resumes`); + } + const record = this.registry.inspect(name); + if (!record?.ended) throw new Error(`resumeAgent: "${name}" is not a known ended agent`); + if (record.lifetime.kind !== 'task') { + throw new Error( + `resumeAgent: "${name}" has a ${record.lifetime.kind} lifetime; only bounded jobs resume this way`, + ); + } + const fullConfig: AgentConfig = { + ...config, + name, + ...(config.selfParticipants === undefined && record.selfParticipants + ? { selfParticipants: record.selfParticipants } + : {}), + ...(config.presentAs === undefined && record.presentAs ? { presentAs: record.presentAs } : {}), + }; + const spawnedBy = record.relationships.spawnedBy; + const resultTo = record.relationships.resultTo; + const keepSpawner = spawnedBy !== undefined && this.registry.has(spawnedBy); + const spec: AgentSpec = { + name, + kind: record.kind, + model: fullConfig.model, + roles: record.roles, + lifetime: record.lifetime, + ...(record.activation ? { activation: record.activation } : {}), + ...(keepSpawner ? { spawnedBy, onParentEnd: record.onParentEnd } : {}), + ...(resultTo && this.registry.has(resultTo.to) ? { resultTo } : {}), + ...(record.inherit ? { inherit: record.inherit } : {}), + ...(fullConfig.selfParticipants?.length ? { selfParticipants: fullConfig.selfParticipants } : {}), + ...(fullConfig.presentAs && fullConfig.presentAs !== name ? { presentAs: fullConfig.presentAs } : {}), + ...(record.namespace ? { namespace: record.namespace } : {}), + ...(record.metadata ? { metadata: record.metadata } : {}), + }; + if (this.beforeDerive) { + const verdict = this.beforeDerive(spec); + if (verdict && typeof verdict === 'object' && 'refuse' in verdict) { + throw new Error(`${name} was not resumed: ${verdict.refuse}`); + } + } + + this.resumesPending.set(name, undefined); + const release: ManagerRelease = { released: false, calls: new Set() }; + let contextManager: ContextManager; + try { + contextManager = await this.reopenAgentContext(record, config.strategy, this.ephemeralAuxiliaryMembraneFor(name, release)); + } catch (error) { + this.resumesPending.delete(name); + throw error; + } + this.trackManager(contextManager); + release.manager = contextManager; + let agent: Agent; + try { + if (this.registry.has(name) || this.agents.has(name) || this.ephemeralRuns.has(name)) { + throw new Error(`resumeAgent: "${name}" was registered while it was being resumed`); + } + agent = new Agent(fullConfig, contextManager, this.membrane); + this.adoptToolResultGuard(agent); + agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable + } catch (error) { + this.resumesPending.delete(name); + this.closeManager(contextManager); + throw error; + } + + this.usedEphemeralAgentNames.set(name, 'run'); // a resumed job is an ephemeral run: no global fallback (takesGlobalFallback) + this.ephemeralAgents.add(agent); // and its usage counts in the shared ephemeral entry (#558) + this.ephemeralCandidates.set(agent, { contextManager, config: fullConfig, resume: true, after: record.incarnation, spec }); + this.managerReleases.set(agent, release); + this.resumesPending.set(name, agent); + const cleanup = () => { + if (this.resumesPending.get(name) === agent) this.resumesPending.delete(name); + this.releasedByCleanup.add(agent); + this.releaseManager(agent); + }; + return { agent, contextManager, cleanup }; + } + + /** + * A context to read an agent's history from, without changing the live + * system. + * + * - A live agent: a snapshot derived from its context — at the head, or + * at `atSequence` for an earlier state. The snapshot is its own branch; + * the agent keeps running and is not affected by anything done to it. + * - An ended agent: the context it left, reopened where it lives. + * + * The returned manager uses a passthrough strategy: it shows what is + * stored, raw. An ended agent's context is the real one — treat it as + * read-only unless you mean to change what a later resume starts from. + */ + async inspectAgentContext(name: string, options: { atSequence?: number } = {}): Promise { + const live = this.registry.has(name) ? this.agents.get(name) : undefined; + if (live) { + const source = live.getContextManager() as ContextManager & Partial; + if (typeof source.derive !== 'function') { + throw new Error( + 'inspectAgentContext: a snapshot of a live agent needs a @animalabs/context-manager that provides ContextManager.derive', + ); + } + const taken = new Set(this.store.listBranches().map((branch) => branch.name)); + let n = 1; + while (taken.has(`dendrite/inspect/${name}/${n}`)) n++; + return source.derive({ + branch: `dendrite/inspect/${name}/${n}`, + strategy: new PassthroughStrategy(), + membrane: this.membrane, + solve: 'fresh', + ...(options.atSequence !== undefined ? { atSequence: options.atSequence } : {}), + }); + } + const record = this.registry.inspect(name); + if (!record) throw new Error(`inspectAgentContext: "${name}" is not a known agent`); + if (options.atSequence !== undefined) { + throw new Error('inspectAgentContext: atSequence applies to a live agent; an ended agent is read as it was left'); + } + return this.reopenAgentContext(record, new PassthroughStrategy()); + } + + /** Open the context an ended agent left, wherever it lives. */ + private async reopenAgentContext(record: AgentRecord, strategy?: ContextStrategy, membrane: Membrane = this.membrane): Promise { + // The residents' view as createAgent gives it: held rows out, and another + // agent's stored context injections out, since those are that agent's + // context, not conversation (Weft's ninth-round probe: an ended reader + // reopened for inspection showed a co-resident's injection it never saw live). + const residentSees = (name: string) => (metadata: unknown): boolean => + RESIDENT_VIEW_FILTER({ metadata } as StoredMessage) && !isAnotherAgentsInjection(metadata, name); + // A context derived from a reader of the shared slot sees, as it did live + // (deriveSharedContext, #291), what its source saw and its own stored + // injections; its source's view is composed the same way, up the shared + // sources the registry names, to the reader the first was derived from. + const derivedSees = (from: AgentRecord, walked = new Set()): ((metadata: unknown) => boolean) => { + const source = from.inherit?.mode === 'shared' ? from.inherit.from : undefined; + if (source === undefined || walked.has(from.name)) return residentSees(from.name); + walked.add(from.name); + const sourceRecord = this.registry.inspect(source); + const sourceSees = sourceRecord ? derivedSees(sourceRecord, walked) : residentSees(source); + return (metadata) => sourceSees(metadata) || isOwnInjection(metadata, from.name); + }; + // The predicate is the manager's view filter, and is recorded beside it, + // so main's checks of whether an agent sees a row (agentSees) ask what its + // view holds when the reopened context is live. + const seen = (contextManager: ContextManager, sees: (metadata: unknown) => boolean): ContextManager => { + this.storeSees.set(contextManager, sees); + return contextManager; + }; + if (record.inherit?.mode === 'shared') { + const derivation = record.inherit.derivation as ContextDerivationRecord | undefined; + const reopen = (ContextManager as unknown as { reopenDerived?: ReopenDerived }).reopenDerived; + if (!derivation || typeof reopen !== 'function') { + throw new Error( + `Agent "${record.name}": its context is a derived branch, which this ` + + '@animalabs/context-manager cannot reopen (ContextManager.reopenDerived)', + ); + } + // A context derived from a reader of the shared slot keeps the view it had. + const sees = derivation.slots?.messageNamespace === null ? derivedSees(record) : undefined; + const reopened = await reopen.call(ContextManager, { + store: this.store, + derivation, + strategy: strategy ?? new PassthroughStrategy(), + membrane, + ...(sees ? { viewFilter: (message: StoredMessage) => sees(message.metadata) } : {}), + debugLogContext: !!process.env.DEBUG_CONTEXT, + }); + return sees ? seen(reopened, sees) : reopened; + } + if (!record.namespace) { + throw new Error(`Agent "${record.name}": its record names no context to reopen`); + } + // A reader of the residents' shared slot (a resident dropped from the + // configuration, say) is reopened as createAgent opened it: on the + // shared slot, through the residents' view. Isolated, its namespace + // holds none of what it read (nissa's #546 review, round 2). + const sees = record.readsSharedSlot === true ? residentSees(record.name) : undefined; + const opened = await ContextManager.open({ + store: this.store, + namespace: record.namespace, + ...(sees ? { viewFilter: (message: StoredMessage) => sees(message.metadata) } : { isolate: true }), + strategy: strategy ?? new PassthroughStrategy(), + membrane, + debugLogContext: !!process.env.DEBUG_CONTEXT, + }); + return sees ? seen(opened, sees) : opened; + } + + /** Open a derived agent's context on a branch of its own (see deriveAgent). */ + private async deriveSharedContext( + config: AgentConfig, + inherit: NonNullable, + release: ManagerRelease, + ): Promise<{ contextManager: ContextManager; inherit: NonNullable; namespace: string }> { + const parent = this.agents.get(inherit.from); + if (!parent) { + throw new Error(`Agent "${config.name}": context source "${inherit.from}" is not a registered agent`); + } + if (this.surgeryHold) { + throw new Error( + `Agent "${config.name}" refused: the store is under live ${this.surgeryHold.verb} ` + + `for ${this.surgeryHold.agentName} — retry when it completes`, + ); + } + const source = parent.getContextManager() as ContextManager & Partial; + if (typeof source.derive !== 'function') { + throw new Error( + `Agent "${config.name}": shared context inheritance needs a @animalabs/context-manager ` + + 'that provides ContextManager.derive; use inherit.mode "copy" with this version', + ); + } + // The branch is the child's durable state and outlives it. Names from an + // earlier process may still own a branch of the same name. + const taken = new Set(this.store.listBranches().map((branch) => branch.name)); + let ownBranch = `dendrite/${config.name}`; + for (let n = 2; taken.has(ownBranch); n++) ownBranch = `dendrite/${config.name}~${n}`; + + // The fork's view: what its source sees, and its own stored context + // injections. context-manager's derive would give it the source's filter + // alone, which is tied to the source's name (isAnotherAgentsInjection), so + // the fork's own injections were hidden from its compile while its + // parent's showed (#291; Weft's eleventh note, on the real compile path). + // The composed predicate goes to derive, so the first compile already + // reads through it, and is recorded beside the manager (storeSees), so + // main's checks of whether an agent sees a row (agentSees: a tool + // boundary's injection, an arrival, a reader of an unread message) ask + // what its compile shows. A source with no view recorded has no filter + // (an isolated run's): it sees every row, so its fork keeps none, and + // sees its own. + const sourceSees = this.storeSees.get(source); + const sees = sourceSees + ? (metadata: unknown): boolean => sourceSees(metadata) || isOwnInjection(metadata, config.name) + : undefined; + const contextManager = await source.derive({ + branch: ownBranch, + strategy: config.strategy ?? new PassthroughStrategy(), + // The run's auxiliary membrane, as the copy path's: its compression calls + // are withdrawn once the run is released (Weft's ninth-round reading). + membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release), + solve: inherit.solve ?? 'reuse', + inherit: { refusals: inherit.refusals === true }, + ...(inherit.atSequence !== undefined ? { atSequence: inherit.atSequence } : {}), + ...(sees ? { viewFilter: (message: StoredMessage) => sees(message.metadata) } : {}), + debugLogContext: !!process.env.DEBUG_CONTEXT, + }); + if (sees) this.storeSees.set(contextManager, sees); + const derivation = (contextManager as ContextManager & Partial).getDerivation?.(); + return { + contextManager, + inherit: { + ...inherit, + solve: inherit.solve ?? 'reuse', + refusals: inherit.refusals === true, + ownBranch, + ...(derivation + ? { branch: derivation.parentBranch, atSequence: derivation.atSequence, derivation } + : {}), + }, + namespace: this.registry.get(inherit.from)?.namespace ?? `subagent/${config.name}`, + }; + } + + // ========================================================================== + // Dendrite: agent registry, lifecycle and attributed communication + // ========================================================================== + // + // One trust domain: there is no permission check on any of this. Any + // caller may list, cancel, stop or reparent any agent. Context isolation + // between agents is not process or filesystem isolation — agents sharing + // tools, a shell or a workspace can affect the same external state. + + /** Flat list of registered agents with their roles, lifetime and relationships. */ + listAgents(options: { includeEnded?: boolean } = {}): AgentRecord[] { + return structuredClone(this.registry.list(options)); + } + + /** One agent's record: the live one, else its most recent ended one. */ + getAgentRecord(name: string): AgentRecord | null { + const record = this.registry.inspect(name); + return record ? structuredClone(record) : null; + } + + /** The agent that owns default delivery, or null before any is declared. */ + getPrimaryAgentName(): string | null { + return this.registry.primary(); + } + + /** + * Activations in flight, each with the agent that owns the work. Derived + * from the live turn state, so it cannot disagree with what is running. + */ + listActivations(): ActivationRecord[] { + const activations: ActivationRecord[] = []; + for (const [name, token] of this.activeTurnTokens) { + const record = this.registry.get(name); + if (!record) continue; + const trigger = this.activeTurnTriggers.get(name); + activations.push({ + id: `${name}#${record.incarnation}:${token}`, + owner: { agent: name, incarnation: record.incarnation }, + ...(trigger?.timestamp !== undefined ? { requestedAt: trigger.timestamp } : {}), + ...(trigger?.reason ? { reason: trigger.reason } : {}), + ...(trigger?.source ? { source: trigger.source } : {}), + ...(trigger?.channelId ? { channelId: trigger.channelId } : {}), + }); + } + return activations; + } + + /** + * Policy epochs currently in force (tune-out holds), read from the + * coordinator's own durable state — not mirrored. An epoch is neither an + * agent nor a task: ending one leaves both running. + */ + listPolicyEpochs(): PolicyEpoch[] { + if (!this.channelRegistry) return []; + const owner = this.registry.primary(); + const epochs: PolicyEpoch[] = []; + for (const entry of this.channelRegistry.listChannelsRaw()) { + const state = this.channelRegistry.getTuneOutState(entry.serverId, entry.descriptor.id); + if (!state) continue; + epochs.push({ + id: state.params.epochId, + kind: 'tune-out', + scope: { serverId: entry.serverId, channelId: entry.descriptor.id }, + ...(owner ? { owner } : {}), + ...(this.subconsciousAgentName ? { reader: this.subconsciousAgentName } : {}), + startedAtSequence: state.params.startedAtSequence, + ...(state.params.expiresAtMs !== undefined ? { expiresAtMs: state.params.expiresAtMs } : {}), + }); + } + const scope = this.tuneOutCoordinator?.getAllExcept(); + if (scope) { + epochs.push({ + id: scope.params.epochId, + kind: 'tune-out', + scope: { kind: 'all-except', except: scope.params.except }, + ...(owner ? { owner } : {}), + ...(this.subconsciousAgentName ? { reader: this.subconsciousAgentName } : {}), + startedAtSequence: scope.params.startedAtSequence, + expiresAtMs: scope.params.expiresAtMs, + }); + } + return epochs; + } + + /** + * Cancel an agent's activation in flight, leaving the agent registered. + * For a bounded job, whose lifetime is that one run, this ends the job. + * Returns false when nothing was running. + */ + cancelActivation(name: string, options: { by?: string; reason?: string } = {}): boolean { + if (!this.registry.has(name)) throw new Error(`cancelActivation: "${name}" is not a registered agent`); + const run = this.ephemeralRuns.get(name); + if (run) { + run.endReason ??= 'stopped'; + run.endedBy ??= options.by; + // The awaiting caller is told who stopped the job and why, whether or + // not a stream was in flight: settled first, so the aborted stream's own + // settle ('Stream cancelled') finds the run already settled. The caller's + // teardown runs after this call, as for stopAgent. + run.settle.reject(new AgentStoppedError(name, 'stopped', options.by, options.reason)); + } + const cancelled = this.abortInference(name, options.reason ?? 'cancelled'); + if (cancelled || run) { + this.emitTrace({ + type: 'dendrite:activation-cancelled', + agentName: name, + ...(options.by ? { by: options.by } : {}), + ...(options.reason ? { reason: options.reason } : {}), + }); + } + return cancelled || run !== undefined; + } + + /** + * End an agent: cancel its run, remove it from the framework, and settle + * what it spawned — attention tenants end with it; task work is orphaned, + * notified and offered for reparenting, and keeps running. + * + * The default-delivery owner cannot be stopped (the framework needs + * exactly one); cancel its activation instead. A persistent agent stopped + * here is declared by configuration and returns at the next boot. + */ + stopAgent(name: string, options: { by?: string; reason?: string } = {}): EndOutcome { + const record = this.registry.get(name); + if (!record) throw new Error(`stopAgent: "${name}" is not a registered agent`); + if (record.roles.defaultDelivery) { + throw new Error( + `stopAgent: "${name}" owns default delivery and cannot be stopped; use cancelActivation to end its current run`, + ); + } + const outcome = this.registry.end(name, 'stopped', options.by)!; + this.teardownEndedAgent(outcome.ended, options.reason); + this.settleEndedAgent(outcome); + return structuredClone(outcome); + } + + /** + * Give an agent a new parent. Clears its orphan state; a result route + * whose recipient is gone follows the new parent, and the mail it already + * produced for the lost recipient, results and messages, is delivered to + * the new one. + */ + reparentAgent(name: string, newParent: string, options: { by?: string } = {}): AgentRecord { + // An orphan that finished before it was adopted: its record stays as it + // ended, and the mail it held for a recipient that is gone goes to the + // new parent. + if (!this.registry.has(name) && this.registry.inspect(name)?.ended) { + const ended = this.registry.readdressHeldMail(name, newParent); + this.emitTrace({ + type: 'dendrite:agent-reparented', + agentName: name, + parent: newParent, + ...(options.by ? { by: options.by } : {}), + }); + for (const mail of ended.deliverable) this.attemptMailDelivery(mail); + return structuredClone(ended.record); + } + const { record, deliverable } = this.registry.reparent(name, newParent); + this.emitTrace({ + type: 'dendrite:agent-reparented', + agentName: name, + parent: newParent, + ...(options.by ? { by: options.by } : {}), + }); + this.lifecycleNotice( + name, + `[${NOTICE_TAG.agentLifecycle}: ${newParent} is now your parent` + + (record.relationships.resultTo?.to === newParent ? ' and will receive your result' : '') + + '.]', + { event: 'reparented', parent: newParent }, + ); + for (const mail of deliverable) this.attemptMailDelivery(mail); + return structuredClone(record); + } + + /** + * An explicit message from one agent to another: the sender's verbatim + * content, in the recipient's context under the SENDER's name, with the + * sender's identity and incarnation and the causal chain in metadata. + * Never an unmarked insertion into the recipient's own voice. + */ + sendAgentMessage( + from: string, + to: string, + content: ContentBlock[], + options: { causedBy?: string[]; wake?: boolean } = {}, + ): { mailId: string; delivered: boolean } { + if (!this.registry.inspect(from)) throw new Error(`sendAgentMessage: sender "${from}" is not a known agent`); + if (!this.registry.inspect(to)) throw new Error(`sendAgentMessage: recipient "${to}" is not a known agent`); + this.registry.noteMessagePath(from, to); + const mail = this.registry.holdMail({ + kind: 'message', + from: this.registry.ref(from), + to, + content, + ...(options.causedBy?.length ? { causedBy: options.causedBy } : {}), + }); + return { mailId: mail.id, delivered: this.attemptMailDelivery(mail, options.wake ?? true) }; + } + + /** + * Return a bounded job's result along the agent's declared result route. + * The result is recorded durably before delivery is attempted, so it is + * neither lost nor delivered twice across a restart; if the recipient is + * gone it is held as attributed mail until the agent is reparented. + * + * May be called after the agent's run has ended — its record stays + * inspectable. Refused for a `tool-result` route: there the awaiting + * caller already holds the result and delivers it itself. + */ + deliverAgentResult( + from: string, + content: ContentBlock[], + options: { causedBy?: string[] } = {}, + ): { mailId: string; delivered: boolean; to: string } { + const record = this.registry.inspect(from); + if (!record) throw new Error(`deliverAgentResult: "${from}" is not a known agent`); + const route = record.relationships.resultTo; + if (!route) throw new Error(`deliverAgentResult: "${from}" has no result route`); + if (route.as !== 'message') { + throw new Error( + `deliverAgentResult: "${from}" returns its result as a ${route.as}; the caller awaiting the run delivers it`, + ); + } + const mail = this.registry.holdMail({ + kind: 'result', + from: { agent: from, incarnation: record.incarnation }, + to: route.to, + content, + ...(options.causedBy?.length ? { causedBy: options.causedBy } : {}), + }); + // A finished job's output survives a hard exit from here on: the hold is + // in the registry's recovery file as it is written (persistAgentRegistry), + // and holdMail failed this call if it couldn't be. The release, once the + // message has landed, waits for the store's sync (releaseLandedMail). + return { mailId: mail.id, delivered: this.attemptMailDelivery(mail), to: route.to }; + } + + /** Communications not yet handed to their recipient, and why. */ + listHeldMail(filter: { to?: string; from?: string } = {}): HeldMail[] { + return structuredClone(this.registry.listMail(filter)); + } + + /** + * Try to hand one held item to its recipient. True when the message is in + * (or queued for) the recipient's context; false when it stays held + * because nobody can receive it. The held copy is released by + * `addMessage` at the moment the message enters the store. + */ + private attemptMailDelivery(mail: HeldMail, wake = true): boolean { + const recipient = this.registry.has(mail.to) ? this.agents.get(mail.to) : undefined; + if (!recipient) { + const newlyHeld = mail.heldBecause !== 'recipient-gone'; + this.registry.markMailHeld(mail.id, 'recipient-gone'); + // A result its sender delivered after it had ended (one delivered while + // it ran is told of when it ends, settleEndedAgent). + const sender = this.registry.inspect(mail.from.agent); + if (newlyHeld && mail.kind === 'result' && sender?.ended) this.tellResultHeld(sender, mail.id); + this.emitTrace({ + type: 'dendrite:mail-held', + mailId: mail.id, + kind: mail.kind, + from: mail.from.agent, + to: mail.to, + reason: 'recipient-gone', + }); + return false; + } + const placement: { agent?: string; messageId?: MessageId; deferredId?: string } = {}; + this.addMessage( + // The sender's own name: its words stay its own. + recipient.isOwnParticipant(mail.from.agent) && mail.from.agent !== recipient.name + ? `${mail.from.agent} (mail)` + : mail.from.agent, + mail.content as ContentBlock[], + { + kind: `agent-${mail.kind}`, + dendrite: { + mailId: mail.id, + kind: mail.kind, + from: mail.from, + // When the sender produced it — distinct from when it is read. A + // result held across a restart arrives later than it was made, + // and the reader should be able to tell. + producedAt: mail.createdAt, + ...(mail.causedBy?.length ? { causedBy: mail.causedBy } : {}), + }, + } as MessageMetadata, + { forAgent: mail.to, placement }, + ); + if (!placement.messageId) { + // Queued for the recipient's next boundary. `mail-delivered` fires + // when the message actually enters its store (releaseLandedMail): + // published is not delivered. + this.emitTrace({ + type: 'dendrite:mail-deferred', + mailId: mail.id, + kind: mail.kind, + from: mail.from.agent, + to: mail.to, + }); + } + if (wake) { + this.pendingRequests.push({ + agentName: mail.to, + reason: `agent-${mail.kind}:${mail.from.agent}`, + source: 'dendrite', + timestamp: Date.now(), + }); + } + return true; + } + + /** + * An ended orphan's result held for a recipient that is gone: its first + * adoption candidate is told it exists and how it arrives, since nothing + * else would surface it (reparentAgent delivers it). + */ + private tellResultHeld(sender: AgentRecord, mailId: string): void { + const candidate = sender.orphaned?.candidates[0]; + if (!candidate || !this.announceOrphans) return; + this.lifecycleNotice( + candidate, + `[${NOTICE_TAG.agentLifecycle}: "${sender.name}" has ended after ${sender.orphaned!.formerParent.agent} did, ` + + `so its result has nowhere to go. The result is held; reparenting "${sender.name}" to you delivers it.]`, + // `heldMailId`, not `mailId`: a row carrying dendrite.mailId is read + // as that mail having landed, which would release it. + { event: 'result-held', agent: sender.name, heldMailId: mailId, formerParent: sender.orphaned!.formerParent }, + ); + } + + /** A host-framed lifecycle fact in an agent's context. Never voiced as anyone. */ + private lifecycleNotice(agentName: string, text: string, detail: Record): void { + if (!this.agents.has(agentName)) return; + this.addMessage( + 'user', + [{ type: 'text', text }], + { system: true, kind: 'agent-lifecycle', dendrite: detail } as MessageMetadata, + { forAgent: agentName }, + ); + } + + /** + * The creation ("consent") event: names what kind of agent this is, what + * ends it and what happens to it when its spawner ends. A derived agent's + * creation is also told to the agent it was derived from (#270, Linn: + * "told when the body changes" as a property, not a courtesy): one + * host-framed line in the source's context, built from the same facts as + * the trace, with no wake; a host's derivationNotice can replace the line + * or send none. Both derivation paths announce after the + * fork's branch or copy exists, so a fork never inherits the notice about + * itself; a source that has ended is told nothing (lifecycleNotice). + */ + private announceAgentCreated(record: AgentRecord): void { + this.emitTrace({ + type: 'dendrite:agent-created', + agentName: record.name, + kind: record.kind, + ...(record.model ? { model: record.model } : {}), + incarnation: record.incarnation, + lifetime: record.lifetime, + ...(record.relationships.spawnedBy + ? { spawnedBy: record.relationships.spawnedBy, onParentEnd: record.onParentEnd } + : {}), + ...(record.relationships.resultTo ? { resultTo: record.relationships.resultTo } : {}), + ...(record.inherit ? { inherit: record.inherit } : {}), + }); + const from = record.inherit?.from; + if (!from || from === record.name) return; + const detail = { + event: 'derived', + agent: record.name, + kind: record.kind, + incarnation: record.incarnation, + ...(record.model ? { model: record.model } : {}), + inherit: record.inherit, + ...(record.relationships.resultTo ? { resultTo: record.relationships.resultTo } : {}), + lifetime: record.lifetime, + }; + // A resumption is rare and always said. A first incarnation is tallied: + // past the threshold, a reader loop that forks a hundred times a session + // would fill its source's context with stamps (Linn, 2026-10-15), so + // the source hears a count line every `threshold` forks instead, with + // the latest described; every derivation still emits its trace. + const threshold = this.collapseDerivationNoticesAfter; + const tell = (defaultText: string, extra: Record = {}) => { + try { + const text = this.derivationNoticeHook ? this.derivationNoticeHook(record, from, defaultText) : defaultText; + if (text !== null) this.lifecycleNotice(from, text, { ...detail, ...extra }); + } catch (error) { + console.error(`[dendrite] ${record.name} was created, but ${from} could not be told:`, error); + } + }; + if (record.incarnation > 1 || threshold <= 0) { + tell(this.derivationNotice(record, from)); + return; + } + let byKind = this.derivationTallies.get(from); + if (!byKind) this.derivationTallies.set(from, (byKind = new Map())); + let tally = byKind.get(record.kind); + if (!tally) byKind.set(record.kind, (tally = { count: 0, since: Date.now() })); + tally.count++; + if (tally.count <= threshold) { + tell(this.derivationNotice(record, from)); + } else if ((tally.count - threshold) % threshold === 0) { + const since = new Date(tally.since).toISOString().slice(11, 16) + ' UTC'; + tell( + `[${NOTICE_TAG.agentLifecycle}: ${tally.count} ${record.kind}s have been derived from you since ${since} ` + + `(${threshold} since the last note); the latest, ${record.name}, is ${this.derivationDescription(record, from)}.]`, + { collapsed: { count: tally.count, sinceLastNote: threshold } }, + ); + } + } + + /** The derivation notice's text, addressed to the agent `from` the record was derived. */ + private derivationNotice(record: AgentRecord, from: string): string { + const made = record.incarnation > 1 + ? `was resumed (incarnation ${record.incarnation}) as` + : 'was derived from you as'; + return `[${NOTICE_TAG.agentLifecycle}: ${record.name} ${made} ${this.derivationDescription(record, from)}.]`; + } + + /** What a derived agent is, to the agent it was derived from: "a task-fork on your weights, …; …; …". */ + private derivationDescription(record: AgentRecord, from: string): string { + const sourceModel = this.agentConfigs.get(from)?.model ?? this.registry.get(from)?.model; + const weights = !record.model + ? '' + : record.model === sourceModel + ? ` on your weights (${record.model})` + : ` on ${record.model}`; + const inherited = record.inherit?.mode === 'shared' + ? 'reading your context up to this point' + : 'with a copy of your context'; + const route = record.relationships.resultTo; + const home = this.channelOfHome(record.homeChannel); + const result = !route + ? home + ? `it speaks for itself in ${this.channelShown(home)}` + : 'it returns nothing to you' + : route.to === from + ? `its result returns to you as ${route.as === 'message' ? 'a message' : 'a tool result'}` + : `its result goes to ${route.to}`; + const lifetime = record.lifetime; + const minutes = (ms: number) => (ms < 60_000 ? `${Math.round(ms / 1_000)} s` : `${Math.round(ms / 60_000)} min`); + const ends = lifetime.kind === 'task' + ? 'it ends when its task does' + + (lifetime.deadlineMs !== undefined || lifetime.idleTimeoutMs !== undefined + ? ` (${[ + lifetime.deadlineMs !== undefined ? `deadline ${minutes(lifetime.deadlineMs)}` : '', + lifetime.idleTimeoutMs !== undefined ? `idle ${minutes(lifetime.idleTimeoutMs)}` : '', + ].filter(Boolean).join(', ')})` + : '') + : lifetime.kind === 'idle' + ? `it ends after ${minutes(lifetime.idleTtlMs)} idle` + : 'it persists'; + const parentEnd = record.relationships.spawnedBy === from + ? record.onParentEnd === 'end' ? ', and ends with you' : ', and is orphaned if you end' + : ''; + return `a ${record.kind}${weights}, ${inherited}; ${result}; ${ends}${parentEnd}`; + } + + /** + * Remove an ended agent's live object from the framework. A bounded run + * is ended through its awaiting caller (whose teardown owns the + * name-keyed state); any other agent is torn down here. + */ + private teardownEndedAgent(record: AgentRecord, detail?: string): void { + const name = record.name; + const reason = record.ended?.reason ?? 'stopped'; + const run = this.ephemeralRuns.get(name); + if (run) { + run.endReason = reason; + run.endedBy = record.ended?.by; + run.settle.reject(new AgentStoppedError(name, reason, record.ended?.by, detail)); + return; + } + const agent = this.agents.get(name); + if (!agent) return; + this.releaseAgentRuntime(agent, this.channelOfHome(record.homeChannel), undefined, record.lifetime.kind === 'persistent'); + } + + /** + * Everything the process holds for a live, non-ephemeral agent that has + * ended, released once, on every path an agent ends by — stopAgent, the + * idle reap of a conversation fork, an end that cascades from a parent's + * (nissa's #546 finding 5: a stop that skipped the window release dropped a + * write queued for the agent at the next flush, and a failure notice it + * never read reached no one). The window release delivers what the agent + * never read to the primary, now or after a resume, as the idle reap did. + */ + private releaseAgentRuntime(agent: Agent, home?: ChannelRef, why = 'its agent ended', declared = false): void { + const name = agent.name; + if (home && this.conversationRouter?.getBinding(home)?.agentName === name) { + this.conversationRouter.unbind(home); + } + this.closingConversationAgents.delete(name); + // A continuation of its turn still on its way goes with it. + this.dropContinuations(name, { kind: 'dropped', why }); + // A batch its guard still holds is settled as `aborted`: the stream's + // own finally does neither once the agent has gone. + if (agent.toolResultGuard.hasPending) { + try { + agent.toolResultGuard.abandon('aborted'); + } catch (error) { + console.error(`[tool-result-guard] agent=${name} failed to record a batch the fork left:`, error); + } + } + // Cancel before deregistration: a late stream event is discarded by + // driveStream's Agent-identity check once the name no longer maps to it. + if (this.activeStreams.has(name)) { + try { + agent.cancelStream(); + } catch (error) { + console.error(`[dendrite] agent=${name} its stream's cancel failed as it ended; it is released all the same:`, error); + } + } + this.activeStreams.delete(name); + this.pendingAssistantBlocks.delete(name); + this.eventGate?.onInferenceEnded(name); + // Its sleep and self-wake end with it (the gate's sleep holds back every + // agent's wakes), and neither wake goes to a name that's gone. + this.eventGate?.agentGone(name); + this.agents.delete(name); + this.agentConfigs.delete(name); + this.thinkingLossIncidents.forget(name); + this.reasoningLoss.forget(name); + // main's manager release: the context manager an ephemeral run or a fork + // opened is released with the agent (releaseManager). + this.releaseManager(agent); + this.toolImageLedgers.delete(name); + this.evictTurnCheckpoints(name); + this.activeTurnTokens.delete(name); + this.activeTurnTriggers.delete(name); + this.logicalTurnToolCalls.delete(agent); + this.pendingRequests = this.pendingRequests.filter((request) => request.agentName !== name); + this.releaseWindowNowOrAfterResume(agent); + this.turnRouting.delete(name); + this.proseSpeechChains.delete(name); + this.openLogical.delete(name); + // What it was last told of where its speech goes goes with it, kept + // only for an agent declared by configuration, which comes back under + // its name at the next boot: so the stored baselines are those of + // agents that are live or come back. + if (!declared) this.forgetAnnouncedLocus(name); + // Its scripts go with it, and leave the map once they've ended: no later + // run of the name trims them. + this.codeExecution?.scripts.agentGone(name); + // A provider-wait hold (nonOwningProviderWait) goes with the agent: left, + // its timer would requeue held wakes for an agent that is gone, and a wait + // held until released would re-arm for the name indefinitely. The recorded + // wait stays, and binds the name if its channel binds again. + const cooldown = this.providerAccelerationCooldowns.get(name); + if (cooldown) { + clearTimeout(cooldown.timer); + this.providerAccelerationCooldowns.delete(name); + console.error(`[provider-wait] agent=${name} ended while held` + + `${cooldown.waitModel !== undefined ? ` on ${cooldown.waitModel}` : ''}: ` + + `${cooldown.heldRequests.length} held request(s) dropped with it (the recorded wait stays)`); + } + // main's per-name turn state goes too (#846's re-arm count and run of + // stated waits, #840's exhausted turn), so a name bound again (a + // channel's next conversation, a resume) starts afresh; a run of stated + // waits ends here as at a turn's end, alerted if it ran long + // (endStatedWaitRun). + this.turnRearms.delete(name); + this.exhaustingRearms.delete(name); + this.exhaustedTurns.delete(name); + this.endStatedWaitRun(name); + if (name === this.subconsciousAgentName) { + this.tuneOutCoordinator?.stopReader(); + this.subconsciousAgentName = null; + } + } + + /** + * Carry out what the registry decided when an agent ended: tear down the + * attention tenants that end with it, and tell each orphan — and the + * first agent that could adopt it — what happened. + */ + private settleEndedAgent(outcome: EndOutcome | null): void { + if (!outcome) return; + for (const record of [...outcome.cascaded, outcome.ended]) { + this.emitTrace({ + type: 'dendrite:agent-ended', + agentName: record.name, + kind: record.kind, + incarnation: record.incarnation, + reason: record.ended?.reason ?? 'stopped', + ...(record.ended?.by ? { by: record.ended.by } : {}), + ...(record.relationships.spawnedBy ? { spawnedBy: record.relationships.spawnedBy } : {}), + }); + } + for (const record of outcome.cascaded) this.teardownEndedAgent(record); + // An orphan that ends with a result it delivered while it ran still held + // for a recipient that is gone: its first candidate hears of it now. + for (const record of [...outcome.cascaded, outcome.ended]) { + if (!record.orphaned) continue; + const held = this.registry.listMail({ from: record.name }) + .find((mail) => mail.kind === 'result' && mail.heldBecause === 'recipient-gone'); + if (held) this.tellResultHeld(record, held.id); + } + + const parent = outcome.ended; + for (const { record, candidates } of outcome.orphaned) { + this.announceOrphaned( + record, + candidates, + record.orphaned?.formerParent.agent ?? parent.name, + record.orphaned?.reason ?? parent.ended?.reason ?? 'stopped', + ); + } + } + + /** An agent whose spawner has ended, and its first adoption candidate, are told; the trace says so. */ + private announceOrphaned(record: AgentRecord, candidates: string[], former: string, reason: string): void { + this.emitTrace({ + type: 'dendrite:agent-orphaned', + agentName: record.name, + formerParent: former, + reason, + candidates, + }); + const route = record.relationships.resultTo; + const resultHeld = route?.as === 'message' && !this.registry.has(route.to); + this.lifecycleNotice( + record.name, + `[${NOTICE_TAG.agentLifecycle}: ${former}, the agent that started you, has ended (${reason}). ` + + 'Your own work is not cancelled.' + + (resultHeld ? ' Your result will be held until another agent becomes its recipient.' : '') + + ']', + { event: 'orphaned', formerParent: record.orphaned?.formerParent, reason, candidates }, + ); + const candidate = candidates[0]; + if (candidate && this.announceOrphans) { + this.lifecycleNotice( + candidate, + `[${NOTICE_TAG.agentLifecycle}: ${former} has ended (${reason}) while its ${record.kind} agent ` + + `"${record.name}" was still running. "${record.name}" now has no parent` + + (resultHeld ? ' and nowhere to return its result' : '') + + '; it can be reparented to you.]', + { event: 'adoption-offered', agent: record.name, formerParent: record.orphaned?.formerParent, reason }, + ); + } + } + + /** The registry's snapshot: the recovery file first (atomic, fsynced; it + * wins at the next boot), then the branch-local state, kept for inspection + * and for stores read by older code. Either write failing fails the call + * that changed the registry (nissa's #546 finding 2): a change that isn't + * durable isn't a success. */ + private persistAgentRegistry(snapshot: RegistrySnapshot): void { + if (this.agentRegistryPath) this.writeRecoveryFile(this.agentRegistryPath, snapshot); + this.store.setStateJson(AGENT_REGISTRY_STATE_ID, snapshot); + } + + /** An ended record: appended to the branch-local log, and the recovery + * file rewritten with the newest records (as many as a restart reads). */ + private appendEndedAgentRecord(record: AgentRecord): void { + this.rewriteEndedRecovery([...this.endedRecovery, record]); + this.store.appendToStateJson(AGENT_REGISTRY_ENDED_ID, record); + } + + /** + * A requested change was put back in the registry's memory: the recovery + * files, each written before the branch-local store, are rewritten from it + * (the snapshot's and the ended log's), so neither keeps the change the + * caller was told failed. The branch-local snapshot needs no rewrite: it is + * the last write a change makes, so a change that failed never reached it. + * The branch-local ended log is append-only, so an end appended before the + * failure stays there. It is read at boot only where no ended recovery + * file exists (or the rewrite of that file failed too); the restore then + * drops a bounded agent it lists as ended for the reason recorded, with no + * restart notice to its recipient, while a persistent agent waits to be + * re-declared as it would anyway. A rewrite that fails is reported, the + * next is still made, and the caller's own error goes on. + */ + private rewriteAgentRegistryAfterRollback(): void { + const rewrites: Array<[string, string, () => void]> = [ + ['the ended log recovery file', 'the next end written', () => this.rewriteEndedRecovery(this.registry.endedHistory())], + ...(this.agentRegistryPath + ? [['the registry recovery file', 'the next registry write', () => this.writeRecoveryFile(this.agentRegistryPath!, this.registry.snapshot())] as [string, string, () => void]] + : []), + ]; + for (const [what, next, rewrite] of rewrites) { + try { + rewrite(); + } catch (error) { + console.error(`[dendrite] after a registry change failed and was put back in memory, ${what} could not be rewritten to match; ${next} rewrites it:`, error); + } + } + } + + /** + * An ended record past the registry's memory, from the branch-local ended + * log. A scan of the whole log takes tens of milliseconds at a few thousand + * ends, and a name nobody used (a tool call's typo) is a miss, so the log is + * indexed by name once, at the first lookup, and each later lookup indexes + * only what was appended since. A log that changed under the index (a + * branch switch) is indexed again: one that shrank, one whose last indexed + * entry is no longer the one indexed, or one whose entry no longer carries + * the name indexed for it. + */ + private findEndedAgentRecord(name: string): AgentRecord | undefined { + return this.findInEndedLog('name', name); + } + + /** + * The ended log's latest record whose own context a namespace holds. A + * shared fork's record names its parent's namespace, where it owns only a + * branch, so it is indexed by name alone. + */ + private findInEndedLog(by: 'name' | 'namespace', value: string): AgentRecord | undefined { + const byKey = by === 'name' ? this.endedLogIndex : this.endedLogNamespaceIndex; + const reset = () => { + this.endedLogIndex.clear(); + this.endedLogNamespaceIndex.clear(); + this.endedLogIndexed = 0; + }; + const read = (index: number) => this.store.getStateItemJson(AGENT_REGISTRY_ENDED_ID, index) as AgentRecord | null; + const mark = (record: AgentRecord | null) => (record ? `${record.name}#${record.incarnation}@${record.ended?.at}` : ''); + // A store that can't be read fails the lookup rather than answering + // "never ended", which would let a namespace check pass. + const length = this.store.getStateLen(AGENT_REGISTRY_ENDED_ID) ?? 0; + for (let attempt = 0; attempt < 2; attempt++) { + if (length < this.endedLogIndexed + || (this.endedLogIndexed > 0 && mark(read(this.endedLogIndexed - 1)) !== this.endedLogTail)) { + reset(); + } + for (let index = this.endedLogIndexed; index < length; index++) { + const record = read(index); + if (!record?.ended || typeof record.name !== 'string') continue; + this.endedLogIndex.set(record.name, index); + if (typeof record.namespace === 'string' && record.inherit?.mode !== 'shared') { + this.endedLogNamespaceIndex.set(record.namespace, index); + } + } + this.endedLogIndexed = length; + this.endedLogTail = length > 0 ? mark(read(length - 1)) : ''; + const at = byKey.get(value); + if (at === undefined) return undefined; + const record = read(at); + if (record?.ended && record[by] === value) return record; + reset(); + } + return undefined; + } + + /** Another agent whose context a namespace holds: opened in this process, in the registry, or in the ended log. */ + private namespaceHolder(namespace: string, except: string): string | undefined { + const opened = this.namespaceOwners.get(namespace); + if (opened !== undefined && opened !== except) return opened; + const held = this.registry.namespaceHolder(namespace, except); + if (held) return held; + const ended = this.findInEndedLog('namespace', namespace); + return ended && ended.name !== except ? ended.name : undefined; + } + + /** The ended log's recovery file, rewritten as the newest records (as many as a restart reads). */ + private rewriteEndedRecovery(ended: AgentRecord[]): void { + this.endedRecovery = ended.slice(-AGENT_REGISTRY_ENDED_RELOAD); + if (this.agentRegistryEndedPath) this.writeRecoveryFile(this.agentRegistryEndedPath, { version: 1, ended: this.endedRecovery }); + } + + /** + * Load the previous process's registry. Returns the bounded work it + * interrupted. A registry that can't be read, or that reads as anything + * but a version-1 registry, refuses the start and names the state (nissa's + * #546 finding 1, main's rule for the framework's own state): taken as + * empty, the results it holds for delivery and the records of the work it + * ran would be overwritten by this boot's first registration. + */ + private restoreAgentRegistry(): AgentRecord[] { + const refuse = (what: string, problem: string, cause?: unknown): Error => new Error( + `the agent registry (${what}) ${problem}. The framework doesn't start without it: taken as empty, the results ` + + 'it holds for delivery and the records of the work it ran would be overwritten at this boot. ' + + (cause === undefined + ? "It's unchanged, and the framework starts once it holds a version-1 registry, such as " + + '{"version":1,"records":[],"mail":[],"incarnations":{}} for an empty one.' + : "It's unchanged, and the framework starts once it can be read."), + cause === undefined ? undefined : { cause }, + ); + let snapshot: unknown; + let snapshotFrom = this.agentRegistryPath ?? AGENT_REGISTRY_STATE_ID; + // Only a registry that was never written is absent: a recovery file that + // exists is the registry, whatever it holds, so one that reads as `null` + // is refused like any other that isn't a version-1 registry. + let absent = false; + try { + snapshot = this.agentRegistryPath ? this.readRecoveryFile(this.agentRegistryPath) : undefined; + if (snapshot === undefined) { + snapshotFrom = AGENT_REGISTRY_STATE_ID; + snapshot = this.store.getStateJson(AGENT_REGISTRY_STATE_ID) ?? null; + absent = snapshot === null; + } + } catch (error) { + throw refuse(snapshotFrom, "can't be read", error); + } + if (!absent && (snapshot === null || typeof snapshot !== 'object' || (snapshot as { version?: unknown }).version !== 1)) { + const read = snapshot === null ? 'null' : `version ${JSON.stringify((snapshot as { version?: unknown }).version)}`; + throw refuse(snapshotFrom, `reads as something other than a version-1 registry (${read})`); + } + if (!absent) { + const { records, mail, incarnations } = snapshot as { records?: unknown; mail?: unknown; incarnations?: unknown }; + if (!Array.isArray(records) || !Array.isArray(mail)) { + throw refuse(snapshotFrom, 'reads as a version-1 registry whose records or mail are not lists'); + } + if (incarnations === null || typeof incarnations !== 'object' || Array.isArray(incarnations)) { + throw refuse(snapshotFrom, 'reads as a version-1 registry with no map of incarnations, without which every agent it names would start again at incarnation 1'); + } + } + + let ended: unknown; + let endedFrom = this.agentRegistryEndedPath ?? AGENT_REGISTRY_ENDED_ID; + try { + const file = this.agentRegistryEndedPath ? this.readRecoveryFile(this.agentRegistryEndedPath) : undefined; + if (file !== undefined) { + ended = (file as { ended?: unknown } | null)?.ended; + } else { + endedFrom = AGENT_REGISTRY_ENDED_ID; + const tail = this.store.getStateTail(AGENT_REGISTRY_ENDED_ID, AGENT_REGISTRY_ENDED_RELOAD); + ended = tail ? JSON.parse(tail.toString('utf8')) : []; + } + } catch (error) { + throw refuse(endedFrom, "has an ended log that can't be read", error); + } + if (!Array.isArray(ended)) throw refuse(endedFrom, 'has an ended log that reads as something other than a list of records'); + this.endedRecovery = (ended as AgentRecord[]).slice(-AGENT_REGISTRY_ENDED_RELOAD); + return this.registry.restore(snapshot as RegistrySnapshot | null, ended as AgentRecord[]).interrupted; + } + + /** + * Boot reconciliation, once configuration has declared its agents. + * Restart behaviour follows from lifetime: persistent agents were just + * re-created; everything else that was live was interrupted, and the + * recipient of an interrupted job's result is told so. Results held from + * before the restart are delivered now, exactly once. + */ + private reconcileAgentRegistry(interrupted: AgentRecord[]): void { + for (const outcome of this.registry.reconcile().unconfigured) this.settleEndedAgent(outcome); + + const byRecipient = new Map(); + for (const record of interrupted) { + this.emitTrace({ + type: 'dendrite:agent-ended', + agentName: record.name, + kind: record.kind, + incarnation: record.incarnation, + reason: 'host-restart', + ...(record.relationships.spawnedBy ? { spawnedBy: record.relationships.spawnedBy } : {}), + }); + const route = record.relationships.resultTo; + if (route?.as !== 'message' || !this.agents.has(route.to)) continue; + // A result it had already handed over needs no apology. + if (this.registry.listMail({ from: record.name }).some((mail) => mail.kind === 'result')) continue; + byRecipient.set(route.to, [...(byRecipient.get(route.to) ?? []), record]); + } + for (const [recipient, all] of byRecipient) { + // The interrupted records stay in the snapshot until this notice is + // durable and their ends are written (settleInterrupted below), so a + // crash after it landed, or an end whose write failed, comes here + // again: a record an earlier interrupted notice to this recipient names + // (its key lists each name, incarnation and creation time, since a + // single-use name that aged out of memory starts again at incarnation + // 1), in the recipient's store or the restored deferred queue, has been + // told, however many of that notice's records have ended since. + const toldKeys = new Set(); + const noteTold = (metadata: unknown) => { + const key = (metadata as { dendrite?: { noticeKey?: unknown } } | undefined)?.dendrite?.noticeKey; + if (typeof key === 'string') for (const one of key.split(',')) toldKeys.add(one); + }; + for (const message of this.agents.get(recipient)!.getContextManager() + .queryMessages({ metadata: { 'dendrite.event': 'interrupted' } }).messages) noteTold(message.metadata); + for (const message of this.deferredMessages) noteTold(message.metadata); + const keyOf = (record: AgentRecord) => `${record.name}#${record.incarnation}@${record.createdAt}`; + const records = all.filter((record) => !toldKeys.has(keyOf(record))); + if (records.length === 0) continue; + const noticeKey = records.map(keyOf).sort().join(','); + const names = records.map((record) => `"${record.name}"`).join(', '); + this.lifecycleNotice( + recipient, + `[${NOTICE_TAG.agentLifecycle}: the host restarted while ${names} ${records.length === 1 ? 'was' : 'were'} ` + + `still working for you. ${records.length === 1 ? 'It was' : 'They were'} ended without a result.]`, + { event: 'interrupted', agents: records.map((record) => record.name), reason: 'host-restart', noticeKey }, + ); + this.pendingRequests.push({ + agentName: recipient, + reason: 'agent-interrupted', + source: 'dendrite', + timestamp: Date.now(), + }); + } + // The notices are durable once the store has synced (a quiesced host's + // are in the deferred queue's recovery file already); only then are the + // interrupted records' ends written and the snapshot let go of them. + if (interrupted.length > 0) { + try { + this.store.sync(); + this.registry.settleInterrupted(); + } catch (error) { + console.error('[dendrite] the restart notices could not be synced; the interrupted records stay in the registry, and the next boot tells their recipients again unless the notice is found:', error); + } + } + + for (const mail of this.registry.listMail()) this.deliverHeldMail(mail); + } + + /** + * Deliver held mail that hasn't landed. Its copy may have reached the + * recipient's store in the same sync that the release missed, and then it + * is released instead; and a write the deferred queue still holds for it + * lands at the next flush and releases it then (releaseLandedMail). Either + * way a second delivery would put it in the window twice under one mail id. + */ + private deliverHeldMail(mail: HeldMail, wake = true): void { + const recipient = this.agents.get(mail.to); + if (recipient) { + const already = recipient.getContextManager().queryMessages({ metadata: { 'dendrite.mailId': mail.id } }); + if (already.messages.length > 0) { + this.registry.releaseMail(mail.id); + return; + } + } + const queued = this.deferredMessages.some( + (m) => (m.metadata as { dendrite?: { mailId?: unknown } } | undefined)?.dendrite?.mailId === mail.id, + ); + if (queued) return; + this.attemptMailDelivery(mail, wake); + } + private createDeferred(): Deferred { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -10463,7 +12344,8 @@ export class AgentFramework { // Only a fresh object returned by createEphemeralAgent may enter this path. // Never overwrite a resident/conversation owner or a concurrent run: their // cleanup is name-keyed and could cancel/deregister the legitimate owner. - if (this.ephemeralCandidates.get(agent) !== contextManager) { + const candidate = this.ephemeralCandidates.get(agent); + if (!candidate || candidate.contextManager !== contextManager) { throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`); } // A live surgery holds the whole store: an agent admitted now would @@ -10478,11 +12360,27 @@ export class AgentFramework { // Consume before any await/registration: the exact (Agent, ContextManager) // generation is one-shot even if startup later fails. this.ephemeralCandidates.delete(agent); + if (candidate.resume && this.resumesPending.get(agent.name) === agent) this.resumesPending.delete(agent.name); if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) { throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`); } + // Admit it to the registry first: a spec the registry refuses (spawner + // or result recipient gone since creation) must not leave a live agent. + const registered = this.registry.register(candidate.spec, { resume: candidate.resume === true, after: candidate.after }); + this.announceAgentCreated(registered); // Register temporarily so the event loop can drive it this.agents.set(agent.name, agent); + // Kept while the run is live so the agent can itself be derived from. + this.agentConfigs.set(agent.name, candidate.config); + // A spawner that ended after creating it: it registered as that + // spawner's orphan, and is told so, as is its first candidate. + if (registered.orphaned) { + this.announceOrphaned(registered, registered.orphaned.candidates, registered.orphaned.formerParent.agent, registered.orphaned.reason); + } + // Mail held for this name (a resumed incarnation's, sent while it was + // ended) reaches it now rather than at the next boot. Its run is + // starting, so it wakes nothing. + for (const mail of this.registry.listMail({ to: agent.name })) this.deliverHeldMail(mail, false); const run: EphemeralRun = { settle: this.createDeferred(), inferenceStarted: false, @@ -10491,15 +12389,39 @@ export class AgentFramework { }; this.ephemeralRuns.set(agent.name, run); + const lifetime = candidate.spec.lifetime; const STARTUP_TIMEOUT_MS = watchdogs?.startupTimeoutMs ?? 30_000; // After inference has started, give it 15 minutes of activity-bounded // life. The stream driver refreshes the deadline as it makes progress; - // sustained silence trips it. - const COMPLETION_IDLE_TIMEOUT_MS = watchdogs?.idleTimeoutMs ?? 15 * 60_000; + // sustained silence trips it. An explicit watchdog wins; otherwise the + // spec's task lifetime is the bound. + const COMPLETION_IDLE_TIMEOUT_MS = watchdogs?.idleTimeoutMs + ?? (lifetime.kind === 'task' ? lifetime.idleTimeoutMs : undefined) + ?? 15 * 60_000; const IDLE_POLL_MS = watchdogs?.idlePollMs ?? 30_000; let startupWatchdog: ReturnType | null = null; let completionWatchdog: ReturnType | null = null; + let deadlineWatchdog: ReturnType | null = null; + + // A task lifetime's wall-clock deadline: the identity must end, however + // lively its stream is. + // A timer holds at most MAX_TIMER_MS, so a longer deadline waits in + // steps, each re-armed until the deadline is due. + const deadline = new Promise((_, reject) => { + if (lifetime.kind !== 'task' || lifetime.deadlineMs === undefined) return; + const deadlineMs = lifetime.deadlineMs; + const dueAt = Date.now() + deadlineMs; + const arm = (): void => { + deadlineWatchdog = setTimeout(() => { + if (Date.now() < dueAt) return arm(); + run.endReason = 'deadline'; + reject(new AgentStoppedError(agent.name, 'deadline', undefined, `${deadlineMs}ms elapsed`)); + }, Math.max(0, Math.min(dueAt - Date.now(), MAX_TIMER_MS))); + deadlineWatchdog.unref?.(); + }; + arm(); + }); // Whether the completion watchdog ended the run as stalled, for a // tool-result batch the run leaves behind (see the finally). let stalled = false; @@ -10552,11 +12474,14 @@ export class AgentFramework { run.settle.promise, startupTimeout, completionIdleTimeout, + deadline, ]); + run.endReason ??= 'completed'; return { speech: result.speech, toolCallsCount: result.toolCallsCount }; } finally { if (startupWatchdog) clearTimeout(startupWatchdog); if (completionWatchdog) clearInterval(completionWatchdog); + if (deadlineWatchdog) clearTimeout(deadlineWatchdog); // A batch still pending here has nothing left to settle it: once the // run is deregistered, driveStream's finally abandons nothing (it does // only while the agent is registered), and no later turn comes. The @@ -10613,6 +12538,7 @@ export class AgentFramework { this.turnRouting.delete(agent.name); this.proseSpeechChains.delete(agent.name); this.openLogical.delete(agent.name); + this.forgetAnnouncedLocus(agent.name); // The late stream finalizer intentionally refuses name-keyed cleanup once // deregistered, so disposal must release EventGate liveness itself. this.eventGate?.onInferenceEnded(agent.name); @@ -10624,6 +12550,8 @@ export class AgentFramework { this.releaseWindowNowOrAfterResume(agent); } this.toolImageLedgers.delete(agent.name); + if (this.agentConfigs.get(agent.name) === candidate.config) this.agentConfigs.delete(agent.name); + this.toolSurfaceOwners.delete(agent.name); // Its scripts end with it: no turn of its is left for them to outlive. this.codeExecution?.scripts.agentGone(agent.name); // Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this, @@ -10641,6 +12569,21 @@ export class AgentFramework { // A continuation of its turn still on its way goes too: the run, and // with it the turn, has ended above. this.dropContinuations(agent.name, null); + if (run.endReason === 'host-stop') { + // Cut off by stop(): left live for the next boot to end as interrupted. + const record = this.registry.inspect(agent.name); + this.emitTrace({ + type: 'dendrite:agent-interrupted', + agentName: agent.name, + kind: record?.kind ?? candidate.spec.kind, + incarnation: record?.incarnation ?? 0, + reason: 'host-stop', + }); + } else { + // The identity ends with its run. Whatever it spawned is settled by + // the registry's rules (attention tenants end, task work is orphaned). + this.settleEndedAgent(this.registry.end(agent.name, run.endReason ?? 'failed', run.endedBy, { record: true })); + } } } @@ -10674,12 +12617,22 @@ export class AgentFramework { 'its messages will reach your subconscious where that\'s known. ' + 'Calling tune_out again for a channel already tuned out keeps its ' + 'settings, and, where this host can open the channel, tries the open ' + - 'again if it isn\'t open.', + 'again if it isn\'t open. Without a subconscious it is a silent hold: ' + + 'nothing summarizes or wakes you; the backlog is delivered when you ' + + 'cancel. scope "all-except" holds EVERY channel (DMs and new channels ' + + 'included) except channelId, for durationSeconds (required); your plain ' + + 'speech lands in that channel meanwhile; the reader pauses rather than ' + + 'cancels when its wake budget is spent. mode "check" reports what is ' + + 'held without releasing it (`peek` shows one channel\'s newest held lines).', inputSchema: { type: 'object', properties: { - channelId: { type: 'string' }, - mode: { type: 'string', enum: ['enter', 'cancel'], description: 'Default: enter.' }, + channelId: { type: 'string', description: 'The channel to tune out — or, with scope "all-except", the one channel to keep.' }, + serverId: { type: 'string', description: 'The channel\'s server, where the channel id exists on several servers; channelId also takes its server-qualified id ("serverId:channelId").' }, + mode: { type: 'string', enum: ['enter', 'cancel', 'check'], description: 'Default: enter.' }, + scope: { type: 'string', enum: ['channel', 'all-except'], description: 'Default: channel.' }, + peek: { type: 'string', description: 'mode "check": a held channel whose newest held lines to show (nothing is released).' }, + peekLimit: { type: 'number', description: 'mode "check": how many lines to peek (default 5, max 50).' }, cadenceSeconds: { type: 'number', description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`, @@ -10701,7 +12654,33 @@ export class AgentFramework { 'delivered) after this many seconds; floors at 60. Unset = until cancelled.', }, }, - required: ['channelId'], + required: [], + }, + }; + + /** `focus`: the resident-facing alias for `tune_out` with scope "all-except" (#232 decision 2). */ + private static readonly FOCUS_TOOL: import('./types/index.js').ToolDefinition = { + name: 'focus', + description: + 'Focus on one channel for a bounded time: every other channel — DMs ' + + 'and channels that appear later included — is held and stops waking ' + + 'you; your plain speech lands in the focused channel. The same hold as ' + + 'tune_out with scope "all-except". mode "cancel" releases the held ' + + 'backlog per channel; mode "check" reports what is held.', + inputSchema: { + type: 'object', + properties: { + channelId: { type: 'string', description: 'The one channel to keep.' }, + serverId: { type: 'string' }, + durationSeconds: { type: 'number', description: 'Required to enter; floors at 60.' }, + mode: { type: 'string', enum: ['enter', 'cancel', 'check'], description: 'Default: enter.' }, + cadenceSeconds: { type: 'number' }, + backlogCap: { type: 'number', description: 'Per held channel, at release.' }, + maxWakes: { type: 'number' }, + peek: { type: 'string' }, + peekLimit: { type: 'number' }, + }, + required: [], }, }; @@ -12677,16 +14656,19 @@ export class AgentFramework { private disposeEmptiedForks(failures: ManagerReinitFailure[]): ManagerReinitFailure[] { const disposals: ManagerReinitFailure[] = []; - for (const [agentName, channelId] of [...this.conversationAgentHomes]) { + // The forks are the registry's conversation-fork records; a fork's home is + // the channel on its server (homeOf), which the router's binding is keyed by. + const forks = this.listAgents().filter((r) => r.kind === 'conversation-fork').map((r) => ({ agentName: r.name, home: this.homeOf(r.name) })); + for (const { agentName, home } of forks) { const agent = this.agents.get(agentName); if (!agent || agent.getContextManager().getMessageCount() > 0) continue; const heldWrites = this.deferredMessages.filter((msg) => (msg.forAgent ?? this.primaryAgentName) === agentName).length; let cleanupFailed: string | undefined; try { - if (this.conversationRouter?.getBinding(channelId)?.agentName === agentName) { - this.conversationRouter.unbind(channelId); + if (home && this.conversationRouter && this.conversationRouter.getBinding(home)?.agentName === agentName) { + this.conversationRouter.unbind(home); } - this.disposeConversationAgent(agentName); + this.disposeConversationAgent(agentName, 'context-emptied'); } catch (error) { cleanupFailed = failureDescription(error); } @@ -13699,8 +15681,13 @@ export class AgentFramework { } private async createAgent(config: AgentConfig): Promise { + // The residents' view (RESIDENT_VIEW_FILTER: held rows out, read by + // heldFromResidents) composed with this agent's own: another agent's + // stored context injections out. Main's boundary and arrival checks read + // the same predicate (storeSees, agentSees), so they and the compiled + // view agree. const sees = (metadata: unknown): boolean => - !(metadata as { tuneOut?: unknown } | undefined)?.tuneOut + !heldFromResidents(metadata as Record | undefined) && !isAnotherAgentsInjection(metadata, config.name); // Create context manager for this agent const contextManager = await ContextManager.open({ @@ -13721,7 +15708,8 @@ export class AgentFramework { // fetch_history, and audit. // Another agent's stored context injections (stored-injections.ts) are // that agent's context, not conversation: they stay out of this view, - // and so out of its folds, as the residents share this slot. + // and so out of its folds, as the residents share this slot. The + // resident filter keeps held (tuned-out) rows out as before (`sees`). viewFilter: (message) => sees(message.metadata), }); this.trackManager(contextManager); @@ -13731,6 +15719,10 @@ export class AgentFramework { this.adoptToolResultGuard(agent); agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable this.sharedSlotAgents.add(agent); + // The first resident declared owns default delivery. + this.announceAgentCreated( + this.registry.register({ ...residentSpec(config.name, { primary: !this.primaryAgentName }), model: config.model }), + ); let restoredSettings: AgentRuntimeSettingsOverrides | null; try { restoredSettings = this.readAgentRuntimeSettings(config.name); @@ -13776,7 +15768,7 @@ export class AgentFramework { * 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 { + private async createSubconsciousAgent(cfg: PersistentReaderConfig): Promise { const primaryName = this.primaryAgentName; const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined; if (!primaryName || !primaryConfig) { @@ -13843,6 +15835,12 @@ export class AgentFramework { this.adoptToolResultGuard(agent); agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable this.sharedSlotAgents.add(agent); // reads the shared slot through its merged view + // An attention tenant of the resident it serves: reads the shared slot + // through its merged view (held traffic included), never a broadcast + // recipient, and outlives policy epochs. + this.announceAgentCreated( + this.registry.register({ ...subconsciousSpec(name, primaryName), model: agentConfig.model }), + ); this.agents.set(name, agent); this.agentConfigs.set(name, agentConfig); this.subconsciousAgentName = name; @@ -13851,6 +15849,193 @@ export class AgentFramework { return agent; } + /** + * `subconscious.reader: 'forks'`: no persistent agent. The coordinator + * asks for a fork per invocation (see invokeReaderFork); the config is + * kept for the voice block, the speech switch and the strategy factory. + */ + private configureReaderForks(cfg: ForkReaderConfig): void { + 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 strategy = primaryConfig.strategy; + if (!cfg.strategyFactory && strategy && !(strategy instanceof PassthroughStrategy)) { + throw new Error( + `subconscious.reader "forks": the primary agent "${primaryName}" runs a ${strategy.name} strategy, ` + + 'so a reader fork needs `subconscious.strategyFactory` returning a fresh instance of the same ' + + 'class and configuration to reuse its fold state and rendering', + ); + } + // Whose weights read the resident's prefix is never defaulted. The + // resident's own model is the copy; another is allowed when stated, so + // the resident's consent can name it (architecture channel, 10-09). + if (typeof cfg.model !== 'string' || cfg.model.length === 0) { + throw new Error( + `subconscious.reader "forks": a reader fork holds the whole prefix of "${primaryName}", so \`model\` ` + + `must say whose weights run it — "${primaryConfig.model}" for a copy of the resident, or another model ` + + 'by name. It is not defaulted', + ); + } + if (typeof cfg.voice !== 'string' || cfg.voice.length === 0) { + throw new Error('subconscious.reader "forks": `voice` (the reader\'s framing block) is required'); + } + this.readerForks = true; + this.subconsciousConfig = cfg; + } + + /** + * One reader invocation: derive a fork of the resident at its head with + * the resident's refusals, hand it the held traffic since the last look + * as ordinary framing, and run it. It reports through `deliver_summary` + * (attributed mail), may cancel the hold, and ends when its turn ends. + */ + private invokeReaderFork(invocation: ReaderInvocation): void { + const primary = this.primaryAgentName; + const cfg = this.subconsciousConfig; + if (!primary || cfg?.reader !== 'forks' || !this.agents.has(primary)) return; + const name = `reader/${primary}/${invocation.epochId.slice(0, 8)}/${++this.readerForkCounter}`; + + void (async () => { + try { + // The queued messages are rows of the backlog a framing reads, after + // the stored ones, so any framing that shows `backlog` shows them. + const backlog = [...invocation.backlog, ...this.queuedReaderRows(primary, invocation.queued)]; + const content = (cfg.framing ?? defaultReaderFraming)({ + resident: primary, + fork: name, + voice: cfg.voice, + channelId: invocation.channelId, + epochId: invocation.epochId, + trigger: invocation.trigger, + notice: invocation.notice, + dispositions: invocation.dispositions, + backlog, + queuedCount: invocation.queued.length, + }); + const fork = await this.deriveAgent({ + name, + from: primary, + kind: 'subconscious-fork', + ...(cfg.strategyFactory ? { strategy: cfg.strategyFactory() } : {}), + // Whose weights, as the configuration states it; its report is a + // tool call, so bare prose never routes anywhere. + config: { + model: cfg.model, + proseRouting: 'disabled', + }, + inheritRefusals: true, + onParentEnd: 'end', + resultTo: { to: primary, as: 'message' }, + idleTimeoutMs: cfg.forkIdleTimeoutMs ?? 10 * 60_000, + framing: [{ + participant: 'user', + content, + metadata: { + system: true, + kind: 'tune-out-reader-framing', + channelId: invocation.channelId, + epochId: invocation.epochId, + trigger: invocation.trigger, + heldMessageIds: backlog.map((m) => m.id), + } as MessageMetadata, + }], + metadata: { epochId: invocation.epochId, channelId: invocation.channelId, trigger: invocation.trigger }, + }); + this.emitTrace({ + type: 'tune-out:reader-fork', + agentName: name, + serverId: invocation.serverId, + channelId: invocation.channelId, + epochId: invocation.epochId, + trigger: invocation.trigger, + held: invocation.backlog.length + invocation.queued.length, + queued: invocation.queued.length, + }); + const outcome = await this.runEphemeralToCompletion(fork.agent, fork.contextManager); + // The report is the fork's whole job. A reader that ends in prose + // without `deliver_summary` has still reported — its final prose is + // delivered as its report, under its own name, instead of vanishing + // into proseRouting=disabled (first live run, 2026-10-12, finding A). + const prose = outcome.speech.trim(); + if (prose && !this.tuneOutCoordinator?.readerReported(name)) { + this.deliverFromReader(name, [{ type: 'text', text: prose }], { + kind: 'subconscious-summary', + channelId: invocation.channelId, + epochId: invocation.epochId, + report: 'prose', + }); + this.emitTrace({ + type: 'tune-out:reader-prose-delivered', + agentName: name, + serverId: invocation.serverId, + channelId: invocation.channelId, + epochId: invocation.epochId, + chars: prose.length, + }); + } + } catch (error) { + console.error(`[tune-out] reader fork ${name} failed:`, error instanceof Error ? error.message : error); + // A fork that failed before it reported never read what it was + // handed: those messages go back to the channel's backlog, so the + // next reader for it is handed them instead of no reader ever being. + const reported = this.tuneOutCoordinator?.readerReported(name) ?? false; + const returned = reported ? 0 : (this.tuneOutCoordinator?.readerFailed(invocation) ?? 0); + this.emitTrace({ + type: 'tune-out:reader-fork-failed', + agentName: name, + serverId: invocation.serverId, + channelId: invocation.channelId, + epochId: invocation.epochId, + trigger: invocation.trigger, + error: error instanceof Error ? error.message : String(error), + reported, + returned, + }); + } finally { + this.tuneOutCoordinator?.forgetReader(name); + } + })(); + } + + /** + * Held messages still queued behind the resident's turn, as rows of a + * reader fork's backlog: each named by its held handle, as module rows name + * it until it lands (heldRows), at a provisional sequence past the + * resident's last stored message, and timed when it was first held. + */ + private queuedReaderRows(primary: string, queued: QueuedHeldMessage[]): StoredMessage[] { + if (queued.length === 0) return []; + const cm = this.agents.get(primary)!.getContextManager(); + const count = cm.getMessageCount(); + const last = count > 0 ? cm.getMessageWindow(count - 1, 1, { resolveBlobs: false }).messages[0]?.sequence ?? 0 : 0; + return queued.map((m, i) => ({ + id: heldHandle(m.writeId), + sequence: last + 1 + i, + participant: m.participant, + content: m.content, + metadata: m.metadata as MessageMetadata | undefined, + timestamp: new Date(m.heldAt ?? Date.now()), + })); + } + + /** A reader fork's own words to the resident: attributed mail when it has a route, else under its name. */ + private deliverFromReader(reader: string, content: ContentBlock[], metadata: Record): void { + const route = this.registry.inspect(reader)?.relationships.resultTo; + if (route?.as === 'message') { + const mail = this.registry.holdMail({ + kind: 'result', + from: this.registry.ref(reader), + to: route.to, + content, + }); + this.attemptMailDelivery(mail); + return; + } + this.addMessage(reader, content, metadata as MessageMetadata); + } + private async runLoop(): Promise { // A loop ends with its lifecycle. A stop() whose budget ran out while // this loop was in a call (a module's onProcess) returns before the call @@ -14164,7 +16349,7 @@ export class AgentFramework { // stand between a tool's call and its results, so this clock // reads the time it's written (clock-rows.ts). const clock = this.storeDueClock(agent, 'tool-boundary'); - const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent.name)); + const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent)); // Stored in queue order, cut at the first that can't be stored: it // and what follows wait for the turn's end, and only what was // stored is injected (the round's results go on either way). One @@ -14177,10 +16362,17 @@ export class AgentFramework { const stored = this.writeDrainedInOrder(deferred, `tool boundary for ${agent.name}`, (msg) => agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id))); for (const [msg, { id, content }] of stored) { + this.releaseLandedMail(msg.metadata, agent.name); this.noteSpokenTo(agent.name, msg.metadata); // Every other resident reads what this boundary stores; for its // own agent, the boundary weighs what it injects below. this.noteForOtherResidents(agent, msg.metadata, id); + // A message the agent's own view leaves out is stored and not + // injected: a held message (the reader has it; admission's + // predicate, heldFromResidents, is in every resident's view + // filter) or another agent's stored context injection. Injecting + // it here would put in the live turn what the next compile + // hides (#232 gap 5; main's #853 reads the view itself). if (!this.agentSees(agent, msg.metadata)) continue; heard.push({ msg, id, content }); } @@ -14306,6 +16498,34 @@ export class AgentFramework { : 0; const overPhysical = projectedRealTokens > (agent.physicalWindowTokens ?? Infinity); + // Under `speech.locus: 'delivery'` incoming traffic never moves the + // pin — so when someone addresses the agent mid-turn from another + // channel, the agent must be told who and from where, or a neutral + // message reads as part of the conversation it landed in and gets + // answered there (first live run, 2026-10-12, finding C). One + // notice per channel per turn; it rides the same injection batch. + // The turn's own mode: a silent turn routes no prose, whatever the + // agent's mode, so its boundary neither notices nor re-pins. The + // trigger is this stream's: its start holds the agent's turn token + // while the agent waits on this round's tools. + const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') + && !this.activeTurnTriggers.get(agent.name)?.suppressProse; + if (this.speechLocus === 'delivery' && routesLocus && !shouldEndTurn && !overBudget && currentState.stream) { + const routing = this.routingOf(agent.name); + for (const inj of [...midTurnInjections]) { + const m = inj.metadata as Record | undefined; + if (!isConversationalInjection(inj.metadata)) continue; + if (typeof m?.channelId !== 'string') continue; + if (!isAddressedMessage(m.tags as string[] | undefined)) continue; + // Its channel as a re-pin reads it (repinChannel): on the + // message's own server. + const channel = this.channelNamed(m.channelId, typeof m.serverId === 'string' ? m.serverId : undefined); + if (!channel) continue; + const notice = this.addressedElsewhereNotice(agent, routing, channel, authorName(m)); + if (notice) midTurnInjections.push(notice); + } + } + // Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified): // when someone ADDRESSES the agent mid-turn from another channel // (mention / reply / DM — the same chat:addressed signal the @@ -14336,9 +16556,7 @@ export class AgentFramework { // agent's mode, so its boundary neither re-pins nor notes. The // trigger is this stream's: its start holds the agent's turn token // while the agent waits on this round's tools. - const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') - && !this.activeTurnTriggers.get(agent.name)?.suppressProse; - if (routesLocus && !shouldEndTurn && !overBudget && currentState.stream) { + if (this.speechLocus === 'latest-inbound' && routesLocus && !shouldEndTurn && !overBudget && currentState.stream) { // Two signals qualify an injection to move the pin (n=6 + n=7): // - chat:addressed — someone explicitly spoke TO the agent // (mention / reply / DM); @@ -14738,12 +16956,12 @@ export class AgentFramework { if (!decision.trigger) return; } - // Broadcast requests exclude conversation forks — they are driven by - // their own channel's messages, not by framework-wide events. + // A broadcast request reaches the agents whose role says they take + // untargeted inbound; channel-bound and bounded-job agents are driven + // by their own channel or task, not by framework-wide events. const targetAgents = response.requestInference === true - ? Array.from(this.agents.keys()).filter( - (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName) + ? this.registry.untargetedRecipients() : response.requestInference; for (const agentName of targetAgents) { @@ -14794,6 +17012,27 @@ export class AgentFramework { * Convert an incoming MCPL channel message to a context message. * This replaces the old MCPLModule.onProcess() message conversion. */ + /** + * A delivered inbound message becomes the latest delivered inbound (the + * channel context's reply anchor; the legacy speech fallback) through the + * registry's noteDelivered — at once for a plain channels/incoming, whose + * acceptance the registry answers synchronously; parked for a coalesced one + * until its acceptance is acknowledged (see parkedAnchors). + */ + private noteDeliveredOrPark(event: { + serverId: string; channelId: string; messageId: string; threadId?: string; tags?: string[]; + eventId?: string; coalescingSubject?: string; firstView?: boolean; + }): void { + // On the server the message came from (noteDelivered). + const anchor = { channel: { serverId: event.serverId, channelId: event.channelId }, messageId: event.messageId, threadId: event.threadId, tags: event.tags, firstView: event.firstView === true }; + if (event.coalescingSubject && typeof event.eventId === 'string') { + this.parkedAnchors.set(`${event.serverId}:${event.eventId}`, anchor); + if (this.parkedAnchors.size > 1000) this.parkedAnchors.delete(this.parkedAnchors.keys().next().value!); + return; + } + this.channelRegistry?.noteDelivered(anchor.channel, anchor.messageId, anchor.threadId, anchor.tags, anchor.firstView); + } + private async handleMcplChannelIncoming(event: { type: 'mcpl:channel-incoming'; serverId: string; @@ -14877,6 +17116,53 @@ export class AgentFramework { // each storage site below, over exactly the blocks stored there. metadata.sourceBodyDigest = sourceBodyDigest(event.content); + // Stored as the model reads it: `author: body`. The wire formatter + // writes the participant, and channel traffic is stored under the + // anonymous `user`, so a body stored bare reaches the model as + // `user: ` with the author nowhere (Weft's rig, 2026-10-13). A + // server that renders the name into the text itself is left as is. + const content = presentAuthor({ participant: 'user', content: event.content, metadata }).content; + + // Admission (Dendrite §D, #232 root cause A): decided once, here, before + // anything downstream acts — the hold, the wake gate, who may be woken, + // whether this counts as the latest delivered inbound. Every path below + // consumes `decision`; none re-asks the policy. + const addressed = isAddressedMessage(event.tags); + const decision = this.admitInbound({ + lane: 'channel', + serverId: event.serverId, + channelId: event.channelId, + messageId: event.messageId, + authorId: event.author?.id, + tags: event.tags, + addressed, + vetoed: event.triggerInference === false, + targetAgents: event.targetAgents, + gateContent: event.content + .filter((b): b is Extract => b.type === 'text') + .map((b) => b.text) + .join('\n'), + gateMetadata: { + ...event.metadata, + eventType: 'mcpl:channel-incoming', + serverId: event.serverId, + channelId: event.channelId, + messageId: event.messageId, + threadId: event.threadId, + author: event.author, + ...(event.tags ? { tags: event.tags } : {}), + }, + }); + metadata.triggered = decision.wake; + if (decision.held) { + // The admission record rides the message; the hold's own bookkeeping + // (cadence tally, suppressed-mention ack, reader wake) follows. + metadata.tuneOut = { epochId: decision.held.epochId }; + this.tuneOutCoordinator?.recordHeld( + event.serverId, event.channelId, event.messageId, event.tags, event.author?.id, decision.gatePass, + ); + } + // Per-channel conversation routing: messages go to the channel's fork // agent (spawned from the template on first qualifying message), never // to the primary conversation. @@ -14886,14 +17172,19 @@ export class AgentFramework { if (event.deliverTo) { const target = this.agents.get(event.deliverTo); if (!target) return undefined; - if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) { + if (this.conversationRouter && this.registry.homeChannel(target.name) !== undefined) { metadata.triggered = event.triggerInference ?? false; // A fork's channel messages are stored past its deferral; a clock goes // ahead of one only while the fork has no turn alive (storeDueClock). if (!this.turnAlive(target) && takesClockBefore('user', event.content, metadata, target.name)) { this.storeDueClock(target, 'event'); } - const placement = this.writeToFork(target, event.content, AgentFramework.withStoredDigest(metadata, event.content)); + // The stored copy carries the author as the fork reads them (presentAuthor), and its digest is of that copy. + const placement = this.writeToFork(target, content, AgentFramework.withStoredDigest(metadata, content)); + // Delivered to the fork, so it is the latest delivered inbound too + // (the channel context's `incoming`; the fallback locus under the + // legacy rule), as a resident's delivery is below. + if (decision.locus) this.noteDeliveredOrPark(event); // A correction may target an older engagement. Refresh only the // binding whose agent actually received the message. const channel: ChannelRef = { serverId: event.serverId, channelId: event.channelId }; @@ -14901,12 +17192,12 @@ export class AgentFramework { this.conversationRouter.touch(channel); } this.emitTrace({ type: 'message:added', messageId: placement.messageId ?? '', source: 'mcpl:channel-incoming' }); - if (event.triggerInference) { + if (decision.wake) { this.pendingRequests.push({ agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), channelId: event.channelId, channelServerId: event.serverId, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, - addressed: isAddressedMessage(event.tags, event.metadata), + addressed, ...systemNoticeMark(event.tags), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }); @@ -14914,14 +17205,14 @@ export class AgentFramework { return placement; } if (this.conversationRouter) return undefined; // the trunk never holds channel traffic - event = { ...event, targetAgents: [target.name] }; + decision.recipients = [target.name]; } else if (this.conversationRouter) { - return this.routeConversationIncoming(event, metadata); + return this.routeConversationIncoming({ ...event, content }, metadata, decision); } // Addressed-while-closed invitation — parity with the push-event path // (channels.publish surfaces like portal-mcpl deliver mentions here). - const incomingContent = [...event.content]; + const incomingContent = [...content]; { const invitation = this.buildClosedChannelInvitation({ serverId: event.serverId, @@ -14938,7 +17229,11 @@ export class AgentFramework { missedCharacters: typeof event.metadata?.missedCharacters === 'number' ? event.metadata.missedCharacters : undefined, - targetAgents: event.targetAgents, + // A correction's one agent (deliverTo) is the agent whose window the + // invitation goes into, so its mode is the one read (main's #852): + // admission recorded it as the recipients, where main's code + // rewrites the event's targetAgents. + targetAgents: event.deliverTo ? decision.recipients : event.targetAgents, }); if (invitation) { incomingContent.push(invitation.block); @@ -14946,38 +17241,17 @@ 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, - // 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 }; - } - + // Captured where it arrived: a held message is stored with its admission + // record and never enters a resident's view; a delivered one is also + // the latest delivered inbound (the channel context's `incoming`, and + // the fallback locus under the legacy rule) — a held one never is. const placement: CoalescingPlacement = { agent: '' }; const id = this.addMessage('user', incomingContent, AgentFramework.withStoredDigest(metadata, incomingContent), { placement, bypassDeferralFor: event.assemblingFor }); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); + if (decision.locus) this.noteDeliveredOrPark(event); - if (event.triggerInference && !divert) { - const addressed = isAddressedMessage(event.tags, event.metadata); - // 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 (decision.wake) { + for (const agentName of decision.recipients) { if (!this.agents.has(agentName)) continue; this.pendingRequests.push({ agentName, @@ -15021,6 +17295,7 @@ export class AgentFramework { coalescingSubject?: string; }, messageMetadata: Record, + admission: AdmissionDecision, ): Promise { const router = this.conversationRouter!; const channel: ChannelRef = { serverId: event.serverId, channelId: event.channelId }; @@ -15083,9 +17358,9 @@ export class AgentFramework { } } - // Respect an explicit server-config-level veto (shouldTriggerInference) - // on top of the router's own trigger policy. - const trigger = decision.trigger && event.triggerInference !== false; + // The admission decision (server veto, wake gate) composes with the + // router's own trigger policy. + const trigger = decision.trigger && admission.wake; messageMetadata.triggered = trigger; // As for a correction above: a clock ahead only while no turn is alive. @@ -15093,6 +17368,10 @@ export class AgentFramework { this.storeDueClock(agent, 'event'); } const placement = this.writeToFork(agent, event.content, AgentFramework.withStoredDigest(messageMetadata, event.content)); + // Delivered to the channel's fork: the latest delivered inbound, as for a + // resident (the channel context's `incoming`; the fallback locus under + // the legacy rule). A held or dropped message never is. + if (admission.locus) this.noteDeliveredOrPark(event as Parameters[0]); router.touch(channel); this.emitTrace({ type: 'message:added', messageId: placement.messageId ?? '', source: 'mcpl:channel-incoming' }); @@ -15106,7 +17385,7 @@ export class AgentFramework { // triggering channel too so the trunk/active path stays consistent. channelId: event.channelId, channelServerId: event.serverId, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, - addressed: isAddressedMessage(event.tags, event.metadata), + addressed: isAddressedMessage(event.tags), ...systemNoticeMark(event.tags), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }); @@ -15250,10 +17529,17 @@ export class AgentFramework { const agent = new Agent(config, contextManager, this.membrane); this.adoptToolResultGuard(agent); agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable + this.announceAgentCreated(this.registry.register({ + ...conversationForkSpec(name, { + template: router.templateAgent, + homeChannel: home, + idleTtlMs: router.idleTtlMs, + }), + model: config.model, + })); this.managerReleases.set(agent, release); this.agents.set(name, agent); this.agentConfigs.set(name, config); - this.conversationAgentHomes.set(name, home); return agent; } catch (error) { if (release.manager) this.closeManager(release.manager); @@ -15399,61 +17685,27 @@ export class AgentFramework { * stop() has ended binds nothing (spawnConversationAgent). The fork's * context stays in Chronicle for investigation. */ - private disposeConversationAgent(agentName: string): void { - this.closingConversationAgents.delete(agentName); - // A continuation of its turn still on its way goes with it. - this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' }); - const home = this.conversationAgentHomes.get(agentName); + private disposeConversationAgent(agentName: string, reason: AgentEndReason = 'idle-ttl'): void { + const home = this.homeOf(agentName); const agent = this.agents.get(agentName); - // A fork stop() ends can still be running: its stream, its tool round. - // Its name is never used again, so its end takes the stream's - // activeStreams and pendingAssistantBlocks entries, as a run's end does - // (runEphemeralToCompletion), and settles a batch its guard still holds, - // withheld as `aborted`: the stream's own finally does neither once the - // fork has gone. A fork the idle sweep ends has no stream entries left, - // and a batch still pending there is one whose successor never ran, - // which a run's end settles too. - if (agent?.toolResultGuard.hasPending) { - try { - agent.toolResultGuard.abandon('aborted'); - } catch (error) { - console.error(`[tool-result-guard] agent=${agentName} failed to record a batch the fork left:`, error); - } - } - this.activeStreams.delete(agentName); - this.pendingAssistantBlocks.delete(agentName); - this.agents.delete(agentName); - this.thinkingLossIncidents.forget(agentName); - this.reasoningLoss.forget(agentName); - // Its pending self-wake ends with it, rather than wake a name that's gone. - this.eventGate?.agentGone(agentName); + // The one release every ended agent gets (releaseAgentRuntime), then the + // registry's end, whose outcome settles what the fork spawned. if (agent) { - this.releaseManager(agent); - this.releaseWindowNowOrAfterResume(agent); - } - this.turnRouting.delete(agentName); - this.proseSpeechChains.delete(agentName); - this.openLogical.delete(agentName); - this.toolImageLedgers.delete(agentName); - this.agentConfigs.delete(agentName); - this.conversationAgentHomes.delete(agentName); - this.evictTurnCheckpoints(agentName); - if (agent) this.logicalTurnToolCalls.delete(agent); - // Its scripts go with it, and leave the map once they've ended: no later - // run of a fork's name trims them. - this.codeExecution?.scripts.agentGone(agentName); - // A provider-wait hold (nonOwningProviderWait) goes with the agent: left, - // its timer would requeue held wakes for an agent that is gone, and a wait - // held until released would re-arm for the name indefinitely. The recorded - // wait stays, and binds the name if its channel binds again. - const cooldown = this.providerAccelerationCooldowns.get(agentName); - if (cooldown) { - clearTimeout(cooldown.timer); - this.providerAccelerationCooldowns.delete(agentName); - console.error(`[provider-wait] agent=${agentName} disposed while held` + - `${cooldown.waitModel !== undefined ? ` on ${cooldown.waitModel}` : ''}: ` + - `${cooldown.heldRequests.length} held request(s) dropped with it (the recorded wait stays)`); - } + this.releaseAgentRuntime(agent, home, 'its agent was disposed'); + } else { + // Nothing live under the name: only what the process keeps by name. + this.closingConversationAgents.delete(agentName); + this.thinkingLossIncidents.forget(agentName); + this.reasoningLoss.forget(agentName); + // A continuation of its turn still on its way goes with it. + this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' }); + this.turnRouting.delete(agentName); + this.proseSpeechChains.delete(agentName); + this.openLogical.delete(agentName); + this.forgetAnnouncedLocus(agentName); + this.codeExecution?.scripts.agentGone(agentName); + } + this.settleEndedAgent(this.registry.end(agentName, reason, 'framework', { record: true })); this.emitTrace({ type: 'mcpl:conversation-disposed', agentName, @@ -15475,7 +17727,7 @@ export class AgentFramework { agentName: string, injections: ContextInjection[], ): ContextInjection[] { - const home = this.conversationAgentHomes.get(agentName); + const home = this.homeOf(agentName); const registry = this.channelRegistry; if (!home || !registry || injections.length === 0) { return injections; @@ -15519,7 +17771,7 @@ export class AgentFramework { // Agent vanished (external reset) — nothing to close, just make sure // its bookkeeping doesn't linger. this.agentConfigs.delete(binding.agentName); - this.conversationAgentHomes.delete(binding.agentName); + this.settleEndedAgent(this.registry.end(binding.agentName, 'idle-ttl', 'framework', { record: true })); continue; } this.writeToFork( @@ -15552,7 +17804,7 @@ export class AgentFramework { !this.activeStreams.has(agentName) && !this.pendingRequests.some((r) => r.agentName === agentName) ) { - this.disposeConversationAgent(agentName); + this.disposeConversationAgent(agentName, 'idle-ttl'); } } } @@ -16067,7 +18319,11 @@ export class AgentFramework { if (restored.recent) this.coalescingRecentReceipts = restored.recent; this.pushCoalescer = restored.coalescer; // Batches restored from a snapshot still owe the agent a wake (§14 v41). - for (const occ of restored.coalescer.pendingBatchOccurrences()) void this.wakeForCoalescedBatch(occ); + for (const occ of restored.coalescer.pendingBatchOccurrences()) { + void this.wakeForCoalescedBatch(occ).catch((error: unknown) => { + console.error(`[coalescing] ${occ.serverId}: the wake a restored batch (${occ.eventId}) owes its agent failed:`, error); + }); + } } /** @@ -16175,6 +18431,13 @@ export class AgentFramework { // counts what was stored before a restart as shown: every agent's // shown watermark starts at the head at boot. const firstView = this.pushCoalescer.receiptFirstView(serverId, this.coalescingBinding(serverId), message.eventId); + // Acknowledged: the anchor the delivery parked is committed now (and a + // retry answered from the receipt commits its first attempt's). + const parked = this.parkedAnchors.get(`${serverId}:${message.eventId}`); + if (parked) { + this.parkedAnchors.delete(`${serverId}:${message.eventId}`); + this.channelRegistry?.noteDelivered(parked.channel, parked.messageId, parked.threadId, parked.tags, parked.firstView || firstView === true); + } return { messageId: message.messageId, accepted: true, coalesce: result.coalesce, ...(firstView ? { firstView: true as const } : {}) }; }); } @@ -16303,7 +18566,7 @@ export class AgentFramework { return []; } } - return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName); + return this.registry.untargetedRecipients(); } /** Deliver through the lane's ordinary path; report where it landed. */ @@ -16391,11 +18654,58 @@ export class AgentFramework { private async queueCoalescedWakes(occ: CoalescedOccurrence, plain = false, readsDeferredWrite?: string, allAmbient = false): Promise { const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return; - const origin = occ.event.event.origin; + const push = occ.event.event; + const origin = push.origin; // The wake carries the batch's channel so the turn freezes its reply // locus there (the materialized delivery at assembly wakes nobody). const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId; const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined); + // A deferred batch is admitted when it asks for its wake: the hold and + // the gate decide now; its materialized delivery at assembly is vetoed + // (`triggerInference: false`) and so is not shown to the gate again. + const decision = this.admitInbound({ + lane: 'push', + serverId: occ.serverId, + channelId, + messageId: occ.eventId, + authorId, + tags: occ.tags, + addressed: isAddressedMessage(occ.tags), + vetoed: false, + targetAgents: push.targetAgents, + gateContent: (push.content ?? []) + .filter((b): b is Extract => b.type === 'text') + .map((b) => b.text) + .join('\n'), + gateMetadata: { + ...(origin ?? {}), + serverId: occ.serverId, + featureSet: push.featureSet, + eventId: occ.eventId, + eventType: 'mcpl:push-event', + ...(occ.tags ? { tags: occ.tags } : {}), + }, + }); + if (decision.held && !plain && this.pushCoalescer) { + // A held batch wakes nobody, so no turn's assembly would store it while + // the hold lasts, and readers of held traffic and the release would + // never see it. It's stored now, stamped with its hold as a plain push + // under the hold is, and consumed, so the turn after the hold doesn't + // take it again. Outside the coalescer's lock: this runs inside it. + // A hold that ended before the batch was stored left it stored unheld: + // it then takes the wake an unheld push gets, every recipient's. + const coalescer = this.pushCoalescer; + void this.coalescedAudience(occ) + .then((audience) => (audience[0] !== undefined ? coalescer.materializeNow(audience[0], subject) : false)) + .then(async (stored) => { + if (stored && !(channelId !== undefined && this.tuneOutCoordinator?.holdFor(occ.serverId, channelId))) { + await this.queueCoalescedWakes(occ, true); + } + }) + .catch((err) => console.error(`[coalescing] a held batch for ${logValue(subject)} could not be stored:`, err)); + return; + } + if (!decision.wake) return; for (const agentName of await this.coalescedAudience(occ)) { this.pendingRequests.push({ agentName, @@ -16404,7 +18714,7 @@ export class AgentFramework { timestamp: Date.now(), channelId, ...(channelId !== undefined ? { channelServerId: occ.serverId } : {}), counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined, - addressed: isAddressedMessage(occ.tags, origin), + addressed: isAddressedMessage(occ.tags), // No systemNotice mark here: a render folds the batch's every // occurrence, and only its latest's tags are at hand, so marking it // would hide a folded message's channel whenever a notice came last. @@ -16493,9 +18803,13 @@ export class AgentFramework { // not the agent that happened to be asked). A reader is one whose view // filter keeps the message: a tune-out-diverted message never enters a // resident's compiled view, and only the subconscious reads it through - // its merged view — no subconscious, nobody reads it. - const readers = (this.sharedSlotAgents.has(agent) - ? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a) + // its merged view — no subconscious, nobody reads it. The slot's readers + // are the registry's (residents and the subconscious), each asked its own + // view (agentSees, main's #853). + const readers = (this.registry.readsSharedSlot(agent.name) + ? this.registry.sharedSlotReaders() + .map((name) => this.agents.get(name)) + .filter((reader): reader is Agent => reader !== undefined) : [agent]).filter((a) => this.agentSees(a, message.metadata)); const branch = cm.currentBranch().name; const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp); @@ -16646,6 +18960,41 @@ export class AgentFramework { // content merely by setting `origin.silent`. const silentHeartbeat = isSilentHeartbeatMarker({ ...event, content }); + // Admission (Dendrite §D): the same decision as the channel lane. A DM + // arrives here, so this is where tuning out a DM takes effect (#232 gap + // 1). An event with no channel provenance cannot be held. + const decision = this.admitInbound({ + lane: 'push', + serverId: event.serverId, + channelId: triggerChannel?.channelId, + messageId: event.eventId, + authorId: typeof event.origin?.authorId === 'string' ? event.origin.authorId : undefined, + tags: event.tags, + addressed: isAddressedMessage(event.tags), + vetoed: event.triggerInference === false, + targetAgents: event.targetAgents, + gateContent: content + .filter((b): b is Extract => b.type === 'text') + .map((b) => b.text) + .join('\n'), + gateMetadata: { + ...(event.origin ?? {}), + serverId: event.serverId, + featureSet: event.featureSet, + eventId: event.eventId, + eventType: 'mcpl:push-event', + ...(event.tags ? { tags: event.tags } : {}), + }, + }); + metadata.triggered = decision.wake; + if (decision.held && triggerChannel) { + metadata.tuneOut = { epochId: decision.held.epochId }; + this.tuneOutCoordinator?.recordHeld( + event.serverId, triggerChannel.channelId, event.eventId, event.tags, + typeof event.origin?.authorId === 'string' ? event.origin.authorId : undefined, decision.gatePass, + ); + } + const placement: CoalescingPlacement = { agent: '' }; if (!silentHeartbeat) { const id = this.addMessage('user', content, AgentFramework.withStoredDigest(metadata, content), { placement, bypassDeferralFor: event.assemblingFor }); @@ -16654,12 +19003,8 @@ export class AgentFramework { console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${logValue(event.eventId ?? '')}`); } - if (event.triggerInference) { - // Default broadcast excludes conversation forks (channel-driven). - const targetAgents = event.targetAgents - ?? [...this.agents.keys()].filter( - (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName); - for (const agentName of targetAgents) { + if (decision.wake) { + for (const agentName of decision.recipients) { this.pendingRequests.push({ agentName, reason: 'mcpl:push-event', @@ -16669,7 +19014,7 @@ export class AgentFramework { // DMs and addressed-while-closed messages arrive as push events; // they must outrank ambient chatter in a batched wake's locus // selection just like their channels/incoming counterparts. - addressed: isAddressedMessage(event.tags, event.origin), + addressed: isAddressedMessage(event.tags), ...ambientMark(event.tags), ...systemNoticeMark(event.tags), ...(silentHeartbeat ? { @@ -16696,10 +19041,10 @@ export class AgentFramework { * a fork is that fork's conversation, so no other agent takes it. */ private mayTakeGateRoute(agentName: string, route: ChannelRef): boolean { - const home = this.conversationAgentHomes.get(agentName); + const home = this.homeOf(agentName); if (home !== undefined) return sameChannel(home, route); - for (const boundChannel of this.conversationAgentHomes.values()) { - if (sameChannel(boundChannel, route)) return false; + for (const record of this.registry.list()) { + if (sameChannel(this.channelOfHome(record.homeChannel), route)) return false; } return true; } @@ -16718,6 +19063,35 @@ export class AgentFramework { return named.length === 1 ? named[0] : undefined; } + /** + * A live agent's home channel, from its registry record (channelOfHome): + * defined for a conversation fork, and only for one. Undefined for an agent + * with none, and for one that has ended. + */ + private homeOf(agentName: string): ChannelRef | undefined { + return this.channelOfHome(this.registry.homeChannel(agentName)); + } + + /** + * The channel a registry record's `homeChannel` names: the channel on its + * server. A bare channel id, as a record written before homes carried + * their server keeps, is read as a wake that names its channel without its + * server is (channelNamed): the one registered channel it names, read when + * it's used, since the servers registered at that moment are what it can + * mean. An id that names no one registered channel, as one no server + * registers now or two servers do, is the home on no server (an empty + * serverId, which channelNamed reads as none given), so the agent keeps a + * home. A path that reads the pair finds no server registering it, and + * refuses it; one that reads the name reads what the name means then: none + * or two channels, which it refuses, or, once one server alone registers + * the id, that server's channel. + */ + private channelOfHome(home: AgentRecord['homeChannel']): ChannelRef | undefined { + if (home === undefined) return undefined; + if (typeof home !== 'string') return home; + return this.channelNamed(home) ?? { serverId: '', channelId: home }; + } + /** * Explicit mode's typing channel for one start of a turn, which has no * locus, read once as the start opens (driveStream then shows what the @@ -17297,6 +19671,42 @@ export class AgentFramework { } } + /** + * `channel_publish` with no channel goes where the turn's plain speech + * would: the frozen turn locus (the waking delivery's channel, or one + * opened this turn), then the registry's own resolution. Under + * `speech.locus: 'delivery'` the registry alone has no default, and the + * tool failed on the very turn its own delivery woke (first live run, + * 2026-10-12). Both dispatch paths — the stream driver's and + * executeToolCall's — pass through here. Only a call made inside the turn + * (callingTurnOf) takes the turn's locus: one made outside it, as a script + * left running after its turn makes or a host's executeToolCall naming the + * agent, takes none of the turn's channels. A conversation fork's home + * isn't the turn's but the fork's own, so such a call from a fork naming + * no channel still goes there (homeOf), as the fork's dispatched calls do + * (dispatchChannelToolCall); anyone else's takes the registry's own + * default. + */ + private publishInputWithTurnLocus(input: unknown, agentName: string, callingTurn: TurnRouting | undefined): unknown { + const given = (input as { channelId?: unknown } | null | undefined)?.channelId; + if (typeof given === 'string') return input; + const locus = callingTurn + ? callingTurn.locus ?? this.channelRegistry?.resolveLocus(agentName) ?? null + : this.homeOf(agentName) ?? null; + if (!locus) return input; + // The pair: a serverId beside a channelId names that server's channel. + // A serverId given with no channelId names the locus's id on that + // server, as the registry reads one given beside its own default: the + // default's id on the server given (handleToolPublish). So a fork's call + // naming another server and no channel reaches that server's channel + // with its home's id, where one is registered: its dispatched calls are + // refused that (dispatchChannelToolCall), and executeToolCall applies + // none of that guard, on main as here. + const record = (input as Record | null | undefined) ?? {}; + const serverId = typeof record.serverId === 'string' && record.serverId ? record.serverId : locus.serverId; + return { ...record, channelId: locus.channelId, serverId }; + } + /** * Stamps for the rows the agent's reply stores in its current turn: its * assistant responses, its tool_result rows (through `storeResults`), and @@ -18077,7 +20487,7 @@ export class AgentFramework { } const registry = this.channelRegistry; if (!registry) return refuse('No channels are configured, so there is nowhere to resend to.'); - const home = this.conversationAgentHomes.get(agentName); + const home = this.homeOf(agentName); let spec = typeof input.destination === 'string' ? input.destination.trim() : ''; if (present(input.destination) && typeof input.destination !== 'string') { return refuse('destination must be "#channel", "@person" or a channel id.'); @@ -18513,17 +20923,56 @@ export class AgentFramework { } } + private rememberAnnouncedLocus(agentName: string, locus: ChannelRef | null): void { + this.lastAnnouncedLocus.set(agentName, locus); + this.writeAnnouncedLocus(agentName, locus ? { serverId: locus.serverId, channelId: locus.channelId } : null); + } + + private forgetAnnouncedLocus(agentName: string): void { + this.lastAnnouncedLocus.delete(agentName); + this.writeAnnouncedLocus(agentName, undefined); + } + + /** The stored baseline's entry for the agent set (rememberAnnouncedLocus), + * or removed (undefined) when the agent ends without coming back. */ + private writeAnnouncedLocus(agentName: string, locus: ChannelRef | null | undefined): void { + try { + const data = this.store.getStateJson(FRAMEWORK_STATE_ID); + const state = (data && typeof data === 'object' ? data : {}) as Record; + const loci = { ...((state.announcedLoci as Record | undefined) ?? {}) }; + if (locus === undefined) { + if (!Object.hasOwn(loci, agentName)) return; + delete loci[agentName]; + } else { + loci[agentName] = locus; + } + state.announcedLoci = loci; + this.store.setStateJson(FRAMEWORK_STATE_ID, state); + } catch (err) { + console.error(`[routing] ${agentName}: the announced locus wasn't ${locus === undefined ? 'removed from' : 'written to'} the store:`, err); + } + } + /** * Announce-on-change locus notice (turn-frozen routing). Appends a durable * `[routing]` window message when the turn's effective outbound locus * differs from the last one announced — the agent must never have to guess * where its plain prose lands. Durable append (never retracted) keeps the - * KV prefix stable; change-only keeps it out of steady-state turns. The - * first resolution after boot announces only a non-null locus ("prose has - * no destination" is the unremarkable boot default). Never triggers + * KV prefix stable; change-only keeps it out of steady-state turns. An + * agent never told of a locus, here or before a restart, hears only of a + * non-null one ("prose has no destination" is the unremarkable start). + * Never triggers * inference; never locus-eligible itself (system + no channel metadata). */ private announceLocusIfChanged(agentName: string, locus: ChannelRef | null): void { + if (!this.lastAnnouncedLocus.has(agentName)) { + try { + const stored = (this.store.getStateJson(FRAMEWORK_STATE_ID) as { announcedLoci?: Record } | null)?.announcedLoci; + if (stored && Object.hasOwn(stored, agentName)) this.lastAnnouncedLocus.set(agentName, stored[agentName] ?? null); + } catch (err) { + console.error(`[routing] ${agentName}: the announced locus couldn't be read back, so this turn compares with none:`, err); + } + } const hasBaseline = this.lastAnnouncedLocus.has(agentName); const prev = this.lastAnnouncedLocus.get(agentName) ?? null; if (hasBaseline ? sameChannel(prev, locus) : locus === null) { @@ -18557,7 +21006,7 @@ export class AgentFramework { ); // The baseline is what the agent was told: it moves once the notice is // stored, so a notice that fails is announced again at the next turn. - this.lastAnnouncedLocus.set(agentName, locus); + this.rememberAnnouncedLocus(agentName, locus); this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); console.error( `[routing] ${agentName}: locus ${prev ? qualifiedChannelName(prev) : '(none)'} -> ${locus ? qualifiedChannelName(locus) : '(none)'} (announced in window)`, @@ -18575,7 +21024,11 @@ export class AgentFramework { * as the agent's first view) that addressed the agent, or that follows up * in a channel the turn sent into (TurnRouting.engaged). Undefined for any * other message. A tool boundary weighs the messages it injects by it, - * and noteArrival the ones no boundary of the turn weighs. + * and noteArrival the ones no boundary of the turn weighs. The rule is + * pure; what is done with its answer is `speech.locus`'s: under + * `'latest-inbound'` the locus moves, under `'delivery'` (the default) the + * agent is told it was addressed from elsewhere and the locus stays + * (addressedElsewhereNotice). */ private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): ChannelRef | undefined { if (!isConversationalInjection(metadata)) return undefined; @@ -18586,7 +21039,7 @@ export class AgentFramework { // registers that id (channelNamed). const channel = this.channelNamed(m.channelId, typeof m.serverId === 'string' ? m.serverId : undefined); if (!channel) return undefined; - if (isAddressedMessage(m.tags as string[] | undefined, m)) return channel; + if (isAddressedMessage(m.tags as string[] | undefined)) return channel; return routing.engaged.has(destinationKey(channel)) ? channel : undefined; } @@ -18834,8 +21287,10 @@ export class AgentFramework { * Weigh a message that no tool boundary of the agent's open logical turn * will weigh: one that would re-pin the locus becomes one of the turn's * arrivals (TurnRouting.arrivals). A message the agent's own view leaves - * out, such as one a tune-out diverted (metadata.tuneOut), never enters - * its compiled view, so the agent won't see it, and it moves nothing. + * out (agentSees), such as one held from the residents (heldFromResidents, + * the admission stage's one predicate, which every resident's view filter + * reads) or another agent's stored context injection, never enters its + * compiled view, so the agent won't see it, and it moves nothing. */ private noteArrival(agentName: string, metadata: MessageMetadata | undefined, messageId: MessageId): void { const open = this.openLogical.get(agentName); @@ -18844,17 +21299,19 @@ export class AgentFramework { if (agent && !this.agentSees(agent, metadata)) return; if (isConversationalInjection(metadata)) (open.heard ??= new Map()).set(messageId, ++lastHeardAt); const channel = this.repinChannel(metadata, open); - if (channel) open.arrivals.push({ messageId, channel }); + if (!channel) return; + const tags = (metadata as Record | undefined)?.tags as string[] | undefined; + open.arrivals.push({ messageId, channel, addressed: isAddressedMessage(tags) }); } /** * Whether `agent`'s store is the residents' one message slot, which every - * ordinary resident reads (sharedSlotAgents). The subconscious reads that + * ordinary resident reads (`readsSharedSlot` in the registry). The subconscious reads that * slot through a merged view, but its own store is isolated, as a fork's * or an ephemeral agent's is. */ private isOrdinaryResident(agent: Agent): boolean { - return this.sharedSlotAgents.has(agent) && agent.name !== this.subconsciousAgentName; + return this.registry.readsSharedSlot(agent.name) && agent.name !== this.subconsciousAgentName; } /** @@ -18865,8 +21322,9 @@ export class AgentFramework { */ private noteForOtherResidents(agent: Agent, metadata: MessageMetadata | undefined, messageId: MessageId): void { if (!this.isOrdinaryResident(agent)) return; - for (const reader of this.sharedSlotAgents) { - if (reader !== agent && this.agents.get(reader.name) === reader && this.isOrdinaryResident(reader)) { + for (const name of this.registry.sharedSlotReaders()) { + const reader = this.agents.get(name); + if (reader && reader !== agent && this.isOrdinaryResident(reader)) { this.noteSpokenTo(reader.name, metadata); this.noteArrival(reader.name, metadata, messageId); } @@ -18948,7 +21406,7 @@ export class AgentFramework { const metadata = { system: true, kind: 'routing-notice' } as MessageMetadata; try { const id = agent.getContextManager().addMessage('user', content, metadata); - this.lastAnnouncedLocus.set(agent.name, channel); + this.rememberAnnouncedLocus(agent.name, channel); this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ? qualifiedChannelName(prevPin) : '(none)'} -> ${qualifiedChannelName(channel)}`); return { participant: 'user', content, metadata, id }; @@ -18958,6 +21416,78 @@ export class AgentFramework { } } + /** + * Under `speech.locus: 'delivery'`: tell the agent that someone addressed + * it from another channel while its turn was busy, since incoming traffic + * never moves the locus and a neutral message carries nothing that names + * its channel (first live run, 2026-10-12, finding C). Once per channel + * per turn (TurnRouting.addressedElsewhere); nothing for the turn's own + * locus. Stored durably; returned as stored for a live stream to inject + * with its boundary's other messages. Undefined when nothing was stored. + */ + private addressedElsewhereNotice( + agent: Agent, + routing: TurnRouting, + channel: ChannelRef, + who: string, + when = 'while you were busy', + ): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined { + const pin = routing.locus ?? null; + const key = destinationKey(channel); + if (sameChannel(channel, pin) || routing.addressedElsewhere.has(key)) return undefined; + const where = pin ? `still lands in ${this.channelShown(pin)}` : 'stays in your archive (no channel)'; + const content: ContentBlock[] = [{ + type: 'text', + text: + // The author's name is a value someone supplied (main's #852: one + // line, its notice openings marked); the channel is shown as + // channelShown gives it. + `[${NOTICE_TAG.routing}] ${suppliedValue(who)} addressed you from ${this.channelShown(channel)} ${when}. ` + + `Your plain speech ${where} this turn; reaching them takes an explicit send.`, + }]; + const metadata = { system: true, kind: 'routing-notice' } as MessageMetadata; + const before = this.storedCountOf(agent); + try { + const id = agent.getContextManager().addMessage('user', content, metadata); + routing.addressedElsewhere.add(key); + this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); + return { participant: 'user', content, metadata }; + } catch (err) { + // A write can throw after its row landed, as storeInjections' can: a + // notice that landed is the turn's notice for the channel, as one + // stored without a throw is. + const text = (content[0] as { text: string }).text; + const landed = before === undefined ? undefined : this.storedSince(agent, before, (m) => + (m.metadata as { kind?: unknown } | undefined)?.kind === 'routing-notice' + && m.content.some((b) => b.type === 'text' && (b as { text: string }).text === text), + 'routing', 'the addressed-elsewhere notice'); + if (landed && landed.length > 0) { + routing.addressedElsewhere.add(key); + console.error(`addressed-elsewhere notice: ${agent.name}: the notice landed though its write threw:`, err); + return { participant: 'user', content, metadata }; + } + console.error(`addressed-elsewhere notice: failed to record routing notice${landed ? '' : ' (whether it landed is unknown)'}:`, err); + return undefined; + } + } + + /** + * The arrivals a start of the turn consumes under `speech.locus: + * 'delivery'`: each one that addressed the agent, whose message is still + * in the store, gets the addressed-elsewhere notice, oldest first. A + * follow-up in an engaged channel addressed nobody and is not announced. + */ + private noticeAddressedArrivals(agent: Agent, routing: TurnRouting, arrivals: Arrival[], when: string): void { + if (arrivals.length === 0) return; + const cm = agent.getContextManager(); + for (const { messageId, channel, addressed } of arrivals) { + if (!addressed) continue; + const message = cm.getMessage(messageId); + if (!message) continue; + this.addressedElsewhereNotice(agent, routing, channel, authorName(message.metadata as Record | undefined), when); + } + } + /** * Durable window notice for a channel the agent did not ask to open: a * subscription-policy admission, announced as the decision before its @@ -19147,7 +21677,7 @@ export class AgentFramework { */ private canCloseChannels(who: string | undefined, channels: readonly ChannelRef[]): boolean { if (who === undefined) return false; - const home = this.conversationAgentHomes.get(who); + const home = this.homeOf(who); return channels.every((channel) => !home || sameChannel(home, channel)) && this.canAgentUseTool(who, 'channel_close'); } @@ -19170,7 +21700,7 @@ export class AgentFramework { */ private holdsChannelTraffic(who: string | undefined, channels: readonly ChannelRef[]): boolean { if (who === undefined) return false; - const home = this.conversationAgentHomes.get(who); + const home = this.homeOf(who); if (home !== undefined) { return channels.every((channel) => sameChannel(channel, home) && this.conversationRouter?.getBinding(channel)?.agentName === who); } @@ -19187,7 +21717,12 @@ export class AgentFramework { * otherwise the delivery agent and the readers of the residents' shared slot do. An ephemeral run holds none. */ private holdsChannelTrafficForTools(who: string): boolean { - if (this.conversationAgentHomes.has(who)) return true; + if (this.registry.homeChannel(who) !== undefined) return true; + // An agent that shares its source's context (a reader fork) shares its + // source's words too: its tool block is the source's prefix, and a + // sentence that differed would send the whole window uncached. + const inherit = this.registry.get(who)?.inherit; + if (inherit?.mode === 'shared' && inherit.from !== who) return this.holdsChannelTrafficForTools(inherit.from); if (this.conversationRouter) return false; return this.residentHoldsTraffic(who); } @@ -19210,7 +21745,7 @@ export class AgentFramework { * notice passed on later, at resume or at boot, names it by its name. */ private agentForPrimary(agentName: string): { first: string; again: string } { - const home = this.conversationAgentHomes.get(agentName); + const home = this.homeOf(agentName); if (home === undefined) return { first: agentName, again: agentName }; const label = this.channelRegistry?.getDescriptor(home)?.label; const bound = this.labelledChannel({ ...home, ...(label ? { label } : {}) }); @@ -19232,7 +21767,7 @@ export class AgentFramework { /** Whether `agentName` is a conversation fork made from the primary: the * router's template is the primary (connectome-host's is). */ private forkOfPrimary(agentName: string): boolean { - return this.conversationAgentHomes.has(agentName) && this.primaryAgentName !== null && + return this.registry.homeChannel(agentName) !== undefined && this.primaryAgentName !== null && this.conversationRouter?.templateAgent === this.primaryAgentName; } @@ -19304,7 +21839,7 @@ export class AgentFramework { return []; } const who = ended ? primary : agentName || this.deliveryAgent()?.name; - const canOpen = who !== undefined && !this.conversationAgentHomes.has(who) && this.canAgentUseTool(who, 'channel_open'); + const canOpen = who !== undefined && this.registry.homeChannel(who) === undefined && this.canAgentUseTool(who, 'channel_open'); const canClose = this.canCloseChannels(who, [{ serverId, channelId: channel.channelId }]); const selected = selectedForOpening === 'unrecorded' ? " It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again" @@ -19346,7 +21881,7 @@ export class AgentFramework { }; const notices: ChannelNotice[] = []; if (tellPrimary) { - const primaryCanOpen = !this.conversationAgentHomes.has(primary!) && this.canAgentUseTool(primary!, 'channel_open'); + const primaryCanOpen = this.registry.homeChannel(primary!) === undefined && this.canAgentUseTool(primary!, 'channel_open'); const reader = this.agentForPrimary(who!); notices.push({ text: `[${NOTICE_TAG.channels}] Not open: ${shown}. A reply from ${reader.first} went out there, but opening the channel failed, and ${reader.again} can't close it.` + @@ -19429,7 +21964,7 @@ export class AgentFramework { if (trigger?.suppressProse) return; // A subagent's run ends with its result; a conversation fork answers // its home channel's message. - if (this.ephemeralRuns.has(name) || this.conversationAgentHomes.has(name)) return; + if (this.ephemeralRuns.has(name) || this.registry.homeChannel(name) !== undefined) return; if (this.providerAdmissionClosed || this.agents.get(name) !== agent) { console.error(`[continue] ${name}: not continued — ${this.providerAdmissionClosed ? 'the framework is stopping' : 'the agent is no longer registered'}`); return; @@ -20388,8 +22923,7 @@ export class AgentFramework { attempt = 0, providerGateAlreadyHeld = false, ): Promise { - const ownsProviderGate = - !this.ephemeralRuns.has(agent.name) && !this.conversationAgentHomes.has(agent.name); + const ownsProviderGate = this.registry.ownsProviderScheduling(agent.name); // A stopping framework starts no turn, whoever owns provider admission // (conversation agents and ephemeral runs do not take the gate). if (this.providerAdmissionClosed) { @@ -21248,11 +23782,28 @@ export class AgentFramework { // A turn in a mode with a locus reads its arrivals' messages, a turn // with no channel (noChannel) included: an addressed message, or a // follow-up where the turn sent, gives it its channel, as a re-pin does. - const arrival = turnProseRouting === 'locus' || turnProseRouting === 'hybrid' + // Under `speech.locus: 'delivery'` an arrival moves nothing; one that + // addressed the agent from another channel is announced instead, as a + // boundary would have announced it (addressedElsewhereNotice). + const routesLocus = turnProseRouting === 'locus' || turnProseRouting === 'hybrid'; + const arrivals = routing.arrivals; + const arrival = routesLocus && this.speechLocus === 'latest-inbound' ? this.remainingArrival(agent, routing) : undefined; routing.arrivals = []; if (arrival) this.repinTurnLocus(agent, routing, arrival, 'arrival'); + else if (routesLocus && this.speechLocus === 'delivery') { + // Which start this is, since from inside they look alike and the + // reason says whether the earlier prose landed (Linn, 2026-10-14). + const when = attempt > 0 + ? 'while your turn was retried' + : trigger?.reason === 'context_budget_restart' + ? 'at your budget restart' + : isTurnContinuation(trigger?.reason ?? '') + ? 'before this re-inference on your tool results' + : 'while your turn waited on a provider hold'; + this.noticeAddressedArrivals(agent, routing, arrivals, when); + } } // A silent start, or a disabled agent's, has no locus to show, whatever a // channel_open in a silent turn left on its object. @@ -21474,7 +24025,7 @@ export class AgentFramework { // can record a wait while this turn compiles, after the last boundary // checked. Check its waits again here, where nothing has been sent, and // hold the turn as the boundary would rather than send it into the wait. - if (this.conversationAgentHomes.has(agent.name)) { + if (this.registry.homeChannel(agent.name) !== undefined) { const wait = this.providerWaits?.active(agent.name, agent.model); // The turn has begun (inference:started), so the hold keeps its retry. if (wait && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(withTurnCarry(trigger, carry))] : [], false, () => { @@ -21495,6 +24046,7 @@ export class AgentFramework { })) { console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` + `(recorded while the turn compiled); ${this.providerAdmissionClosed ? 'the framework is stopping, so nothing is held' + : this.agents.get(agent.name) !== agent ? 'the agent ended as the failure was reported, so nothing is held' : this.providerAccelerationCooldowns.get(agent.name)?.heldRequests.some(isTurnRetry) ? 'turn held' : 'the wait was released as the failure was reported, so the retry is queued'}, nothing sent`); this.endFailureAtStop(agent.name, turnToken, turnStartedAt, routing); @@ -23206,7 +25758,7 @@ export class AgentFramework { .filter((c): c is { id?: unknown; name: string; input?: unknown; unparseableInput?: string } => typeof c.name === 'string' && !cutIds.has(c.id as string) && markedInputRefusal({ ...c, name: c.name }) === undefined - && this.canAgentUseTool(agent.name, c.name)); + && this.mayCallTool(agent.name, c.name)); const toolNames = toolCalls.map((c) => c.name); // A refused skip_reply isn't a silencing call; a plain one is. const fallbackCause = liveProseRouting @@ -24101,7 +26653,7 @@ export class AgentFramework { // turn isn't over (pendingContinuationAgents). if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name) && !this.pendingContinuationAgents().has(agent.name)) { - this.disposeConversationAgent(agent.name); + this.disposeConversationAgent(agent.name, 'idle-ttl'); } } } @@ -24122,8 +26674,10 @@ export class AgentFramework { * (ctx.callTool → executeToolCallFrom with {kind:'module'}), which is exempt * from agent allowlists. Registered agents' allowedTools is enforced at * dispatch, including namespaced utility names. Genuinely unidentified public callers (including - * unknown legacy names) remain unrestricted. Framework-created identities - * are refused while unregistered, even if their configured list allows it. + * unknown legacy names) remain unrestricted. A name the framework made in + * this process is refused while it isn't registered, even if its configured + * list allows the tool; after a restart, such a name is a host's caller name + * like any other. */ async executeToolCall(call: ToolCall): Promise { return this.executeToolCallFrom(call, { @@ -24400,7 +26954,11 @@ export class AgentFramework { // synthesized verbs (think, skip_reply) are deliberately not routed // here. if (call.name.startsWith('channel_') && this.channelRegistry) { - return this.channelRegistry.handleChannelToolCall(call.name, call.input, origin); + const turn = call.name === 'channel_publish' && call.callerAgentName ? this.callingTurnOf(call.callerAgentName, call) : undefined; + const input = call.name === 'channel_publish' && call.callerAgentName + ? this.publishInputWithTurnLocus(call.input, call.callerAgentName, turn) + : call.input; + return this.channelRegistry.handleChannelToolCall(call.name, input, origin.kind === 'agent' && turn ? { ...origin, inTurn: true } : origin); } // drafts: the calling resident's own held drafts. A puppeted call has @@ -24423,6 +26981,20 @@ export class AgentFramework { // Module tools. The caller uses the result in process, so it gets the // module's own result, as the module returned it, less a refusal it // claims, which is the dispatcher's to say. + // The tune-out tool, in process: the same body the dispatcher runs, its + // answer awaited here (it can wait for the tune-out's transport). + if ((call.name === 'tune_out' || call.name === 'focus') && this.tuneOutCoordinator) { + const agentName = call.callerAgentName ?? this.primaryAgentName ?? '__ephemeral__'; + const input = (call.input ?? {}) as Record; + // The calling turn's own channels, as the channel tools read them here (origin.own). + const own = origin.kind === 'agent' ? origin.own ?? [] : []; + const turn = this.callingTurnOf(agentName, call); + return call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn); + } + if ((SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(call.name)) { + const routed = this.readerToolCall(origin.kind === 'agent' ? origin.agentName : call.callerAgentName ?? '__ephemeral__', call); + return 'refused' in routed ? failed({ success: false, error: `${routed.refused}, so the call didn't run.`, isError: true }) : routed.result; + } return this.moduleRegistry.handleToolCall(call); } @@ -24463,6 +27035,38 @@ export class AgentFramework { return { ok: true, call: inner, serverId: target.serverId, prefix, ...(target.instance !== undefined ? { instance: target.instance } : {}) }; } + /** + * A reader reads and reports: a reader fork (registry kind + * 'subconscious-fork', in every incarnation) may call only its own tools, + * and speak_in_channel where allowChannelSpeech allows it. Every other + * tool the resident has stays in its block (the shared prefix) and is + * refused here, so with speech off a fork can post nowhere (scout's + * condition, 2026-10-19). It's decided at each call from what the reader + * may call, not from what it is shown, so a tool added or moved out of + * the block later is refused too. + */ + private readerRefuses(agentName: string, toolName: string): boolean { + if (this.registry.get(agentName)?.kind !== 'subconscious-fork') return false; + return !this.readerMayCall(agentName, toolName); + } + + /** + * What a reader fork may call: exactly its own tools and the speech its + * configuration allows, never widened or narrowed by its resident's + * allowedTools. The resident's list bounds the resident's own surface; + * the reader's tools are the framework's, for reading on its behalf. A + * denyToolsAtDispatch entry the fork takes from its resident that names + * one of them still refuses it (mayCallTool checks the deny first): a + * resident never holds a reader tool, so such an entry is about readers. + * The fork still shows its resident's block (the shared prefix): what it + * may call is decided here, at each call, apart from what it is shown. + */ + private readerMayCall(agentName: string, toolName: string): boolean { + if (this.registry.get(agentName)?.kind !== 'subconscious-fork') return false; + if (toolName === 'speak_in_channel') return this.subconsciousConfig?.allowChannelSpeech === true; + return (SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(toolName); + } + /** A toolsets--act call a host toolset resolved (HostPresentation.resolveAct): * its refusal answered, or the tool's own call dispatched in its place, * with the act's id, so its result is the act's. */ @@ -26128,23 +28732,41 @@ export class AgentFramework { return best; } - /** Registered policy; framework-created identities have no dispatch authority - * while unregistered. Genuine legacy caller names retain their surface. */ + /** Registered policy. Within this process, a name the framework made has no + * dispatch authority while it isn't registered; after a restart, a host call names + * its caller as any host call does. Genuine legacy caller names retain + * their surface. */ private canAgentUseTool(agentName: string, toolName: string): boolean { const agent = this.agents.get(agentName); return agent ? agent.canUseTool(toolName) : !this.usedEphemeralAgentNames.has(agentName); } + /** Whether a call of `toolName` as `agentName` is refused at dispatch (AgentConfig.denyToolsAtDispatch, or a reader's own rule). */ + private deniedAtDispatch(agentName: string, toolName: string): boolean { + return this.agents.get(agentName)?.deniesAtDispatch(toolName) === true || this.readerRefuses(agentName, toolName); + } + + /** Whether `agentName` may call `toolName`: the one rule dispatch applies (agentToolPermissionFailure), for whatever else judges a call. */ + private mayCallTool(agentName: string, toolName: string): boolean { + return !this.deniedAtDispatch(agentName, toolName) + && (this.canAgentUseTool(agentName, toolName) || this.readerMayCall(agentName, toolName)); + } + private agentToolPermissionFailure(agentName: string, call: ToolCall, reportRefusal = true): ToolResult | undefined { - if (this.canAgentUseTool(agentName, call.name)) return undefined; - const why = !this.agents.has(agentName) && this.usedEphemeralAgentNames.has(agentName) - ? `Caller '${agentName}' is not a running agent; framework-created agents have tool authority only while registered` - : `Tool '${call.name}' is not permitted by this agent's allowedTools`; + // A tool refused at dispatch is refused on every path that runs the + // agent's call (a toolset's act, executeToolCall), not only the stream's. + if (this.mayCallTool(agentName, call.name)) return undefined; + const denied = this.deniedAtDispatch(agentName, call.name); + const why = denied + ? `${call.name} is not available to ${agentName}` + : !this.agents.has(agentName) && this.usedEphemeralAgentNames.has(agentName) + ? `Caller '${agentName}' is not a running agent; framework-created agents have tool authority only while registered` + : `Tool '${call.name}' is not permitted by this agent's allowedTools`; // Off the dispatcher, nothing opened the call to refuse it in RFC-007's // lifecycle, so it's a failure, not a refusal (ToolResult.refused), whose // text says the call didn't run. if (!reportRefusal) return { success: false, error: `${why}, so the call didn't run.`, isError: true }; - const error = `${why}.`; + const error = denied ? why : `${why}.`; const refusal = this.refusedResult(agentName, call.id, error); this.emitTrace({ type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error, @@ -26215,7 +28837,7 @@ export class AgentFramework { * (`refusedHere`) counts as one the list refuses. */ private roundCallOf(agentName: string, call: { name: string; input?: unknown; unparseableInput?: string }, refusedHere = false): RoundCall { - const permitted = !refusedHere && markedInputRefusal(call) === undefined && this.canAgentUseTool(agentName, call.name); + const permitted = !refusedHere && markedInputRefusal(call) === undefined && this.mayCallTool(agentName, call.name); const refused = bareToolName(call.name) === 'skip_reply' && (!permitted || this.refusesSelfWake(call)); return { name: call.name, cause: permitted || refused ? silenceCauseOf([call]) : null, refused, permitted }; } @@ -26239,6 +28861,23 @@ export class AgentFramework { }); return; } + // Dispatch-time deny (AgentConfig.denyToolsAtDispatch): the tool stays + // advertised so the agent's request prefix is unchanged; the call is + // refused here, before anything runs. + if (this.deniedAtDispatch(agentName, call.name)) { + const error = `${call.name} is not available to ${agentName}`; + const result = this.refusedResult(agentName, call.id, error); + this.emitTrace({ type: 'tool:failed', module: 'dendrite', tool: call.name, callId: call.id, error }); + this.pushEvent({ + type: 'tool-result', + callId: call.id, + toolName: call.name, + agentName, + moduleName: 'dendrite', + result, + }); + return; + } const refusal = this.agentToolPermissionFailure(agentName, call) ?? this.markedInputFailure(agentName, call); if (refusal) { this.pushEvent({ @@ -26324,20 +28963,22 @@ export class AgentFramework { } // Route the tune-out tool (residents) and the subconscious's surface - if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) { + if ((enrichedCall.name === 'tune_out' || enrichedCall.name === 'focus') && 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, this.callOwnChannels(agentName, enrichedCall)) - // A rejection is answered with a failed result, as a framework tool's - // dispatch failure is (dispatchAgentSettingsToolCall), never left unhandled. - .catch((err): ToolResult => ({ success: false, error: thrownText(err), isError: true })) + if ((SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(enrichedCall.name)) { + const routed = this.readerToolCall(agentName, enrichedCall); + if ('refused' in routed) { + const error = routed.refused; + const result = this.refusedResult(agentName, call.id, error); + this.emitTrace({ type: 'tool:failed', module: 'tune-out', tool: call.name, callId: call.id, error }); + this.pushEvent({ + type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result, + }); + return; + } + void routed.result .then((result) => { // Through pushEvent, like every other tool's result, so one that // arrives after stop() is dropped rather than rejected unhandled. @@ -26717,6 +29358,7 @@ export class AgentFramework { opts?.fromQueue ? withDeferredWriteId(metadata, opts.fromQueue.id) : metadata, ); if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; } + this.releaseLandedMail(metadata, agent.name); this.noteStored(agent, metadata, stored, opts?.bypassDeferralFor); return stored; } @@ -26875,6 +29517,37 @@ export class AgentFramework { this.noteForOtherResidents(agent, metadata, stored); } + /** + * Held agent mail is released only when its message has actually entered + * the recipient's store — never on a deferral. Called at every site that + * writes a message into a context: the direct path above and the two + * deferred-write flushes (turn start, tool boundary). Both writes reach + * disk in the same sync, so a crash keeps either both or neither. + */ + private releaseLandedMail(metadata: MessageMetadata | undefined, recipient: string): void { + const stamp = (metadata as { dendrite?: { mailId?: unknown; kind?: unknown; from?: { agent?: unknown } } } | undefined)?.dendrite; + if (typeof stamp?.mailId !== 'string') return; + // The message is in the recipient's store; the release is written only + // once the store has synced it, so a hard exit between the two leaves + // the mail held and the next boot's check of the recipient's store + // settles it — never a release on disk for a message that isn't (nissa's + // #546 review, round 2, K). A sync that fails leaves it held the same way. + try { + this.store.sync(); + } catch (error) { + console.error(`[dendrite] mail ${stamp.mailId} landed for ${recipient} but the store did not sync, so it stays held until the next boot settles it:`, error); + return; + } + this.registry.releaseMail(stamp.mailId); + this.emitTrace({ + type: 'dendrite:mail-delivered', + mailId: stamp.mailId, + kind: String(stamp.kind ?? 'message'), + from: String(stamp.from?.agent ?? ''), + to: recipient, + }); + } + /** * Splice out the deferred messages addressed to `agentName` (explicitly * via forAgent, or implicitly when it is the primary), leaving other @@ -27324,6 +29997,8 @@ export class AgentFramework { const stored = this.writeDrainedInOrder(this.drainDeferredFor(agent.name), `${occasion} flush for ${agent.name}`, (msg) => agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id))); for (const [msg, { id }] of stored) { + // Held mail whose message just landed is released (Dendrite). + this.releaseLandedMail(msg.metadata, agent.name); this.emitTrace({ type: 'message:added', messageId: id, source: `deferred-flush:${occasion}` }); this.noteSpokenTo(agent.name, msg.metadata); this.noteArrival(agent.name, msg.metadata, id); @@ -27911,7 +30586,7 @@ export class AgentFramework { // target never read it (passesToPrimary); addMessage takes it off any // other write. const forRun = opts?.forAgent !== undefined - && (this.ephemeralRuns.has(opts.forAgent) || this.conversationAgentHomes.has(opts.forAgent)); + && (this.ephemeralRuns.has(opts.forAgent) || this.registry.homeChannel(opts.forAgent) !== undefined); const placement: { deferredId?: string } = {}; const id = this.addMessage(participant, content, forRun ? { ...metadata, [PRIMARY_IF_UNREAD_KEY]: true } as MessageMetadata : metadata, { placement, ...(opts?.forAgent !== undefined ? { forAgent: opts.forAgent } : {}), ...(forRun ? { markedForRun: true as const } : {}), @@ -27923,7 +30598,7 @@ export class AgentFramework { * The writes the framework holds for the store `reader` reads, in queue * order: those addressed to it and, when it's an ordinary resident, those * addressed to any ordinary resident, since residents share one message - * slot (sharedSlotAgents). A write whose target is gone isn't among them: + * slot (`readsSharedSlot` in the registry). A write whose target is gone isn't among them: * no module reads that agent's store, and the write waits for its window's * release (drainDeferredFor) or, at its flush, is dropped (addMessage), a * failed reply, or a channel notice its reader could act on, passing to the @@ -28315,6 +30990,10 @@ export class AgentFramework { if (typeof bounded.error === 'string') bounded.error = boundFailureText(bounded.error); if (typeof bounded.stack === 'string') bounded.stack = boundFailureText(bounded.stack); + if (this.earlyTraces) { + if (this.earlyTraces.length < 1000) this.earlyTraces.push(traceEvent); + else this.earlyTracesDropped++; + } // An event goes to the subscriptions present when its delivery starts, // each one only if it is still active when its turn comes, as the DOM's // EventTarget delivers: a subscription made during delivery waits for the @@ -29023,10 +31702,11 @@ export class AgentFramework { this.setMcplServerConfig(config); } - // Find shouldTriggerInference callback: - // Per-server callback takes precedence; fall back to EventGate; fall back to no filter. - const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference - ?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined); + // The server-level veto on waking (a per-server shouldTriggerInference). + // The wake gate is deliberately NOT wired into the inbound lanes any + // more: it runs in the admission stage, after the hold decision, so a + // held message never forms a gate batch (#232 gap 3, root cause A). + const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference; // Push events handler (Step 6) this.installPushCoalescer(coalescing); @@ -29081,14 +31761,28 @@ export class AgentFramework { } }, shouldTriggerInference: triggerFilter, + latestInboundLocus: this.speechLocus === 'latest-inbound', + // A tool close of the channel an all-except hold excepts ends that + // hold through the cancel flow, since there is nothing left to speak + // into. A tool close of a tuned-out channel is refused + // before this hook is called (ChannelRegistry.tunedOutRefusal). + onToolClose: (serverId, channelId) => { + const coordinator = this.tuneOutCoordinator; + if (!coordinator) return undefined; + const scope = coordinator.getAllExcept(); + if (!scope || scope.params.except.serverId !== serverId || scope.params.except.channelId !== channelId) return undefined; + coordinator.cancelAllExcept('channel-close', 'the excepted channel was closed'); + return 'The focus on it ended all the same, and what the other channels held was delivered.'; + }, + focusChannelResolver: () => this.tuneOutCoordinator?.focusChannel() ?? null, // Route a conversation fork's plain-text speech to its HOME channel, not // the process-global most-recent-inbound locus (item 3). The trunk agent // has no home entry, so this returns undefined and routeSpeech falls back - // to defaultPublishChannel. `conversationAgentHomes` is the permanent + // to defaultPublishChannel. The registry's `homeChannel` is the permanent // spawn-time binding; `channelForAgent` is the router's live binding as a // belt-and-suspenders fallback. homeChannelResolver: (agentName) => - this.conversationAgentHomes.get(agentName) + this.homeOf(agentName) ?? this.conversationRouter?.channelForAgent(agentName), // A single TRUNK agent's channel for its CURRENT turn (item-3 redux): // its locus once the turn has one, a re-pin's or a channel_open's @@ -29989,6 +32683,9 @@ export class AgentFramework { */ private makeHostPresentation(storePath: string | undefined): HostPresentation { return new HostPresentation({ + // A derived agent showing its parent's tool list (toolSurfaceOwners) is + // shown its parent's presentation too, so the two blocks stay one. + ownerOf: (agentName) => this.toolSurfaceOwners.get(agentName) ?? agentName, // As the agent is shown them: its description overrides applied. available: (agentName) => (this.agents.has(agentName) ? this.compressionToolsForAgent(agentName) : this.getAllTools()), placedInBuiltin: (name) => this.placedInBuiltin(name), @@ -33229,7 +35926,7 @@ export class AgentFramework { // ones; channel_open is rejected outright — opening channels mutates // framework-global state (the open-channel set every agent's channel // context is built from), which is not a fork's call to make. - const home = this.conversationAgentHomes.get(agentName); + const home = this.homeOf(agentName); if (home) { const reject = (error: string): void => { // RFC-007: the guard refuses before the tool runs — no lifecycle events. @@ -33333,8 +36030,11 @@ export class AgentFramework { const readThrough = publisher ? this.publishReadThrough(publisher, call.id) : null; const published: { outcome?: PublishOutcome } = {}; - this.channelRegistry!.handleChannelToolCall(call.name, call.input, { - kind: 'agent', agentName, own: callingTurn ? this.ownChannels(callingTurn) : [], ...(earlierWake ? { earlierWake } : {}), + const channelInput = call.name === 'channel_publish' + ? this.publishInputWithTurnLocus(call.input, agentName, callingTurn) + : call.input; + this.channelRegistry!.handleChannelToolCall(call.name, channelInput, { + kind: 'agent', agentName, own: callingTurn ? this.ownChannels(callingTurn) : [], ...(callingTurn ? { inTurn: true } : {}), ...(earlierWake ? { earlierWake } : {}), }, published) .then((result) => { const destination = published.outcome?.status === 'delivered' ? published.outcome.destination : undefined; @@ -33382,7 +36082,7 @@ export class AgentFramework { && this.openLogical.get(agentName) === callingTurn) { callingTurn.locus = opened; noteTurnChannel(callingTurn, opened); - this.lastAnnouncedLocus.set(agentName, opened); + this.rememberAnnouncedLocus(agentName, opened); const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(opened); // The routing word comes first, ahead of any backscroll, so a // result cut to its head for being long keeps it. @@ -33647,6 +36347,26 @@ export class AgentFramework { } } + /** + * A reader's own tool (SUBCONSCIOUS_TOOL_NAMES), for both entries that run + * an agent's call: the dispatcher's, for the model's call, and + * executeToolCallFrom's, for a host's or the puppet's. The tools are + * advertised to residents so reader forks share their prefix, and only a + * registered reader may use them. A rejection is answered with a failed + * result, never left unhandled. + */ + private readerToolCall(agentName: string, call: ToolCall): { refused: string } | { result: Promise } { + const isReader = agentName === this.subconsciousAgentName || this.registry.get(agentName)?.kind === 'subconscious-fork'; + if (!this.tuneOutCoordinator || !isReader) return { refused: `${call.name} is available to the resident's reader, not to ${agentName}` }; + const epoch = this.registry.get(agentName)?.metadata?.epochId; + const fork = agentName === this.subconsciousAgentName ? undefined : { epochId: typeof epoch === 'string' ? epoch : undefined }; + return { + result: this.tuneOutCoordinator + .handleSubconsciousTool(call.name, call.input as Record, this.callOwnChannels(agentName, call), agentName, fork) + .catch((err): ToolResult => ({ success: false, error: thrownText(err), isError: true })), + }; + } + /** * Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's * suppression window, optionally announces in the sticky channel, and ends @@ -33654,127 +36374,277 @@ export class AgentFramework { */ /** 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; - durationSeconds?: number; - }; - const name = String(input.channelId ?? ''); + const input = (call.input ?? {}) as Record; // Inside the calling turn, a name one of the turn's own channels answers // to by its id means that channel, as for the channel tools - // (ChannelRegistry.ownChannelFor). Otherwise the one channel the name - // means, which may be server-qualified; a name two servers' channels - // answer to is refused with them as candidates, never taken to be the - // first registered. - const mine = this.channelRegistry?.ownChannelFor(name, this.callOwnChannels(agentName, call)); - const named = mine ? ('channel' in mine ? [mine.channel] : []) : this.channelRegistry?.channelsNamed(name) ?? []; - const shared = mine && 'error' in mine ? mine.error - : named.length > 1 ? this.channelRegistry?.sharedNameRefusalFor(name) : undefined; - const channel = named.length === 1 ? named[0]! : undefined; - const channelId = channel?.channelId ?? name; - // The name the agent knows the channel by: its id, or where another - // server shares the id, its server-qualified name. An answer that waits - // for the transport reads it again then, since a registration meanwhile - // can change which name picks the channel. - const shownNow = (): string => (channel ? this.channelRegistry?.nameShown(channel) ?? channelId : name); - const shown = shownNow(); - let result: { success: boolean; data?: unknown; error?: string; isError?: boolean }; + // (ChannelRegistry.ownChannelFor): read here, while the call is the + // turn's, before the answer can wait. + const own = this.callOwnChannels(agentName, call); + const turn = this.callingTurnOf(agentName, call); + // The answer can wait for the tune-out's transport (openForTuneOut), so + // the result goes through pushEvent when it is known, as every other + // tool's does; a rejection is answered too, never left unhandled. + void (call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn)) + .catch((err: unknown) => ({ success: false, error: thrownText(err), isError: true })) + .then((result) => { + this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result }); + }); + } + + /** `focus` is `tune_out` with scope "all-except". */ + private handleFocusTool(agentName: string, input: Record, own: readonly ChannelRef[] = [], turn?: TurnRouting): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> { + return this.handleTuneOutTool(agentName, { ...input, scope: 'all-except' }, own, turn); + } + + /** + * A tune-out call's refusal for a channel it can't resolve: main's words + * for a name no registered channel, and none of the calling turn's own, + * answers to, and the registry's own otherwise, as for an id that is + * ambiguous across servers (#232 gap 8). + */ + private unknownChannelWords(channelId: string, resolution: string, own: readonly ChannelRef[]): string { + const known = (this.channelRegistry?.channelsNamed(channelId).length ?? 0) > 0 + || own.some((c) => c.channelId === channelId); + return known ? resolution : `unknown channel: ${channelId}`; + } + + /** + * The tune_out tool's body. Channel resolution is server-qualified + * (ChannelRegistry.resolveChannel): with a `serverId`, that server's + * channel with the id; inside the calling turn, a name one of the turn's + * own channels answers to by its id means that channel; otherwise the one + * channel the name means, which may be server-qualified, and a name two + * servers' channels answer to is refused with them as candidates (#232 + * gap 8). `turn` is the turn that made the call (callingTurnOf), the only + * one whose speech an all-except entry moves. + */ + private async handleTuneOutTool( + agentName: string, + input: Record, + own: readonly ChannelRef[] = [], + turn?: TurnRouting, + ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> { + const coordinator = this.tuneOutCoordinator; + const channelRegistry = this.channelRegistry; + if (!coordinator || !channelRegistry) { + return { success: false, error: 'tune-out is not available (no MCPL channels)', isError: true }; + } // An open that throws is answered too, never left unhandled. const openThrew = (err: unknown): string => 'The tune-out is recorded, but its messages may not reach the subconscious, since opening its channel ' + `failed: ${thrownText(err)}`; - if (shared) { - result = { success: false, error: shared, isError: true }; - } else if (!channel) { - result = { success: false, error: `unknown channel: ${name}`, isError: true }; - } else if (input.mode === 'cancel') { - const r = coordinator.cancel(channel.serverId, channelId, 'agent-tool', 'agent request'); - result = r.ok - ? { success: true, data: { cancelled: shown } } + const serverId = typeof input.serverId === 'string' && input.serverId ? input.serverId : undefined; + const allExcept = input.scope === 'all-except'; + + if (input.mode === 'check') { + // A peek's name means what the other modes' names mean: the one + // channel it picks, by its id or the server-qualified name the agent + // is shown where another server shares the id, or inside the turn one + // of the turn's own channels (resolveChannel), and its lines are that + // channel's, on its server. + let peek: ChannelRef | undefined; + if (typeof input.peek === 'string' && input.peek) { + const resolved = channelRegistry.resolveChannel(input.peek, serverId, own); + if ('error' in resolved) return { success: false, error: this.unknownChannelWords(input.peek, resolved.error, own), isError: true }; + peek = { serverId: resolved.serverId, channelId: resolved.channelId }; + } + // Read as the other options are (readOptions): a numeric string is its + // number, and a value that gives none is refused, named. + const peekLimit = input.peekLimit == null ? undefined : Number(input.peekLimit); + if (peekLimit !== undefined && !Number.isFinite(peekLimit)) { + const got = typeof input.peekLimit === 'string' ? `the string ${JSON.stringify(input.peekLimit)}` : `the value ${JSON.stringify(input.peekLimit)}`; + return { success: false, error: `Nothing was checked: peekLimit must be a finite number of lines, and it was ${got}. Leave it out for 5.`, isError: true }; + } + return { success: true, data: coordinator.check({ ...(peek ? { peek } : {}), ...(peekLimit !== undefined ? { peekLimit } : {}) }) }; + } + if (input.mode === 'cancel' && (allExcept || (!input.channelId && coordinator.getAllExcept()))) { + const r = coordinator.cancelAllExcept('agent-tool', 'agent request'); + return r.ok ? { success: true, data: { cancelled: 'all-except' } } : { success: false, error: r.error, isError: true }; + } + + const channelId = String(input.channelId ?? ''); + const resolved = channelRegistry.resolveChannel(channelId, serverId, own); + if ('error' in resolved) return { success: false, error: this.unknownChannelWords(channelId, resolved.error, own), isError: true }; + // The name the agent knows the channel by: its id, or where another + // server shares the id, its server-qualified name. An answer that waits + // for the transport reads it again then, since a registration meanwhile + // can change which name picks the channel. + const shownNow = (): string => channelRegistry.nameShown(resolved); + if (input.mode === 'cancel') { + const r = coordinator.cancel(resolved.serverId, resolved.channelId, 'agent-tool', 'agent request'); + return r.ok + ? { success: true, data: { cancelled: shownNow() } } : { success: false, error: r.error, isError: true }; - } else { - const r = coordinator.enter(channel.serverId, channelId, { + } + if (allExcept) { + const r = coordinator.enterAllExcept(resolved, { cadenceSeconds: input.cadenceSeconds, backlogCap: input.backlogCap, maxWakes: input.maxWakes, durationSeconds: input.durationSeconds, }, 'agent-tool'); - if (r.ok) { - const data = { + if (!r.ok) return { success: false, error: r.error, isError: true }; + const speaker = this.agents.get(agentName); + const home = this.homeOf(agentName); + const open = turn && this.openLogical.get(agentName) === turn ? turn : undefined; + if (open && open.locus == null && !open.noChannel && !open.heartbeat && !home + && (speaker?.proseRouting === 'locus' || speaker?.proseRouting === 'hybrid')) { + open.locus = resolved; + noteTurnChannel(open, resolved); + this.rememberAnnouncedLocus(agentName, resolved); + } + const speech = home + ? `Your plain speech stays in ${this.channelShown(home)}, your conversation's channel.` + : open?.locus && !sameChannel(open.locus, resolved) + ? `This turn's plain speech still lands in ${this.channelShown(open.locus)}; from your next turn it lands in the excepted channel while this holds, except on a turn that has no channel, such as a heartbeat's.` + : open && (open.noChannel || open.heartbeat) + ? "This turn has no channel, so its plain speech goes to none; from your next turn it lands in the excepted channel while this holds, except on a turn that has no channel, such as a heartbeat's." + : "Your plain speech lands in the excepted channel while this holds, except on a turn that has no channel, such as a heartbeat's."; + return { + success: true, + data: { + scope: 'all-except', + except: shownNow(), + announcedIn: coordinator.announces() ? shownNow() : null, + expiresAtMs: r.params.expiresAtMs, cadenceSeconds: r.params.cadenceSeconds, backlogCap: r.params.backlogCap, maxWakes: r.params.maxWakes, - }; - // A tuned-out channel's traffic must keep arriving for the - // subconscious, so the answer waits for its transport (openForTuneOut). - // The tune-out is recorded either way, so it's a success, and where - // the transport isn't open, openError says why its messages don't or - // may not reach the subconscious, and when they will where that's - // known, or that the tune-out isn't in effect any more. A rejection is - // answered too, never left unhandled, and the result goes through - // pushEvent, as every other tool's does. - void this.channelRegistry!.openForTuneOut(channel.serverId, channelId) - .catch(openThrew) - .then((openError) => { - this.pushEvent({ - type: 'tool-result', - callId: call.id, - toolName: call.name, - agentName, - moduleName: 'tune-out', - result: { success: true, data: { tunedOut: shownNow(), ...data, ...(openError ? { openError } : {}) } }, - }); - }); - return; - } + note: coordinator.hasReader() + ? `${speech} Reader wakes pause, not cancel, when the budget is spent.` + : `No subconscious is configured: this is a silent hold. ${speech} The backlog is delivered per channel when you cancel or the duration ends.`, + }, + }; + } + const r = coordinator.enter(resolved.serverId, resolved.channelId, { + cadenceSeconds: input.cadenceSeconds, + backlogCap: input.backlogCap, + maxWakes: input.maxWakes, + durationSeconds: input.durationSeconds, + }, 'agent-tool'); + if (!r.ok) { // tune_out repeated on a channel already tuned out is where an agent // retries a transport the first open left closed, so where it isn't // open, or work is queued for it, the repeat goes through // openForTuneOut, which joins the open in flight or waits and, where // this host can open the channel, opens it again; the refusal says // what its transport is now. - const tuneOut = this.channelRegistry!.getTuneOutState(channel.serverId, channelId); - if (r.alreadyTunedOut && tuneOut && !this.channelRegistry!.tuneOutTransportOpen(channel.serverId, channelId)) { - void this.channelRegistry!.openForTuneOut(channel.serverId, channelId) - .catch(openThrew) - .then((openError) => { - // The tune-out is read again: one that ended or was replaced while - // the open was out isn't described as standing, whatever the open - // answered for it. - const now = this.channelRegistry!.getTuneOutState(channel.serverId, channelId); - const error = now?.params.epochId === tuneOut.params.epochId - ? alreadyTunedOutText(this.channelRegistry!.quotedShown(channel), now.params, - openError ?? 'Its transport is open now, so its messages reach the subconscious.') - : TUNE_OUT_REPLACED; - this.pushEvent({ - type: 'tool-result', - callId: call.id, - toolName: call.name, - agentName, - moduleName: 'tune-out', - result: { success: false, error, isError: true }, - }); - }); - return; - } - result = { success: false, error: r.error, isError: true }; - } - this.pushEvent({ - type: 'tool-result', - callId: call.id, - toolName: call.name, - agentName, - moduleName: 'tune-out', - result, + const tuneOut = channelRegistry.getTuneOutState(resolved.serverId, resolved.channelId); + if (r.alreadyTunedOut && tuneOut && !channelRegistry.tuneOutTransportOpen(resolved.serverId, resolved.channelId)) { + const openError = await channelRegistry.openForTuneOut(resolved.serverId, resolved.channelId).catch(openThrew); + // The tune-out is read again: one that ended or was replaced while + // the open was out isn't described as standing, whatever the open + // answered for it. + const now = channelRegistry.getTuneOutState(resolved.serverId, resolved.channelId); + const error = now?.params.epochId === tuneOut.params.epochId + // A name in a sentence is quoted as tune_out takes it back (main's #852). + ? alreadyTunedOutText(channelRegistry.quotedShown(resolved), now.params, + openError ?? 'Its transport is open now, so its messages reach the subconscious.') + : TUNE_OUT_REPLACED; + return { success: false, error, isError: true }; + } + return { success: false, error: r.error, isError: true }; + } + // A tuned-out channel's traffic must keep arriving for its reader, so the + // answer waits for its transport (openForTuneOut). The tune-out is + // recorded either way, so it's a success, and where the transport isn't + // open, openError says why its messages don't or may not reach the + // reader, and when they will where that's known, or that the tune-out + // isn't in effect any more. + const openError = await channelRegistry.openForTuneOut(resolved.serverId, resolved.channelId).catch(openThrew); + return { + success: true, + data: { + tunedOut: shownNow(), + cadenceSeconds: r.params.cadenceSeconds, + backlogCap: r.params.backlogCap, + maxWakes: r.params.maxWakes, + ...(openError ? { openError } : {}), + ...(coordinator.hasReader() + ? {} + : { note: 'No subconscious is configured: this is a silent hold. Nothing summarizes or wakes you; the backlog is delivered when you cancel.' }), + }, + }; + } + + /** + * The admission stage for one inbound event: the pure decision over the + * framework's policy inputs (recipients, the hold in force, the wake + * gate), traced. Callers apply it; nothing downstream re-asks. + */ + private admitInbound(env: InboundEnvelope): AdmissionDecision { + const gateInfo = (e: InboundEnvelope) => ({ + content: e.gateContent, + eventType: String(e.gateMetadata.eventType ?? 'unknown'), + serverId: String(e.gateMetadata.serverId ?? e.serverId), + channelId: typeof e.gateMetadata.channelId === 'string' ? e.gateMetadata.channelId : '', + metadata: e.gateMetadata, + tags: Array.isArray(e.gateMetadata.tags) ? (e.gateMetadata.tags as string[]) : undefined, + }); + const decision = admit(env, { + untargetedRecipients: () => this.registry.untargetedRecipients(), + holdFor: (serverId, channelId) => { + const hold = this.tuneOutCoordinator?.holdFor(serverId, channelId) ?? null; + return hold ? { kind: 'tune-out', epochId: hold.epochId, serverId, channelId } : null; + }, + ...(this.eventGate + ? { + gate: { + evaluate: (e: InboundEnvelope) => this.eventGate!.evaluate(gateInfo(e)).trigger, + // "Would the gate have woken them": a debounce policy says yes, + // later — for the hold's reader that is a pass, not a mute. + probe: (e: InboundEnvelope) => { + const d = this.eventGate!.probe(gateInfo(e)); + return d.trigger || (typeof d.behavior === 'object' && d.behavior !== null && 'debounce' in d.behavior); + }, + }, + } + : {}), }); + this.emitTrace({ + type: 'admission:decided', + lane: env.lane, + serverId: env.serverId, + channelId: env.channelId, + messageId: env.messageId, + recipients: decision.recipients, + held: decision.held ? decision.held.epochId : null, + wake: decision.wake, + gatePass: decision.gatePass, + }); + return decision; + } + + /** + * Make delivered messages durable now, including those the delivery left + * in the deferred queue: a tune-out release flips durable epoch state + * first, so its dump must not be lost to a crash in between (#232 gap 6). + */ + private commitDeliveries(): void { + if (this.deferredMessages.length > 0) { + this.deferredWritesPersisted = true; + this.persistDeferredWrites(); + } + try { + this.store.sync(); + } catch (err) { + console.error('[tune-out] could not sync the store after a release:', err); + } + } + + /** The history-reading tool the primary resident actually has, if any (#232 gap 11). */ + private residentHistoryTool(): string | null { + const primary = this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined; + if (!primary) return null; + const names = this.getAllTools().map((t) => t.name).filter((n) => primary.canUseTool(n)); + return names.find((n) => /(^|--)(fetch_history|channel_read|read_history|history)$/.test(n)) ?? null; } private dispatchSleepToolCall(agentName: string, call: ToolCall): void { // The gate's sleep quiets the whole agent, every conversation of it, so a // conversation fork or an ephemeral run neither sets one nor ends the // agent's own. Refused before the tool runs, as a fork's channel_open is. - const fork = this.conversationAgentHomes.has(agentName); + const fork = this.registry.homeChannel(agentName) !== undefined; if (fork || this.ephemeralRuns.has(agentName)) { const one = fork ? 'conversation' : 'run'; // Only where the fork can follow it: skip_reply needs the channel diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index 6bc724f51..f6e6a96bc 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -2156,6 +2156,8 @@ export class EventGate { ) => unknown; private requestInferenceFn: (agentName: string, reason: string, source: string, provenance?: WakeProvenance, after?: WritePlacement) => void; private getAgentNamesFn: () => string[]; + /** Delivery-time re-check: is this channel held from the recipients now? */ + private isHeldFn: (serverId: string | undefined, channelId: string | undefined) => boolean; private resolveRouteChannelFn: (info: GateEventInfo) => string | undefined; private channelShownFn?: (channel: { serverId: string; channelId: string }) => string; private isNewestEntryFn?: (entry: GateEntryRef) => boolean; @@ -2180,6 +2182,13 @@ export class EventGate { * message is answered. */ requestInference: (agentName: string, reason: string, source: string, provenance?: WakeProvenance, after?: WritePlacement) => void; getAgentNames: () => string[]; + /** + * Whether a channel is held from the recipients (a tune-out epoch in + * force). A held event never enters a batch — admission runs before the + * gate — but a hold can begin after an event was batched; at delivery + * the batch is re-checked and held events are dropped (#232 H3). + */ + isHeld?: (serverId: string | undefined, channelId: string | undefined) => boolean; /** Optional clock — defaults to Date.now. Tests inject for deterministic time. */ now?: () => number; /** Per-event timeout (ms) for the optional gate.js script. Default 50. */ @@ -2228,6 +2237,7 @@ export class EventGate { this.addMessageFn = opts.addMessage; this.requestInferenceFn = opts.requestInference; this.getAgentNamesFn = opts.getAgentNames; + this.isHeldFn = opts.isHeld ?? (() => false); this.resolveRouteChannelFn = opts.resolveRouteChannel ?? defaultRouteChannel; this.channelShownFn = opts.channelShown; this.isNewestEntryFn = opts.isNewestEntry; @@ -3687,7 +3697,23 @@ export class EventGate { // Event delivery // ========================================================================= - private deliverEvents(events: PendingEvent[]): void { + private deliverEvents(batched: PendingEvent[]): void { + // Delivery-time re-check: a channel tuned out after its events were + // batched is held now, and "already shown above at arrival" would be + // untrue of them. They are dropped; the hold's own reader has them. + const heldAt = (e: PendingEvent): string | undefined => e.routeChannelId ?? e.channelId; + const events = batched.filter((e) => { + const channel = heldAt(e); + return !(channel && this.isHeldFn(e.serverId, channel)); + }); + if (events.length < batched.length) { + this.emitTrace({ + type: 'gate:held-dropped', + dropped: batched.length - events.length, + channels: [...new Set(batched.filter((e) => !events.includes(e)).map(heldAt))], + timestamp: this.now(), + }); + } if (events.length === 0) return; const policyNames = [...new Set(events.map(e => e.policyName))]; @@ -4269,10 +4295,9 @@ export class EventGate { } /** - * `agentName` is gone, as a conversation fork's end or an ephemeral run's - * removes one: its pending self-wake is cancelled, rather than wake a name - * that no longer exists. It holds no sleep: neither can set one (the - * framework refuses their `sleep`). + * `agentName` is gone, as an agent's end removes one: its pending + * self-wake is cancelled, and a sleep it set ends, rather than go on + * quieting every agent and then wake a name that no longer exists. */ agentGone(agentName: string): void { const selfWake = this.selfWakeTimers.get(agentName); @@ -4280,6 +4305,7 @@ export class EventGate { clearTimeout(selfWake); this.selfWakeTimers.delete(agentName); } + if (this.sleepAgent === agentName) this.clearSleep(); } // ========================================================================= diff --git a/src/host-presentation.ts b/src/host-presentation.ts index 7ae121705..e76aee152 100644 --- a/src/host-presentation.ts +++ b/src/host-presentation.ts @@ -72,6 +72,8 @@ export interface HostPresentationDeps { isUtility(toolName: string): boolean; /** The resident agents of this host (no forks, ephemeral runs or the subconscious). */ residents(): string[]; + /** The agent whose presentation this one is shown: a derived agent that shows its parent's tool list, its parent; anyone else, itself. */ + ownerOf?(agentName: string): string; } interface AgentState { @@ -198,10 +200,15 @@ export class HostPresentation { return server?.configured === 'closed-tab' && !server.connected; } + /** The agent whose presentation `agentName` is shown (HostPresentationDeps.ownerOf). */ + ownerOf(agentName: string): string { + return this.deps.ownerOf?.(agentName) ?? agentName; + } + /** How a server is presented to an agent now. */ presentation(agentName: string, serverId: string): ServerPresentation { if (this.isClosedTab(serverId)) return 'closed-tab'; - const chosen = this.doc.agents[agentName]?.servers[serverId]; + const chosen = this.doc.agents[this.ownerOf(agentName)]?.servers[serverId]; if (chosen) return chosen; const configured = this.server(serverId)?.configured ?? 'full'; return configured === 'closed-tab' ? 'tab' : configured; @@ -218,7 +225,7 @@ export class HostPresentation { } isOpen(agentName: string, key: string): boolean { - return this.doc.agents[agentName]?.open.includes(key) ?? false; + return this.doc.agents[this.ownerOf(agentName)]?.open.includes(key) ?? false; } private setOpen(agentName: string, key: string, open: boolean): void { @@ -578,7 +585,7 @@ export class HostPresentation { openMarkers(agentName: string): Array<{ marker: string; reminder: () => string; dueWhenAbsent: true }> { if (!this.active(agentName)) return []; const out: Array<{ marker: string; reminder: () => string; dueWhenAbsent: true }> = []; - for (const key of this.doc.agents[agentName]?.open ?? []) { + for (const key of this.doc.agents[this.ownerOf(agentName)]?.open ?? []) { if (key === BUILTIN_TOOLSET) { out.push({ marker: `${this.builtinLabel} open`, diff --git a/src/index.ts b/src/index.ts index 8bad14e76..b33de8fb1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,8 @@ export * from './types/index.js'; // Core classes -export { AgentFramework, BudgetPreflightError, DefaultErrorPolicy, ProviderWaitNotSleptError, QuiesceBlockedError, ResumeBlockedError } from './framework.js'; -export type { RuntimeSettingsPreview, HostModeStatus } from './framework.js'; +export { AgentFramework, AgentStoppedError, BudgetPreflightError, DefaultErrorPolicy, ProviderWaitNotSleptError, QuiesceBlockedError, ResumeBlockedError } from './framework.js'; +export type { RuntimeSettingsPreview, HostModeStatus, DeriveAgentOptions } from './framework.js'; export { Agent } from './agent.js'; export { storedInjectionMarkOf, type StoredInjectionMark } from './stored-injections.js'; export type { StartStreamResult } from './agent.js'; @@ -15,6 +15,9 @@ export { CLOCK_ROW_KIND, resolveClockConfig } from './clock-rows.js'; export type { ClockConfig, ClockStamp, ResolvedClockConfig } from './clock-rows.js'; export { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js'; +// Dendrite: agent registry, lifecycle and presets +export * from './dendrite/index.js'; + // Built-in modules export * from './modules/index.js'; @@ -196,6 +199,16 @@ export type { } from './recovery/offline-branch.js'; export * from "./tool-presentation.js"; + +// Tune-out: the subconscious configuration and the default reader-fork framing. +export { presentAuthor, authorLine, authorNamed, ANONYMOUS_PARTICIPANT } from './author-presentation.js'; +export { + defaultReaderFraming, + type SubconsciousConfig, + type PersistentReaderConfig, + type ForkReaderConfig, + type ReaderFramingContext, +} from './tune-out/tools.js'; export { SILENT_HEARTBEAT_SEPARATOR, silentHeartbeatOf, diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index e98f2ba3a..10137c97b 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -566,11 +566,11 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [ }, { name: 'channel_publish', - description: "Publish a message to a channel. If channelId is omitted, publishes to your conversation's channel if you are bound to one; otherwise to the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it. Reactions, reaction removals, deletions and edits don't set it, though an edit of a message you hadn't been shown can count as that message. A message pushed to you rather than posted on a channel never sets it. A subagent has neither default, so it must name a channel.", + description: "Publish a message to a channel. If channelId is omitted, publishes where your plain speech goes this turn: your conversation's channel if you are bound to one; otherwise the channel of the message that woke this turn, or one you opened during it; otherwise, while a focus holds, the channel it keeps. Where none of those names a channel, nothing is posted, unless this host is set to use the channel of the latest incoming message instead. A turn with no channel of its own, such as a heartbeat's, has no default, so it names one. A subagent has no default, so it must name a channel.", inputSchema: { type: 'object' as const, properties: { - channelId: { type: 'string', description: "ID of the channel to publish to (defaults to your conversation's channel if you are bound to one; otherwise the channel of the most recent incoming message this host received on any channel, whether or not you've been shown it, other than a reaction, reaction removal, deletion or edit, though an edit of a message you hadn't been shown can count; a message pushed to you rather than posted on a channel never sets it; required of a subagent)" }, + channelId: { type: 'string', description: "ID of the channel to publish to (defaults to where your plain speech goes this turn, as the tool's description says; required of a subagent)" }, content: { type: 'string', description: 'Text content to publish' }, text: { type: 'string', description: 'Alias for content' }, }, @@ -992,8 +992,35 @@ interface ChannelRegistryOptions { ) => Promise; /** Chronicle store used for durable desired channel lifecycle state. */ store?: JsStore; - /** Callback to determine whether an incoming message should trigger inference. */ + /** + * The server-level veto on waking for an incoming message (a per-server + * `McplServerConfig.shouldTriggerInference`). The wake gate is NOT wired + * here: it runs in the framework's admission stage, after the hold + * decision, so a held message never forms a gate batch (issue #232 gap 3). + */ shouldTriggerInference?: (content: string, metadata: Record) => boolean; + /** + * Whether the most recent delivered inbound message is a fallback speech + * locus for turns that have none of their own (`speech.locus: + * 'latest-inbound'`). Off by default: incoming traffic never moves the + * locus (architecture channel, 2026-10-06). + */ + latestInboundLocus?: boolean; + /** + * A channel is being closed by tool: an all-except hold whose exception + * this is ends through the cancel flow (release the backlog, record the + * end) before the lifecycle state flips to closed. A + * tuned-out channel's close is refused before this (its cancel ends it). + * Returns what it ended, in a sentence the close's answer adds when the + * close itself isn't recorded. + */ + onToolClose?: (serverId: string, channelId: string) => string | undefined; + /** + * The channel unprefixed speech lands in while an all-except hold is + * active (its exception), else null. Consulted after a fork's home and + * the turn's own delivery, before the legacy fallback. + */ + focusChannelResolver?: () => ChannelRef | null; /** * Called when a text-only turn's speech was not delivered to its * conversational locus (`failed`), or may not have been (`unknown`), for @@ -1043,7 +1070,7 @@ interface ChannelRegistryOptions { /** * Resolve a conversation fork's HOME channel from its agent name. Conversation * forks are spawned bound to a single channel (the framework tracks this in - * `conversationAgentHomes` / `ConversationRouter.channelForAgent`); their + * the agent registry's `homeChannel` / `ConversationRouter.channelForAgent`); their * plain-text speech must route THERE. The process-global `defaultPublishChannel` * tracks only the most recent inbound message across ALL channels (reaction, * reaction-removal, edit and deletion markers aside, apart from an edit the @@ -1120,6 +1147,10 @@ export type ChannelToolOrigin = * wake due before it, when there is one, which its confirmation names * (skipReplyNote). */ earlierWake?: SkipReplyEarlierWake; + /** Made inside one of the agent's turns (the framework's callingTurnOf): + * a channel_publish naming no channel arrives with that turn's default + * already applied, so it takes no other. */ + inTurn?: boolean; }; /** The machine decision sources a module-origin channel_close may record. @@ -1154,6 +1185,9 @@ export class ChannelRegistry { op?: 'start' | 'stop', ) => void; private shouldTriggerInference?: (content: string, metadata: Record) => boolean; + private readonly latestInboundLocus: boolean; + private onToolClose?: (serverId: string, channelId: string) => string | undefined; + private focusChannelResolver?: () => ChannelRef | null; private onRouteFailure?: ChannelRegistryOptions['onRouteFailure']; private onChannelAutoOpened?: (info: { conversationId?: string; @@ -1582,6 +1616,9 @@ export class ChannelRegistry { this.emitTraceFn = emitTraceFn; this.sendTypingFn = options?.sendTypingFn; this.shouldTriggerInference = options?.shouldTriggerInference; + this.latestInboundLocus = options?.latestInboundLocus === true; + this.onToolClose = options?.onToolClose; + this.focusChannelResolver = options?.focusChannelResolver; this.onRouteFailure = options?.onRouteFailure; this.onChannelAutoOpened = options?.onChannelAutoOpened; this.homeChannelResolver = options?.homeChannelResolver; @@ -1823,9 +1860,6 @@ export class ChannelRegistry { // The batch came on the transport open now: a close after this point // (transportClosed) leaves its traffic nothing to confirm. const closesAtArrival = this.transportCloses.get(serverId) ?? 0; - // And came from a server registered now: one removed since (removeServer) - // gives no message the agent replies to (markAccepted). - const removalsAtArrival = this.serverRemovals.get(serverId) ?? 0; for (const message of params.messages) { if (!message || typeof message.channelId !== 'string' || !message.channelId @@ -1931,28 +1965,19 @@ export class ChannelRegistry { continue; } - // Track default publish channel (most recent ACCEPTED incoming) — - // deliberately after §14.5 validation: a rejected message from an - // unregistered channel must not retarget outbound speech (the locus is - // exactly the authority a self-attested channel would be stealing). - // A coalesced item is "accepted" only once the coalescer admits it, so - // for those this runs after the hook (below). + // A server sending channels/incoming is evidence that the transport + // is open. This repairs transient status only; durable desired state + // still changes exclusively through lifecycle operations. Nothing else + // is decided here: whether this message is delivered, wakes anyone or + // counts as the latest inbound is the framework's admission decision, + // taken once the hold is known (see `noteDelivered`; issue #232 root + // cause A). A coalesced item is "accepted" only once the coalescer + // admits it, so for those this runs after the hook (below). const incomingEntry = this.channels.get(incomingKey)!; - const markAccepted = (firstView = false) => { - // Non-conversational markers (a reaction added or removed, an edit, a - // deletion, and a chat:system notice such as a join or the weather) - // are ABOUT a message or the place, not one the agent replies to: they - // must not retarget the default publish locus or become - // `context.incoming`'s reply anchor. An edit the coalescer delivered - // as the agent's first view counts as the message it replaced. - // A message the coalescer admits after its server was removed sets no - // default: the removal cleared that server's message, and a server - // that's gone mustn't displace a live one's default. - if (!isMarkerTagged(message.tags, firstView) && (this.serverRemovals.get(serverId) ?? 0) === removalsAtArrival) { - this.defaultPublishChannel = { serverId, channelId: message.channelId }; - this.defaultPublishMessageId = message.messageId; - this.defaultPublishThreadId = message.threadId; - } + // `firstView` is the coalescer's mark that an edit was the agent's first + // view of its message; the registry no longer records the publish locus + // here (admission does, through noteDelivered, with the same marker rule). + const markAccepted = (_firstView = false) => { // Where the host directs this server's lifecycle, traffic shows the // transport open only for a channel still decided open or tuned out, // and, but for one exception, only for a confirmed target: after a @@ -1987,7 +2012,7 @@ export class ChannelRegistry { }; if (!coalesced) markAccepted(); - // Determine whether to trigger inference + // The server-level veto only; the wake gate runs at admission. let triggerInference = true; if (this.shouldTriggerInference) { // The text the agent's context gets, as the push lane's filter reads @@ -2289,16 +2314,58 @@ export class ChannelRegistry { // Accessors // ========================================================================== + /** + * The most recent DELIVERED inbound message (channel, message, thread). + * Recorded by the framework after admission, so a held message never + * becomes "the latest inbound" (issue #232 gap 2), and never for a marker + * (a reaction, reaction removal, edit or deletion, apart from an edit the + * coalescer delivered as the agent's first view: isMarkerTagged). Read as + * the `incoming` reference in the channel context and, under + * `speech.locus: 'latest-inbound'`, as the fallback speech locus. The + * channel is on the server the message came from. A message whose server + * no longer registers its channel sets nothing, as one the coalescer admits + * after its server was removed: the removal cleared that server's message, + * and a server that's gone mustn't displace a live one's anchor. That's + * read at admission, where main's guard compared the server's removal + * count at arrival and at acceptance (markAccepted): a message that + * arrived before its server's removal and is admitted once the server + * registers the channel again sets the anchor here, as a message delivered + * on a live channel, where main's would set nothing. + */ + noteDelivered(channel: ChannelRef, messageId: string, threadId?: string, tags?: unknown, firstView = false): void { + // A marker is ABOUT a message, not one the agent replies to: it leaves + // the anchor where it was (#157), and so does a chat:system notice (a + // join, the weather), which is about the place (isMarkerTagged). An edit + // the coalescer delivered as the agent's first view of its message counts + // as that message. + if (isMarkerTagged(tags, firstView)) return; + if (!this.entryAt(channel)) return; + this.defaultPublishChannel = { serverId: channel.serverId, channelId: channel.channelId }; + this.defaultPublishMessageId = messageId; + this.defaultPublishThreadId = threadId; + } + /** * Get the default publish channel ID: the channel of the most recent - * incoming message that isn't a reaction, reaction removal, edit or - * deletion marker, apart from an edit the coalescer delivers on acceptance - * as the agent's first view. + * delivered incoming message that isn't a reaction, reaction removal, edit + * or deletion marker, apart from an edit the coalescer delivers on + * acceptance as the agent's first view. */ getDefaultPublishChannel(): ChannelRef | null { return this.defaultPublishChannel; } + /** + * Public form of the tool-input channel resolution (resolveToolChannelEntry): + * optional server, the calling turn's own channels (`own`), ambiguity is an + * error. + */ + resolveChannel(channelId: string, serverId?: string, own?: readonly ChannelRef[]): ChannelRef | { error: string } { + const resolved = this.resolveToolChannelEntry(channelId, serverId, own); + if (!resolved.entry) return { error: resolved.error ?? `Channel not found: ${channelId}` }; + return { serverId: resolved.entry.serverId, channelId: resolved.entry.descriptor.id }; + } + /** * RFC-006 §3.2: is `channelId` registered BY `serverId` through a server * declaration (channels/register, channels/changed) or an authorized open? @@ -2439,7 +2506,7 @@ export class ChannelRegistry { /** * The message the agent is replying to, with its channel: the most recent - * inbound message that isn't a reaction, reaction removal, edit or deletion + * delivered inbound message (noteDelivered) that isn't a reaction, reaction removal, edit or deletion * marker, apart from an edit the coalescer delivers on acceptance as the * agent's first view. Left process-global: per-channel inbound tracking * (the right messageId for a fork's own channel) is a separate concern from @@ -2456,9 +2523,13 @@ export class ChannelRegistry { if (agentName !== undefined && this.activeChannelResolver?.(agentName) === null && !this.homeChannelResolver?.(agentName)) { return undefined; } - const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel; - if (!fallback || !this.defaultPublishMessageId) return undefined; - return { channel: fallback, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId }; + // The latest delivered inbound itself, not the speech fallback + // (globalFallbackFor), which under `speech.locus: 'delivery'` is the + // all-except hold's excepted channel while one is open, and none + // otherwise. + const anchor = agentName !== undefined && this.takesGlobalFallback?.(agentName) === false ? null : this.defaultPublishChannel; + if (!anchor || !this.defaultPublishMessageId) return undefined; + return { channel: anchor, messageId: this.defaultPublishMessageId, threadId: this.defaultPublishThreadId }; } /** @@ -2484,16 +2555,19 @@ export class ChannelRegistry { // '' is a name an ephemeral run can have, and resolveLocus reads it too. const home = agentName !== undefined ? this.homeChannelResolver?.(agentName) : undefined; const active = agentName !== undefined ? this.activeChannelResolver?.(agentName) : undefined; - // An ephemeral run takes no global fallback (takesGlobalFallback): neither - // the latest inbound message's channel as its outgoing one, nor that - // message as the one it is replying to. - const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.defaultPublishChannel; + // An ephemeral run takes no global fallback (takesGlobalFallback): not the + // all-except hold's channel, not the latest inbound message's channel as + // its outgoing one, nor that message as the one it is replying to. + const fallback = agentName !== undefined ? this.globalFallbackFor(agentName) : this.sharedFallback(); // A turn that deliberately has no channel (null) takes no fallback. const outgoing = home ?? (active !== undefined ? active : fallback); const context: ChannelContext = {}; - // Incoming: the message the agent is replying to (incomingFor). + // Incoming: the message the agent is replying to (incomingFor). The + // anchor is independent of the speech rule: under `speech.locus: + // 'delivery'` the latest inbound is no fallback for where prose lands, + // but it is still the message the agent is replying to. const incoming = this.incomingFor(agentName); if (incoming && incoming.channel.serverId === serverId) { context.incoming = { @@ -3994,8 +4068,8 @@ export class ChannelRegistry { /** * How a tuned-out channel's tune-out ends, for the texts that tell an agent * channel_open doesn't change it: a tune_out cancel, by the name that - * picks the channel (nameFor), or, where no name picks it alone, its own - * end, since tune_out takes a name. + * picks the channel (nameFor), or, where no name picks it alone, by its id + * and server, since tune_out takes a serverId. * For channel_close, which refuses a tuned-out channel too, it adds that * the close comes after that. */ @@ -4008,12 +4082,15 @@ export class ChannelRegistry { private tuneOutEndText(entry: ChannelEntry, tool?: string): string { const close = tool === 'channel_close'; - return this.nameFor(entry) !== undefined - ? `Call tune_out with channelId ${quotedValue(this.nameFor(entry)!)} and mode "cancel" for it to resume normal ` + - `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}` - : 'No channelId names it alone, so tune_out can\'t cancel it: its tune-out ends by itself when its duration ' + - 'runs out, or at the first wake past its wake budget, and its backlog is delivered then.' + - (close ? ' Once it has ended, channel_close can close it.' : ''); + // A name is written as a literal tune_out's channelId takes back + // (quotedValue, main's #852); one no name picks alone, by its id and + // server, since this branch's tune_out takes a serverId. + const name = this.nameFor(entry); + const names = name !== undefined + ? `channelId ${quotedValue(name)}` + : `channelId ${quotedValue(entry.descriptor.id)}, serverId ${JSON.stringify(entry.serverId)}`; + return `Call tune_out with ${names} and mode "cancel" for it to resume normal ` + + `attention; cancelling delivers the diverted backlog${close ? ', and channel_close closes it after that.' : '.'}`; } /** @@ -5156,12 +5233,13 @@ export class ChannelRegistry { // Closed already, perhaps only by default: the agent's close is still // its decision, and is recorded as one (setDesiredState), so a later // registration's lift of a default leaves the channel closed. + const ended = this.onToolClose?.(entry.serverId, input.channelId); try { this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource); } catch (err) { return { success: false, - error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}`, + error: `Channel ${shown} is already closed, but recording the decision to close it failed: ${(err as Error).message}${ended ? ` ${ended}` : ''}`, isError: true, }; } @@ -5204,12 +5282,18 @@ export class ChannelRegistry { const refusal = this.tunedOutRefusal('channel_close', entry); return isModuleOrigin ? { ...refusal, isError: false } : refusal; } + // A hold around this channel — the all-except scope it is the excepted + // channel of — ends through the cancel flow: backlog released, end + // recorded, never by overwriting state (#232 gap 9). + const ended = this.onToolClose?.(entry.serverId, input.channelId); - // A decision the log refused changed nothing, and the answer says so. + // A decision the log refused changed nothing for the channel, and the + // answer says so, and what the hook ended all the same. try { this.setDesiredState(entry.serverId, input.channelId, 'closed', closeSource); } catch (err) { - return { success: false, error: this.lifecycleFailureText('close', entry, unrecordedDecision(err)), isError: true }; + const text = this.lifecycleFailureText('close', entry, unrecordedDecision(err)); + return { success: false, error: ended ? `${text} ${ended}` : text, isError: true }; } // This channel's typing, not every channel with its id (stopTyping's // fan-out of a bare name). @@ -5686,9 +5770,29 @@ export class ChannelRegistry { return active !== undefined ? active : this.globalFallbackFor(conversationId); } - /** The process-global fallback channel for this conversation, or null when it takes none (takesGlobalFallback). */ + /** + * The process-global fallback for this conversation, or null when it takes + * none (takesGlobalFallback): an ephemeral run's prose is its caller's + * result, so neither the all-except hold's channel nor the latest inbound + * message's is ever its destination. + */ private globalFallbackFor(conversationId: string): ChannelRef | null { - return this.takesGlobalFallback?.(conversationId) === false ? null : this.defaultPublishChannel; + return this.takesGlobalFallback?.(conversationId) === false ? null : this.sharedFallback(); + } + + /** + * What every conversation that takes the global fallback falls back to when + * neither a home channel nor its own delivery names one: the all-except + * hold's channel while one is open, else the latest delivered inbound + * channel under the legacy locus rule, else none — the prose stays private + * (architecture channel §3 carve-out, 2026-10-06). + */ + private sharedFallback(): ChannelRef | null { + return this.focusChannelResolver?.() ?? this.fallbackLocus(); + } + + private fallbackLocus(): ChannelRef | null { + return this.latestInboundLocus ? this.defaultPublishChannel : null; } /** @@ -6140,17 +6244,20 @@ export class ChannelRegistry { if (input.channelId === '' && tool === 'channel_publish') { return { success: false, error: `Nothing was posted: channelId is empty. ${findIds}`, isError: true }; } - // Resolve channelId: default to the channel of the most recent incoming - // message that isn't a reaction, reaction removal, edit or deletion - // marker, apart from an edit the coalescer delivers on acceptance as the - // agent's first view. An ephemeral run never takes that default + // Resolve channelId: a call made inside the agent's turn (inTurn) has + // the turn's default applied already, where its plain speech goes, and + // takes none here. Any other defaults to what the caller falls back to: + // the all-except hold's channel, else the latest delivered inbound + // channel under the legacy locus rule (a marker — reaction, edit, + // deletion — is never that channel), which an ephemeral run never takes // (takesGlobalFallback), so it names one. // A channel named by a string means what it means to every channel tool // (entriesNamed): one channel, by its id or its server-qualified name, // and a name two servers' channels answer to is refused with them as // candidates, never sent to the first. const caller = origin?.kind === 'agent' ? origin.agentName : undefined; - const fallback = caller !== undefined ? this.globalFallbackFor(caller) : this.defaultPublishChannel; + const inTurn = origin?.kind === 'agent' && origin.inTurn === true; + const fallback = caller === undefined ? this.sharedFallback() : inTurn ? null : this.globalFallbackFor(caller); if (!(input.channelId ?? fallback)) { return { success: false, @@ -6159,7 +6266,10 @@ export class ChannelRegistry { : caller !== undefined && this.takesGlobalFallback?.(caller) === false ? 'Nothing was posted: name the channel with channelId. A subagent has no default channel, since its plain ' + 'speech is returned to whoever called it, so channel_publish sends only to a channel it names.' - : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`, + : caller !== undefined && inTurn && this.activeChannelResolver?.(caller) === null + ? `Nothing was posted: name the channel with channelId. This turn has no channel of its own, so its plain speech ` + + `goes to none and channel_publish sends only to a channel it names. ${findIds}` + : `Nothing was posted: channel_publish needs a \`channelId\`, and there's no channel to default to yet. ${findIds}`, isError: true, }; } diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 38c420f80..37b8635ff 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -1000,6 +1000,25 @@ export class PushCoalescer { return result; } + /** + * Render and materialize one subject's pending batch for `agentName` now, + * outside a turn's assembly: a batch whose wake a hold keeps back, stored + * as it renders (held, as a plain push under the hold is), so whoever reads + * held traffic sees it and the turn after the hold doesn't take it again. + * A batch that opened while another render ran is rendered once that + * render settles. Resolves whether it materialized. + */ + async materializeNow(agentName: string, subject: string): Promise { + for (;;) { + if (this.suspended) return false; + const started = await this.locked(() => this.freeze(subject, agentName)); + if (!started || started === 'declined') return false; + await started.done; + this.persist(); + if (!started.joined) return started.rendering.materialized === true; + } + } + /** * Under the lock: decide whether `subject`'s batch renders for `agentName`, * freeze it, make the render-start durable, re-check authority, and start diff --git a/src/mcpl/toolsets-module.ts b/src/mcpl/toolsets-module.ts index 270f1e1ca..449976db4 100644 --- a/src/mcpl/toolsets-module.ts +++ b/src/mcpl/toolsets-module.ts @@ -315,6 +315,13 @@ export class ToolsetsModule implements Module { const args = isObj(call.input) ? call.input : {}; try { checkSelectors(args); + // An agent shown another's tool list can't switch it: what it opened + // or closed would change its owner's list, or nothing it is shown. + const caller = call.callerAgentName; + const owner = caller && this.host ? this.host.ownerOf(caller) : caller; + if ((call.name === 'open' || call.name === 'close') && caller && owner !== caller) { + fail(`${caller} is shown ${owner}'s tools and can't ${call.name} a toolset; it reaches the toolsets ${owner} has open. Nothing changed.`); + } const hosted = await this.hostCall(call.name, args, call.callerAgentName); if (hosted) return hosted; const take = this.takerFor(call); diff --git a/src/notice-tags.ts b/src/notice-tags.ts index 3a376374a..c9a28d730 100644 --- a/src/notice-tags.ts +++ b/src/notice-tags.ts @@ -33,6 +33,8 @@ export const NOTICE_TAG = { tuneOutWake: 'Tune-out wake', tuneOutCadence: 'Tune-out cadence', tuneOutCancelled: 'Tune-out cancelled', + focus: 'Focus', + agentLifecycle: 'Agent lifecycle', standingDispositions: 'Standing dispositions', channelInvitation: 'Channel invitation', system: 'system', diff --git a/src/silent-heartbeat.ts b/src/silent-heartbeat.ts index c1ca233f9..024493613 100644 --- a/src/silent-heartbeat.ts +++ b/src/silent-heartbeat.ts @@ -190,14 +190,17 @@ export interface RequestOnlyRowIndex { /** Index the agent's own rows that follow request-only turns: the rows of its * silent ticks, and the rows of each reply to a `[Continue]`. The two are * independent, so a row can be both. Only stamped rows are keyed, so an - * agent's history walk costs a metadata read per row. */ -export function indexRequestOnlyRows(stored: readonly StoredRowLike[], agentName: string): RequestOnlyRowIndex { + * agent's history walk costs a metadata read per row. `own` is the agent's + * name, or every participant that is its own voice, as a derived agent's + * inherited rows are. */ +export function indexRequestOnlyRows(stored: readonly StoredRowLike[], own: string | ReadonlySet): RequestOnlyRowIndex { + const ownNames = typeof own === 'string' ? new Set([own]) : own; const ticks = new Map(); const prompts = new Map(); for (const row of stored) { - if (row.participant !== agentName) continue; + if (!ownNames.has(row.participant)) continue; const stamp = silentHeartbeatOf(row.metadata); - const ownTick = stamp !== undefined && stamp.agentName === agentName; + const ownTick = stamp !== undefined && stamp.agentName === row.participant; const prompt = promptedByOf(row.metadata); if (!ownTick && !prompt) continue; const keys = identityKeys(row.content); diff --git a/src/stored-injections.ts b/src/stored-injections.ts index 5d34f0ca5..3a85aea98 100644 --- a/src/stored-injections.ts +++ b/src/stored-injections.ts @@ -84,6 +84,13 @@ export function isAnotherAgentsInjection(metadata: unknown, agentName: string): return mark !== undefined && mark.agent !== agentName; } +/** Whether a row is `agentName`'s own stored injection: its context, which + * its view keeps however the rest is read, as a shared fork's does, whose + * view is otherwise its source's (AgentFramework's deriveSharedContext). */ +export function isOwnInjection(metadata: unknown, agentName: string): boolean { + return storedInjectionMarkOf(metadata)?.agent === agentName; +} + /** * Whether an injection is stored, rather than placed by `compile()` each * time: an `afterUser` one, unless it opts out diff --git a/src/tune-out/coordinator.ts b/src/tune-out/coordinator.ts index 8d5f323eb..26a28e26f 100644 --- a/src/tune-out/coordinator.ts +++ b/src/tune-out/coordinator.ts @@ -16,8 +16,9 @@ */ import { NOTICE_TAG } from '../notice-tags.js'; -import { cutToFit, suppliedValue } from '../supplied-text.js'; +import { cutToFit, quotedValue, suppliedValue } from '../supplied-text.js'; import { randomUUID } from 'node:crypto'; +import { authorLine, authorNamed, beginsWithAuthorLine } from '../author-presentation.js'; import type { ContentBlock } from '@animalabs/membrane'; import type { StoredMessage } from '@animalabs/context-manager'; import type { ChannelRef, ChannelRegistry, TuneOutParams } from '../mcpl/channel-registry.js'; @@ -25,6 +26,9 @@ import type { McplServerRegistry } from '../mcpl/server-registry.js'; import { CapabilityGrant } from '../mcpl/capability-grant.js'; import { armAfter, armAt } from '../deadline-timer.js'; +/** The options a tune-out call can give. */ +type TuneOutOption = 'durationSeconds' | 'cadenceSeconds' | 'backlogCap' | 'maxWakes'; + /** Defaults for tune_out tool params. */ export const TUNE_OUT_DEFAULTS = { cadenceSeconds: 1800, @@ -36,6 +40,111 @@ export const TUNE_OUT_DEFAULTS = { * subconscious turn, and max-wakes counts invocations, not messages. */ const WAKE_COALESCE_MS = 2_000; +/** + * One backlog line: author, then the message's blocks in order — text as + * is, anything else named by kind (`[image]`, `[document]`), so a release + * never drops a block silently (#232 gap 4). The author is named once: a + * server that renders `Name: body` into the text itself is not doubled. + * A name the line adds is a value someone supplied, kept to one line with + * its notice openings marked (authorLine), as main's dump writes it. + */ +export function renderHeldLine(m: Pick): string { + const author = authorNamed(m.metadata) ?? m.participant; + const parts = m.content.map((b: ContentBlock) => + b.type === 'text' ? (b as Extract).text : `[${b.type}]`, + ); + const line = parts.filter((p) => p.length > 0).join(' '); + const firstText = m.content.find((b): b is Extract => b.type === 'text')?.text; + return firstText !== undefined && beginsWithAuthorLine(author, firstText) ? line : authorLine(author, line); +} + +/** One reader invocation under `reader: 'forks'` (see SubconsciousConfig). */ +export interface ReaderInvocation { + serverId: string; + channelId: string; + epochId: string; + trigger: 'cadence' | 'wake' | 'cancel'; + /** The host-framed notice for this invocation (bracket style). */ + notice: string; + /** Held messages not yet handed to any reader fork, oldest first. */ + backlog: StoredMessage[]; + /** + * Held messages not yet handed to any reader fork that are still queued + * behind the resident's turn (admitted, not yet stored), oldest first. + * They come after `backlog` in arrival order. A reader is most useful + * while the resident is busy, which is exactly when its traffic is here + * rather than in the store. + */ + queued: QueuedHeldMessage[]; + /** Standing dispositions, as text, or null when there are none. */ + dispositions: string | null; +} + +/** + * A held message admitted for the resident but not yet in its store: it was + * deferred while a turn was alive. `writeId` is the deferred write's durable + * id, which the stored row carries as `metadata.deferredWriteId` once it + * lands. + */ +export type QueuedHeldMessage = Pick & { + writeId: string; + /** When the framework first held the write, in ms since the epoch. */ + heldAt?: number; +}; + +/** The deferred write a stored row landed from, when it came through the queue. */ +function writeIdOf(m: Pick): string | undefined { + const writeId = (m.metadata as { deferredWriteId?: unknown } | undefined)?.deferredWriteId; + return typeof writeId === 'string' ? writeId : undefined; +} + +/** + * Whether a held message is one of this channel's: by its channel id, and by + * its server where it records one, since two servers can register the same + * channel id. A message recording neither is taken as the channel's, as + * before servers were recorded. + */ +function inChannelOf(serverId: string, channelId: string): (m: Pick) => boolean { + return (m) => { + const meta = m.metadata as { channelId?: string; serverId?: string } | undefined; + return (meta?.channelId ?? channelId) === channelId && (meta?.serverId ?? serverId) === serverId; + }; +} + +/** + * The all-except scope (issue #232 §3.2; focus folded into tune-out): a + * resident-level policy epoch holding every channel but one, for a bounded + * time. It covers DMs and channels registered after entry, since the + * decision is "not the exception" rather than a per-channel state. + */ +export interface AllExceptParams { + epochId: string; + except: { serverId: string; channelId: string }; + cadenceSeconds: number; + /** Per held channel, at release. */ + backlogCap: number; + /** Reader wakes before the reader is paused (the hold itself stays). */ + maxWakes: number; + startedAtSequence: number; + startedAtMs: number; + /** Mandatory: the scope always ends on its own. */ + expiresAtMs: number; + source: string; +} + +export interface AllExceptState { + params: AllExceptParams; + wakeCount: number; +} + +/** `check`: what is held right now, without releasing anything. */ +export interface TuneOutCheck { + allExcept: { epochId: string; except: { serverId: string; channelId: string }; expiresAtMs: number; wakeCount: number; held: Record } | null; + channels: Array<{ serverId: string; channelId: string; epochId: string; held: number; expiresAtMs?: number }>; + /** Up to `peekLimit` newest held lines of the peeked channel, oldest first. */ + peek?: string[]; +} + /** How long after an end time whose cancel threw the cancel is tried again. */ const EXPIRY_RETRY_MS = 60_000; @@ -82,8 +191,20 @@ export interface TuneOutFrameworkHooks { ) => string; /** Queue an inference request for an agent. */ requestInference: (agentName: string, reason: string, source: string) => void; - /** The subconscious's registry/participant name. */ + /** The persistent subconscious's registry/participant name, or null. */ subconsciousName: () => string | null; + /** + * `reader: 'forks'`: derive and run one reader fork. Absent in persistent + * mode. The framework owns the fork's lifecycle; the coordinator owns the + * epoch. + */ + invokeReader?: (invocation: ReaderInvocation) => void; + /** + * `reader: 'forks'`: deliver a reader fork's own words to the resident + * as attributed mail (its name, its incarnation). Absent in persistent + * mode, where the subconscious writes under its own name directly. + */ + deliverFromReader?: (reader: string, content: ContentBlock[], metadata: Record) => void; /** The primary resident's registry name. */ primaryName: () => string | null; /** Read the primary's stored messages (shared slot, unfiltered). */ @@ -93,7 +214,19 @@ export interface TuneOutFrameworkHooks { * while a turn was alive), oldest first. A cancel reads them too, so a * message held mid-turn is in the backlog it belongs to (#232 gap 7). */ - getDeferredMessages?: () => Array>; + getDeferredMessages?: () => Array & { writeId?: string; heldAt?: number }>; + /** + * Make what was just delivered durable, including anything the delivery + * left in the deferred queue: a release flips durable epoch state, so its + * dump must survive a crash in the same window (#232 gap 6). + */ + commitDeliveries?: () => void; + /** + * The history-reading tool the resident actually has (e.g. a server's + * `fetch_history`), or null. A truncation note names it only when it exists + * (#232 gap 11). + */ + historyTool?: () => string | null; /** * The most characters of text every reader of the residents' shared slot * is shown whole in one message: the smallest of their strategies' @@ -112,6 +245,19 @@ export interface TuneOutFrameworkHooks { isPrivilegedAuthor: (authorId: string | null | undefined) => boolean; /** speak_in_channel master switch (recipe-side, canary-gated). */ allowChannelSpeech: () => boolean; + /** + * Whether a tune-out may hold a channel with no reader at all — a silent + * hold (issue #232 §3.3), which the host opts into (`tuneOut.enabled`). + * Left out, or false, a hold with no reader is refused. + */ + holdWithoutReader?: () => boolean; + /** + * Post a line into a channel under the resident's name. Used for the + * all-except scope's entry and release announcements in the excepted + * channel (Linn, 2026-10-12: the room should hear the door shut, not + * discover it). Absent when `tuneOut.announce` is off. + */ + announce?: (serverId: string, channelId: string, text: string) => Promise<{ success: boolean; error?: string }>; emitTrace: (event: { type: string; [key: string]: unknown }) => void; } @@ -125,8 +271,60 @@ export class TuneOutCoordinator { private expiryTimers = new Map>(); /** Channels with a wake invocation pending inside the coalescing window. */ private pendingWakes = new Map }>(); - /** Diverted-message tallies since the last subconscious invocation, per channel key. */ + /** + * Held messages the channel's reader hasn't looked at, per channel key: + * what makes a cadence period worth a look. Counted up as messages are + * held and back to zero at each look; rebuilt from the held rows where + * memory can't know it (rebuildTally): after a restart, and when a + * channel's own tune-out ends while the all-except hold still holds it. + * A failed reader fork's messages count again (readerFailed). + */ private divertedSinceInvocation = new Map(); + /** + * The persistent subconscious's last look at each held channel, per epoch: + * the store's sequence when the look was asked for. Durable, so that after + * a restart its next look counts only what was held since (rebuildTally). + * Reader forks need none: what they were handed is in memory, and after a + * restart the first fork of an epoch is handed all it has held. + */ + private lastLooksCache: Record> | undefined; + /** + * `reader: 'forks'`: the highest sequence of a held message already + * handed to a reader fork, per epoch and channel (handOffKey). Each held + * message is delivered to exactly one fork. A channel the all-except hold + * holds can be taken by a tune-out of its own and given back, and each + * epoch's readers read that epoch's messages, so what one epoch's readers + * were handed never moves or clears the other's. In-memory: after a + * restart the first fork of an epoch is handed everything the epoch has + * held, which is the right side to err on. + */ + private readerCursor = new Map(); + /** + * `reader: 'forks'`: the deferred writes handed to a reader fork while + * still queued, per epoch and channel. Each lands in the store later as a row + * stamped with the same id (`metadata.deferredWriteId`), past the cursor; + * that row is not handed again. Cleared with the cursor. + */ + private readerHandedWrites = new Map>(); + /** + * `reader: 'forks'`: held messages a reader fork was handed but never read, + * per epoch and channel: its derive failed, or its run threw before it reported + * (readerFailed). The next invocation is handed them again, cursor or no: + * stored rows by id, and by write id those that were queued when handed + * (stored or still queued now). Cleared with the cursor. + */ + private readerReturned = new Map; writeIds: Set }>(); + /** + * `reader: 'forks'`: the late forks derived for an ended epoch's messages + * (readerFailed), so that a late fork's own failure derives no other. + */ + private lateForks = new WeakSet(); + /** The all-except scope's durable record, cached (undefined = not read yet). */ + private scopeCache: AllExceptState | null | undefined; + /** The epoch whose release this process delivered and whose hold is not yet recorded as cleared. */ + private scopeReleased: string | undefined; + /** Timer key for the scope's cadence and expiry. */ + private static readonly SCOPE_KEY = '\0all-except'; constructor( private readonly channelRegistry: ChannelRegistry, @@ -138,6 +336,28 @@ export class TuneOutCoordinator { return `${serverId}\0${channelId}`; } + /** The readers' hand-off bookkeeping's key: one epoch's reading of one channel. */ + private handOffKey(epochId: string, serverId: string, channelId: string): string { + return `${epochId}\0${serverId}\0${channelId}`; + } + + /** An ended epoch's hand-off bookkeeping goes with it, every channel's. */ + private dropHandOffs(epochId: string): void { + for (const map of [this.readerCursor, this.readerHandedWrites, this.readerReturned]) { + for (const key of map.keys()) if (key.startsWith(`${epochId}\0`)) map.delete(key); + } + } + + /** A reader exists: the persistent subconscious, or forks on demand. */ + private readerAvailable(): boolean { + return this.hooks.subconsciousName() != null || typeof this.hooks.invokeReader === 'function'; + } + + /** Reader forks are the configured reader. */ + private get forkReader(): boolean { + return this.hooks.subconsciousName() == null && typeof this.hooks.invokeReader === 'function'; + } + /** A channel as the resident and the subconscious are shown it * (ChannelRegistry.nameShown): its id, or, where another server registers * the same id, its server-qualified name, which tune_out and @@ -168,9 +388,39 @@ export class TuneOutCoordinator { oldestAnchor = params.startedAtSequence; } } + const scope = this.getAllExcept(); + if (scope) { + if (scope.params.expiresAtMs <= Date.now()) { + this.cancelAllExcept('duration', 'duration elapsed'); + } else { + this.armScopeTimers(scope.params); + if (oldestAnchor === null || scope.params.startedAtSequence < oldestAnchor) { + oldestAnchor = scope.params.startedAtSequence; + } + } + } if (oldestAnchor !== null) { this.hooks.setSubconsciousAnchor(oldestAnchor); } + // What each held channel's reader hasn't looked at was counted in + // memory, so it is counted again from the held rows: without it, a + // channel held across the restart gets no cadence look until something + // new arrives. + // Stored and still queued alike: at a quiesced boot, restored writes stay + // queued until the resume flushes them. + const held = new Set(); + for (const m of [...this.hooks.getStoredMessages(), ...(this.hooks.getDeferredMessages?.() ?? [])]) { + const meta = m.metadata as { tuneOut?: { epochId?: string }; serverId?: string; channelId?: string } | undefined; + const epochId = meta?.tuneOut?.epochId; + if (!meta || !epochId || !meta.channelId) continue; + const key = this.key(meta.serverId ?? '', meta.channelId); + if (!held.has(key) && this.holdFor(meta.serverId ?? '', meta.channelId)?.epochId === epochId) held.add(key); + } + for (const key of held) { + const [serverId, channelId] = key.split('\0') as [string, string]; + this.rebuildTally(serverId, channelId); + } + this.pruneLooks(); } private armExpiry(serverId: string, channelId: string, params: TuneOutParams): void { @@ -225,13 +475,65 @@ export class TuneOutCoordinator { // Enter / cancel // ========================================================================== + /** + * The options a tune-out call gives, read as numbers before anything + * changes (F76). A tool's input reaches here as the model wrote it, and + * the store can't keep a NaN or an Infinity: its append would throw after + * the registry had marked the channel tuned out. So each option is read + * as a number first, a numeric string included, as the sleep tool reads + * its seconds; null counts as left out. A value that gives no finite + * number is refused, naming the option and, where it can be left out, + * what leaving it out means; so is a duration whose end time is too far + * off to store. `now` is the clock the end time was counted from. One + * rule for a channel's tune-out and the all-except hold. + */ + private readOptions( + opts: { cadenceSeconds?: unknown; backlogCap?: unknown; maxWakes?: unknown; durationSeconds?: unknown }, + leftOut: Record, + ): { ok: true; given: Partial>; now: number; expiresAtMs: number | undefined } | { ok: false; error: string } { + const given: Partial> = {}; + for (const name of Object.keys(leftOut) as TuneOutOption[]) { + const value: unknown = opts[name]; + if (value == null) continue; + const n = Number(value); + if (!Number.isFinite(n)) { + const got = typeof value === 'number' + ? String(value) + : `${typeof value === 'string' ? 'the string' : 'the value'} ${JSON.stringify(value)}`; + return { + ok: false, + error: `Nothing was tuned out: ${name} must be a finite number${name.endsWith('Seconds') ? ' of seconds' : ''}, ` + + `and it was ${got}.${leftOut[name] ? ` Leave it out ${leftOut[name]}.` : ''}`, + }; + } + given[name] = n; + } + const now = Date.now(); + const expiresAtMs = given.durationSeconds === undefined + ? undefined + : now + Math.max(60, given.durationSeconds) * 1000; + if (expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) { + return { + ok: false, + error: `Nothing was tuned out: durationSeconds ${given.durationSeconds} is longer than an end time can be kept.` + + (leftOut.durationSeconds ? ` Leave it out ${leftOut.durationSeconds}.` : ''), + }; + } + return { ok: true, given, now, expiresAtMs }; + } + enter( serverId: string, channelId: string, - opts: { cadenceSeconds?: number; backlogCap?: number; maxWakes?: number; durationSeconds?: number }, + opts: { cadenceSeconds?: unknown; backlogCap?: unknown; maxWakes?: unknown; durationSeconds?: unknown }, source: string, ): { ok: true; params: TuneOutParams } | { ok: false; error: string; alreadyTunedOut?: true } { - if (!this.hooks.subconsciousName()) { + // No reader (issue #232 §3.3): a silent hold, where the host opted into + // one. Nothing summarizes, nothing wakes, mentions are still + // acknowledged, the backlog is released on cancel; the resident is told + // so in the tool result. A host that didn't opt in has no tune_out + // without a reader. + if (!this.readerAvailable() && this.hooks.holdWithoutReader?.() !== true) { return { ok: false, error: 'Nothing was tuned out: this host has no subconscious resident to watch a channel, so tune_out ' @@ -250,45 +552,18 @@ export class TuneOutCoordinator { } const current = this.channelRegistry.getTuneOutState(serverId, channelId); if (current) return { ok: false, error: alreadyTunedOutText(this.quotedShown(serverId, channelId), current.params), alreadyTunedOut: true }; - // A tool's input reaches here as the model wrote it, and the store can't - // keep a NaN or an Infinity: its append would throw after the registry - // had marked the channel tuned out. So each option is read as a number - // before anything changes, a numeric string included, as the sleep tool - // reads its seconds; null counts as left out. A value that gives no - // finite number is refused. - const leftOut = { + const scope = this.getAllExcept(); + if (scope && scope.params.except.serverId === serverId && scope.params.except.channelId === channelId) { + return { ok: false, error: `channel ${this.quotedShown(serverId, channelId)} is the excepted channel of the active all-except hold; cancel that first` }; + } + const read = this.readOptions(opts, { durationSeconds: 'to stay tuned out until you cancel', cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`, backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`, maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`, - } as const; - const given: Partial> = {}; - for (const name of Object.keys(leftOut) as Array) { - const value: unknown = opts[name]; - if (value == null) continue; - const n = Number(value); - if (!Number.isFinite(n)) { - const got = typeof value === 'number' - ? String(value) - : `${typeof value === 'string' ? 'the string' : 'the value'} ${JSON.stringify(value)}`; - return { - ok: false, - error: `Nothing was tuned out: ${name} must be a finite number${name.endsWith('Seconds') ? ' of seconds' : ''}, ` - + `and it was ${got}. Leave it out ${leftOut[name]}.`, - }; - } - given[name] = n; - } - const expiresAtMs = given.durationSeconds === undefined - ? undefined - : Date.now() + Math.max(60, given.durationSeconds) * 1000; - if (expiresAtMs !== undefined && !Number.isFinite(expiresAtMs)) { - return { - ok: false, - error: `Nothing was tuned out: durationSeconds ${given.durationSeconds} is longer than an end time can be kept. ` - + `Leave it out ${leftOut.durationSeconds}.`, - }; - } + }); + if (!read.ok) return read; + const { given, expiresAtMs } = read; const params: TuneOutParams = { epochId: randomUUID(), cadenceSeconds: Math.max(30, given.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds), @@ -313,11 +588,18 @@ export class TuneOutCoordinator { this.hooks.setSubconsciousAnchor(oldest); this.divertedSinceInvocation.set(this.key(serverId, channelId), 0); - this.startCadence(serverId, channelId, params.cadenceSeconds); - this.hooks.emitTrace({ type: 'tune-out:entered', serverId, channelId, epochId: params.epochId }); + if (this.readerAvailable()) this.startCadence(serverId, channelId, params.cadenceSeconds); + this.hooks.emitTrace({ + type: 'tune-out:entered', serverId, channelId, epochId: params.epochId, reader: this.readerAvailable(), + }); return { ok: true, params }; } + /** Whether a reader (persistent subconscious or reader forks) attends holds. */ + hasReader(): boolean { + return this.readerAvailable(); + } + /** * End a tune-out: durable flip, system-framed backlog dump into the * resident's window (capped), lifecycle notice, one wake for the @@ -331,8 +613,12 @@ export class TuneOutCoordinator { source: string, reason: string, subconsciousNote?: string, + /** `reader: 'forks'`: the reader fork whose note this is. */ + noteAuthor?: string, + /** The lifecycle state the channel returns to (a tool close ends in 'closed'). */ + nextState: 'open' | 'closed' = 'open', ): { ok: true } | { ok: false; error: string } { - const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, 'open', source); + const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, nextState, source); if (!ended) { // Its reader is the resident's tune_out: cancel_tuneout finds the channel first. return { ok: false, error: this.notTunedOut(this.quotedShown(serverId, channelId), 'you') }; @@ -359,13 +645,12 @@ export class TuneOutCoordinator { const cap = Math.floor(ended.params.backlogCap); const authorOf = (m: Pick): string => suppliedValue((m.metadata as { author?: { name?: string } } | undefined)?.author?.name ?? m.participant); - const lineOf = (m: Pick): string => { - const text = m.content - .filter((b): b is Extract => b.type === 'text') - .map((b) => b.text) - .join('\n'); - return `${authorOf(m)}: ${text}`; - }; + // A line names its author as the resident knows them and marks what a + // text line can't carry (`[image]`), so the dump says what it leaves out. + const lineOf = renderHeldLine; + // The tool the truncation note names (#232 gap 11): the one the resident + // has, when the host says; main's `fetch_history` where no host says. + const historyTool = this.hooks.historyTool ? this.hooks.historyTool() : 'fetch_history'; // The channel as the resident and the subconscious are shown it. const name = this.shown(serverId, channelId); const lifecycle = `[${NOTICE_TAG.tuneOut}: ${name} cancelled — ${reason}, ${ended.wakeCount} wake${ended.wakeCount === 1 ? '' : 's'}]`; @@ -373,7 +658,9 @@ export class TuneOutCoordinator { const attrs = [ `channel=${this.quotedShown(serverId, channelId)}`, `messages=${backlog.length}`, - ...(truncated > 0 ? [`truncated=${truncated} (oldest; fetch_history covers the rest)`] : []), + ...(truncated > 0 + ? [`truncated=${truncated} (oldest; ${historyTool ? `${historyTool} covers the rest` : 'not delivered'})`] + : []), ]; return `${lifecycle}\n\n\n${lines.join('\n')}\n`; }; @@ -444,6 +731,8 @@ export class TuneOutCoordinator { [{ type: 'text', text: render(lines, truncated) }], { system: true, kind: 'tune-out-cancel', channelId, epochId }, ); + // The epoch is already ended durably; the release must be as durable. + this.hooks.commitDeliveries?.(); // The subconscious's own words, under its own name, verbatim. const subName = this.hooks.subconsciousName(); if (subconsciousNote && subName) { @@ -454,6 +743,14 @@ export class TuneOutCoordinator { // reads (repinChannel), where another server's channel can share the id. { kind: 'subconscious-note', channelId, serverId, epochId }, ); + } else if (subconsciousNote && noteAuthor && this.hooks.deliverFromReader) { + // A reader fork's note: attributed mail, under the fork's name and + // incarnation, never the resident's voice. + this.hooks.deliverFromReader( + noteAuthor, + [{ type: 'text', text: subconsciousNote }], + { kind: 'subconscious-note', channelId, epochId }, + ); } const primary = this.hooks.primaryName(); if (primary) { @@ -493,7 +790,34 @@ export class TuneOutCoordinator { ); } this.hooks.requestInference(subName, `tune-out cancelled: ${name}`, 'tune-out'); + } else if (!subName && this.forkReader) { + // Forks are the reader: one last fork for the epoch, handed what no + // fork has been handed yet, stored or still queued behind the + // resident's turn, plus the cancel notice; its report follows the dump. + // Once the epoch ends, a message still queued lands in the store + // view-filtered, where nothing else reads it, so it goes to this fork + // now. On a reader's own cancel, the reader has read what it was + // handed, so a fork follows only when messages arrived after that. + const stored = this.hooks.getStoredMessages().filter(ofEpoch); + const queued = this.queuedHeld(ofEpoch); + const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued); + const unread = rest.backlog.length + rest.queued.length; + if (!selfCancel || unread > 0) { + const truncNote = truncated > 0 + ? ` ${truncated} of ${backlog.length} diverted messages, the oldest, weren't delivered raw to the resident.` + : ''; + const lateNote = selfCancel + ? ` ${unread === 1 ? 'This message' : `These ${unread} messages`} arrived after the reader that cancelled was handed its own.` + : ''; + this.invokeReaderFork(serverId, channelId, epochId, 'cancel', `[${NOTICE_TAG.tuneOutCancelled}: ${name} — ${reason}.${truncNote}${lateNote}]`, stored, queued); + } } + this.dropHandOffs(epochId); + this.dropLooks(epochId); + // Where the all-except hold still holds the channel, its own messages + // there that its reader hasn't looked at are counted again, as the + // channel's own tune-out's count stood in for theirs. + this.rebuildTally(serverId, channelId); this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ended.wakeCount }); return { ok: true }; } @@ -518,9 +842,377 @@ export class TuneOutCoordinator { authorId?: string, gatePass = true, ): { epochId: string } | null { + const hold = this.holdFor(serverId, channelId); + if (!hold) return null; + this.recordHeld(serverId, channelId, messageId, tags, authorId, gatePass); + return hold; + } + + /** The epoch holding this channel, or null. Pure: the admission stage's lookup. */ + holdFor(serverId: string, channelId: string): { epochId: string } | null { + // Precedence: a per-channel epoch keeps its own hold (cadence, budget, + // stamp); the all-except scope holds whatever else is not the exception. const state = this.channelRegistry.getTuneOutState(serverId, channelId); - if (!state) return null; + if (state) return { epochId: state.params.epochId }; + const scope = this.getAllExcept(); + if (scope && !(scope.params.except.serverId === serverId && scope.params.except.channelId === channelId)) { + return { epochId: scope.params.epochId }; + } + return null; + } + + /** The channel unprefixed speech lands in while an all-except hold is active, with its server, else null. */ + focusChannel(): ChannelRef | null { + const except = this.getAllExcept()?.params.except; + return except ? { serverId: except.serverId, channelId: except.channelId } : null; + } + + // ========================================================================== + // All-except scope + // ========================================================================== + + private get scopeStateId(): string { + return `tune-out/${this.hooks.primaryName() ?? 'default'}/all-except`; + } + + getAllExcept(): AllExceptState | null { + if (this.scopeCache !== undefined) return this.scopeCache; + // A record that can't be read, or isn't a hold's, is not taken for no + // hold: the error goes to the caller and nothing is cached, so the next + // read tries again. At create() that refuses the start, as an + // unreadable registry does. + const raw = this.channelRegistry.readCoordinatorState(this.scopeStateId) as AllExceptState | null; + if (raw !== null && raw !== undefined && !(typeof raw === 'object' && raw.params && typeof raw.params.epochId === 'string')) { + throw new Error(`the all-except hold's stored record (${this.scopeStateId}) is not a hold's record; it was left as it is`); + } + this.scopeCache = raw ?? null; + return this.scopeCache; + } + + private writeScope(state: AllExceptState | null): void { + // Durable first: a write that fails leaves memory as the store has it. + this.channelRegistry.writeCoordinatorState(this.scopeStateId, state); + this.scopeCache = state; + } + + enterAllExcept( + except: { serverId: string; channelId: string }, + opts: { cadenceSeconds?: unknown; backlogCap?: unknown; maxWakes?: unknown; durationSeconds?: unknown }, + source: string, + ): { ok: true; params: AllExceptParams } | { ok: false; error: string } { + const read = this.readOptions(opts, { + durationSeconds: null, + cadenceSeconds: `for the default, ${TUNE_OUT_DEFAULTS.cadenceSeconds}`, + backlogCap: `for the default, ${TUNE_OUT_DEFAULTS.backlogCap}`, + maxWakes: `for the default, ${TUNE_OUT_DEFAULTS.maxWakes}`, + }); + if (!read.ok) return read; + const { given, expiresAtMs } = read; + if (expiresAtMs === undefined) { + return { ok: false, error: 'an all-except hold needs `durationSeconds`: it always ends on its own' }; + } + if (this.getAllExcept()) { + return { ok: false, error: 'an all-except hold is already active (cancel it first)' }; + } + if (this.channelRegistry.getTuneOutState(except.serverId, except.channelId)) { + return { ok: false, error: `channel ${except.channelId} is tuned out; it cannot be the exception` }; + } + if (this.hooks.isForkBound(except)) { + return { ok: false, error: `channel ${except.channelId} is bound to a conversation fork` }; + } + const params: AllExceptParams = { + epochId: randomUUID(), + except, + cadenceSeconds: Math.max(30, given.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds), + // Whole messages, as a channel's cap is counted: a cap below one shows + // none, where slice(-0.5) would take them all. + backlogCap: Math.max(0, Math.floor(given.backlogCap ?? TUNE_OUT_DEFAULTS.backlogCap)), + maxWakes: Math.max(1, given.maxWakes ?? TUNE_OUT_DEFAULTS.maxWakes), + startedAtSequence: this.hooks.currentSequence(), + startedAtMs: read.now, + expiresAtMs, + source, + }; + this.writeScope({ params, wakeCount: 0 }); + const oldest = this.listActive().reduce((min, e) => Math.min(min, e.params.startedAtSequence), params.startedAtSequence); + this.hooks.setSubconsciousAnchor(oldest); + this.armScopeTimers(params); + this.hooks.emitTrace({ + type: 'tune-out:entered', serverId: except.serverId, channelId: except.channelId, epochId: params.epochId, + scope: 'all-except', reader: this.readerAvailable(), expiresAtMs: params.expiresAtMs, + }); + const minutes = Math.max(1, Math.round((params.expiresAtMs - params.startedAtMs) / 60_000)); + const until = new Date(params.expiresAtMs).toISOString().slice(11, 16); + this.announce(except, params.epochId, 'entered', + `[${NOTICE_TAG.focus}: ${this.hooks.primaryName() ?? 'the resident'} is here for ${minutes} min, until ${until} UTC; other channels are held meanwhile.]`); + return { ok: true, params }; + } + + /** Whether entry and release of the all-except scope are announced in the excepted channel. */ + announces(): boolean { + return typeof this.hooks.announce === 'function'; + } + + /** + * The excepted channel hears the scope open and close: one bracketed line + * under the resident's name, posted through the same path as a publish. + * Fire-and-forget; the outcome is traced, never awaited by the epoch flip. + */ + private announce( + target: { serverId: string; channelId: string }, + epochId: string, + kind: 'entered' | 'released', + text: string, + ): void { + if (!this.hooks.announce) return; + const base = { serverId: target.serverId, channelId: target.channelId, epochId, kind }; + this.hooks.announce(target.serverId, target.channelId, text).then( + (r) => this.hooks.emitTrace(r.success + ? { type: 'tune-out:announced', ...base } + : { type: 'tune-out:announce-failed', ...base, error: r.error ?? 'publish failed' }), + (err: unknown) => this.hooks.emitTrace({ type: 'tune-out:announce-failed', ...base, error: (err as Error).message }), + ); + } + + /** The scope's end time and cadence, on timers no longer than one holds + * (deadline-timer), as a channel's tune-out arms its own. */ + private armScopeTimers(params: AllExceptParams): void { + const key = TuneOutCoordinator.SCOPE_KEY; + const expiry = this.expiryTimers.get(key); + if (expiry) clearTimeout(expiry); + // As a channel's timers: the next turn is armed before this one's work, + // and a throw is reported (timerFailed) rather than left to end the host. + const { serverId: exceptServer, channelId: exceptChannel } = params.except; + const holdExpiry = (timer: ReturnType): void => { this.expiryTimers.set(key, timer); }; + const end = (): void => { + try { + holdExpiry(setTimeout(end, EXPIRY_RETRY_MS)); + if (!this.cancelAllExcept('duration', 'duration elapsed').ok) this.clearScopeTimers(); + } catch (err) { + this.timerFailed('expiry', exceptServer, exceptChannel, err); + } + }; + armAt(params.expiresAtMs, holdExpiry, end); + if (!this.readerAvailable()) return; + const cadence = this.cadenceTimers.get(key); + if (cadence) clearTimeout(cadence); + const hold = (timer: ReturnType): void => { this.cadenceTimers.set(key, timer); }; + const tick = () => { + try { + const scope = this.getAllExcept(); + if (!scope) return; + armAfter(params.cadenceSeconds * 1000, hold, tick); + // One look per held channel with new traffic since the last look. + for (const [channelKey, diverted] of this.divertedSinceInvocation) { + if (diverted <= 0) continue; + const [serverId, channelId] = channelKey.split('\0') as [string, string]; + if (this.channelRegistry.getTuneOutState(serverId, channelId)) continue; // a per-channel epoch's own cadence + this.invokeSubconscious(serverId, channelId, 'cadence', diverted); + } + } catch (err) { + this.timerFailed('cadence', exceptServer, exceptChannel, err); + } + }; + armAfter(params.cadenceSeconds * 1000, hold, tick); + } + + private clearScopeTimers(): void { + const key = TuneOutCoordinator.SCOPE_KEY; + const cadence = this.cadenceTimers.get(key); + if (cadence) clearTimeout(cadence); + this.cadenceTimers.delete(key); + const expiry = this.expiryTimers.get(key); + if (expiry) clearTimeout(expiry); + this.expiryTimers.delete(key); + } + + /** Channels held by the scope (not by their own epoch) with the count held under it. */ + private scopeHeldCounts(epochId: string): Map { + const counts = new Map(); + const all = [...this.hooks.getStoredMessages(), ...(this.hooks.getDeferredMessages?.() ?? [])]; + for (const m of all) { + const meta = m.metadata as { tuneOut?: { epochId?: string }; channelId?: string; serverId?: string } | undefined; + if (meta?.tuneOut?.epochId !== epochId) continue; + const channelId = typeof meta.channelId === 'string' ? meta.channelId : '(unknown channel)'; + const serverId = typeof meta.serverId === 'string' ? meta.serverId : ''; + const k = this.key(serverId, channelId); + const entry = counts.get(k) ?? { serverId, channelId, count: 0 }; + entry.count += 1; + counts.set(k, entry); + } + return counts; + } + + /** + * End the all-except hold: durable flip, one release notice with a + * per-channel backlog (each capped), the reader's note if any, one wake + * for the resident, and the reader's own look at what it had not seen. + */ + cancelAllExcept( + source: string, + reason: string, + note?: string, + noteAuthor?: string, + ): { ok: true } | { ok: false; error: string } { + const scope = this.getAllExcept(); + if (!scope) return { ok: false, error: 'no all-except hold is active' }; + const { epochId, except, backlogCap } = scope.params; + const ofEpoch = (m: Pick): boolean => + (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId; + const all = [...this.hooks.getStoredMessages().filter(ofEpoch), ...(this.hooks.getDeferredMessages?.() ?? []).filter(ofEpoch)]; + const byChannel = new Map> }>(); + for (const m of all) { + const meta = m.metadata as { channelId?: string; serverId?: string } | undefined; + const serverId = meta?.serverId ?? ''; + const channelId = meta?.channelId ?? '(unknown channel)'; + const k = this.key(serverId, channelId); + const entry = byChannel.get(k) ?? { serverId, channelId, backlog: [] }; + entry.backlog.push(m); + byChannel.set(k, entry); + } + const historyTool = this.hooks.historyTool?.() ?? null; + const dumps = [...byChannel.values()].map(({ serverId, channelId, backlog }) => { + // Floored here too: a hold stored before entry floored its cap can hold + // a fraction. + const cap = Math.floor(backlogCap); + const shown = cap > 0 ? backlog.slice(-cap) : []; + const truncated = backlog.length - shown.length; + const attrs = [ + `channel=${quotedValue(this.shown(serverId, channelId))}`, + `messages=${backlog.length}`, + ...(truncated > 0 ? [`truncated=${truncated} (oldest; ${historyTool ? `${historyTool} covers the rest` : 'not delivered'})`] : []), + ]; + return `\n${shown.map(renderHeldLine).join('\n')}\n`; + }); + const summary = byChannel.size === 0 + ? 'Nothing was held.' + : `${all.length} message${all.length === 1 ? '' : 's'} held in ${byChannel.size} channel${byChannel.size === 1 ? '' : 's'}.`; + if (this.scopeReleased !== epochId) { + this.hooks.addMessage( + 'user', + [{ type: 'text', text: `[${NOTICE_TAG.tuneOut}: all channels except ${this.shown(except.serverId, except.channelId)} released — ${reason}, ${scope.wakeCount} wake${scope.wakeCount === 1 ? '' : 's'}]\n\n${summary}${dumps.length > 0 ? '\n\n' + dumps.join('\n\n') : ''}` }], + { system: true, kind: 'tune-out-cancel', scope: 'all-except', channelId: except.channelId, epochId }, + ); + this.hooks.commitDeliveries?.(); + this.scopeReleased = epochId; + } + // The release is recorded before the hold is: a failure past this point + // leaves the hold in force with its release already delivered. A retry in + // this process records only the hold's end; a restart between the two + // delivers the release again, where the other order lost it outright. + this.writeScope(null); + this.scopeReleased = undefined; + this.clearScopeTimers(); + for (const [key, pending] of this.pendingWakes) { + const [serverId, channelId] = key.split('\0') as [string, string]; + if (this.channelRegistry.getTuneOutState(serverId, channelId)) continue; + clearTimeout(pending.timer); + this.pendingWakes.delete(key); + } + const subName = this.hooks.subconsciousName(); + if (note && subName) { + this.hooks.addMessage(subName, [{ type: 'text', text: note }], { kind: 'subconscious-note', epochId }); + } else if (note && noteAuthor && this.hooks.deliverFromReader) { + this.hooks.deliverFromReader(noteAuthor, [{ type: 'text', text: note }], { kind: 'subconscious-note', epochId }); + } + const primary = this.hooks.primaryName(); + if (primary) this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out'); + const notice = `[${NOTICE_TAG.tuneOutCancelled}: all channels except ${this.shown(except.serverId, except.channelId)} — ${reason}.]`; + if (source !== 'subconscious' && subName) { + this.hooks.addMessage('user', [{ type: 'text', text: notice }], { system: true, kind: 'tune-out-cancel-report', scope: 'all-except' }, subName); + this.hooks.requestInference(subName, 'tune-out cancelled: all-except', 'tune-out'); + } else if (!subName && this.forkReader) { + // One last look per channel with messages no fork was handed, stored or + // still queued behind the resident's turn. That holds for a reader's + // own release too: the reader that released read one channel's + // messages, and every other channel's would otherwise reach no reader. + // A channel is its server and id here, as the readers' bookkeeping is. + const channels = new Map(); + for (const m of all) { + const meta = m.metadata as { channelId?: string; serverId?: string } | undefined; + const ref = { serverId: meta?.serverId ?? '', channelId: meta?.channelId ?? '(unknown channel)' }; + channels.set(this.key(ref.serverId, ref.channelId), ref); + } + for (const { serverId, channelId } of channels.values()) { + const ofChannel = inChannelOf(serverId, channelId); + const inChannel = (m: Pick): boolean => ofEpoch(m) && ofChannel(m); + const stored = this.hooks.getStoredMessages().filter(inChannel); + const queued = this.queuedHeld(inChannel); + const rest = this.unhandedBacklog(this.handOffKey(epochId, serverId, channelId), stored, queued); + if (rest.backlog.length + rest.queued.length > 0) { + this.invokeReaderFork(serverId, channelId, epochId, 'cancel', notice, stored, queued); + } + } + } + for (const key of this.divertedSinceInvocation.keys()) { + const [serverId, channelId] = key.split('\0') as [string, string]; + if (!this.channelRegistry.getTuneOutState(serverId, channelId)) this.divertedSinceInvocation.delete(key); + } + this.dropHandOffs(epochId); + this.dropLooks(epochId); + this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId: except.serverId, channelId: except.channelId, epochId, scope: 'all-except', reason, wakeCount: scope.wakeCount }); + this.announce(except, epochId, 'released', `[${NOTICE_TAG.focus}: ${primary ?? 'the resident'} released — ${reason}.]`); + return { ok: true }; + } + + /** What is held right now — per-channel epochs and the scope — without releasing. */ + check(opts: { peek?: { serverId?: string; channelId: string }; peekLimit?: number } = {}): TuneOutCheck { + const stored = this.hooks.getStoredMessages(); + const deferred = this.hooks.getDeferredMessages?.() ?? []; + const countOf = (epochId: string) => [...stored, ...deferred].filter( + (m) => (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId, + ).length; + const channels = this.listActive().map(({ serverId, channelId, params }) => ({ + serverId, channelId, epochId: params.epochId, held: countOf(params.epochId), + ...(params.expiresAtMs !== undefined ? { expiresAtMs: params.expiresAtMs } : {}), + })); + const scope = this.getAllExcept(); + let allExcept: TuneOutCheck['allExcept'] = null; + if (scope) { + const held: Record = {}; + for (const { serverId, channelId, count } of this.scopeHeldCounts(scope.params.epochId).values()) held[this.shown(serverId, channelId)] = count; + allExcept = { epochId: scope.params.epochId, except: scope.params.except, expiresAtMs: scope.params.expiresAtMs, wakeCount: scope.wakeCount, held }; + } + const result: TuneOutCheck = { allExcept, channels }; + if (opts.peek) { + const limit = Math.max(1, Math.min(50, opts.peekLimit ?? 5)); + // Only what is held NOW: the channel's own epoch, or the scope's. A + // released epoch's messages are delivered, not held; showing them here + // would be a false declaration (first live run, 2026-10-12, finding B). + const active = new Set(); + for (const e of channels) { + if (e.channelId === opts.peek.channelId && (!opts.peek.serverId || e.serverId === opts.peek.serverId)) active.add(e.epochId); + } + if (scope) active.add(scope.params.epochId); + const lines = [...stored, ...deferred] + .filter((m) => { + const meta = m.metadata as { tuneOut?: { epochId?: string }; channelId?: string; serverId?: string } | undefined; + return typeof meta?.tuneOut?.epochId === 'string' && active.has(meta.tuneOut.epochId) + && meta.channelId === opts.peek!.channelId + && (!opts.peek!.serverId || meta.serverId === opts.peek!.serverId); + }) + .slice(-limit) + .map(renderHeldLine); + result.peek = lines; + } + return result; + } + + /** + * Bookkeeping for one held message, after admission decided the hold: + * the cadence tally, the deterministic acknowledgement of a suppressed + * mention, and the reader wake when the resident's gate would have woken + * them (`gatePass` is that verdict, probed without side effects). + */ + recordHeld( + serverId: string, + channelId: string, + messageId: string, + tags: string[] | undefined, + authorId?: string, + gatePass = true, + ): void { const key = this.key(serverId, channelId); this.divertedSinceInvocation.set(key, (this.divertedSinceInvocation.get(key) ?? 0) + 1); @@ -541,10 +1233,20 @@ export class TuneOutCoordinator { // standing mute to its subject. void this.acknowledgeSuppressed(serverId, channelId, messageId); } - if (gatePass && (addressed || privileged)) { + if (gatePass && (addressed || privileged) && this.readerAvailable()) { this.scheduleWakeInvocation(serverId, channelId); } - return { epochId: state.params.epochId }; + } + + /** Reader forks that reported through `deliver_summary` (so their trailing prose is not a second report). */ + private readerReports = new Set(); + + readerReported(reader: string): boolean { + return this.readerReports.has(reader); + } + + forgetReader(reader: string): void { + this.readerReports.delete(reader); } private async acknowledgeSuppressed(serverId: string, channelId: string, messageId: string): Promise { @@ -625,19 +1327,7 @@ export class TuneOutCoordinator { try { const entry = this.pendingWakes.get(key); this.pendingWakes.delete(key); - // Count ONE durable wake per coalesced invocation. - const counted = this.channelRegistry.recordTuneOutWake(serverId, channelId); - if (!counted) return; // cancelled inside the window - if (counted.wakeCount > counted.params.maxWakes) { - this.cancel( - serverId, - channelId, - 'tune-out-max-wakes', - `wake budget exhausted (${counted.params.maxWakes})`, - ); - return; - } - this.invokeSubconscious(serverId, channelId, 'wake', entry?.count ?? 1); + this.fireWake(serverId, channelId, entry?.count ?? 1); } catch (err) { this.timerFailed('wake', serverId, channelId, err); } @@ -645,6 +1335,41 @@ export class TuneOutCoordinator { this.pendingWakes.set(key, { count: 1, timer }); } + /** One coalesced wake: counted durably against the epoch that holds the channel. */ + private fireWake(serverId: string, channelId: string, count: number): void { + if (this.channelRegistry.getTuneOutState(serverId, channelId)) { + // Count ONE durable wake per coalesced invocation. + const counted = this.channelRegistry.recordTuneOutWake(serverId, channelId); + if (!counted) return; // cancelled inside the window + if (counted.wakeCount > counted.params.maxWakes) { + this.cancel( + serverId, + channelId, + 'tune-out-max-wakes', + `wake budget exhausted (${counted.params.maxWakes})`, + ); + return; + } + this.invokeSubconscious(serverId, channelId, 'wake', count); + return; + } + const scope = this.getAllExcept(); + if (!scope) return; // released inside the window + // The all-except scope: exhausting the budget pauses the reader, it does + // not end the hold (#232 §3.2 — the hold is the resident's bounded + // commitment; the deadline ends it). + const wakeCount = scope.wakeCount + 1; + this.writeScope({ ...scope, wakeCount }); + if (wakeCount > scope.params.maxWakes) { + this.hooks.emitTrace({ + type: 'tune-out:wake-budget-paused', epochId: scope.params.epochId, scope: 'all-except', + serverId, channelId, wakeCount, maxWakes: scope.params.maxWakes, + }); + return; + } + this.invokeSubconscious(serverId, channelId, 'wake', count); + } + /** * One subconscious turn: a system-framed trigger notice (bracket style — * what happened, never what to say) plus standing dispositions, then an @@ -657,13 +1382,38 @@ export class TuneOutCoordinator { count: number, ): void { const subName = this.hooks.subconsciousName(); - if (!subName) return; + if (!subName && !this.forkReader) return; const key = this.key(serverId, channelId); const shown = this.shown(serverId, channelId); const notice = trigger === 'wake' ? `[${NOTICE_TAG.tuneOutWake}: ${shown} — ${count} addressed message${count === 1 ? '' : 's'}]` : `[${NOTICE_TAG.tuneOutCadence}: ${shown} — ${count} diverted message${count === 1 ? '' : 's'} since your last look]`; + if (!subName) { + const hold = this.holdFor(serverId, channelId); + if (!hold) return; + const ofChannel = inChannelOf(serverId, channelId); + const inChannel = (m: Pick): boolean => + (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === hold.epochId && ofChannel(m); + // Stored and still-queued alike: while the resident's turn runs, what + // arrives waits in the deferred queue, and that is the traffic a reader + // is there for. + this.invokeReaderFork( + serverId, + channelId, + hold.epochId, + trigger, + notice, + this.hooks.getStoredMessages().filter(inChannel), + this.queuedHeld(inChannel), + ); + // The count restarts once the fork has been handed what was held, as it + // does below once the subconscious's turn has been asked for. Otherwise + // every later cadence period still sees this one's count and derives a + // fork with nothing new to read. + this.divertedSinceInvocation.set(key, 0); + return; + } this.hooks.addMessage( 'user', [{ type: 'text', text: notice }], @@ -674,6 +1424,280 @@ export class TuneOutCoordinator { // Counted from here only once the turn has been asked for, so a turn that // throws leaves its messages for the next period's turn. this.divertedSinceInvocation.set(key, 0); + const epochId = this.holdFor(serverId, channelId)?.epochId; + if (epochId) this.markLook(epochId, serverId, channelId); + } + + /** + * Count again, from the held messages, stored or still queued behind the + * resident's turn, those the reader of the epoch holding this channel now + * hasn't looked at: for reader forks, those no fork was handed; for the + * persistent subconscious, the stored ones held since its last look + * (lastLooks) and every queued one, which no view of it can show yet. + * Nothing holding the channel leaves no count. + */ + private rebuildTally(serverId: string, channelId: string): void { + const key = this.key(serverId, channelId); + const hold = this.holdFor(serverId, channelId); + if (!hold || !this.readerAvailable()) { + this.divertedSinceInvocation.delete(key); + return; + } + const ofChannel = inChannelOf(serverId, channelId); + const inEpoch = (m: Pick): boolean => + (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === hold.epochId && ofChannel(m); + const rows = this.hooks.getStoredMessages().filter(inEpoch); + const queued = this.queuedHeld(inEpoch); + let count: number; + if (this.forkReader) { + const unread = this.unhandedBacklog(this.handOffKey(hold.epochId, serverId, channelId), rows, queued); + count = unread.backlog.length + unread.queued.length; + } else { + const mark = this.lastLooks()?.[hold.epochId]?.[key]; + count = (mark === undefined ? rows.length : rows.filter((m) => m.sequence > mark).length) + queued.length; + } + this.divertedSinceInvocation.set(key, count); + } + + private get lastLooksStateId(): string { + return `tune-out/${this.hooks.primaryName() ?? 'default'}/last-looks`; + } + + /** + * The stored last looks. Where they can't be read, none: every held row + * counts, and nothing is cached, so the next read tries again. Nor is + * anything written back over the stored record then (markLook, dropLooks, + * pruneLooks), which would erase the other epochs' and channels' looks. + */ + private lastLooks(): Record> | undefined { + if (this.lastLooksCache !== undefined) return this.lastLooksCache; + let raw: unknown; + try { + raw = this.channelRegistry.readCoordinatorState(this.lastLooksStateId); + } catch (error) { + console.error(`[tune-out] the subconscious's last looks (${this.lastLooksStateId}) could not be read:`, error); + return undefined; + } + const looks: Record> = {}; + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + for (const [epochId, channels] of Object.entries(raw as Record)) { + if (!channels || typeof channels !== 'object' || Array.isArray(channels)) continue; + const marks = Object.entries(channels as Record).filter((e): e is [string, number] => typeof e[1] === 'number'); + if (marks.length > 0) looks[epochId] = Object.fromEntries(marks); + } + } + this.lastLooksCache = looks; + return looks; + } + + private writeLooks(looks: Record>): void { + this.lastLooksCache = looks; + try { + this.channelRegistry.writeCoordinatorState(this.lastLooksStateId, looks); + } catch (error) { + // The look itself stands. After a restart, this look's messages would + // count as unseen at the next one. + console.error(`[tune-out] the subconscious's last looks (${this.lastLooksStateId}) could not be stored:`, error); + } + } + + private markLook(epochId: string, serverId: string, channelId: string): void { + const looks = this.lastLooks(); + if (!looks) return; + this.writeLooks({ ...looks, [epochId]: { ...looks[epochId], [this.key(serverId, channelId)]: this.hooks.currentSequence() } }); + } + + /** An ended epoch's last looks go with it. */ + private dropLooks(epochId: string): void { + if (this.hooks.subconsciousName() == null) return; + const looks = this.lastLooks(); + if (!looks || !(epochId in looks)) return; + const { [epochId]: _ended, ...rest } = looks; + this.writeLooks(rest); + } + + /** At boot: the last looks of epochs no longer active go. */ + private pruneLooks(): void { + if (this.hooks.subconsciousName() == null) return; + const looks = this.lastLooks(); + if (!looks) return; + const active = new Set(this.listActive().map((e) => e.params.epochId)); + const scope = this.getAllExcept(); + if (scope) active.add(scope.params.epochId); + const kept = Object.fromEntries(Object.entries(looks).filter(([epochId]) => active.has(epochId))); + if (Object.keys(kept).length !== Object.keys(looks).length) this.writeLooks(kept); + } + + /** + * `reader: 'forks'`: hand the framework one invocation. The backlog is + * the epoch's held messages not yet handed to any fork — each is + * delivered to exactly one reader — stored ones first, then those still + * queued behind the resident's turn, plus the standing dispositions. A + * cadence look with nothing new is skipped. True when a fork was asked for. + */ + private invokeReaderFork( + serverId: string, + channelId: string, + epochId: string, + trigger: ReaderInvocation['trigger'], + notice: string, + epochBacklog: StoredMessage[], + epochQueued: QueuedHeldMessage[] = [], + ): boolean { + if (!this.hooks.invokeReader) return false; + const key = this.handOffKey(epochId, serverId, channelId); + const { backlog, queued } = this.unhandedBacklog(key, epochBacklog, epochQueued); + if (trigger === 'cadence' && backlog.length === 0 && queued.length === 0) return false; + // The cursor only moves forward: a returned row handed again can be + // behind it. + const cursor = Math.max(this.readerCursor.get(key) ?? -1, ...backlog.map((m) => m.sequence)); + if (backlog.length > 0) this.readerCursor.set(key, cursor); + const handed = this.readerHandedWrites.get(key) ?? new Set(); + for (const m of queued) handed.add(m.writeId); + // A handed write whose row has landed behind the cursor can no longer be + // handed by sequence, so its id has done its work. + for (const m of epochBacklog) { + const writeId = writeIdOf(m); + if (writeId !== undefined && m.sequence <= cursor) handed.delete(writeId); + } + if (handed.size > 0) this.readerHandedWrites.set(key, handed); + else this.readerHandedWrites.delete(key); + const returned = this.readerReturned.get(key); + if (returned) { + for (const m of backlog) { + returned.ids.delete(m.id); + const writeId = writeIdOf(m); + if (writeId !== undefined) returned.writeIds.delete(writeId); + } + for (const m of queued) returned.writeIds.delete(m.writeId); + if (returned.ids.size === 0 && returned.writeIds.size === 0) this.readerReturned.delete(key); + } + const entries = Object.entries(this.readDispositions()); + this.hooks.invokeReader({ + serverId, + channelId, + epochId, + trigger, + notice, + backlog, + queued, + dispositions: entries.length > 0 ? entries.map(([k, v]) => `- ${k}: ${v}`).join('\n') : null, + }); + return true; + } + + /** + * What no reader fork has read yet, of the given held messages: the stored + * ones past the epoch's cursor for the channel, less those a fork was handed while they + * were still queued, plus those a failed fork returned, oldest first; then + * the queued ones no fork was handed, or a failed fork returned. + */ + private unhandedBacklog( + key: string, + stored: StoredMessage[], + queued: QueuedHeldMessage[], + ): { backlog: StoredMessage[]; queued: QueuedHeldMessage[] } { + const cursor = this.readerCursor.get(key) ?? -1; + const handed = this.readerHandedWrites.get(key); + const returned = this.readerReturned.get(key); + const wasReturned = (id: string | undefined, writeId: string | undefined): boolean => + returned !== undefined && ((id !== undefined && returned.ids.has(id)) || (writeId !== undefined && returned.writeIds.has(writeId))); + const unread = (m: StoredMessage): boolean => { + const writeId = writeIdOf(m); + if (wasReturned(m.id, writeId)) return true; + return m.sequence > cursor && !(handed !== undefined && writeId !== undefined && handed.has(writeId)); + }; + return { + backlog: stored.filter(unread).sort((a, b) => a.sequence - b.sequence), + queued: queued.filter((m) => !handed?.has(m.writeId) || wasReturned(undefined, m.writeId)), + }; + } + + /** + * A reader fork that never read what it was handed: its derive failed, or + * its run threw before it reported. While the epoch that held the + * messages is still active, they go back to that epoch's backlog for the + * channel, and its next invocation for the channel is handed them: the + * scope's while the scope is active, though the channel's own tune-out + * has taken the channel meanwhile, and a channel's own tune-out's while it + * holds the channel. Once the epoch has ended, its cancel's last fork was + * handed only what no fork had been, so a fork still running then is the + * only reader its messages had: one late fork is handed them instead + * (lateFork). Returns how many went back or to the late fork. + */ + readerFailed(invocation: Pick): number { + const { serverId, channelId, epochId } = invocation; + const active = this.getAllExcept()?.params.epochId === epochId + || this.channelRegistry.getTuneOutState(serverId, channelId)?.params.epochId === epochId; + if (!active) return this.lateFork(invocation); + const key = this.handOffKey(epochId, serverId, channelId); + const returned = this.readerReturned.get(key) ?? { ids: new Set(), writeIds: new Set() }; + for (const m of invocation.backlog) returned.ids.add(m.id); + for (const m of invocation.queued) returned.writeIds.add(m.writeId); + const count = invocation.backlog.length + invocation.queued.length; + if (count > 0) { + this.readerReturned.set(key, returned); + // They count again toward the next cadence look of the epoch holding + // the channel, which hands them back. Where another epoch holds it + // now, they're counted again when the channel comes back to theirs + // (rebuildTally), and its release hands them in any case. + const tally = this.key(serverId, channelId); + if (this.holdFor(serverId, channelId)?.epochId === epochId) { + this.divertedSinceInvocation.set(tally, (this.divertedSinceInvocation.get(tally) ?? 0) + count); + } + } + return count; + } + + /** + * A fork that failed after its epoch ended: one late fork is handed what it + * was, with a notice saying a reader handed them failed after the tune-out + * ended. Those still queued when it was handed that have landed since are + * handed as stored. A late fork's own failure derives no other, so a + * reader that keeps failing costs one more fork, not a chain of them. + * Returns how many the late fork was handed. + */ + private lateFork(invocation: Pick): number { + if (!this.hooks.invokeReader || this.lateForks.has(invocation)) return 0; + const { serverId, channelId, epochId } = invocation; + const landed = new Map(); + for (const m of this.hooks.getStoredMessages()) { + const writeId = writeIdOf(m); + if (writeId !== undefined) landed.set(writeId, m); + } + const backlog = [...invocation.backlog, ...invocation.queued.flatMap((m) => landed.get(m.writeId) ?? [])] + .sort((a, b) => a.sequence - b.sequence); + const queued = invocation.queued.filter((m) => !landed.has(m.writeId)); + const count = backlog.length + queued.length; + if (count === 0) return 0; + const these = count === 1 ? 'This message was' : `These ${count} messages were`; + const notice = `[${NOTICE_TAG.tuneOutCancelled}: ${this.shown(serverId, channelId)} — ${these} handed to a reader that ` + + `failed after the tune-out ended, so no reader has read ${count === 1 ? 'it' : 'them'} yet.]`; + const entries = Object.entries(this.readDispositions()); + const late: ReaderInvocation = { + serverId, + channelId, + epochId, + trigger: 'cancel', + notice, + backlog, + queued, + dispositions: entries.length > 0 ? entries.map(([k, v]) => `- ${k}: ${v}`).join('\n') : null, + }; + this.lateForks.add(late); + this.hooks.invokeReader(late); + return count; + } + + /** + * The held messages still queued behind the resident's turn that `keep` + * keeps, oldest first, with their write ids. One the host gives no id for + * is left to reach a reader once it is stored: without the id, its stored + * row couldn't be told from a message no reader has had. + */ + private queuedHeld(keep: (m: Pick) => boolean): QueuedHeldMessage[] { + return (this.hooks.getDeferredMessages?.() ?? []) + .filter((m): m is QueuedHeldMessage => typeof m.writeId === 'string' && keep(m)); } // ========================================================================== @@ -754,6 +1778,11 @@ export class TuneOutCoordinator { input: Record, /** The calling turn's own channels, for a call made inside it (the framework's callOwnChannels). */ own: readonly ChannelRef[] = [], + /** The calling agent: the persistent subconscious, or a reader fork. */ + caller?: string, + /** A reader fork, with the epoch it was derived to read, if any: it ends and posts only through that epoch's + * hold (forkRefusal). The persistent subconscious, which reads every epoch, passes none. */ + fork?: { epochId?: string }, ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> { switch (name) { case 'deliver_summary': { @@ -768,49 +1797,105 @@ export class TuneOutCoordinator { }; } const subName = this.hooks.subconsciousName(); - if (!subName) return { success: false, error: 'subconscious not configured', isError: true }; - // Verbatim, under its own participant name, into the resident's window, - // tagged with the channel the name means and its server, since the - // framework reads a conversational row's channel (repinChannel). The - // name means what it means to every channel tool: inside the calling - // turn, the id of one of the turn's own channels means that channel, - // refused where its server no longer registers it or the turn has - // channels with that id on two servers (ownChannelFor); otherwise a - // name several registered channels answer to is refused with them - // (sharedNameRefusalFor). So no row is left for a re-pin to read as - // another server's channel. A name no registered channel answers to - // is kept as given, with no server, which a re-pin reads by name when - // it weighs the row, as it reads any message without its server. + // Verbatim, under its own participant name, into the resident's window + // (or, from a reader fork, as its attributed mail), tagged with the + // channel the name means and its server, since the framework reads a + // conversational row's channel (repinChannel). The name means what it + // means to every channel tool: inside the calling turn, the id of one + // of the turn's own channels means that channel, refused where its + // server no longer registers it or the turn has channels with that id + // on two servers (ownChannelFor); otherwise a name several registered + // channels answer to is refused with them (sharedNameRefusalFor). So + // no row is left for a re-pin to read as another server's channel. A + // name no registered channel answers to is kept as given, with no + // server, which a re-pin reads by name when it weighs the row, as it + // reads any message without its server. + if (!subName && !(caller && this.hooks.deliverFromReader)) { + return { success: false, error: 'subconscious not configured', isError: true }; + } const mine = this.channelRegistry.ownChannelFor(channelId, own, 'deliver'); if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true }; const shared = mine ? undefined : this.channelRegistry.sharedNameRefusalFor(channelId, 'nothing was delivered'); if (shared) return { success: false, error: shared, isError: true }; const [named] = mine ? [mine.channel] : this.channelRegistry.channelsNamed(channelId); - this.hooks.addMessage(subName, [{ type: 'text', text }], { + const metadata = { kind: 'subconscious-summary', ...(named ? { channelId: named.channelId, serverId: named.serverId } : { channelId }), - }); + }; + if (subName) { + this.hooks.addMessage(subName, [{ type: 'text', text }], metadata); + return { success: true, data: { delivered: true } }; + } + // A reader fork's report: attributed mail under its own name. The fork + // counts as having reported only once the mail is held: a delivery + // that throws is an error result to the fork, and its prose is then + // still delivered as its report when it ends (#546, probe R14). + this.hooks.deliverFromReader!(caller!, [{ type: 'text', text }], metadata); + this.readerReports.add(caller!); return { success: true, data: { delivered: true } }; } case 'cancel_tuneout': { const name = String(input.channelId ?? ''); + const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined; + const endScope = (): { success: boolean; data?: unknown; error?: string; isError?: boolean } => { + const scope = this.getAllExcept(); + const refused = fork && scope + ? this.forkRefusal(fork, { epochId: scope.params.epochId }, 'cancel', "the all-except hold isn't the hold you were started to read") + : undefined; + if (refused) return { success: false, error: refused, isError: true }; + const result = this.cancelAllExcept('subconscious', 'subconscious judgment', note, caller); + return result.ok + ? { success: true, data: { cancelled: true, scope: 'all-except' } } + : { success: false, error: result.error, isError: true }; + }; + // A call naming the all-except scope ends that hold outright, as the + // tool describes it and as the resident's tune_out cancel does: the + // channel it names, tuned out on its own or not, doesn't turn it + // into that channel's cancel, and with no hold it ends nothing. + if (input.scope === 'all-except') return endScope(); + const serverId = typeof input.serverId === 'string' && input.serverId ? input.serverId : undefined; // The name means what it means to every channel tool, the - // resident's tune_out included: inside the calling turn, the id of - // one of the turn's own channels means that channel (ownChannelFor); - // otherwise a name several registered channels answer to is refused + // resident's tune_out included: with a serverId, that server's + // channel with the id; inside the calling turn, the id of one of the + // turn's own channels means that channel (ownChannelFor); otherwise a + // name several registered channels answer to picks none of them // (channelsNamed), whichever of them is tuned out, never taken to be - // the tuned-out one. - const mine = this.channelRegistry.ownChannelFor(name, own); - if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true }; - const named = mine ? [mine.channel] : this.channelRegistry.channelsNamed(name); - const shared = mine ? undefined : this.channelRegistry.sharedNameRefusalFor(name); - if (shared) return { success: false, error: shared, isError: true }; - const channel = named[0]; + // the tuned-out one. A name that picks no channel, such a name or one + // of the turn's own channels that ownChannelFor refuses, is refused, + // unless the call names the all-except scope, which ends that hold + // below. + const mine = serverId ? undefined : this.channelRegistry.ownChannelFor(name, own); + const refusal = mine && 'error' in mine ? mine.error + : serverId || mine ? undefined : this.channelRegistry.sharedNameRefusalFor(name); + const named = serverId ? [{ serverId, channelId: name }] + : mine && 'channel' in mine ? [mine.channel] : this.channelRegistry.channelsNamed(name); + const channel = refusal ? undefined : named[0]; if (!channel || !this.channelRegistry.getTuneOutState(channel.serverId, channel.channelId)) { - return { success: false, error: this.notTunedOut(JSON.stringify(name), 'the resident'), isError: true }; + // The reader keeps its latitude over the all-except hold too: a + // channel it holds (or the scope named as the channel) ends the + // scope. A call naming the scope "channel" ends only a channel's + // own tune-out, so on a channel only the scope holds it ends + // nothing. + const scope = this.getAllExcept(); + const heldByScope = scope && (name === 'all-except' + || (channel !== undefined && this.holdFor(channel.serverId, channel.channelId)?.epochId === scope.params.epochId)); + if (!heldByScope) return { success: false, error: refusal ?? this.notTunedOut(JSON.stringify(name), 'the resident'), isError: true }; + if (input.scope === 'channel') { + const shown = channel ? this.quotedShown(channel.serverId, channel.channelId) : JSON.stringify(name); + return { + success: false, + error: `Nothing was cancelled: ${shown} isn't tuned out on its own, and scope "channel" ends only a channel's own ` + + 'tune-out. The all-except hold holds it, and calling cancel_tuneout with scope "all-except" ends that hold.', + isError: true, + }; + } + return endScope(); } - const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined; - const result = this.cancel(channel.serverId, channel.channelId, 'subconscious', 'subconscious judgment', note); + const held = this.channelRegistry.getTuneOutState(channel.serverId, channel.channelId)!; + const refused = fork && this.forkRefusal(fork, { epochId: held.params.epochId }, 'cancel', + `the tune-out on ${this.quotedShown(channel.serverId, channel.channelId)} isn't the hold you were started to read`); + if (refused) return { success: false, error: refused, isError: true }; + const result = this.cancel(channel.serverId, channel.channelId, 'subconscious', 'subconscious judgment', note, caller); return result.ok ? { success: true, data: { cancelled: true } } : { success: false, error: result.error, isError: true }; @@ -864,13 +1949,47 @@ export class TuneOutCoordinator { // means that channel, as for every channel tool (ownChannelFor). const mine = this.channelRegistry.ownChannelFor(channelId, own, 'publish'); if (mine && 'error' in mine) return { success: false, error: mine.error, isError: true }; - return this.channelRegistry.publishForAgent(mine ? mine.channel : channelId, text, this.hooks.subconsciousName() ?? 'Subconscious'); + if (fork) { + const named = mine ? [mine.channel] : this.channelRegistry.channelsNamed(channelId); + const at = named.length === 1 ? named[0] : undefined; + const hold = at ? this.holdFor(at.serverId, at.channelId) : null; + const shown = at ? this.quotedShown(at.serverId, at.channelId) : JSON.stringify(channelId); + const refused = (at || fork.epochId === undefined) && this.forkRefusal(fork, hold, 'speech', hold + ? `${shown} is held by a hold other than the one you were started to read` + : `${shown} isn't held now`); + if (refused) return { success: false, error: refused, isError: true }; + } + return this.channelRegistry.publishForAgent(mine ? mine.channel : channelId, text, this.hooks.subconsciousName() ?? caller ?? 'Subconscious'); } default: return { success: false, error: `unknown subconscious tool: ${name}`, isError: true }; } } + /** + * Why a reader fork may not act through `hold` (the epoch holding what its + * call names, or null): a fork ends and posts only through the hold of the + * epoch it was derived to read, and one derived for none acts through no + * hold. Undefined where the hold is its own. `why` says, for the fork, how + * what its call names isn't its own hold; its reports stay open to it. + */ + private forkRefusal( + fork: { epochId?: string }, + hold: { epochId: string } | null, + act: 'cancel' | 'speech', + why: string, + ): string | undefined { + if (fork.epochId !== undefined && hold?.epochId === fork.epochId) return undefined; + const nothing = act === 'cancel' ? 'Nothing was cancelled' : 'Nothing was posted'; + const report = `Your report can say what you would ${act === 'cancel' ? 'judge of it' : 'have posted'}, and the resident reads it.`; + if (fork.epochId === undefined) { + return `${nothing}: you weren't started to read a tune-out, so there's no hold you can ` + + `${act === 'cancel' ? 'end' : 'post through'}. ${report}`; + } + const rule = act === 'cancel' ? 'a reader ends only its own' : 'a reader posts only into a channel its own hold holds'; + return `${nothing}: ${why}, and ${rule}. ${report}`; + } + /** * A cancel of a channel that isn't tuned out, by the name, quoted, the caller gave * or knows it by: which channels are, so the next call can name one, and @@ -892,6 +2011,18 @@ export class TuneOutCoordinator { + `first wake past its wake budget, and its backlog goes to ${backlogTo} then.`; } + /** + * The reader ended, not the framework: its turns stop (cadence and + * coalescing wakes), and every hold keeps its end time, which releases it + * to the resident with no reader. + */ + stopReader(): void { + for (const timer of this.cadenceTimers.values()) clearTimeout(timer); + this.cadenceTimers.clear(); + for (const { timer } of this.pendingWakes.values()) clearTimeout(timer); + this.pendingWakes.clear(); + } + /** Stop all timers (framework shutdown). */ stop(): void { for (const timer of this.cadenceTimers.values()) clearTimeout(timer); diff --git a/src/tune-out/tools.ts b/src/tune-out/tools.ts index af5c4bd7d..7d2c6ca83 100644 --- a/src/tune-out/tools.ts +++ b/src/tune-out/tools.ts @@ -1,3 +1,6 @@ +import type { ContentBlock } from '@animalabs/membrane'; +import { authorLine } from '../author-presentation.js'; +import type { StoredMessage } from '@animalabs/context-manager'; /** * The subconscious's tool surface (issue #77). * @@ -51,11 +54,13 @@ export const SUBCONSCIOUS_TOOLS: ToolDefinition[] = [ '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.', + '`text`; it arrives alongside the backlog, in your voice. If you were ' + + 'started to read one hold, you can end only that hold.', inputSchema: { type: 'object', properties: { - channelId: { type: 'string' }, + channelId: { type: 'string', description: 'A tuned-out channel, or a channel held by the all-except hold (which ends that hold, unless scope is "channel").' }, + scope: { type: 'string', enum: ['channel', 'all-except'], description: '"all-except" ends the all-except hold outright; "channel" ends only the channel\'s own tune-out.' }, text: { type: 'string', description: 'Your accompanying note to the resident (optional).', @@ -86,10 +91,14 @@ export const SUBCONSCIOUS_TOOLS: ToolDefinition[] = [ { 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.', + 'Post a message into a tuned-out channel, in your own words and ' + + 'clearly as yourself (not as the resident); it goes out through the ' + + 'resident\'s publish path, so the channel sees it under their account. ' + + 'Use sparingly — e.g. to tell someone the resident is tuned out and ' + + 'when to expect them. If you were started to read one hold, you can ' + + 'post only into a channel that hold still holds, and a post made once ' + + 'it has ended is refused; your report can say it instead. Disabled unless ' + + 'the resident\'s configuration allows it.', inputSchema: { type: 'object', properties: { @@ -102,11 +111,42 @@ export const SUBCONSCIOUS_TOOLS: ToolDefinition[] = [ ]; /** - * Configuration for the subconscious resident (FrameworkConfig.subconscious). + * Configuration for the subconscious (FrameworkConfig.subconscious). Two + * shapes, by `reader`: + * + * - `'persistent'` (default): one persistent side-agent with an isolated + * slot and a windowed read-only view of the residents' timeline, as + * issue #77 built it. Its `systemPrompt` is a system prompt. + * - `'forks'`: a succession of forks of the resident (Dendrite). Each + * cadence tick, coalesced wake and cancel derives a short-lived fork at + * the resident's head — the resident's whole prefix, its refusals + * included, ending with it — hands it the traffic held since the last + * look as the first message after the fork point, and lets it report + * back as attributed mail. No agent persists between invocations; + * dispositions do, in the coordinator's state. Its `voice` is framing, + * not a system prompt, and residents are shown the reader's four tools + * in their tool block (refused at dispatch), so the fork's request is + * the resident's up to the fork point. */ -export interface SubconsciousConfig { +export type SubconsciousConfig = PersistentReaderConfig | ForkReaderConfig; + +interface SubconsciousConfigBase { /** Master switch. */ enabled: boolean; + /** + * Allow speak_in_channel. Default false until the voice block has passed + * its canary round. A reader's post goes out through the resident's own + * publish path, so the channel sees it under the resident's account, in + * the reader's words; nothing in the request names the reader. With it + * off, a reader fork can post nowhere: every tool but its own is refused + * at dispatch (the block it is shown stays the resident's, for the + * shared prefix). + */ + allowChannelSpeech?: boolean; +} + +export interface PersistentReaderConfig extends SubconsciousConfigBase { + reader?: 'persistent'; /** * Registry + participant name. Default 'Subconscious' — following the * Context Manager precedent: a title-case functional voice, not an @@ -124,9 +164,110 @@ export interface SubconsciousConfig { * 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; } + +export interface ForkReaderConfig extends SubconsciousConfigBase { + reader: 'forks'; + /** + * Whose weights run the fork. Required, never defaulted: a fork holds the + * resident's whole prefix, and which model reads it is the one fact the + * resident's consent names. The resident's own model is the copy; another + * model is allowed when stated here. `create` refuses the omission. + */ + model: string; + /** + * The reader's voice block — report-shaped, second person toward the + * resident, co-authored with them. Delivered as the first message after + * the fork point, with the notice, the standing dispositions and the held + * traffic; never a system prompt. + */ + voice: string; + /** + * A fresh context strategy instance for each fork, same class and + * configuration as the resident's, so the fork reuses the resident's fold + * state and rendering. Required unless the resident runs a passthrough + * strategy. + */ + strategyFactory?: () => import('@animalabs/context-manager').ContextStrategy; + /** Idle timeout for one reader fork (default 10 minutes). */ + forkIdleTimeoutMs?: number; + /** + * How a reader fork is prompted: the content of the one message it + * receives after the fork point. Default `defaultReaderFraming`. The + * framework stores whatever this returns as a user message with + * `metadata.kind = 'tune-out-reader-framing'`; the notice, voice, + * dispositions and held traffic are in the context, and nothing else + * about the fork's prompt is fixed by the path. + */ + framing?: (context: ReaderFramingContext) => ContentBlock[]; +} + +/** What a reader fork's framing is built from. */ +export interface ReaderFramingContext { + /** The resident the fork was derived from. */ + resident: string; + /** The fork's own name. */ + fork: string; + /** `ForkReaderConfig.voice`. */ + voice: string; + channelId: string; + epochId: string; + trigger: 'cadence' | 'wake' | 'cancel'; + /** The coordinator's notice for this invocation (`[Tune-out wake: …]` etc.). */ + notice: string; + /** Standing dispositions recorded on the epoch, or null. */ + dispositions: string | null; + /** + * The traffic held since the last look, oldest first; empty when nothing + * arrived. It includes the messages still queued behind the resident's + * turn (admitted, not yet stored), last: their ids are held handles and + * their sequences provisional, as a module's held rows are. + */ + backlog: StoredMessage[]; + /** How many of `backlog`'s last entries are still queued behind the resident's turn. Absent is none. */ + queuedCount?: number; +} + +/** + * The default reader-fork framing: a header naming the channel, epoch and + * trigger; the notice; the voice block; standing dispositions if any; then + * the held traffic as `author: text` lines with non-text blocks (images, + * documents) preserved in order as the source supplied them. + */ +export function defaultReaderFraming(ctx: ReaderFramingContext): ContentBlock[] { + const header = + `[Tune-out reader — ${ctx.channelId}, epoch ${ctx.epochId.slice(0, 8)}, ${ctx.trigger}]\n` + + `${ctx.notice}\n\n${ctx.voice}` + + (ctx.dispositions ? `\n\n[Standing dispositions]\n${ctx.dispositions}` : ''); + const content: ContentBlock[] = [{ type: 'text', text: header }]; + if (ctx.backlog.length === 0) { + content.push({ type: 'text', text: '\n[Nothing new has been held since your last look.]' }); + return content; + } + const n = ctx.backlog.length; + const q = Math.min(Math.max(0, Math.floor(ctx.queuedCount ?? 0)), n); + const lines: string[] = [`\n[Held in ${ctx.channelId} since your last look: ${n} message${n === 1 ? '' : 's'}]`]; + const flush = () => { + if (lines.length > 0) content.push({ type: 'text', text: lines.splice(0).join('\n') }); + }; + const which = q === n ? (q === 1 ? 'It' : 'They') : (q === 1 ? 'The last one' : `The last ${q}`); + const queuedNote = `[${which} arrived while ${ctx.resident}'s turn was running, so ${q === 1 ? 'it is' : 'they are'} still queued, not yet stored.]`; + for (const [i, message] of ctx.backlog.entries()) { + if (q > 0 && i === n - q) lines.push(queuedNote); + const author = (message.metadata as { author?: { name?: string } } | undefined)?.author?.name ?? message.participant; + const text = message.content + .filter((b): b is Extract => b.type === 'text') + .map((b) => b.text) + .join('\n'); + lines.push(authorLine(author, text)); + const media = message.content.filter((b) => b.type !== 'text'); + if (media.length > 0) { + flush(); + content.push(...media); + } + } + flush(); + return content; +} diff --git a/src/types/agent.ts b/src/types/agent.ts index b4ac8542e..2c2194de5 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -340,6 +340,34 @@ export interface AgentConfig { */ proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled'; + /** + * Stored participants that are this agent's own voice, in addition to its + * name: their turns are presented to the model as this agent's assistant + * turns. A derived agent sets this to the agent it inherited from, so the + * parent's turns read as its own history. Model-facing role assignment + * only, applied at request assembly — stored authorship is never rewritten. + */ + selfParticipants?: string[]; + + /** + * The participant name this agent's own turns are presented under at + * request assembly. Defaults to `name`. A derived agent that should share + * its parent's provider prefix sets the parent's name, so the two + * requests are identical in formatters that render participant names as + * well as in those that only assign roles. New turns are still STORED + * under the agent's own name. + */ + presentAs?: string; + + /** + * Tools refused when this agent calls them, although they stay advertised. + * Restricts a derived agent without changing the tool block at the front + * of its request — removing a tool from `allowedTools` instead would + * change those bytes and forfeit the provider prefix it shares with its + * parent. + */ + denyToolsAtDispatch?: string[]; + /** * Fail-closed containment for a text response whose entire visible prose is * an invocation-shaped wrapper naming a tool registered on that exact turn. diff --git a/src/types/framework.ts b/src/types/framework.ts index be5ed5455..ad69361c7 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -116,6 +116,48 @@ export type ProviderHoldHook = ( ) => ProviderHold | undefined; export interface FrameworkConfig { + /** + * Dendrite agent lifecycle options. + */ + dendrite?: { + /** + * When an agent ends while task work it spawned is still running, the + * orphan is always told. With this on (the default), the first agent + * that could adopt it — its nearest live ancestor, else the primary — + * also receives a factual notice, which is what makes the reparenting + * offer visible to a model. Turn it off when the host makes adoption + * decisions itself from the `dendrite:agent-orphaned` trace. + */ + announceOrphans?: boolean; + /** + * By default every derivation tells the agent it was made from, in one + * host-framed line. After this many derivations of one kind from one + * agent, that agent hears a count line every so many instead of a line + * per fork, so a reader loop that forks a hundred times a session doesn't + * fill its source's context with stamps; every derivation still emits its + * `dendrite:agent-created` trace. Default 10; 0 never collapses. + */ + collapseDerivationNoticesAfter?: number; + /** + * A host's say over each derivation, ephemeral spawn and resume, before the + * agent or its new incarnation exists, given the spec of what is about to + * exist (name, kind, spawner, route, inheritance, metadata). A shared + * derivation's record then adds the child's branch, and names the + * parent's namespace as its own. Return nothing to allow, or `{ refuse }` + * with the reason, which the caller gets as the error. The framework + * attaches no meaning to the decision: consent, budget, hours, a mind's + * standing instruction, a use not yet imagined are all the host's. + */ + beforeDerive?: (spec: import('../dendrite/types.js').AgentSpec) => void | { refuse: string }; + /** + * What an agent is told when something is derived from it. Given the + * record, the agent it was derived from and the framework's default line, + * return the text to add, or null to add nothing. Absent, the default + * line is used. The `dendrite:agent-created` trace is emitted either way. + */ + derivationNotice?: (record: import('../dendrite/types.js').AgentRecord, from: string, defaultText: string) => string | null; + }; + /** * The subconscious resident (issue #77): a persistent side-agent that * receives diverted traffic from tuned-out channels and reports to the @@ -123,6 +165,46 @@ export interface FrameworkConfig { */ subconscious?: import('../tune-out/tools.js').SubconsciousConfig; + /** + * Tune-out without a reader (issue #232 §3.3). The hold machinery always + * runs when MCPL channels exist, so a persisted epoch is resumed and + * released whatever the configuration; this switch only decides whether + * residents are offered `tune_out` when no reader is configured. Without + * a reader a tune-out is a silent hold: no cadence, no wake budget, + * mentions are acknowledged, the backlog is released on cancel. + */ + tuneOut?: { + /** Offer `tune_out` without a subconscious. Default: only with one. */ + enabled?: boolean; + /** + * Announce the all-except scope in the excepted channel: one bracketed + * line under the resident's name when it opens (who, for how long) and + * one when it closes. Default true — a hold the room cannot see is the + * failure the scope exists to prevent. `false` keeps both quiet. + */ + announce?: boolean; + }; + + /** + * Where a turn's plain prose lands when nothing names a destination + * (architecture channel, 2026-10-06: the §3 carve-out accepted by the + * residents). + * + * - `'delivery'` (default): the locus comes only from the delivery that + * woke the turn, a conversation fork's home channel, or a channel the + * agent opened during the turn. Incoming traffic never moves it — not + * between turns, not mid-turn. A turn with no such locus keeps its + * prose private, and says so in a `[routing]` notice. + * - `'latest-inbound'`: the previous rule. The most recent delivered + * inbound message is a global fallback locus for turns without one, and + * an addressed message injected mid-turn re-pins the turn's locus. + * + * Held traffic never moves the locus under either rule. + */ + speech?: { + locus?: 'delivery' | 'latest-inbound'; + }; + /** * IANA zone of the agent's wall clock: it renders wall-clock times for the * agent, and it sets where clock rows' boundaries fall (AgentConfig.clock, @@ -160,6 +242,26 @@ export interface FrameworkConfig { */ deferredWritesPath?: string; + /** + * The Dendrite agent registry's recovery file: its live records and the + * results it holds for delivery, written atomically and fsynced, and read + * first at boot. Defaults to `/recovery/dendrite-registry.json`. + * Outside the chronicle for the reason `hostModePath` is: a branch switch + * or historical rollback would otherwise take back held results. Set + * explicitly when providing an app-owned `store` without `storePath`; + * without either, the registry persists only in the branch-local store, + * with a warning at create. + */ + agentRegistryPath?: string; + + /** + * The registry's ended-record log recovery file (its newest records, as + * many as a restart reads). Defaults to + * `/recovery/dendrite-ended.json`; same rationale and fallback + * as `agentRegistryPath`. + */ + agentRegistryEndedPath?: string; + /** Emoji used for branch-awareness markers (default: 💤). */ discordAwarenessEmoji?: string; diff --git a/src/types/trace.ts b/src/types/trace.ts index e3daff6fa..21ac603e8 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -887,6 +887,156 @@ export type TraceEvent = since?: number; }) + /** A reader fork ended in prose without `deliver_summary`; its final prose was delivered as its report. */ + | (TraceEventBase & { + type: 'tune-out:reader-prose-delivered'; + agentName: string; + serverId: string; + channelId: string; + epochId: string; + chars: number; + }) + + /** Bounded work the host's stop() cut off: left live for the next boot to end as `host-restart`. */ + | (TraceEventBase & { + type: 'dendrite:agent-interrupted'; + agentName: string; + kind: string; + incarnation: number; + reason: 'host-stop'; + }) + + /** A tune-out reader fork was derived for one invocation (subconscious.reader: 'forks'). */ + | (TraceEventBase & { + type: 'tune-out:reader-fork'; + agentName: string; + serverId: string; + channelId: string; + epochId: string; + trigger: 'cadence' | 'wake' | 'cancel'; + /** Held messages the fork was handed, stored and queued. */ + held: number; + /** Of those, the ones still queued behind the resident's turn. */ + queued: number; + }) + + /** + * A tune-out reader fork threw: its framing, its derive or its run. One + * that failed before it reported gives its held messages back to the + * channel's backlog while the epoch still holds the channel (`returned` + * counts them), so the next reader for the channel is handed them. + */ + | (TraceEventBase & { + type: 'tune-out:reader-fork-failed'; + agentName: string; + serverId: string; + channelId: string; + epochId: string; + trigger: 'cadence' | 'wake' | 'cancel'; + error: string; + /** Whether the fork had reported through `deliver_summary` before it failed. */ + reported: boolean; + returned: number; + }) + + // Dendrite: agent registry and lifecycle. + // `agent-created` is the consent event: it names the kind of agent, what + // ends it, and what happens to it when its spawner ends. + | (TraceEventBase & { + type: 'dendrite:agent-created'; + agentName: string; + kind: string; + /** Whose weights; `inherit.from` is whose context. */ + model?: string; + incarnation: number; + lifetime: import('../dendrite/types.js').AgentLifetime; + spawnedBy?: string; + onParentEnd?: import('../dendrite/types.js').ParentEndPolicy; + resultTo?: import('../dendrite/types.js').ResultRoute; + inherit?: import('../dendrite/types.js').ContextInheritance; + }) + | (TraceEventBase & { + type: 'dendrite:agent-ended'; + agentName: string; + kind: string; + incarnation: number; + reason: string; + by?: string; + /** The agent that spawned it, when its record names one. */ + spawnedBy?: string; + }) + | (TraceEventBase & { + /** The last event a replay of create()'s traces delivers when the + * buffer kept only their first 1,000: how many came after. */ + type: 'dendrite:early-traces-dropped'; + dropped: number; + }) + | (TraceEventBase & { + type: 'dendrite:agent-orphaned'; + agentName: string; + formerParent: string; + reason: string; + /** Agents offered as a new parent, nearest first. */ + candidates: string[]; + }) + | (TraceEventBase & { + type: 'dendrite:agent-reparented'; + agentName: string; + parent: string; + by?: string; + }) + | (TraceEventBase & { + type: 'dendrite:activation-cancelled'; + agentName: string; + by?: string; + reason?: string; + }) + /** The message entered the recipient's store (not merely queued for it). */ + | (TraceEventBase & { + type: 'dendrite:mail-delivered'; + mailId: string; + kind: string; + from: string; + to: string; + }) + /** Queued for the recipient's next boundary; `mail-delivered` follows when it lands. */ + | (TraceEventBase & { + type: 'dendrite:mail-deferred'; + mailId: string; + kind: string; + from: string; + to: string; + }) + | (TraceEventBase & { + type: 'dendrite:mail-held'; + mailId: string; + kind: string; + from: string; + to: string; + reason: string; + }) + /** + * The admission stage's decision for one inbound event (Dendrite §D): + * who may receive it, whether it is held (the epoch id) or delivered, + * whether it may wake them, and the gate's verdict. + */ + | (TraceEventBase & { + type: 'admission:decided'; + lane: 'channel' | 'push'; + serverId: string; + channelId?: string; + messageId: string; + recipients: string[]; + held: string | null; + wake: boolean; + gatePass: boolean; + }) + /** Batched gate events dropped at delivery because their channel is held now. */ + | (TraceEventBase & { + type: 'gate:held-dropped'; + dropped: number; + channels: Array; + }) // Inbound acceptance observation (mcpl/inbound-source.ts): an installed // InboundAcceptanceObserver threw. Delivery is unaffected; the observer's // own coverage is interrupted for that acceptance. diff --git a/test/addressed-elsewhere-notice.test.ts b/test/addressed-elsewhere-notice.test.ts new file mode 100644 index 000000000..fe732013f --- /dev/null +++ b/test/addressed-elsewhere-notice.test.ts @@ -0,0 +1,66 @@ +/** + * The addressed-elsewhere notice (docs/dendrite.md, `speech.locus: + * 'delivery'`): one per channel per turn, and a notice whose store failed is + * still owed to the next message from that channel in the turn. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { fixture } from './helpers/coalescing-fixture.js'; + +type Channel = { serverId: string; channelId: string }; + +describe('the addressed-elsewhere notice', () => { + it('is stored once per channel per turn, and one whose store failed is still owed', async () => { + const f = await fixture(); + try { + const fw = f.framework as unknown as { + routingOf(agentName: string): unknown; + addressedElsewhereNotice(agent: unknown, routing: unknown, channel: Channel, who: string): unknown; + }; + const agent = f.framework.getAgent('agent')!; + const cm = agent.getContextManager() as unknown as { addMessage(...args: unknown[]): string }; + const add = cm.addMessage.bind(cm); + let refuse = 1; + cm.addMessage = (...args: unknown[]) => { + if (refuse > 0) { refuse -= 1; throw new Error('store refused'); } + return add(...args); + }; + const routing = fw.routingOf('agent'); + const dm = { serverId: 'editor', channelId: 'dm:antra' }; + const other = { serverId: 'editor', channelId: 'general' }; + const notice = (channel: Channel) => fw.addressedElsewhereNotice(agent, routing, channel, 'antra'); + + assert.equal(notice(dm), undefined, 'the first store was refused, so nothing was stored'); + assert.ok(notice(dm), "the next message from that channel still gets the turn's notice"); + assert.equal(notice(dm), undefined, 'and only once in the turn'); + assert.ok(notice(other), 'another channel gets its own'); + } finally { await f.close(); } + }); + + it('one whose write threw after it landed is the turn\'s notice for that channel, and is not stored twice', async () => { + const f = await fixture(); + try { + const fw = f.framework as unknown as { + routingOf(agentName: string): unknown; + addressedElsewhereNotice(agent: unknown, routing: unknown, channel: Channel, who: string): unknown; + }; + const agent = f.framework.getAgent('agent')!; + const cm = agent.getContextManager() as unknown as { addMessage(...args: unknown[]): string }; + const add = cm.addMessage.bind(cm); + let landThenThrow = 1; + cm.addMessage = (...args: unknown[]) => { + const id = add(...args); + if (landThenThrow > 0) { landThenThrow -= 1; throw new Error('propagation failed after the row landed'); } + return id; + }; + const routing = fw.routingOf('agent'); + const dm = { serverId: 'editor', channelId: 'dm:antra' }; + assert.ok(fw.addressedElsewhereNotice(agent, routing, dm, 'antra'), 'the notice landed, so it is returned'); + assert.equal(fw.addressedElsewhereNotice(agent, routing, dm, 'antra'), undefined, 'and is the turn\'s one for that channel'); + const notices = agent.getContextManager().getAllMessages() + .filter((m) => JSON.stringify(m.content).includes('addressed you from')); + assert.equal(notices.length, 1, 'stored once'); + } finally { await f.close(); } + }); +}); diff --git a/test/admission-e2e.test.ts b/test/admission-e2e.test.ts new file mode 100644 index 000000000..5c6a04af6 --- /dev/null +++ b/test/admission-e2e.test.ts @@ -0,0 +1,264 @@ +/** + * The admission stage (issue #232 §3.5, Dendrite §D): one decision per + * inbound event per recipient, taken before any side effect. These are the + * #232 gap probes as regression tests — each `it` names the gap it closes. + * + * Harness: the RFC-006 loopback MCPL server and a mocked model. The event + * loop is driven explicitly (`runUntilIdle`), so every wake that is expected + * has a scripted response and every wake that is not shows up as a hang-free + * `membrane.calls` count. The persistent subconscious is the reader unless a + * test says otherwise. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fixture, eventually } from './helpers/coalescing-fixture.js'; +import { GateModule } from './helpers/dendrite.js'; +import type { FrameworkConfig } from '../src/types/framework.js'; +import { + type Stored, SUB, internals, textOf, stored, held, has, find, AMBIENT, MENTION, + incoming, dm, registerOpen, enter, settle, channellessTurnLocus, blockInTool, +} from './helpers/admission.js'; + +describe('admission: the hold is decided once, before any side effect', () => { + it('gap 1 — tuning out a DM holds the next DM (the push-event lane consults the hold)', async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await dm(f, 'dm-0', 'first dm'); + await f.turn(); + assert.ok(has(f, 'first dm')); + const callsBefore = f.membrane.calls.length; + assert.ok(callsBefore >= 1, 'the first DM woke the resident'); + + enter(f, 'discord:dm:777'); + await dm(f, 'dm-1', 'second dm'); + await settle(f); + assert.ok(held(find(f, 'second dm')), 'the second DM carries the hold record'); + const compiled = await f.framework.getAgent('agent')!.getContextManager().compile(); + assert.ok(!compiled.messages.some((m) => textOf(m as Stored).includes('second dm')), 'a held DM is not in the resident\'s view'); + } finally { await f.close(); } + }); + + it('gap 2 — held traffic never moves the speech locus; under the default rule no inbound traffic does', async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + await incoming(f, 'work', 'w1', 'hello from work'); + await f.turn(); + assert.ok(has(f, 'hello from work')); + // `speech.locus: 'delivery'` (default): a turn nothing delivered into + // (a heartbeat here) has no locus — not the last channel that spoke. + assert.equal(await channellessTurnLocus(f), null, 'no global latest-inbound locus'); + assert.ok(has(f, 'no channel'), 'the resident is told their prose stays private'); + + enter(f, 'noisy'); + await incoming(f, 'noisy', 'n1', 'noise'); + await settle(f); + assert.ok(held(find(f, 'noise'))); + assert.notEqual(internals(f).channelRegistry.getDefaultPublishChannel(), 'noisy', 'held traffic is not "the latest inbound" either'); + assert.equal(await channellessTurnLocus(f), null); + } finally { await f.close(); } + }); + + it('gap 2 (legacy rule) — `speech.locus: "latest-inbound"`: delivered traffic moves the locus, held traffic does not', async () => { + const f = await fixture({ framework: { subconscious: SUB, speech: { locus: 'latest-inbound' } } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + await incoming(f, 'work', 'w1', 'hello from work'); + await f.turn(); + assert.equal(await channellessTurnLocus(f), 'work', 'legacy: the last delivered channel is the fallback locus'); + + enter(f, 'noisy'); + await incoming(f, 'noisy', 'n1', 'noise'); + await settle(f); + assert.equal(await channellessTurnLocus(f), 'work', 'held traffic did not move it'); + } finally { await f.close(); } + }); + + it('gap 3 — a debounce policy never queues a held mention; the reader still gets the gate\'s verdict', async () => { + const f = await fixture({ + framework: { + subconscious: SUB, + gate: { config: { default: 'always', policies: [ + { name: 'noisy-debounce', match: { scope: ['mcpl:channel-incoming'], channel: 'noisy' }, behavior: { debounce: 60_000 } }, + ] } }, + } as Partial, + }); + try { + await registerOpen(f, 'noisy'); + const gate = internals(f).eventGate!; + // Control: not held → the debounce batches the mention, nobody wakes yet. + await incoming(f, 'noisy', 'c1', 'hey agent', MENTION); + await settle(f); + assert.ok(has(f, 'hey agent')); + assert.equal(gate.debounceTimers.size, 1, 'control: the gate batched the mention'); + gate.debounceTimers.forEach((s) => clearTimeout((s as { timer: ReturnType }).timer)); + gate.debounceTimers.clear(); + + enter(f, 'noisy'); + await incoming(f, 'noisy', 'h1', 'agent, you there?', MENTION); + await settle(f); + assert.ok(held(find(f, 'you there'))); + assert.equal(gate.debounceTimers.size, 0, 'a held mention is not batched for the resident'); + const coordinator = internals(f).tuneOutCoordinator as unknown as { pendingWakes: Map }; + assert.equal(coordinator.pendingWakes.size, 1, 'the reader wake is scheduled: the gate\'s verdict (would wake) reached the coordinator'); + } finally { await f.close(); } + }); + + it('gap 4 — the release names non-text blocks instead of dropping them', async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'noisy'); + enter(f, 'noisy', { backlogCap: 10 }); + await incoming(f, 'noisy', 'i1', '', AMBIENT, [{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }]); + await incoming(f, 'noisy', 'i2', '', AMBIENT, [ + { type: 'text', text: 'look at this' }, + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + ]); + await settle(f); + assert.equal(stored(f).filter(held).length, 2, 'two held messages'); + assert.ok(internals(f).tuneOutCoordinator!.cancel('editor', 'noisy', 'agent-tool', 'test').ok); + const release = textOf(find(f, ' { + const gate = new GateModule(); + const f = await fixture({ framework: { subconscious: SUB, modules: [gate] } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + enter(f, 'noisy'); + const { loop } = await blockInTool(f, gate); + await incoming(f, 'noisy', 'n1', 'held while acting'); + await eventually( + () => internals(f).deferredMessages.some((m) => textOf(m as Stored).includes('held while acting')) || has(f, 'held while acting'), + 'held message arrived', + ); + gate.release('g1'); + await loop; + assert.ok(held(find(f, 'held while acting')), 'stored with the hold record'); + const injected = f.membrane.lastStream!.receivedToolResultOptions + .flatMap((o) => o?.injectedMessages ?? []) + .map((m) => JSON.stringify(m.content)); + assert.ok(!injected.some((s) => s.includes('held while acting')), 'the live turn never saw the held message'); + const compiled = await f.framework.getAgent('agent')!.getContextManager().compile(); + assert.ok(!compiled.messages.some((m) => textOf(m as Stored).includes('held while acting')), 'nor does the next compile'); + } finally { await f.close(); } + }); + + it('gap 7 — a held message still in the deferred queue at cancel is in the release', async () => { + const gate = new GateModule(); + const f = await fixture({ framework: { subconscious: SUB, modules: [gate] } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + enter(f, 'noisy', { backlogCap: 10 }); + const { loop } = await blockInTool(f, gate); + await incoming(f, 'noisy', 'n1', 'deferred and held'); + await eventually(() => internals(f).deferredMessages.some((m) => textOf(m as Stored).includes('deferred and held')), 'held message deferred'); + assert.ok(internals(f).tuneOutCoordinator!.cancel('editor', 'noisy', 'agent-tool', 'test').ok); + // The cancel wakes the resident (after this turn) and the reader; the + // mock answers every further request so neither wake is left streaming. + f.alwaysRespond(); + gate.release('g1'); + await loop; + const release = textOf(find(f, ' { + const gate = new GateModule(); + const f = await fixture({ framework: { subconscious: SUB, modules: [gate] } as Partial }); + const recovery = join(f.dir, 'store', 'recovery', 'deferred-writes.json'); + let saved: string; + try { + await registerOpen(f, 'work', 'noisy'); + enter(f, 'noisy', { backlogCap: 10 }); + const { loop } = await blockInTool(f, gate); + await incoming(f, 'noisy', 'n1', 'held before the crash'); + await eventually(() => internals(f).deferredMessages.some((m) => textOf(m as Stored).includes('held before the crash')), 'held message deferred'); + assert.ok(internals(f).tuneOutCoordinator!.cancel('editor', 'noisy', 'agent-tool', 'test').ok); + // The epoch is ended durably; the release, still deferred, is too. + assert.ok(existsSync(recovery), 'the deferred queue was persisted by the cancel'); + saved = readFileSync(recovery, 'utf8'); + assert.match(saved, /tune-out-cancel/, 'the release is in the durable queue'); + f.alwaysRespond(); + gate.release('g1'); + await loop; + } finally { await f.close(); } + + // A fresh process over a store that never saw the release land: the + // same file, found at boot. + const dir = mkdtempSync(join(tmpdir(), 'admission-reboot-')); + const deferredWritesPath = join(dir, 'deferred-writes.json'); + writeFileSync(deferredWritesPath, saved!); + const g = await fixture({ framework: { subconscious: SUB, deferredWritesPath } as Partial, dir }); + try { + const pending = (g.framework as unknown as { pendingRequests: Array<{ agentName: string; reason: string }> }).pendingRequests; + assert.ok(pending.some((r) => r.agentName === 'agent' && /tune-out released/.test(r.reason)), 'the restored release owes the resident its wake'); + assert.match(textOf(find(g, ' { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'noisy'); + enter(f, 'noisy', { backlogCap: 10 }); + await incoming(f, 'noisy', 'n1', 'held before close'); + await settle(f); + assert.equal(stored(f).filter(held).length, 1); + // main's rule: a tuned-out channel's decision is the tune-out; a close + // recorded over it would end it without its cancel, so the backlog it + // diverted would never reach the agent. The close is refused and says so. + const result = await f.framework.executeToolCall({ id: 'c1', name: 'channel_close', input: { channelId: 'noisy' }, callerAgentName: 'agent' } as never); + assert.equal(result.success, false); + assert.match(result.error ?? '', /is tuned out: its messages go to the subconscious, and channel_close doesn't change that/); + assert.equal(internals(f).channelRegistry.getDesiredState('editor', 'noisy'), 'tuned-out', 'nothing changed'); + assert.equal(stored(f).filter(held).length, 1, 'the held message is still held'); + // The cancel ends it, with the release — and the channel can be closed after. + assert.ok(internals(f).tuneOutCoordinator!.cancel('editor', 'noisy', 'agent-tool', 'test').ok); + assert.equal(internals(f).channelRegistry.getTuneOutState('editor', 'noisy'), null, 'the epoch ended'); + assert.match(textOf(find(f, ' { + const f = await fixture({ framework: { tuneOut: { enabled: true } } as Partial }); + try { + await registerOpen(f, 'noisy'); + const coordinator = internals(f).tuneOutCoordinator; + assert.ok(coordinator, 'the coordinator exists without a subconscious'); + enter(f, 'noisy', { backlogCap: 10 }); + await incoming(f, 'noisy', 'n1', 'agent?', MENTION); + await settle(f); + assert.equal(stored(f).filter(held).length, 1, 'held'); + assert.ok(coordinator!.cancel('editor', 'noisy', 'agent-tool', 'test').ok); + assert.match(textOf(find(f, ' { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'noisy'); + const i = internals(f); + // A second server with the same channel id. + i.channelRegistry.channels.set('other:noisy', { serverId: 'other', descriptor: { id: 'noisy', type: 'discord', label: 'noisy' }, open: true }); + const ambiguous = await i.handleTuneOutTool('agent', { channelId: 'noisy' }); + assert.equal(ambiguous.success, false); + assert.match(ambiguous.error ?? '', /ambiguous|is on more than one server/); + const resolved = await i.handleTuneOutTool('agent', { channelId: 'noisy', serverId: 'editor' }); + assert.ok(resolved.success, resolved.error); + assert.ok(i.channelRegistry.getTuneOutState('editor', 'noisy')); + } finally { await f.close(); } + }); +}); diff --git a/test/admission.test.ts b/test/admission.test.ts new file mode 100644 index 000000000..ebc44c661 --- /dev/null +++ b/test/admission.test.ts @@ -0,0 +1,90 @@ +/** + * The admission decision, in isolation: held events never wake and never + * reach the gate's side effects; vetoed events never wake; deliverable + * events wake iff the gate says so. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { admit, type InboundEnvelope, type AdmissionPolicy } from '../src/admission/admit.js'; + +const env = (over: Partial = {}): InboundEnvelope => ({ + lane: 'channel', serverId: 'disc', channelId: 'noisy', messageId: 'm1', addressed: false, vetoed: false, + gateContent: 'hi', gateMetadata: {}, ...over, +}); + +function policy(opts: { held?: boolean; gateEvaluate?: boolean; gateProbe?: boolean } = {}) { + const calls = { evaluate: 0, probe: 0 }; + const p: AdmissionPolicy = { + untargetedRecipients: () => ['mira', 'nell'], + holdFor: (serverId, channelId) => opts.held ? { kind: 'tune-out', epochId: 'e1', serverId, channelId } : null, + gate: { + evaluate: () => { calls.evaluate++; return opts.gateEvaluate ?? true; }, + probe: () => { calls.probe++; return opts.gateProbe ?? true; }, + }, + }; + return { p, calls }; +} + +describe('admit', () => { + it('a deliverable event reaches the untargeted recipients and wakes when the gate passes', () => { + const { p, calls } = policy(); + const d = admit(env(), p); + assert.deepEqual(d.recipients, ['mira', 'nell']); + assert.equal(d.held, null); + assert.equal(d.wake, true); + assert.equal(d.locus, true); + assert.deepEqual(calls, { evaluate: 1, probe: 0 }); + }); + + it('explicit targets replace the untargeted set', () => { + const d = admit(env({ targetAgents: ['fork-1'] }), policy().p); + assert.deepEqual(d.recipients, ['fork-1']); + }); + + it('a gate that says no leaves the event delivered but unwoken', () => { + const d = admit(env(), policy({ gateEvaluate: false }).p); + assert.equal(d.held, null); + assert.equal(d.wake, false); + assert.equal(d.gatePass, false); + assert.equal(d.locus, true, 'a delivered-but-quiet message is still the latest inbound'); + }); + + it('a held event never wakes, never moves the locus, and only probes the gate', () => { + const { p, calls } = policy({ held: true, gateProbe: true }); + const d = admit(env({ addressed: true }), p); + assert.deepEqual(d.held, { kind: 'tune-out', epochId: 'e1', serverId: 'disc', channelId: 'noisy' }); + assert.equal(d.wake, false); + assert.equal(d.locus, false); + assert.equal(d.gatePass, true, 'the hold learns the gate would have woken them'); + assert.deepEqual(calls, { evaluate: 0, probe: 1 }, 'no batch forms, no counter moves'); + }); + + it('a held event the gate would have suppressed carries gatePass=false', () => { + const d = admit(env(), policy({ held: true, gateProbe: false }).p); + assert.equal(d.gatePass, false); + }); + + it('a vetoed event wakes nobody and is not shown to the gate; held or not', () => { + const open = policy(); + const d1 = admit(env({ vetoed: true }), open.p); + assert.equal(d1.wake, false); + assert.equal(d1.gatePass, false); + assert.deepEqual(open.calls, { evaluate: 0, probe: 0 }); + const heldP = policy({ held: true }); + const d2 = admit(env({ vetoed: true }), heldP.p); + assert.ok(d2.held); + assert.equal(d2.gatePass, false); + assert.deepEqual(heldP.calls, { evaluate: 0, probe: 0 }); + }); + + it('an event without a channel cannot be held', () => { + const d = admit(env({ lane: 'push', channelId: undefined }), policy({ held: true }).p); + assert.equal(d.held, null); + assert.equal(d.wake, true); + }); + + it('without a gate every deliverable event wakes', () => { + const p: AdmissionPolicy = { untargetedRecipients: () => ['mira'], holdFor: () => null }; + assert.equal(admit(env(), p).wake, true); + }); +}); diff --git a/test/agent-allowed-tools.test.ts b/test/agent-allowed-tools.test.ts index 31050e8cd..49e12862d 100644 --- a/test/agent-allowed-tools.test.ts +++ b/test/agent-allowed-tools.test.ts @@ -302,6 +302,22 @@ function asXmlTurn(membrane: MockMembrane, call: { type: 'tool_use'; id: string; }; } +for (const fallback of [false, true]) { + test(`${fallback ? 'fallback' : 'native'}: a skip_reply refused at dispatch silences nothing after its round, as one allowedTools refuses`, async () => { + const h = await harness({ allowedTools: ['skip_reply'], denyToolsAtDispatch: ['skip_reply'], sameRoundThinkTextPolicy: 'private' }); + try { + const routed = captureRouting(h.framework); + const call = { type: 'tool_use' as const, id: 'disposition', name: 'skip_reply', input: { reason: 'test' } }; + if (fallback) asXmlTurn(h.membrane, call); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public round prose' }, call], 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public trailing prose' }])); + h.framework.pushEvent({ type: 'external-message', source: 'probe', content: 'Run probe', metadata: {} }); + await h.framework.runUntilIdle(); + assert.ok(routed.includes('Public trailing prose'), 'a refused call silences nothing after its round'); + } finally { await h.close(); } + }); +} + for (const fallback of [false, true]) { for (const name of ['skip_reply', 'think', 'channel_publish']) { for (const permitted of [false, true]) { @@ -774,3 +790,32 @@ test('never-run and retired ephemerals report missing registration, not allowlis await check(); created.cleanup(); } finally { await h.close(); } }); + +test('a call denied at dispatch is refused, so a script is told it never ran', async () => { + const h = await harness({ denyToolsAtDispatch: ['probe--denied'] }); + try { + const pending = scriptCall(h.framework, 'assistant', 'probe--denied', {}); + await h.framework.runUntilIdle(); + const answer = await pending; + assert.equal(answer.refused, true); + assert.match(answer.refused ? answer.message : '', /probe--denied is not available to assistant/); + assert.equal(h.effects.length, 0); + } finally { await h.close(); } +}); + +test("a resident's call of its reader's tool is refused, so a script is told it never ran", async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-allowed-tools-reader-')); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store.chronicle'), membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test', systemPrompt: 'Test' }], + modules: [], syncIntervalMs: 0, codeExecution: { enabled: true }, + subconscious: { enabled: true, reader: 'forks', model: 'test', voice: 'Read for scout.' }, + }); + try { + const pending = scriptCall(framework, 'scout', 'deliver_summary', { text: 'me?', channelId: 'somewhere' }); + await framework.runUntilIdle(); + const answer = await pending; + assert.equal(answer.refused, true); + assert.match(answer.refused ? answer.message : '', /available to the resident's reader, not to scout/); + } finally { await framework.stop(); rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/test/all-except-e2e.test.ts b/test/all-except-e2e.test.ts new file mode 100644 index 000000000..8e1147e22 --- /dev/null +++ b/test/all-except-e2e.test.ts @@ -0,0 +1,552 @@ +/** + * The all-except scope (issue #232 §3.2; focus folded into tune-out): hold + * every channel except one, for a bounded time. A resident-level policy + * epoch, decided by the same admission stage as a per-channel tune-out. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { fixture, eventually } from './helpers/coalescing-fixture.js'; +import type { FrameworkConfig } from '../src/types/framework.js'; +import type { ContentBlock } from '@animalabs/membrane'; +import { createMockResponse, HeldStream } from './helpers/mock-membrane.js'; +import { + SUB, internals, textOf, stored, held, has, find, MENTION, + incoming, dm, registerOpen, enter, settle, channellessTurnLocus, +} from './helpers/admission.js'; + +const epochOf = (m: { metadata?: Record }): string | undefined => + (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId; + +function enterAllExcept(f: Parameters[0], except: string, opts: Record = {}) { + const r = internals(f).tuneOutCoordinator!.enterAllExcept( + { serverId: 'editor', channelId: except }, { durationSeconds: 120, backlogCap: 10, ...opts }, 'agent-tool', + ); + assert.ok(r.ok, `all-except: ${(r as { error?: string }).error ?? ''}`); + return (r as { params: { epochId: string } }).params; +} + +describe('tune-out, all-except scope', () => { + it('holds every channel but the excepted one — including DMs and channels registered later', async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + const scope = enterAllExcept(f, 'work'); + + await incoming(f, 'noisy', 'n1', 'noise one'); + await settle(f); + assert.equal(epochOf(find(f, 'noise one')), scope.epochId, 'held under the scope\'s epoch'); + + await dm(f, 'dm-1', 'a dm while focused'); + await settle(f); + assert.equal(epochOf(find(f, 'a dm while focused')), scope.epochId, 'a DM is held too (resident-level, not per channel)'); + + await registerOpen(f, 'late'); + await incoming(f, 'late', 'l1', 'registered after entry'); + await settle(f); + assert.equal(epochOf(find(f, 'registered after entry')), scope.epochId, 'a channel registered after entry is held'); + + await incoming(f, 'work', 'w1', 'the excepted channel', MENTION); + await f.turn(); + assert.ok(!held(find(f, 'the excepted channel')), 'the excepted channel is delivered'); + assert.ok(f.membrane.calls.length >= 1, 'and wakes'); + + const epochs = f.framework.listPolicyEpochs(); + const listed = epochs.find((e) => e.id === scope.epochId); + assert.ok(listed, 'the scope is a policy epoch'); + assert.equal(listed!.kind, 'tune-out', 'of the tune-out kind'); + assert.deepEqual(listed!.scope, { kind: 'all-except', except: { serverId: 'editor', channelId: 'work' } }); + assert.equal(typeof listed!.expiresAtMs, 'number', 'bounded'); + } finally { await f.close(); } + }); + + it('while active, a turn with no delivery of its own speaks into the excepted channel; after cancel it has no locus', async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + assert.equal(await channellessTurnLocus(f), null, 'default rule: no locus without a delivery'); + enterAllExcept(f, 'work'); + assert.equal(await channellessTurnLocus(f), 'work', 'the focus destination'); + assert.ok(internals(f).tuneOutCoordinator!.cancelAllExcept('agent-tool', 'test').ok); + f.alwaysRespond(); // the release wakes the resident and the reader + await f.framework.runUntilIdle(); + assert.equal(await channellessTurnLocus(f), null, 'gone with the scope'); + } finally { await f.close(); } + }); + + it('focus entered during a turn with no channel of its own gives that turn the excepted channel: its plain speech lands there', async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + f.membrane.responses.length = 0; // the fixture pre-scripts an `ok` + (f.membrane as unknown as { responseIndex: number }).responseIndex = 0; + f.membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'fx', name: 'focus', input: { channelId: 'work', durationSeconds: 120 } }, + ] as ContentBlock[], 'tool_use')); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'zz-here for a while' }] as ContentBlock[])); + (f.framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'agent', reason: 'heartbeat', source: 'test', timestamp: Date.now(), + }); + await f.framework.runUntilIdle(); + await eventually(() => f.published.some((p) => JSON.stringify(p).includes('zz-here for a while')), 'the speech went out'); + assert.equal(f.published.find((p) => JSON.stringify(p).includes('zz-here for a while'))!.channelId, 'work'); + } finally { await f.close(); } + }); + + it("a turn woken in one channel that focuses on another is told its speech stays where the turn began, and it does", async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + f.membrane.responses.length = 0; // the fixture pre-scripts an `ok` + (f.membrane as unknown as { responseIndex: number }).responseIndex = 0; + f.membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'fx', name: 'focus', input: { channelId: 'noisy', durationSeconds: 120 } }, + ] as ContentBlock[], 'tool_use')); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'zz-still in work' }] as ContentBlock[])); + await incoming(f, 'work', 'w1', 'agent, are you there?', MENTION); + await f.framework.runUntilIdle(); + await eventually(() => f.published.some((p) => JSON.stringify(p).includes('zz-still in work')), 'the speech went out'); + assert.equal(f.published.find((p) => JSON.stringify(p).includes('zz-still in work'))!.channelId, 'work'); + const told = stored(f).some((m) => JSON.stringify(m.content).includes("This turn's plain speech still lands in work; from your next turn it lands in the excepted channel while this holds, except on a turn that has no channel, such as a heartbeat's.")); + assert.ok(told, "the focus result says this turn's speech stays in work"); + } finally { await f.close(); } + }); + + it("a focus whose call no open turn made, as a script's left from an earlier turn, leaves the open turn's speech where it was", async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + f.membrane.responses.length = 0; + (f.membrane as unknown as { responseIndex: number }).responseIndex = 0; + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'zz-mine alone' }] as ContentBlock[])); + const m = f.membrane as unknown as { responses: unknown[]; responseIndex: number; calls: unknown[]; lastStream: unknown }; + let outside: Promise<{ success: boolean; error?: string }> | undefined; + // While the turn is open, a call naming the agent that carries no turn. + f.membrane.streamYielding = ((request: unknown) => { + m.calls.push(request); + const remaining = m.responses.slice(m.responseIndex); + m.responseIndex = m.responses.length; + outside ??= f.framework.executeToolCall({ id: 'fz', name: 'focus', input: { channelId: 'work', durationSeconds: 120 }, callerAgentName: 'agent' } as never) as never; + const stream = new HeldStream(outside.then(() => undefined), remaining as never); + m.lastStream = stream; + return stream; + }) as never; + (f.framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'agent', reason: 'heartbeat', source: 'test', timestamp: Date.now(), + }); + await f.framework.runUntilIdle(); + assert.ok(outside, 'premise: the call was made while the turn was open'); + const entered = await outside; + assert.ok(entered.success, entered.error); + assert.ok(internals(f).tuneOutCoordinator!.getAllExcept(), 'premise: the focus holds'); + await eventually(() => stored(f).some((x) => textOf(x).includes('zz-mine alone')), 'the turn spoke'); + const routing = (f.framework as unknown as { routingOf(agentName: string): { locus?: { channelId: string } | null } }).routingOf('agent'); + assert.equal(routing.locus ?? null, null, "the open turn's locus didn't move"); + await new Promise((r) => setTimeout(r, 100)); + assert.ok(!f.published.some((p) => JSON.stringify(p).includes('zz-mine alone')), 'its speech went to no channel'); + } finally { await f.close(); } + }); + + it("a turn that has no channel by design publishes nowhere when channel_publish names none, though a focus holds, as its plain speech goes nowhere", async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + enterAllExcept(f, 'work'); + f.membrane.responses.length = 0; // the fixture pre-scripts an `ok` + (f.membrane as unknown as { responseIndex: number }).responseIndex = 0; + f.membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'p1', name: 'channel_publish', input: { content: 'zz-nowhere' } }, + ] as ContentBlock[], 'tool_use')); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'done' }] as ContentBlock[])); + // A wake naming a channel no server registers: the turn has no channel by design (noChannel). + (f.framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'agent', reason: 'test', source: 'test', channelId: 'ghost', timestamp: Date.now(), + }); + await f.framework.runUntilIdle(); + await eventually(() => stored(f).some((m) => JSON.stringify(m.content).includes('zz-nowhere')), 'the call was made'); + const refused = stored(f).some((m) => JSON.stringify(m.content).includes('name the channel with channelId. This turn has no channel of its own')); + assert.ok(refused, "the call was refused for the turn's having no channel"); + assert.ok(!f.published.some((p) => JSON.stringify(p).includes('zz-nowhere')), 'nothing was posted'); + } finally { await f.close(); } + }); + + it("under the legacy latest-inbound rule too, a heartbeat turn's channel_publish naming no channel is refused, as its plain speech has no channel", async () => { + const f = await fixture({ framework: { subconscious: SUB, speech: { locus: 'latest-inbound' } } as Partial }); + try { + await registerOpen(f, 'work'); + await incoming(f, 'work', 'w1', 'agent, are you there?', MENTION); + await f.turn(); + assert.equal((internals(f).channelRegistry.getDefaultPublishChannel() as unknown as { channelId: string } | null)?.channelId, 'work', 'premise: work is the latest inbound channel'); + f.membrane.responses.length = 0; + (f.membrane as unknown as { responseIndex: number }).responseIndex = 0; + f.membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'pb', name: 'channel_publish', input: { content: 'zz-beat' } }, + ] as ContentBlock[], 'tool_use')); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'beat done' }] as ContentBlock[])); + (f.framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'agent', reason: 'heartbeat', source: 'heartbeat', timestamp: Date.now(), + }); + await f.framework.runUntilIdle(); + await eventually(() => stored(f).some((m) => JSON.stringify(m.content).includes('This turn has no channel of its own')) || f.published.some((p) => JSON.stringify(p).includes('zz-beat')), 'the publish was answered'); + assert.ok(!f.published.some((p) => JSON.stringify(p).includes('zz-beat')), 'nothing was posted'); + } finally { await f.close(); } + }); + + it('check reports held counts per channel and peeks one channel without releasing', async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy', 'other'); + const scope = enterAllExcept(f, 'work'); + await incoming(f, 'noisy', 'n1', 'noise one'); + await incoming(f, 'noisy', 'n2', 'noise two'); + await incoming(f, 'other', 'o1', 'elsewhere'); + await settle(f); + const report = internals(f).tuneOutCoordinator!.check({ peek: { serverId: 'editor', channelId: 'noisy' }, peekLimit: 1 }); + assert.ok(report.allExcept, 'the scope is reported'); + assert.equal(report.allExcept!.epochId, scope.epochId); + assert.deepEqual(report.allExcept!.held, { noisy: 2, other: 1 }); + assert.deepEqual(report.peek, ['antra: noise two'], 'peek shows the newest held line, capped'); + const viaTool = await internals(f).handleTuneOutTool('agent', { mode: 'check', peek: 'noisy', peekLimit: '1' }); + assert.deepEqual((viaTool.data as { peek?: string[] }).peek, ['antra: noise two'], "the tool reads peekLimit's numeric string as its number"); + const refused = await internals(f).handleTuneOutTool('agent', { mode: 'check', peek: 'noisy', peekLimit: 'many' }); + assert.equal(refused.success, false); + assert.match(refused.error ?? '', /peekLimit must be a finite number of lines, and it was the string "many"/); + assert.ok(internals(f).tuneOutCoordinator!.getAllExcept(), 'still active'); + assert.equal(stored(f).filter(held).length, 3, 'nothing released'); + assert.ok(!has(f, ' { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy', 'other'); + enterAllExcept(f, 'work', { backlogCap: 1 }); + await incoming(f, 'noisy', 'n1', 'noise one'); + await incoming(f, 'noisy', 'n2', 'noise two'); + await incoming(f, 'other', 'o1', 'elsewhere'); + await settle(f); + assert.ok(internals(f).tuneOutCoordinator!.cancelAllExcept('agent-tool', 'test').ok); + f.alwaysRespond(); // the release wakes the resident and the reader + const release = textOf(find(f, 'all channels except')); + assert.match(release, /\[Tune-out: all channels except work released — test/); + assert.match(release, / textOf(m as never).includes('noise one') && !textOf(m as never).includes(' { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + enter(f, 'noisy'); + const perChannel = internals(f).channelRegistry.getTuneOutState('editor', 'noisy')!.params.epochId; + const scope = enterAllExcept(f, 'work'); + await incoming(f, 'noisy', 'n1', 'noise under both'); + await settle(f); + assert.equal(epochOf(find(f, 'noise under both')), perChannel, 'the per-channel epoch wins'); + assert.notEqual(perChannel, scope.epochId); + + const refused = internals(f).tuneOutCoordinator!.enter('editor', 'work', { cadenceSeconds: 3600 }, 'agent-tool'); + assert.equal(refused.ok, false); + assert.match((refused as { error: string }).error, /excepted/); + + assert.ok(internals(f).tuneOutCoordinator!.cancelAllExcept('agent-tool', 'test').ok); + const again = internals(f).tuneOutCoordinator!.enterAllExcept({ serverId: 'editor', channelId: 'noisy' }, { durationSeconds: 120 }, 'agent-tool'); + assert.equal(again.ok, false, 'a tuned-out channel cannot be the exception'); + assert.match((again as { error: string }).error, /tuned out/); + } finally { await f.close(); } + }); + + it("the subconscious's cancel_tuneout naming the scope ends the scope, though channelId names a channel tuned out on its own", async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + enter(f, 'noisy'); + enterAllExcept(f, 'work'); + const coordinator = internals(f).tuneOutCoordinator!; + f.alwaysRespond(); // the scope's release wakes the resident and the reader + const cancelled = await coordinator.handleSubconsciousTool('cancel_tuneout', { channelId: 'noisy', scope: 'all-except' }); + assert.ok(cancelled.success, cancelled.error); + assert.equal(coordinator.getAllExcept(), null, 'the scope named outright ended'); + assert.ok(internals(f).channelRegistry.getTuneOutState('editor', 'noisy'), "the channel's own tune-out stays"); + + // With no scope to end, naming it ends nothing, rather than the channel the call names. + const noScope = await coordinator.handleSubconsciousTool('cancel_tuneout', { channelId: 'noisy', scope: 'all-except' }); + assert.equal(noScope.success, false, 'there was no scope to end'); + assert.match(noScope.error ?? '', /no all-except hold/); + assert.ok(internals(f).channelRegistry.getTuneOutState('editor', 'noisy'), "the channel's own tune-out still stays"); + await f.framework.runUntilIdle(); + } finally { await f.close(); } + }); + + it("the subconscious's cancel_tuneout naming the scope \"channel\" ends only a channel's own tune-out, never the all-except hold", async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy', 'other'); + enter(f, 'noisy'); + enterAllExcept(f, 'work'); + const coordinator = internals(f).tuneOutCoordinator!; + const heldByScope = await coordinator.handleSubconsciousTool('cancel_tuneout', { channelId: 'other', scope: 'channel' }); + assert.equal(heldByScope.success, false, 'a channel only the scope holds has no tune-out of its own to end'); + assert.match(heldByScope.error ?? '', /isn't tuned out on its own/); + assert.notEqual(coordinator.getAllExcept(), null, 'the scope still holds'); + + f.alwaysRespond(); // the channel's release wakes the resident and the reader + const own = await coordinator.handleSubconsciousTool('cancel_tuneout', { channelId: 'noisy', scope: 'channel' }); + assert.ok(own.success, own.error); + assert.equal(internals(f).channelRegistry.getTuneOutState('editor', 'noisy'), null, "the channel's own tune-out ended"); + assert.notEqual(coordinator.getAllExcept(), null, 'and the scope still holds'); + await f.framework.runUntilIdle(); + } finally { await f.close(); } + }); + + it('the duration is mandatory, and a second scope is refused', async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work'); + const noDuration = internals(f).tuneOutCoordinator!.enterAllExcept({ serverId: 'editor', channelId: 'work' }, {} as never, 'agent-tool'); + assert.equal(noDuration.ok, false); + assert.match((noDuration as { error: string }).error, /durationSeconds/); + enterAllExcept(f, 'work'); + const second = internals(f).tuneOutCoordinator!.enterAllExcept({ serverId: 'editor', channelId: 'work' }, { durationSeconds: 120 }, 'agent-tool'); + assert.equal(second.ok, false); + assert.match((second as { error: string }).error, /already/); + } finally { await f.close(); } + }); + + it("the tool reads a numeric string as its number and refuses a value that gives none, naming it, as the coordinator does", async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + const i = internals(f); + const soon = await i.handleTuneOutTool('agent', { channelId: 'work', scope: 'all-except', durationSeconds: 'soon' }); + assert.equal(soon.success, false); + assert.match(soon.error ?? '', /durationSeconds must be a finite number of seconds, and it was the string "soon"/); + assert.equal(i.tuneOutCoordinator!.getAllExcept(), null, 'nothing changed'); + const entered = await i.handleTuneOutTool('agent', { channelId: 'work', scope: 'all-except', durationSeconds: '120', backlogCap: '7' }); + assert.ok(entered.success, entered.error); + assert.equal(i.tuneOutCoordinator!.getAllExcept()!.params.backlogCap, 7); + } finally { await f.close(); } + }); + + it('the tool: scope all-except, check, cancel — and `focus` as the alias', async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + const i = internals(f); + const noDuration = await i.handleTuneOutTool('agent', { channelId: 'work', scope: 'all-except' }); + assert.equal(noDuration.success, false); + assert.match(noDuration.error ?? '', /durationSeconds/); + const entered = await i.handleTuneOutTool('agent', { channelId: 'work', scope: 'all-except', durationSeconds: 120 }); + assert.ok(entered.success, entered.error); + assert.equal((entered.data as { except?: string }).except, 'work'); + await incoming(f, 'noisy', 'n1', 'noise'); + await settle(f); + const check = await i.handleTuneOutTool('agent', { mode: 'check', peek: 'noisy' }); + assert.ok(check.success, check.error); + assert.deepEqual((check.data as { allExcept: { held: Record } }).allExcept.held, { noisy: 1 }); + assert.deepEqual((check.data as { peek: string[] }).peek, ['antra: noise']); + const cancelled = await i.handleTuneOutTool('agent', { mode: 'cancel', scope: 'all-except' }); + assert.ok(cancelled.success, cancelled.error); + assert.equal(i.tuneOutCoordinator!.getAllExcept(), null); + + const focus = await (f.framework as unknown as { handleFocusTool(agent: string, input: Record): { success: boolean; error?: string; data?: unknown } }) + .handleFocusTool('agent', { channelId: 'work', durationSeconds: 120 }); + assert.ok(focus.success, focus.error); + assert.equal(i.tuneOutCoordinator!.getAllExcept()!.params.except.channelId, 'work'); + assert.ok(f.framework.getAllTools().some((t) => t.name === 'focus'), 'focus is offered as the alias'); + } finally { await f.close(); } + }); + + it('closing the excepted channel ends the scope', async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + enterAllExcept(f, 'work'); + await incoming(f, 'noisy', 'n1', 'held'); + await settle(f); + const result = await f.framework.executeToolCall({ id: 'c1', name: 'channel_close', input: { channelId: 'work' }, callerAgentName: 'agent' } as never); + assert.ok(result.success, result.error); + assert.equal(internals(f).tuneOutCoordinator!.getAllExcept(), null, 'nothing left to speak into: the scope ended'); + assert.match(textOf(find(f, 'all channels except')), /antra: held/); + } finally { await f.close(); } + }); + + it("a close of the excepted channel that can't be recorded still says the focus ended, since it did", async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + enterAllExcept(f, 'work'); + const registry = internals(f).channelRegistry as unknown as { setDesiredState(...args: unknown[]): void }; + const record = registry.setDesiredState.bind(registry); + registry.setDesiredState = (...args: unknown[]) => { + if (args[2] === 'closed') throw new Error('the log refused it'); + return record(...args); + }; + const result = await f.framework.executeToolCall({ id: 'c1', name: 'channel_close', input: { channelId: 'work' }, callerAgentName: 'agent' } as never); + assert.equal(result.success, false); + assert.equal(internals(f).tuneOutCoordinator!.getAllExcept(), null, 'the scope ended'); + assert.match(result.error ?? '', /The focus on it ended all the same/); + } finally { await f.close(); } + }); + + it('closing the excepted channel ends the scope when the channel was closed already', async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + const close = (id: string) => f.framework.executeToolCall({ id, name: 'channel_close', input: { channelId: 'work' }, callerAgentName: 'agent' } as never); + assert.ok((await close('c0')).success); + await eventually(() => internals(f).channelRegistry.getDesiredState('editor', 'work') === 'closed', 'work closed'); + enterAllExcept(f, 'work'); + const result = await close('c1'); + assert.ok(result.success, result.error); + assert.equal((result.data as { status?: string }).status, 'already closed'); + assert.equal(internals(f).tuneOutCoordinator!.getAllExcept(), null, 'the scope ended'); + } finally { await f.close(); } + }); + + it("where another server shares a channel's id, check/peek and focus take the names the other modes take, and the subconscious's cancel_tuneout naming the scope ends it though channelId names that shared id", async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + const map = (internals(f).channelRegistry as unknown as { channels: Map }).channels; + for (const id of ['work', 'noisy']) { + map.set(JSON.stringify(['other', id]), { serverId: 'other', descriptor: { id, type: 'discord', label: id }, open: true }); + } + const tool = internals(f) as unknown as { + handleTuneOutTool(agent: string, input: Record, own?: Array<{ serverId: string; channelId: string }>): Promise<{ success: boolean; error?: string; data?: unknown }>; + handleFocusTool(agent: string, input: Record, own?: Array<{ serverId: string; channelId: string }>): Promise<{ success: boolean; error?: string; data?: unknown }>; + }; + // Inside a turn whose channel is editor's 'work', the bare id means that channel. + const focused = await tool.handleFocusTool('agent', { channelId: 'work', durationSeconds: 120 }, [{ serverId: 'editor', channelId: 'work' }]); + assert.ok(focused.success, focused.error); + assert.deepEqual(internals(f).tuneOutCoordinator!.getAllExcept()!.params.except, { serverId: 'editor', channelId: 'work' }); + await incoming(f, 'noisy', 'n1', 'noise'); + await settle(f); + const shared = await tool.handleTuneOutTool('agent', { mode: 'check', peek: 'noisy' }); + assert.equal(shared.success, false, 'a peek at a name two servers share is refused, as every mode refuses it'); + assert.match(shared.error ?? '', /editor:noisy/); + const qualified = await tool.handleTuneOutTool('agent', { mode: 'check', peek: 'editor:noisy' }); + assert.ok(qualified.success, qualified.error); + assert.deepEqual((qualified.data as { peek: string[] }).peek, ['antra: noise'], 'the qualified name peeks that server\'s channel'); + const other = await tool.handleTuneOutTool('agent', { mode: 'check', peek: 'other:noisy' }); + assert.deepEqual((other.data as { peek: string[] }).peek, [], "and the other server's channel holds nothing"); + const withServer = await tool.handleTuneOutTool('agent', { mode: 'check', peek: 'noisy', serverId: 'editor' }); + assert.deepEqual((withServer.data as { peek: string[] }).peek, ['antra: noise'], 'the shared id with a serverId peeks that server\'s channel'); + const inTurn = await tool.handleTuneOutTool('agent', { mode: 'check', peek: 'noisy' }, [{ serverId: 'editor', channelId: 'noisy' }]); + assert.deepEqual((inTurn.data as { peek: string[] }).peek, ['antra: noise'], "inside a turn whose channel it is, the shared id peeks the turn's channel"); + + const cancelled = await internals(f).tuneOutCoordinator!.handleSubconsciousTool('cancel_tuneout', { channelId: 'noisy', scope: 'all-except' }); + assert.ok(cancelled.success, cancelled.error); + assert.equal(internals(f).tuneOutCoordinator!.getAllExcept(), null, 'the scope named outright ended the hold'); + + // Nor does a turn's own channel its server no longer registers, which picks no channel either. + const again = await tool.handleFocusTool('agent', { channelId: 'editor:work', durationSeconds: 120 }); + assert.ok(again.success, again.error); + const gone = { serverId: 'other', channelId: 'gone' }; + const withoutScope = await internals(f).tuneOutCoordinator!.handleSubconsciousTool('cancel_tuneout', { channelId: 'gone' }, [gone]); + assert.equal(withoutScope.success, false, "without the scope, the turn's channel that's gone is refused"); + assert.match(withoutScope.error ?? '', /until that server registers it again/, "with the refusal for the turn's channel, not the one for a channel that isn't tuned out"); + assert.notEqual(internals(f).tuneOutCoordinator!.getAllExcept(), null); + const withScope = await internals(f).tuneOutCoordinator!.handleSubconsciousTool('cancel_tuneout', { channelId: 'gone', scope: 'all-except' }, [gone]); + assert.ok(withScope.success, withScope.error); + assert.equal(internals(f).tuneOutCoordinator!.getAllExcept(), null, "naming the scope ends the hold, though the turn's channel it names is gone"); + } finally { await f.close(); } + }); + + it('check/peek shows only what the active epoch holds, never a released one', async () => { + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(f, 'work', 'noisy'); + enter(f, 'noisy'); + await incoming(f, 'noisy', 'n1', 'old held one'); + await incoming(f, 'noisy', 'n2', 'old held two'); + await settle(f); + assert.ok(internals(f).tuneOutCoordinator!.cancel('editor', 'noisy', 'agent-tool', 'test').ok); + f.alwaysRespond(); + await f.framework.runUntilIdle(); + enterAllExcept(f, 'work'); + await incoming(f, 'noisy', 'n3', 'new held'); + await f.framework.runUntilIdle(); + const report = internals(f).tuneOutCoordinator!.check({ peek: { serverId: 'editor', channelId: 'noisy' } }); + assert.deepEqual(report.allExcept!.held, { noisy: 1 }); + assert.deepEqual(report.peek, ['antra: new held'], 'released traffic is not shown as held (first live run, finding B)'); + assert.ok(internals(f).tuneOutCoordinator!.cancelAllExcept('agent-tool', 'test').ok); + await f.framework.runUntilIdle(); + assert.deepEqual(internals(f).tuneOutCoordinator!.check({ peek: { serverId: 'editor', channelId: 'noisy' } }).peek, [], 'nothing active: nothing to peek'); + } finally { await f.close(); } + }); + + it('the room hears the door shut: entry and release are announced in the excepted channel; `tuneOut.announce: false` keeps quiet', async () => { + const until = async (cond: () => boolean, what: string) => { + for (let i = 0; i < 100 && !cond(); i++) await new Promise((r) => setTimeout(r, 20)); + assert.ok(cond(), what); + }; + const textOfPublish = (p: Record) => + ((p.content as Array<{ type: string; text?: string }>) ?? []).map((b) => b.text ?? '').join(''); + const f = await fixture({ framework: { subconscious: SUB } as Partial }); + const traces: string[] = []; + f.framework.onTrace((t) => { traces.push(t.type); }); + try { + await registerOpen(f, 'work', 'noisy'); + const entered = await internals(f).handleTuneOutTool('agent', { channelId: 'work', scope: 'all-except', durationSeconds: 120 }); + assert.ok(entered.success, entered.error); + assert.equal((entered.data as { announcedIn?: string | null }).announcedIn, 'work', 'the tool result names where the entry was announced'); + await until(() => f.published.length >= 1, 'the entry is announced'); + assert.equal(f.published[0].channelId, 'work'); + assert.match(textOfPublish(f.published[0]), /^\[Focus: agent is here for 2 min, until \d\d:\d\d UTC; other channels are held meanwhile\.\]$/); + await incoming(f, 'noisy', 'n1', 'held'); + await settle(f); + assert.equal(f.published.length, 1, 'a held channel hears nothing'); + + assert.ok(internals(f).tuneOutCoordinator!.cancelAllExcept('agent-tool', 'test').ok); + f.alwaysRespond(); + await until(() => f.published.length >= 2, 'the release is announced'); + assert.equal(f.published[1].channelId, 'work'); + assert.match(textOfPublish(f.published[1]), /^\[Focus: agent released — test\.\]$/); + await until(() => traces.filter((t) => t === 'tune-out:announced').length >= 2, 'both announcements traced'); + } finally { await f.close(); } + + // Where another server registers the excepted channel's id, the entry is + // still announced in the excepted channel, on its own server, and the + // answer names the channel as the agent can name it back. + const shared = await fixture({ framework: { subconscious: SUB } as Partial }); + try { + await registerOpen(shared, 'work', 'noisy'); + (internals(shared).channelRegistry as unknown as { channels: Map }).channels.set( + JSON.stringify(['other', 'work']), { serverId: 'other', descriptor: { id: 'work', type: 'discord', label: 'work' }, open: true }); + const entered = await internals(shared).handleTuneOutTool('agent', { channelId: 'editor:work', scope: 'all-except', durationSeconds: 120 }); + assert.ok(entered.success, entered.error); + assert.equal((entered.data as { except?: string }).except, 'editor:work'); + assert.equal((entered.data as { announcedIn?: string | null }).announcedIn, 'editor:work'); + await until(() => shared.published.length >= 1, 'the entry is announced, though another server shares the id'); + // `published` holds what the fixture's one connection, 'editor', is + // sent: 'other' has no connection, so nothing sent there is recorded. + assert.equal(shared.published[0].channelId, 'work'); + } finally { await shared.close(); } + + const quiet = await fixture({ framework: { subconscious: SUB, tuneOut: { announce: false } } as Partial }); + try { + await registerOpen(quiet, 'work', 'noisy'); + const entered = await internals(quiet).handleTuneOutTool('agent', { channelId: 'work', scope: 'all-except', durationSeconds: 120 }); + assert.ok(entered.success, entered.error); + assert.equal((entered.data as { announcedIn?: string | null }).announcedIn, null); + await incoming(quiet, 'noisy', 'n1', 'held'); + await settle(quiet); + assert.ok(internals(quiet).tuneOutCoordinator!.cancelAllExcept('agent-tool', 'test').ok); + quiet.alwaysRespond(); + await quiet.framework.runUntilIdle(); + await new Promise((r) => setTimeout(r, 100)); + assert.equal(quiet.published.length, 0, 'announce: false — nothing is posted'); + } finally { await quiet.close(); } + }); +}); diff --git a/test/all-except-unit.test.ts b/test/all-except-unit.test.ts new file mode 100644 index 000000000..7c10b3941 --- /dev/null +++ b/test/all-except-unit.test.ts @@ -0,0 +1,208 @@ +/** + * The all-except scope's own rules, on the coordinator alone: exhausting the + * wake budget pauses the reader rather than ending the hold (#232 §3.2, the + * decision carried from the issue), and a scope whose deadline passed while + * the process was down is released at resume. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { TuneOutCoordinator, type TuneOutFrameworkHooks } from '../src/tune-out/coordinator.js'; +import type { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; + +function harness(stored: Array<{ participant: string; content: Array<{ type: string; text?: string }>; metadata: Record }> = []) { + const state = new Map(); + const wakes: string[] = []; + const messages: Array<{ participant: string; text: string; forAgent?: string }> = []; + const traces: string[] = []; + const fail = { read: false, write: false, addMessage: false }; + const registry = { + getTuneOutState: () => null, + // Each channel is shown by its id where no other server registers it, + // and with its server where two do (general, on disc and slack). + nameShown: (ref: { serverId: string; channelId: string }) => (ref.channelId === 'general' ? `${ref.channelId} (${ref.serverId})` : ref.channelId), + listChannelsRaw: () => [ + { serverId: 'disc', descriptor: { id: 'work' } }, { serverId: 'disc', descriptor: { id: 'noisy' } }, + { serverId: 'disc', descriptor: { id: 'general' } }, { serverId: 'slack', descriptor: { id: 'general' } }, + ], + readCoordinatorState: (id: string) => { + if (fail.read) throw new Error('store unreadable'); + return state.get(id) ?? null; + }, + writeCoordinatorState: (id: string, value: unknown) => { + if (fail.write) throw new Error('store write failed'); + state.set(id, value); + }, + } as unknown as ChannelRegistry; + const servers = { getServer: () => undefined } as unknown as McplServerRegistry; + const hooks = { + addMessage: (participant: string, content: Array<{ type: string; text?: string }>, _m: unknown, forAgent?: string) => { + if (fail.addMessage) throw new Error('context write failed'); + messages.push({ participant, text: content.map((b) => b.text ?? '').join(''), forAgent }); + return 'id'; + }, + requestInference: (agentName: string) => { wakes.push(agentName); }, + subconsciousName: () => 'Subconscious', + primaryName: () => 'scout', + getStoredMessages: () => stored, + currentSequence: () => 1, + setSubconsciousAnchor: () => {}, + isForkBound: () => false, + isPrivilegedAuthor: () => false, + allowChannelSpeech: () => false, + emitTrace: (e: { type: string }) => { traces.push(e.type); }, + } as unknown as TuneOutFrameworkHooks; + const make = () => new TuneOutCoordinator(registry, servers, hooks); + return { make, state, wakes, messages, traces, fail, stored }; +} + +describe('all-except scope on the coordinator', () => { + it('exhausting the wake budget pauses reader wakes; the hold stays', () => { + const h = harness(); + const c = h.make(); + const r = c.enterAllExcept({ serverId: 'disc', channelId: 'work' }, { durationSeconds: 120, maxWakes: 1 }, 'agent-tool'); + assert.ok(r.ok); + const fire = (c as unknown as { fireWake(s: string, ch: string, n: number): void }).fireWake.bind(c); + fire('disc', 'noisy', 1); + assert.deepEqual(h.wakes, ['Subconscious'], 'the first wake reaches the reader'); + assert.equal(c.getAllExcept()!.wakeCount, 1, 'counted durably'); + fire('disc', 'noisy', 1); + assert.deepEqual(h.wakes, ['Subconscious'], 'the second is paused, not delivered'); + assert.ok(c.getAllExcept(), 'the scope is still in force'); + assert.ok(h.traces.includes('tune-out:wake-budget-paused')); + c.stop(); + }); + + it("two servers' channels that share an id are counted and released apart", () => { + const stored: Parameters[0] = []; + const h = harness(stored); + const c = h.make(); + const r = c.enterAllExcept({ serverId: 'disc', channelId: 'work' }, { durationSeconds: 120, backlogCap: 1 }, 'agent-tool'); + assert.ok(r.ok); + const epochId = c.getAllExcept()!.params.epochId; + const held = (serverId: string, text: string) => stored.push({ + participant: 'user', content: [{ type: 'text', text }], + metadata: { channelId: 'general', serverId, tuneOut: { epochId } }, + }); + held('disc', 'one on disc'); + held('disc', 'two on disc'); + held('slack', 'one on slack'); + assert.deepEqual(c.check().allExcept!.held, { 'general (disc)': 2, 'general (slack)': 1 }); + assert.ok(c.cancelAllExcept('agent-tool', 'test').ok); + const release = h.messages.find((m) => /released/.test(m.text))!.text; + assert.equal(release.split(' { + const h = harness(); + const first = h.make(); + assert.ok(first.enterAllExcept({ serverId: 'disc', channelId: 'work' }, { durationSeconds: 120 }, 'agent-tool').ok); + first.stop(); + // Age the durable record past its deadline, as a long outage would. + const id = [...h.state.keys()].find((k) => k.endsWith('/all-except'))!; + const record = h.state.get(id) as { params: { expiresAtMs: number } }; + record.params.expiresAtMs = Date.now() - 1000; + const second = h.make(); + second.resumeActiveEpochs(); + assert.equal(second.getAllExcept(), null, 'released'); + assert.ok(h.messages.some((m) => /all channels except work released — duration elapsed/.test(m.text)), 'the release reached the resident'); + assert.ok(h.wakes.includes('scout'), 'and woke them'); + second.stop(); + }); + + it('a stored hold that can\'t be read is not taken for no hold, and the next read tries again', () => { + const h = harness(); + const first = h.make(); + assert.ok(first.enterAllExcept({ serverId: 'disc', channelId: 'work' }, { durationSeconds: 120 }, 'agent-tool').ok); + first.stop(); + h.fail.read = true; + const second = h.make(); + assert.throws(() => second.resumeActiveEpochs(), /store unreadable/, 'resume refuses rather than resume with no hold'); + h.fail.read = false; + assert.equal(second.getAllExcept()?.params.except.channelId, 'work', 'the failure was not remembered as no hold'); + assert.ok(second.holdFor('disc', 'noisy'), 'and the hold holds'); + second.stop(); + }); + + it('a stored record that isn\'t a hold\'s is refused, not read as no hold', () => { + const h = harness(); + const c = h.make(); + h.state.set('tune-out/scout/all-except', { params: { except: 'not a hold' } }); + assert.throws(() => c.getAllExcept(), /is not a hold's record/); + assert.ok(h.state.has('tune-out/scout/all-except'), 'left as it is'); + c.stop(); + }); + + it('an entry whose write fails leaves no hold in memory', () => { + const h = harness(); + const c = h.make(); + h.fail.write = true; + assert.throws(() => c.enterAllExcept({ serverId: 'disc', channelId: 'work' }, { durationSeconds: 120 }, 'agent-tool'), /store write failed/); + h.fail.write = false; + assert.equal(c.getAllExcept(), null, 'memory agrees with the store'); + assert.equal(c.holdFor('disc', 'noisy'), null); + c.stop(); + }); + + it('a release that fails to be recorded leaves the hold in force, to release again', () => { + const h = harness(); + const c = h.make(); + assert.ok(c.enterAllExcept({ serverId: 'disc', channelId: 'work' }, { durationSeconds: 120 }, 'agent-tool').ok); + h.fail.addMessage = true; + assert.throws(() => c.cancelAllExcept('agent-tool', 'done'), /context write failed/); + h.fail.addMessage = false; + assert.ok(c.getAllExcept(), 'the hold is still in force'); + assert.ok([...h.state.values()].some((v) => v !== null), 'and still stored'); + const again = c.cancelAllExcept('agent-tool', 'done'); + assert.ok(again.ok); + assert.equal(c.getAllExcept(), null); + assert.ok(h.messages.some((m) => /all channels except work released — done/.test(m.text)), 'the release reached the resident'); + c.stop(); + }); + + it('a release whose hold fails to clear is delivered once, however often the clear is retried', () => { + const h = harness(); + const c = h.make(); + assert.ok(c.enterAllExcept({ serverId: 'disc', channelId: 'work' }, { durationSeconds: 120 }, 'agent-tool').ok); + h.fail.write = true; + assert.throws(() => c.cancelAllExcept('duration', 'duration elapsed'), /store write failed/); + assert.throws(() => c.cancelAllExcept('duration', 'duration elapsed'), /store write failed/); + assert.ok(c.getAllExcept(), 'the hold is still in force while its end is not recorded'); + h.fail.write = false; + assert.ok(c.cancelAllExcept('duration', 'duration elapsed').ok); + assert.equal(c.getAllExcept(), null); + const releases = h.messages.filter((m) => /all channels except work released/.test(m.text)); + assert.equal(releases.length, 1, 'the resident got the release once'); + c.stop(); + }); + + it('a fractional backlog cap counts whole messages, as a channel\'s does: below one shows none', () => { + const h = harness(); + const c = h.make(); + assert.ok(c.enterAllExcept({ serverId: 'disc', channelId: 'work' }, { durationSeconds: 120, backlogCap: 0.5 }, 'agent-tool').ok); + assert.equal(c.getAllExcept()!.params.backlogCap, 0); + c.stop(); + }); + + it('a hold stored with a fractional cap, before entry floored it, still releases whole messages', () => { + const h = harness(); + const first = h.make(); + assert.ok(first.enterAllExcept({ serverId: 'disc', channelId: 'work' }, { durationSeconds: 120 }, 'agent-tool').ok); + first.stop(); + const id = [...h.state.keys()].find((k) => k.endsWith('/all-except'))!; + const record = h.state.get(id) as { params: { backlogCap: number; epochId: string } }; + record.params.backlogCap = 0.5; + for (const text of ['held one', 'held two']) { + h.stored.push({ participant: 'u', content: [{ type: 'text', text }], metadata: { channelId: 'noisy', serverId: 'disc', tuneOut: { epochId: record.params.epochId } } }); + } + const second = h.make(); + assert.ok(second.cancelAllExcept('agent-tool', 'done').ok); + const release = h.messages.find((m) => /released — done/.test(m.text))!; + assert.match(release.text, /messages=2/); + assert.doesNotMatch(release.text, /held one|held two/, 'a cap below one shows no message'); + second.stop(); + }); +}); diff --git a/test/author-presentation.test.ts b/test/author-presentation.test.ts new file mode 100644 index 000000000..e68b12b87 --- /dev/null +++ b/test/author-presentation.test.ts @@ -0,0 +1,256 @@ +/** + * A channel message's author reaches the model. + * + * MCPL's `channels/incoming` carries the author as a field — `author: {id, + * name}` — and the body alone as content (SPEC §14). The framework stores + * the message under the anonymous participant `user` with the author in + * metadata, and nothing between the store and the wire rendered the name, + * so the model read `user: `: who spoke was lost. Servers that + * pre-render `Name: body` into the text (portal-mcpl) masked it, and the + * tune-out backlog, which does read the metadata, doubled their name. + * Found by Weft's rig with a spec-shaped driver, 2026-10-13 ("Zinnia-Probe"). + * + * The rule: a message stored under `user` that names an author is stored as + * `author: text`, unless its text already begins that way. The compiled view + * carries no metadata, so the name has to be in the stored text to reach the + * wire; the author stays in `metadata.author` as the structured record. + */ +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 type { ContentBlock, NormalizedRequest } from '@animalabs/membrane'; +import { AgentFramework } from '../src/index.js'; +import type { ProcessEvent } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { renderHeldLine } from '../src/tune-out/coordinator.js'; +import { defaultReaderFraming } from '../src/tune-out/tools.js'; +import { presentAuthor, authorLine, ANONYMOUS_PARTICIPANT } from '../src/author-presentation.js'; + +const CHANNEL = 'probe:guild:alpha'; + +function incoming(text: string, author = { id: 'Z1', name: 'Zinnia-Probe' }): ProcessEvent { + return { + type: 'mcpl:channel-incoming', + serverId: 'probe', + channelId: CHANNEL, + messageId: `m-${Math.random().toString(36).slice(2)}`, + author, + content: [{ type: 'text', text }], + timestamp: new Date().toISOString(), + metadata: {}, + tags: ['chat:addressed'], + triggerInference: true, + } as unknown as ProcessEvent; +} + +const texts = (request: NormalizedRequest): string[] => + request.messages.flatMap((m) => + m.content + .filter((b): b is Extract => b.type === 'text') + .map((b) => b.text)); + +describe('author presentation', () => { + describe('on the wire', () => { + let tempDir: string; + let membrane: MockMembrane; + let framework: AgentFramework; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'author-presentation-')); + 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.' }], + modules: [], + }); + }); + + afterEach(async () => { + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('a spec-shaped message (body only, author in the field) reaches the model as "author: text"', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Later, yes.' }])); + framework.pushEvent(incoming('scout, quick question while you are busy: are you free later?')); + await framework.runUntilIdle(); + + assert.equal(membrane.calls.length, 1, 'one turn ran'); + const wire = texts(membrane.calls[0]!); + assert.ok( + wire.includes('Zinnia-Probe: scout, quick question while you are busy: are you free later?'), + `the request names the author:\n${wire.join('\n')}`, + ); + }); + + it('is stored as the model reads it, under the anonymous participant, with the author kept in metadata', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); + framework.pushEvent(incoming('are you free later?')); + await framework.runUntilIdle(); + + const { messages } = await framework.getAgent('scout')!.getContextManager().compile(); + const stored = messages.find((m) => JSON.stringify(m.content).includes('are you free later?')); + assert.ok(stored, 'the message is stored'); + assert.equal(stored!.participant, 'user'); + assert.deepEqual(stored!.content, [{ type: 'text', text: 'Zinnia-Probe: are you free later?' }]); + const row = framework.getAgent('scout')!.getContextManager().getAllMessages() + .find((m) => JSON.stringify(m.content).includes('are you free later?')); + assert.deepEqual((row?.metadata as { author?: unknown })?.author, { id: 'Z1', name: 'Zinnia-Probe' }); + }); + + it('a server that already renders "Name: body" into the text is not doubled', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); + framework.pushEvent(incoming('Zinnia-Probe: are you free later?')); + await framework.runUntilIdle(); + + const wire = texts(membrane.calls[0]!); + assert.ok(wire.includes('Zinnia-Probe: are you free later?'), wire.join('\n')); + assert.ok(!wire.some((t) => t.includes('Zinnia-Probe: Zinnia-Probe:')), `not doubled:\n${wire.join('\n')}`); + }); + }); + + describe('on the wire, delivered to a conversation fork', () => { + let tempDir: string; + let membrane: MockMembrane; + let framework: AgentFramework; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'author-presentation-fork-')); + membrane = new MockMembrane(); + framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ name: 'trunk', model: 'test-model', systemPrompt: 'You are the trunk.' }], + modules: [], + conversations: { templateAgent: 'trunk' }, + }); + }); + + afterEach(async () => { + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + const dm = (text: string, extra: Record = {}): ProcessEvent => ({ + ...(incoming(text) as unknown as Record), + channelId: 'probe:dm:zinnia', + metadata: { channel_type: 'im' }, + ...extra, + }) as unknown as ProcessEvent; + + it('a routed message and a correction delivered to the fork by name (deliverTo) are both stored under the author', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Hello.' }])); + framework.pushEvent(dm('hello there')); + await framework.runUntilIdle(); + const fork = framework.getConversationRouter()!.getBinding({ serverId: 'probe', channelId: 'probe:dm:zinnia' })!.agentName; + assert.ok(framework.getAgent(fork), 'the DM spawned a fork'); + + // RFC-006: a replacement notice follows its delivery into the agent + // that holds the prior occurrence, named by `deliverTo`. + const internals = framework as unknown as { handleMcplChannelIncoming(event: unknown): Promise }; + await internals.handleMcplChannelIncoming(dm('hello there (edited)', { triggerInference: false, deliverTo: fork })); + + const stored = framework.getAgent(fork)!.getContextManager().getAllMessages() + .filter((m) => m.participant === 'user') + .map((m) => m.content.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text).join('')); + assert.ok(stored.includes('Zinnia-Probe: hello there'), `the routed message names its author:\n${stored.join('\n')}`); + assert.ok(stored.includes('Zinnia-Probe: hello there (edited)'), `the deliverTo correction names its author:\n${stored.join('\n')}`); + assert.ok( + !framework.getAgent('trunk')!.getContextManager().getAllMessages().some((m) => JSON.stringify(m.content).includes('hello there')), + 'the trunk holds neither', + ); + }); + }); + + describe('the rule', () => { + const stored = (participant: string, content: ContentBlock[], metadata?: Record) => + ({ participant, content, ...(metadata ? { metadata } : {}) }); + + it('prefixes the first text block of an anonymous message that names an author', () => { + const m = stored('user', [{ type: 'text', text: 'hi' }], { author: { id: '1', name: 'Alice' } }); + assert.deepEqual(presentAuthor(m).content, [{ type: 'text', text: 'Alice: hi' }]); + assert.deepEqual(m.content, [{ type: 'text', text: 'hi' }], 'the input is not mutated'); + // The first text block only, wherever it is: not the first block, and not every text block. + const image = { type: 'image', source: { type: 'base64', mediaType: 'image/png', data: 'AA==' } } as unknown as ContentBlock; + const mixed = stored('user', [image, { type: 'text', text: 'hi' }, { type: 'text', text: 'and more' }], { author: { id: '1', name: 'Alice' } }); + assert.deepEqual(presentAuthor(mixed).content, [image, { type: 'text', text: 'Alice: hi' }, { type: 'text', text: 'and more' }]); + }); + + it('is idempotent: text already beginning with the author\'s name is left alone', () => { + const m = stored('user', [{ type: 'text', text: 'Alice: hi' }], { author: { id: '1', name: 'Alice' } }); + assert.equal(presentAuthor(m), m); + // A name that merely prefixes another word is not the author's line. + const other = stored('user', [{ type: 'text', text: 'Alicent: hi' }], { author: { id: '1', name: 'Alice' } }); + assert.deepEqual(presentAuthor(other).content, [{ type: 'text', text: 'Alice: Alicent: hi' }]); + }); + + it('names the author before media when the message has no text', () => { + const image = { type: 'image', source: { type: 'base64', mediaType: 'image/png', data: 'AA==' } } as unknown as ContentBlock; + const m = stored('user', [image], { author: { id: '1', name: 'Alice' } }); + assert.deepEqual(presentAuthor(m).content, [{ type: 'text', text: 'Alice:' }, image]); + }); + + it('leaves a named participant, a message without an author, and an empty name as they are', () => { + const named = stored('Alice', [{ type: 'text', text: 'hi' }], { author: { id: '1', name: 'Alice' } }); + assert.equal(presentAuthor(named), named); + const anonymous = stored('user', [{ type: 'text', text: 'hi' }], { system: true }); + assert.equal(presentAuthor(anonymous), anonymous); + const blank = stored('user', [{ type: 'text', text: 'hi' }], { author: { id: '1', name: '' } }); + assert.equal(presentAuthor(blank), blank); + }); + + it('authorLine is the same rule for a single line', () => { + assert.equal(authorLine('Alice', 'hi'), 'Alice: hi'); + assert.equal(authorLine('Alice', 'Alice: hi'), 'Alice: hi'); + assert.equal(authorLine('Alice', 'Alice:'), 'Alice:'); + }); + }); + + describe('tune-out backlog lines', () => { + const held = (text: string) => ({ + participant: 'user', + content: [{ type: 'text', text }] as ContentBlock[], + metadata: { author: { id: 'Z1', name: 'Zinnia-Probe' } }, + }); + + it('a release line names the author once, whichever shape the server sent', () => { + assert.equal(renderHeldLine(held('hello?')), 'Zinnia-Probe: hello?'); + assert.equal(renderHeldLine(held('Zinnia-Probe: hello?')), 'Zinnia-Probe: hello?'); + }); + + it('a release line names the author once when media comes before the text, as presentAuthor stores it', () => { + const image = { type: 'image', source: { type: 'base64', mediaType: 'image/png', data: 'AA==' } } as unknown as ContentBlock; + const stored = presentAuthor({ + participant: ANONYMOUS_PARTICIPANT, + content: [image, { type: 'text', text: 'hello?' } as ContentBlock], + metadata: { author: { id: 'Z1', name: 'Zinnia-Probe' } }, + }); + const line = renderHeldLine(stored); + assert.equal(line.split('Zinnia-Probe:').length - 1, 1, line); + assert.ok(line.includes('[image]') && line.includes('hello?'), line); + }); + + it('a release line reads a blank author name as no author, as presentAuthor does', () => { + const content = [{ type: 'text', text: 'hello?' }] as ContentBlock[]; + assert.equal( + renderHeldLine({ participant: 'user', content, metadata: { author: { id: 'Z1', name: '' } } }), + renderHeldLine({ participant: 'user', content, metadata: {} }), + ); + }); + + it('the reader framing names the author once, whichever shape the server sent', () => { + const content = defaultReaderFraming({ + resident: 'scout', fork: 'reader', voice: 'Report.', + channelId: CHANNEL, epochId: 'e1-epoch', trigger: 'wake', notice: '[Tune-out wake]', + dispositions: null, backlog: [held('hello?'), held('Zinnia-Probe: again?')], + } as unknown as Parameters[0]); + const text = content + .filter((b): b is Extract => b.type === 'text') + .map((b) => b.text).join('\n'); + assert.ok(text.includes('\nZinnia-Probe: hello?\nZinnia-Probe: again?'), text); + }); + }); +}); diff --git a/test/auto-continue.test.ts b/test/auto-continue.test.ts index c13333d9d..a382b5221 100644 --- a/test/auto-continue.test.ts +++ b/test/auto-continue.test.ts @@ -26,6 +26,7 @@ import { AgentFramework } from '../src/index.js'; import { resolveAutoContinue } from '../src/agent.js'; import { autoContinueNotice } from '../src/auto-continue.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { bindAsConversationFork } from './helpers/dendrite.js'; import type { ContentBlock, NormalizedResponse } from '@animalabs/membrane'; class TriggerModule implements Module { @@ -517,7 +518,6 @@ describe('autoContinue', () => { autoContinueAfterTurn(agent: unknown, routing: unknown, trigger: unknown, stopReason: string, opts?: unknown): void; pendingRequests: InferenceRequest[]; ephemeralRuns: Map; - conversationAgentHomes: Map; providerAdmissionClosed: boolean; autoContinueStreaks: Map; }; @@ -529,9 +529,9 @@ describe('autoContinue', () => { internals.ephemeralRuns.delete('assistant'); assert.equal(queued().length, 0, 'an ephemeral run ends with its result'); - internals.conversationAgentHomes.set('assistant', 'chan-alpha'); + const unbind = bindAsConversationFork(framework, 'assistant', { serverId: 'stub', channelId: 'chan-alpha' }); internals.autoContinueAfterTurn(agent, {}, undefined, 'end_turn'); - internals.conversationAgentHomes.delete('assistant'); + unbind(); assert.equal(queued().length, 0, 'a conversation fork answers its home'); internals.providerAdmissionClosed = true; diff --git a/test/batched-wake-locus.test.ts b/test/batched-wake-locus.test.ts index 5c809e9cb..b856fd3df 100644 --- a/test/batched-wake-locus.test.ts +++ b/test/batched-wake-locus.test.ts @@ -9,7 +9,9 @@ * - ambient AFTER the DM: the ambient channel became the fallback; * - ambient BEFORE the DM: the DM never displaced it. * Now a batch containing an addressed message routes to that message's - * registered channel; an ambient-only batch keeps the legacy fallback. + * registered channel. An ambient-only batch names no channel: under the + * default `speech.locus: 'delivery'` the turn has no locus and its prose + * stays private; under `'latest-inbound'` it keeps the legacy fallback. * * The tests hold the gate's batch until both events are in it (holdBatches), * so they don't depend on the second reaching the gate inside the window. @@ -20,6 +22,7 @@ import { tmpdir } from 'node:os'; import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, appendFileSync } from 'node:fs'; import { join } from 'node:path'; import { AgentFramework } from '../src/index.js'; +import { conversationForkSpec } from '../src/dendrite/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { appendInTwoParts } from './helpers/command-file.js'; @@ -38,7 +41,7 @@ async function waitFor(cond: () => boolean, what: string, timeoutMs = 15_000): P throw new Error(`timed out waiting for: ${what}`); } -describe('batched wake routing', () => { +function batchedWakeRig(speech?: { locus: 'delivery' | 'latest-inbound' }) { let tempDir: string; let statusPath: string; let commandPath: string; @@ -56,6 +59,7 @@ describe('batched wake routing', () => { storePath: join(tempDir, 'test.chronicle'), membrane: membrane.asMembrane(), agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + ...(speech ? { speech } : {}), gate: { config: { policies: [{ @@ -87,8 +91,13 @@ describe('batched wake routing', () => { const command = (line: string): void => appendFileSync(commandPath, line + '\n'); const dmLine = (text: string): string => `dm ev-${++n} 134 ${RAW_DM} ${text}`; const dm = (text: string): void => command(dmLine(text)); + // A DM written in two parts, as a poll can read a write half done. + const dmInTwoParts = (text: string): Promise => appendInTwoParts(commandPath, dmLine(text), 200); const incoming = (channelId: string, mode: 'ambient' | 'addressed', text: string): void => command(`incoming ${channelId} m-${++n} ${mode} ${text}`); + const sent = (event: string): number => !existsSync(statusPath) ? 0 + : readFileSync(statusPath, 'utf8').split('\n').filter(Boolean) + .filter((l) => (JSON.parse(l) as { event: string }).event === event).length; const publishes = (): string[] => !existsSync(statusPath) ? [] : readFileSync(statusPath, 'utf8').split('\n').filter(Boolean) .map((l) => JSON.parse(l) as { event: string; channelId?: string }) @@ -127,6 +136,18 @@ describe('batched wake routing', () => { return () => held; }; + return { + membrane: () => membrane, + framework: () => framework, + dm, dmInTwoParts, incoming, sent, publishes, holdBatches, + }; +} + +describe('batched wake routing', () => { + const rig = batchedWakeRig(); + const { dm, dmInTwoParts, incoming, sent, publishes, holdBatches } = rig; + const membrane = { pushResponse: (r: ReturnType) => rig.membrane().pushResponse(r) }; + it('a DM followed one beat later by ambient chatter elsewhere, in one batch: the reply goes to the DM', async () => { const held = holdBatches(); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'answering the DM' }])); @@ -134,27 +155,30 @@ describe('batched wake routing', () => { // the DM only once its line is whole. Sent from the first part, which // ends inside the raw channel id, it would carry no text, and the host // refuses that, so no batch would be held. - await appendInTwoParts(commandPath, dmLine('are you there?'), 200); + await dmInTwoParts('are you there?'); + await waitFor(() => sent('dm-sent') === 1, 'dm sent'); await waitFor(() => held() >= 1, "the DM's batch held past its window"); incoming(GENERAL, 'ambient', 'unrelated chatter'); await waitFor(() => publishes().length >= 1, 'reply published'); assert.deepEqual(publishes(), [DM]); }); - it('ambient chatter followed by a DM in one batch: the reply goes to the DM', async () => { + it('ambient chatter followed by a DM in the same window: the reply goes to the DM', async () => { const held = holdBatches(); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'answering the DM' }])); incoming(GENERAL, 'ambient', 'unrelated chatter'); + await waitFor(() => sent('incoming-sent') === 1, 'ambient sent'); await waitFor(() => held() >= 1, "the ambient message's batch held past its window"); dm('quick question'); await waitFor(() => publishes().length >= 1, 'reply published'); assert.deepEqual(publishes(), [DM]); }); - it('a mention in one channel then ambient chatter in another, in one batch: the reply goes to the mention', async () => { + it('a mention in one channel then ambient chatter in another: the reply goes to the mention', async () => { const held = holdBatches(); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'answering the mention' }])); incoming(ROOM, 'addressed', '@scout hello'); + await waitFor(() => sent('incoming-sent') === 1, 'mention sent'); await waitFor(() => held() >= 1, "the mention's batch held past its window"); incoming(GENERAL, 'ambient', 'unrelated chatter'); await waitFor(() => publishes().length >= 1, 'reply published'); @@ -162,6 +186,10 @@ describe('batched wake routing', () => { }); it('two closed channels addressed in one batch: the turn speaks in the later one, and no [routing] notice names the earlier', async () => { + // main's test (#852), read onto this branch's rig: the framework is the + // rig's accessor, and the batch's addressed message picks the locus + // under the default 'delivery' rule as it does under main's. + const framework = rig.framework(); const registry = (framework as unknown as { channelRegistry: { handleChannelToolCall(name: string, args: Record, caller: { kind: 'agent'; agentName: string }): Promise<{ success: boolean }> }; }).channelRegistry; @@ -187,10 +215,34 @@ describe('batched wake routing', () => { assert.ok(!routing.some((t) => t.includes('room')), JSON.stringify(routing)); }); - it('an ambient-only batch keeps the legacy fallback (most recent inbound)', async () => { + it("an ambient-only batch names no channel: under the default rule the turn has no locus and its prose stays private", async () => { const held = holdBatches(); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'joining in' }])); incoming(ROOM, 'ambient', 'first'); + await waitFor(() => sent('incoming-sent') === 1, 'first sent'); + await waitFor(() => held() >= 1, "the first message's batch held past its window"); + incoming(GENERAL, 'ambient', 'second'); + await waitFor(() => rig.membrane().calls.length >= 1, 'the batch woke the agent'); + await rig.framework().runUntilIdle(); + assert.deepEqual(publishes(), [], 'nothing was published: no delivery woke this turn'); + const texts = rig.framework().getAgent('scout')!.getContextManager().getAllMessages() + .flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text)); + // The announce-on-change baseline treats "no channel" as the state a + // resident boots in, so a first turn without a locus adds no notice; a + // later turn that loses one is told (announceLocusIfChanged). + assert.ok(texts.includes('joining in'), 'the prose stays in the window'); + }); +}); + +describe("batched wake routing under speech.locus 'latest-inbound'", () => { + const rig = batchedWakeRig({ locus: 'latest-inbound' }); + const { incoming, sent, publishes, holdBatches } = rig; + + it('an ambient-only batch keeps the legacy fallback (most recent inbound)', async () => { + const held = holdBatches(); + rig.membrane().pushResponse(createMockResponse([{ type: 'text', text: 'joining in' }])); + incoming(ROOM, 'ambient', 'first'); + await waitFor(() => sent('incoming-sent') === 1, 'first sent'); await waitFor(() => held() >= 1, "the first message's batch held past its window"); incoming(GENERAL, 'ambient', 'second'); await waitFor(() => publishes().length >= 1, 'reply published'); @@ -209,8 +261,6 @@ describe('batched wake routing with conversation forks', () => { membrane: new MockMembrane().asMembrane(), agents: [ { name: 'trunk', model: 'test-model', systemPrompt: 'You are trunk.' }, - { name: 'fork-room', model: 'test-model', systemPrompt: 'You are a fork.' }, - { name: 'fork-other', model: 'test-model', systemPrompt: 'You are a fork.' }, ], gate: { config: { @@ -229,12 +279,14 @@ describe('batched wake routing with conversation forks', () => { it('the addressed route applies only to the fork homed there; other forks and the trunk get none', async () => { const internals = framework as unknown as { - conversationAgentHomes: Map; + registry: { register(spec: ReturnType): unknown }; pendingRequests: Array<{ agentName: string; channelId?: string; channelServerId?: string; addressed?: boolean; counterparty?: string }>; eventGate: { evaluate(info: unknown): unknown }; }; - internals.conversationAgentHomes.set('fork-room', { serverId: 'discord', channelId: ROOM }); - internals.conversationAgentHomes.set('fork-other', { serverId: 'discord', channelId: GENERAL }); + // A conversation fork's home is its registry record (Dendrite); the gate + // fans out to every agent that receives gate wakes, forks included. + internals.registry.register(conversationForkSpec('fork-room', { template: 'trunk', homeChannel: { serverId: 'discord', channelId: ROOM }, idleTtlMs: 60_000 })); + internals.registry.register(conversationForkSpec('fork-other', { template: 'trunk', homeChannel: { serverId: 'discord', channelId: GENERAL }, idleTtlMs: 60_000 })); internals.eventGate.evaluate({ content: '@agent hi', eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: ROOM, tags: ['chat:addressed'], metadata: { authorId: '5' }, @@ -244,9 +296,9 @@ describe('batched wake routing with conversation forks', () => { assert.equal(byAgent.get('fork-room')?.channelId, ROOM, 'the fork that owns the channel takes the route'); assert.equal(byAgent.get('fork-room')?.channelServerId, 'discord', "with the route's server"); assert.equal(byAgent.get('fork-room')?.addressed, true); - assert.equal(byAgent.get('fork-other')?.channelId, undefined, 'a fork bound elsewhere gets no locus'); - assert.ok(byAgent.has('trunk'), 'premise: the wake reached the trunk too'); - assert.equal(byAgent.get('trunk')?.channelId, undefined, 'the trunk never takes a fork-bound channel'); + assert.equal(byAgent.get('fork-other')!.channelId, undefined, 'a fork bound elsewhere gets no locus'); + assert.ok(byAgent.has('trunk'), 'the trunk is woken too'); + assert.equal(byAgent.get('trunk')!.channelId, undefined, 'the trunk never takes a fork-bound channel'); // Telemetry provenance still reaches every agent. assert.equal(byAgent.get('fork-other')?.counterparty, 'discord:user:5'); }); diff --git a/test/channel-lifecycle-order.test.ts b/test/channel-lifecycle-order.test.ts index 9875fcf89..96ea0dfda 100644 --- a/test/channel-lifecycle-order.test.ts +++ b/test/channel-lifecycle-order.test.ts @@ -1513,7 +1513,7 @@ test('a close that lands while the coalescer holds a message stands', async () = assert.equal(f.isOpen(), false, 'the close receipt stands'); }); -test("a coalesced message the coalescer admits after its server was removed sets no default, so another server's default stands", async () => { +test("a coalesced message admitted after its server was removed sets no default, so another server's default stands", async () => { let admit!: () => void; const f = fixture(undefined, undefined, async (_serverId, held) => { await new Promise((resolve) => { admit = resolve; }); @@ -1531,6 +1531,11 @@ test("a coalesced message the coalescer admits after its server was removed sets f.registry.removeServer('test'); admit(); await incoming; + // The anchor is recorded at admission (noteDelivered), as the framework + // records a message it delivers: the removed server's message is admitted + // here, after its removal, and sets nothing, since its server no longer + // registers the channel. + f.registry.noteDelivered({ serverId: 'test', channelId: 'x' }, 'm1'); assert.deepEqual(f.registry.getDefaultPublishChannel(), { serverId: 'other', channelId: 'z' }, "the other server's default stands"); assert.deepEqual(f.registry.incomingFor(), { channel: { serverId: 'other', channelId: 'z' }, messageId: 'm-other', threadId: undefined }, "no message on the removed server is given as the one replied to"); @@ -2510,7 +2515,8 @@ async function replyOpenFailedNotices(opts: { framework.agents = new Map((opts.live ?? [agentName]).map((name) => [name, { canUseTool: (tool: string) => !(opts.denied ?? []).includes(tool) }])); framework.usedEphemeralAgentNames = new Set(); // A fork's home is a channel on the send's server. - framework.conversationAgentHomes = new Map((opts.homes ?? []).map(([name, id]) => [name, { serverId: 'chat-server', channelId: id }])); + framework.registry = { homeChannel: (name: string) => new Map((opts.homes ?? []).map(([n, id]) => [n, { serverId: 'chat-server', channelId: id }])).get(name), readsSharedSlot: (name: string) => [...(framework.sharedSlotAgents ?? [])].some((a: { name: string }) => a.name === name) }; + framework.sharedSlotAgents = new Set(); framework.primaryAgentName = opts.primary; framework.emitTrace = () => {}; framework.addMessage = (_participant: string, content: Array<{ text: string }>, metadata: unknown, routed?: { forAgent?: string }) => { @@ -2668,7 +2674,8 @@ test("a reply notice with no agent named, or an empty name, goes out unrouted, w framework.agents = new Map(c.agents.map(([name, denied]) => [name, { name, canUseTool: (tool: string) => !denied.includes(tool) }])); framework.primaryAgentName = c.primary; framework.usedEphemeralAgentNames = new Set(); - framework.conversationAgentHomes = new Map(); + framework.registry = { homeChannel: (name: string) => new Map().get(name), readsSharedSlot: (name: string) => [...(framework.sharedSlotAgents ?? [])].some((a: { name: string }) => a.name === name) }; + framework.sharedSlotAgents = new Set(); framework.addMessage = (_participant: string, content: Array<{ text: string }>, _metadata: unknown, routed?: { forAgent?: string }) => { told.push({ text: content[0]!.text, ...(routed ? { forAgent: routed.forAgent } : {}) }); return 'm'; @@ -2746,7 +2753,8 @@ test("where a reply's reader can't close a channel selected for opening, the pri framework.agents = new Map(c.agents.map(([name, denied]) => [name, { name, canUseTool: (tool: string) => !denied.includes(tool) }])); framework.primaryAgentName = c.primary; framework.usedEphemeralAgentNames = new Set(); - framework.conversationAgentHomes = new Map((c.homes ?? []).map(([name, id]) => [name, { serverId: 'srv', channelId: id }])); + framework.registry = { homeChannel: (name: string) => new Map((c.homes ?? []).map(([n, id]) => [n, { serverId: 'srv', channelId: id }])).get(name), readsSharedSlot: (name: string) => [...(framework.sharedSlotAgents ?? [])].some((a: { name: string }) => a.name === name) }; + framework.sharedSlotAgents = new Set(); // Forks are made where conversations are routed, here from the primary. framework.conversationRouter = c.homes ? { templateAgent: 'primary' } : undefined; framework.addMessage = (_participant: string, content: Array<{ text: string }>, metadata: unknown, routed?: { forAgent?: string }) => { @@ -2871,7 +2879,8 @@ test('the opened notice names channel_close only where the agent it\'s addressed framework.agents = new Map(c.agents.map(([name, denied]) => [name, { name, canUseTool: (tool: string) => !denied.includes(tool) }])); framework.primaryAgentName = c.primary; framework.usedEphemeralAgentNames = new Set(); - framework.conversationAgentHomes = new Map((c.homes ?? []).map(([name, id]) => [name, { serverId: 'srv', channelId: id }])); + framework.registry = { homeChannel: (name: string) => new Map((c.homes ?? []).map(([n, id]) => [n, { serverId: 'srv', channelId: id }])).get(name), readsSharedSlot: (name: string) => [...(framework.sharedSlotAgents ?? [])].some((a: { name: string }) => a.name === name) }; + framework.sharedSlotAgents = new Set(); // Each fork is bound to its home, unless the sweep has ended its binding; // of two forks with one home, the router binds the later. const bound = new Map((c.homes ?? []).filter(([fork]) => !(c.expired ?? []).includes(fork)).map(([fork, home]) => [home, fork])); @@ -2882,7 +2891,7 @@ test('the opened notice names channel_close only where the agent it\'s addressed // Residents, and the subconscious through its merged view, read one shared // slot; a fork's store, or a run's, is its own. framework.sharedSlotAgents = new Set([...framework.agents.values()] - .filter((a: { name: string }) => !framework.conversationAgentHomes.has(a.name) && !(c.isolated ?? []).includes(a.name))); + .filter((a: { name: string }) => framework.registry.homeChannel(a.name) === undefined && !(c.isolated ?? []).includes(a.name))); const labels = new Map(c.labels ?? []); framework.channelRegistry = { getDescriptor: (ref: { channelId: string }) => (labels.has(ref.channelId) ? { id: ref.channelId, label: labels.get(ref.channelId) } : undefined), @@ -2918,7 +2927,8 @@ test("each channel notice is written on its own, and the reader's says the prima framework.agents = new Map([['fork', { name: 'fork', canUseTool: () => true }], ['primary', { name: 'primary', canUseTool: () => true }]]); framework.primaryAgentName = 'primary'; framework.usedEphemeralAgentNames = new Set(); - framework.conversationAgentHomes = new Map([['fork', { serverId: 'srv', channelId: 'y' }]]); + framework.registry = { homeChannel: (name: string) => new Map([['fork', { serverId: 'srv', channelId: 'y' }]]).get(name), readsSharedSlot: (name: string) => [...(framework.sharedSlotAgents ?? [])].some((a: { name: string }) => a.name === name) }; + framework.sharedSlotAgents = new Set(); framework.conversationRouter = { templateAgent: 'primary' }; framework.addMessage = (_participant: string, content: Array<{ text: string }>, _metadata: unknown, routed?: { forAgent?: string }) => { // The reader's notice is addressed to the fork; the primary's to no one. @@ -2961,7 +2971,8 @@ test("a channel notice's inputs give the primary's by-name notice once its reade framework.agents = new Map([['primary', { name: 'primary', canUseTool: (tool: string) => tool !== 'channel_open' }]]); framework.primaryAgentName = 'primary'; framework.usedEphemeralAgentNames = new Set(); - framework.conversationAgentHomes = new Map(); + framework.registry = { homeChannel: (name: string) => new Map().get(name), readsSharedSlot: (name: string) => [...(framework.sharedSlotAgents ?? [])].some((a: { name: string }) => a.name === name) }; + framework.sharedSlotAgents = new Set(); // Every channel still as the notice says: open, or still waiting to open. framework.channelRegistry = { getDesiredState: () => 'open', isChannelOpen: () => false, nameShown: (ref: { channelId: string }) => ref.channelId }; const error = console.error; diff --git a/test/channel-registry-routing.test.ts b/test/channel-registry-routing.test.ts index 06edba4ae..e396cf293 100644 --- a/test/channel-registry-routing.test.ts +++ b/test/channel-registry-routing.test.ts @@ -86,6 +86,9 @@ function makeRegistry( homeChannelResolver, activeChannelResolver, onChannelAutoOpened, + // These tests exercise the precedence home → active → global; the + // global fallback exists only under the legacy locus rule. + latestInboundLocus: true, }, ); @@ -143,6 +146,19 @@ function seedRegistered(registry: ChannelRegistry, serverId: string, ...ids: str } } + +/** + * Ingest a message and record it as delivered — what the framework does + * after admission (the registry itself no longer sets the locus at + * ingestion; a held message is never noted). + */ +function deliver(registry: ChannelRegistry, channelId: string, text: string): void { + const message = incoming(channelId, text); + registry.handleIncoming('discord', message); + const m = message.messages[0]!; + registry.noteDelivered(discord(m.channelId), m.messageId); +} + test('handleIncoming REJECTS an unknown channel instead of minting it (§14.5)', async () => { const { registry, traces, lookup } = makeRegistry({ delivered: true }); @@ -162,7 +178,7 @@ test('handleIncoming REJECTS an unknown channel instead of minting it (§14.5)', test('routeSpeech surfaces a failure when the server reports delivered:false', async () => { const { registry, failures, traces } = makeRegistry({ delivered: false }); seedRegistered(registry, 'discord', 'ch-x'); - registry.handleIncoming('discord', incoming('ch-x', 'hi')); + deliver(registry, 'ch-x', 'hi'); const res = await registry.routeSpeech('cairn', 'undeliverable reply', registry.resolveLocus('cairn')); @@ -177,7 +193,7 @@ test('routeSpeech surfaces a failure when the server reports delivered:false', a test('routeSpeech counts a publish reply that leaves delivered out as unconfirmed, not delivered', async () => { const { registry, failures } = makeRegistry({}); seedRegistered(registry, 'discord', 'ch-x'); - registry.handleIncoming('discord', incoming('ch-x', 'hi')); + deliver(registry, 'ch-x', 'hi'); const res = await registry.routeSpeech('cairn', 'a reply', registry.resolveLocus('cairn')); @@ -189,7 +205,7 @@ test('routeSpeech counts a publish reply that leaves delivered out as unconfirme test('routeSpeech reports each delivered publish as mcpl:speech-routed, with nothing that ties it to a stream or ends one', async () => { const { registry, traces } = makeRegistry({ delivered: true }); seedRegistered(registry, 'discord', 'ch-x'); - registry.handleIncoming('discord', incoming('ch-x', 'hi')); + deliver(registry, 'ch-x', 'hi'); const before = traces.length; await registry.routeSpeech('cairn', 'first reply', registry.resolveLocus('cairn')); @@ -219,9 +235,9 @@ test('routeSpeech routes a conversation fork to its HOME channel, not the global ); seedRegistered(registry, 'discord', 'chanA'); - registry.handleIncoming('discord', incoming('chanA', 'hi from A')); + deliver(registry, 'chanA', 'hi from A'); seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanB', 'hi from B')); + deliver(registry, 'chanB', 'hi from B'); // Global locus is now chanB. assert.deepEqual(registry.getDefaultPublishChannel(), discord('chanB')); @@ -240,9 +256,9 @@ test('routeSpeech falls back to the global locus for the trunk agent (no home)', ); seedRegistered(registry, 'discord', 'chanA'); - registry.handleIncoming('discord', incoming('chanA', 'hi from A')); + deliver(registry, 'chanA', 'hi from A'); seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanB', 'hi from B')); + deliver(registry, 'chanB', 'hi from B'); const res = await registry.routeSpeech('trunk', 'heartbeat reply', registry.resolveLocus('trunk')); assert.deepEqual(res, { delivered: true, serverId: 'discord', channelId: 'chanB' }); @@ -257,9 +273,9 @@ test('buildChannelContext advertises the fork home as defaultOutgoing (item 3)', ); seedRegistered(registry, 'discord', 'chanA'); - registry.handleIncoming('discord', incoming('chanA', 'hi from A')); + deliver(registry, 'chanA', 'hi from A'); seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanB', 'hi from B')); + deliver(registry, 'chanB', 'hi from B'); // The fork is told chanA (where its speech actually lands)... const forkCtx = registry.buildChannelContext('conversation-chanA-g1', 'discord'); @@ -310,9 +326,9 @@ test('routeSpeech routes a TRUNK agent to its ACTIVE triggering channel, not the ); seedRegistered(registry, 'discord', 'chanA'); - registry.handleIncoming('discord', incoming('chanA', 'A: sleep && date')); + deliver(registry, 'chanA', 'A: sleep && date'); seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanB', 'B: unrelated')); // flips global to chanB + deliver(registry, 'chanB', 'B: unrelated'); // flips global to chanB assert.deepEqual(registry.getDefaultPublishChannel(), discord('chanB')); const res = await registry.routeSpeech('scout', 'the date is ...', registry.resolveLocus('scout')); @@ -330,9 +346,9 @@ test('routeSpeech precedence: fork HOME wins over the active triggering channel' () => discord('chanB'), ); seedRegistered(registry, 'discord', 'chanA'); - registry.handleIncoming('discord', incoming('chanA', 'hi')); + deliver(registry, 'chanA', 'hi'); seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanB', 'hi')); + deliver(registry, 'chanB', 'hi'); const res = await registry.routeSpeech('conversation-chanA-g1', 'reply', registry.resolveLocus('conversation-chanA-g1')); assert.deepEqual(res, { delivered: true, serverId: 'discord', channelId: 'chanA' }); @@ -347,9 +363,9 @@ test('buildChannelContext advertises the active triggering channel as defaultOut (n) => active[n], ); seedRegistered(registry, 'discord', 'chanA'); - registry.handleIncoming('discord', incoming('chanA', 'hi from A')); + deliver(registry, 'chanA', 'hi from A'); seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanB', 'hi from B')); // global → chanB + deliver(registry, 'chanB', 'hi from B'); // global → chanB // The trunk is TOLD chanA (where its speech will actually land), matching // what routeSpeech resolves — not the global chanB. @@ -414,7 +430,7 @@ test('routeSpeech into a closed locus opens the channel first, then delivers', a // A DM-shaped situation: the channel is registered but closed. seedRegistered(registry, 'discord', 'dm-alice'); - registry.handleIncoming('discord', incoming('dm-alice', 'hello?')); + deliver(registry, 'dm-alice', 'hello?'); lookup('dm-alice')!.open = false; const res = await registry.routeSpeech('sol', 'a reply meant for the DM', discord('dm-alice')); @@ -432,7 +448,7 @@ test('routeSpeech does NOT deliver when the open-on-delivery fails', async () => const { registry, failures, publishCalls, lookup, setFailOpens } = makeRegistry({ delivered: true }); seedRegistered(registry, 'discord', 'dm-alice'); - registry.handleIncoming('discord', incoming('dm-alice', 'hello?')); + deliver(registry, 'dm-alice', 'hello?'); lookup('dm-alice')!.open = false; const publishesBefore = publishCalls.length; setFailOpens(true); @@ -543,7 +559,7 @@ test("speech opens a channel a machine closed, and the agent's own close of it t test('routeSpeech with an omitted locus fails loudly as a routing bug', async () => { const { registry, failures, traces } = makeRegistry({ delivered: true }); seedRegistered(registry, 'discord', 'ch-y'); - registry.handleIncoming('discord', incoming('ch-y', 'hi')); + deliver(registry, 'ch-y', 'hi'); const res = await (registry.routeSpeech as unknown as ( c: string, t: string) => Promise)('cairn', 'who knows where'); @@ -1004,7 +1020,7 @@ test('opened-by-delivery is announced to the delivering agent', async () => { const { registry, lookup } = makeRegistry({ delivered: true }, undefined, undefined, (info) => autoOpened.push(info)); seedRegistered(registry, 'discord', 'dm-alice'); - registry.handleIncoming('discord', incoming('dm-alice', 'hello?')); + deliver(registry, 'dm-alice', 'hello?'); lookup('dm-alice')!.open = false; await registry.routeSpeech('sol', 'reply', discord('dm-alice')); @@ -1080,10 +1096,15 @@ test('non-conversational markers do not retarget the default publish locus (#157 setDesiredState(s: string, c: string, d: 'open' | 'closed', src: string): void; }).setDesiredState('discord', 'chanB', 'open', 'agent-tool'); registry.handleIncoming('discord', incoming('chanA', 'hi from A', undefined, undefined, 'thread-A')); + registry.noteDelivered(discord('chanA'), 'm1', 'thread-A'); + // The framework records every delivered message through noteDelivered, + // tags and the coalescer's first-view mark included; the rule is the + // registry's (isMarkerTagged), so it is exercised here as the framework calls it. for (const tag of ['chat:reaction', 'chat:reaction-remove', 'chat:edited', 'chat:deleted', 'chat:system']) { lookup('chanB')!.open = false; registry.handleIncoming('discord', incoming('chanB', `[${tag}] marker`, undefined, [tag])); + registry.noteDelivered(discord('chanB'), 'marker-chanB', undefined, [tag]); assert.deepEqual(registry.getDefaultPublishChannel(), discord('chanA'), `a ${tag} marker on chanB must not steal the default publish locus`); const anchor = registry.buildChannelContext(undefined, 'discord')?.incoming; @@ -1095,6 +1116,7 @@ test('non-conversational markers do not retarget the default publish locus (#157 assert.equal(lookup('chanB')?.open, true, `a ${tag} marker still repairs chanB's open state`); // On the anchor's own channel, a marker leaves the anchor too. registry.handleIncoming('discord', incoming('chanA', `[${tag}] marker on A`, undefined, [tag])); + registry.noteDelivered(discord('chanA'), 'marker-chanA', undefined, [tag]); const sameChannel = registry.buildChannelContext(undefined, 'discord')?.incoming; assert.equal(sameChannel?.messageId, 'm1', `a ${tag} marker on the anchor's channel leaves its message`); assert.equal(sameChannel?.threadId, 'thread-A', `a ${tag} marker on the anchor's channel leaves its thread`); @@ -1104,6 +1126,7 @@ test('non-conversational markers do not retarget the default publish locus (#157 const real = incoming('chanB', 'real message on B', undefined, undefined, 'thread-B'); real.messages[0]!.messageId = 'm2'; registry.handleIncoming('discord', real); + registry.noteDelivered(discord('chanB'), 'm2', 'thread-B'); assert.deepEqual(registry.getDefaultPublishChannel(), discord('chanB')); const after = registry.buildChannelContext(undefined, 'discord')?.incoming; assert.equal(after?.messageId, 'm2', 'a real message becomes the reply anchor'); @@ -1112,6 +1135,7 @@ test('non-conversational markers do not retarget the default publish locus (#157 const unthreaded = incoming('chanA', 'real message on A, no thread'); unthreaded.messages[0]!.messageId = 'm3'; registry.handleIncoming('discord', unthreaded); + registry.noteDelivered(discord('chanA'), 'm3'); const last = registry.buildChannelContext(undefined, 'discord')?.incoming; assert.equal(last?.messageId, 'm3', 'a real message with no thread becomes the reply anchor'); assert.equal(last?.threadId, undefined, 'and takes no thread from the one before it'); @@ -1120,11 +1144,19 @@ test('non-conversational markers do not retarget the default publish locus (#157 test('a chat:system notice leaves the default publish locus where the conversation is, ambient or addressed by its place (RFC-001 rev 3)', async () => { // The weather line that took a resident's DM reply into a world: tagged // as overheard chatter, it became the most recent inbound message. + // On this branch the latest delivered inbound is recorded at admission + // (noteDelivered, with the message's tags), not at ingestion, so each + // message is delivered here as the framework delivers it. + const deliver = (message: ReturnType): void => { + registry.handleIncoming('discord', message); + const m = message.messages[0]!; + registry.noteDelivered(discord(m.channelId), m.messageId, m.threadId, m.tags); + }; const { registry } = makeRegistry(undefined); seedRegistered(registry, 'discord', 'chanA'); seedRegistered(registry, 'discord', 'world'); - registry.handleIncoming('discord', incoming('chanA', 'the conversation')); - registry.handleIncoming('discord', incoming('world', '* world weather: clear (forecast)', undefined, + deliver(incoming('chanA', 'the conversation')); + deliver(incoming('world', '* world weather: clear (forecast)', undefined, ['chat:ambient', 'chat:system', 'eidoverse:weather'])); assert.deepEqual(registry.getDefaultPublishChannel(), discord('chanA'), 'the forecast must not become where unaddressed speech goes'); @@ -1133,7 +1165,7 @@ test('a chat:system notice leaves the default publish locus where the conversati seedRegistered(registry, 'discord', 'dm'); const pin = incoming('dm', '[pinned a message]', undefined, ['chat:dm', 'chat:system']); pin.messages[0]!.messageId = 'm-pin'; - registry.handleIncoming('discord', pin); + deliver(pin); assert.deepEqual(registry.getDefaultPublishChannel(), discord('chanA'), 'a notice in a DM steers nothing either'); }); @@ -1141,7 +1173,10 @@ test('a chat:system notice leaves the default publish locus where the conversati test('a marker on a NEVER-conversed channel leaves the locus null, not marker-anchored (#157)', async () => { const { registry } = makeRegistry(undefined); seedRegistered(registry, 'discord', 'chanB'); - registry.handleIncoming('discord', incoming('chanB', '[edited] marker', undefined, ['chat:edited'])); + const marker = incoming('chanB', '[edited] marker', undefined, ['chat:edited']); + registry.handleIncoming('discord', marker); + // Recorded at admission (noteDelivered), with the marker's tags. + registry.noteDelivered(discord('chanB'), marker.messages[0]!.messageId, undefined, marker.messages[0]!.tags); assert.equal(registry.getDefaultPublishChannel(), null, 'an edit marker alone must not establish a publish locus'); assert.equal(registry.buildChannelContext(undefined, 'discord')?.incoming, undefined, diff --git a/test/coalescing-restore-wake.test.ts b/test/coalescing-restore-wake.test.ts new file mode 100644 index 000000000..4ab5b8aa1 --- /dev/null +++ b/test/coalescing-restore-wake.test.ts @@ -0,0 +1,35 @@ +/** + * A batch restored from the coalescing snapshot still owes its agent a wake + * (RFC-006 §14 v41), asked for at boot with nothing awaiting it. A wake that + * fails there is logged, never left as an unhandled rejection, which Node's + * default mode turns into the host's exit. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { AgentFramework } from '../src/index.js'; +import { fixture, eventually, crash } from './helpers/coalescing-fixture.js'; + +test('a restored batch whose wake fails is logged, not left unhandled', async (t) => { + const f = await fixture(); t.after(f.close); + await f.send('push/event', f.params('1', 'restored_batch', { deferred: true, initial: true })); + await crash(f); + const proto = AgentFramework.prototype as unknown as { queueCoalescedWakes: (...args: unknown[]) => Promise }; + const queueCoalescedWakes = proto.queueCoalescedWakes; + proto.queueCoalescedWakes = () => Promise.reject(new Error('the wake could not be queued')); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => { unhandled.push(reason); }; + process.on('unhandledRejection', onUnhandled); + const logged: string[] = []; + const consoleError = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + try { + await f.create(); + await eventually(() => logged.some((line) => line.includes('the wake could not be queued')), 'the failed wake logged'); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + proto.queueCoalescedWakes = queueCoalescedWakes; + process.off('unhandledRejection', onUnhandled); + console.error = consoleError; + } + assert.deepEqual(unhandled, []); +}); diff --git a/test/compression-after-release.test.ts b/test/compression-after-release.test.ts index fe965bd25..b0f72fd8a 100644 --- a/test/compression-after-release.test.ts +++ b/test/compression-after-release.test.ts @@ -5,12 +5,19 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { NormalizedRequest, NormalizedResponse } from '@animalabs/membrane'; import { AnthropicAdapter, Membrane, MembraneError, NativeFormatter } from '@animalabs/membrane'; -import { CompressionWithdrawn } from '@animalabs/context-manager'; +import { CompressionWithdrawn, ContextManager } from '@animalabs/context-manager'; import { JsStore } from '@animalabs/chronicle'; import type { EventResponse, Module, ModuleContext, ProcessEvent, ProcessState, ToolDefinition, ToolResult } from '../src/index.js'; -import { Agent, AgentFramework, AutobiographicalStrategy, PassthroughStrategy } from '../src/index.js'; +import { Agent, AgentFramework, AutobiographicalStrategy, PassthroughStrategy, taskForkSpec } from '../src/index.js'; import { createMockResponse, MockMembrane } from './helpers/mock-membrane.js'; +const supported = + typeof (JsStore.prototype as unknown as { view?: unknown }).view === 'function' && + typeof (ContextManager.prototype as unknown as { derive?: unknown }).derive === 'function'; +const skip = supported + ? false + : 'installed @animalabs/chronicle / context-manager lack branch-bound handles or ContextManager.derive'; + class CompressionMembrane extends MockMembrane { readonly compressions: number[] = []; readonly aborted: number[] = []; @@ -236,6 +243,71 @@ describe('a context manager the framework is done with makes no further provider } }); + it('a shared-inheritance run that has ended, with compression still queued (the derived manager is on the run\'s membrane)', { timeout: 60_000, skip }, async () => { + const membrane = new CompressionMembrane(200); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); + const { dir, framework } = await setup('af-done-shared-', membrane, { agents: true }); + const errors = captureErrors(); + try { + // The parent's slot carries the backlog; the child inherits it shared, on + // its own branch, with a draining strategy, so compression queues on the + // derived manager (which, before the tenth round, was on the framework's + // membrane and so outlived the run's release). + addBacklog(framework.getAgent('trunk')!.getContextManager(), 30); + const { agent, contextManager } = await framework.createEphemeralAgent( + { name: 'worker', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', strategy: drainingStrategy() }, + taskForkSpec('worker', { parent: 'trunk', inherit: { mode: 'shared' } }), + ); + contextManager.addMessage('user', [{ type: 'text', text: 'Run once.' }]); + await until(() => membrane.compressions.length >= 1); + const run = framework.runEphemeralToCompletion(agent, contextManager); + framework.start(); + await run; + const sent = membrane.compressions.length; + await sleep(1_000); + assert.equal(membrane.compressions.length, sent, 'no compression call reached the provider after the run ended'); + await assert.rejects(contextManager.resetHeadWindow(), (error: unknown) => error instanceof CompressionWithdrawn, + 'a reset with no text is withdrawn'); + assert.equal(membrane.compressions.length, sent, 'and reached no provider'); + assert.deepEqual(drainLines(errors.lines), []); + } finally { + errors.restore(); + await settle(membrane); + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('a resumed job after cleanup(): the call its manager has out is aborted, and it makes no call', { timeout: 60_000 }, async () => { + const membrane = new CompressionMembrane(100); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }])); + const { dir, framework } = await setup('af-done-resumed-', membrane); + const errors = captureErrors(); + try { + const first = await framework.createEphemeralAgent({ name: 'job', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all' }); + first.contextManager.addMessage('user', [{ type: 'text', text: 'Run once.' }]); + const run = framework.runEphemeralToCompletion(first.agent, first.contextManager); + framework.start(); + await run; + const resumed = await framework.resumeAgent('job', { + model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', strategy: drainingStrategy(), + }); + addBacklog(resumed.contextManager, 30); + await until(() => membrane.compressions.length >= 1 && membrane.inFlight > 0); + resumed.cleanup(); + const sent = membrane.compressions.length; + await sleep(800); + assert.equal(membrane.compressions.length, sent, 'no compression call reached the provider after cleanup()'); + assert.ok(membrane.aborted.length >= 1, 'the call it had out was aborted'); + assert.deepEqual(drainLines(errors.lines), []); + } finally { + errors.restore(); + await settle(membrane); + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + it('after cleanup() with no run: the call it has out is aborted, it makes no call, and another agent compresses as before', { timeout: 60_000 }, async () => { const membrane = new CompressionMembrane(100); const { dir, framework } = await setup('af-done-cleanup-', membrane); diff --git a/test/conversation-fork-injection-scoping.test.ts b/test/conversation-fork-injection-scoping.test.ts index fc0958632..c58002ff4 100644 --- a/test/conversation-fork-injection-scoping.test.ts +++ b/test/conversation-fork-injection-scoping.test.ts @@ -17,12 +17,12 @@ import type { import { ChannelRegistry, destinationKey, type ChannelRef } from '../src/mcpl/channel-registry.js'; import { CapabilityGrant, ALL_CAPABILITY_PATHS } from '../src/mcpl/capability-grant.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { bindAsConversationFork, homeChannelOf } from './helpers/dendrite.js'; const FORK_AGENT = 'conversation-chanA-g1'; type FrameworkInternals = { channelRegistry: ChannelRegistry | null; - conversationAgentHomes: Map; hookOrchestrator: { beforeInference(params: unknown): Promise; emitLifecycle(params: unknown): void; @@ -87,7 +87,10 @@ function installChannelRegistry( () => {}, () => {}, { - homeChannelResolver: (agentName) => internals.conversationAgentHomes.get(agentName), + homeChannelResolver: (agentName) => { + const home = homeChannelOf(framework, agentName); + return typeof home === 'string' ? undefined : home; + }, }, ); for (const channelId of channelIds) { @@ -111,7 +114,7 @@ function installChannelRegistry( } function bindForkHome(framework: AgentFramework, agentName = FORK_AGENT, channelId = 'chanA'): void { - frameworkInternals(framework).conversationAgentHomes.set(agentName, { serverId: 'srv', channelId }); + bindAsConversationFork(framework, agentName, { serverId: 'srv', channelId }); } function installHookInjections(framework: AgentFramework, injections: ContextInjection[]): void { diff --git a/test/conversation-routing.test.ts b/test/conversation-routing.test.ts index 9d7537d45..7bf486741 100644 --- a/test/conversation-routing.test.ts +++ b/test/conversation-routing.test.ts @@ -7,7 +7,7 @@ import { describe, it, beforeEach, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import { tmpdir } from 'node:os'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { AgentFramework } from '../src/index.js'; import type { ProcessEvent, ConversationRouterConfig, TraceEvent, Module, ModuleContext, ToolCall, ToolResult } from '../src/index.js'; @@ -222,9 +222,9 @@ describe('Conversation routing', () => { assert.ok(!messages.some((m) => JSON.stringify(m.content).includes('from srv')), "and not srv's"); // Each spawned fork's home keeps its server, which its channel tools and // speech go to (shared-channel-id's fork tests, over recording servers). - const homes = (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes; - assert.deepEqual(homes.get(forkName('slack:D1', 1)), { serverId: 'srv', channelId: 'slack:D1' }); - assert.deepEqual(homes.get(otherFork.name), { serverId: 'other', channelId: 'slack:D1' }); + const homes = (framework as unknown as { registry: { homeChannel(name: string): unknown } }).registry; + assert.deepEqual(homes.homeChannel(forkName('slack:D1', 1)), { serverId: 'srv', channelId: 'slack:D1' }); + assert.deepEqual(homes.homeChannel(otherFork.name), { serverId: 'other', channelId: 'slack:D1' }); assert.deepEqual(spawned, [{ serverId: 'srv', channelId: 'slack:D1' }, { serverId: 'other', channelId: 'slack:D1' }], 'each spawn is traced with its server'); await framework.stop(); @@ -422,8 +422,9 @@ describe('Conversation routing', () => { const { seeded, lines } = await seedOf(framework); const types = seeded.flatMap((m) => m.content.map((b) => b.type)); assert.ok(!types.includes('tool_use') && !types.includes('thinking'), `no unanswered call or thinking in the seed: ${types}`); - // The seed is everything before the fork's own first message ('hi'). - const firstOwn = seeded.findIndex((m) => JSON.stringify(m.content).includes('"hi"')); + // The seed is everything before the fork's own first message ('hi', + // stored as the model reads it: under its author's name). + const firstOwn = seeded.findIndex((m) => JSON.stringify(m.content).includes('"alice: hi"')); assert.ok(firstOwn > 1, 'the incoming message follows the seed'); // The thinking goes first, so the round's row holds only its unanswered // call, which the pairing pass replaces with its placeholder. @@ -837,14 +838,14 @@ describe('Conversation routing', () => { // The gate's sleep quiets the whole agent, every conversation of it, so a // fork's sleep and wake are refused (#646), and a fork's end tells the gate, // which cancels the fork's pending self-wake. - async function makeGatedFramework(conversations: Partial = {}) { + async function makeGatedFramework(conversations: Partial = {}, gate: { privilegedUsersPath?: string } = {}) { return AgentFramework.create({ storePath: join(tempDir, 'test.chronicle'), membrane: membrane.asMembrane(), agents: [{ name: 'trunk', model: 'test-model', systemPrompt: 'You are the trunk.' }], modules: [], conversations: { templateAgent: 'trunk', ...conversations }, - gate: { config: { policies: [], default: 'always' }, configPath: join(tempDir, 'gate.json') }, + gate: { config: { policies: [], default: 'always' }, configPath: join(tempDir, 'gate.json'), ...gate }, }); } const gateOf = (framework: AgentFramework) => (framework as unknown as { eventGate: EventGate }).eventGate; @@ -873,7 +874,12 @@ describe('Conversation routing', () => { it("a fork's wake is refused, so the primary's sleep stays", async () => { membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'w1', name: 'wake', input: {} }], 'tool_use')); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'ok' }])); - const framework = await makeGatedFramework(); + // The message that wakes the fork is a privileged user's: admission gates a + // fork's channel message as it gates every inbound (Dendrite §D), and a + // sleeping agent is roused by its heartbeat or a privileged user alone. + const privilegedUsersPath = join(tempDir, 'privileged.json'); + writeFileSync(privilegedUsersPath, JSON.stringify(['U1'])); + const framework = await makeGatedFramework({}, { privilegedUsersPath }); const gate = gateOf(framework); const calls = gateToolCalls(framework); try { diff --git a/test/deferred-window-only.test.ts b/test/deferred-window-only.test.ts index d0aa459fe..03ec74e2a 100644 --- a/test/deferred-window-only.test.ts +++ b/test/deferred-window-only.test.ts @@ -1948,7 +1948,7 @@ describe('mid-turn deliveries keep the window what the stream carried', () => { // A logical turn still open between its streams, engaged with chan-beta: // no turn is alive, so no tool boundary weighs what is stored now, and // it's noted as one of the turn's arrivals. - const open = { engaged: new Set([destinationKey(srv('chan-beta'))]), wasIn: [] as ChannelRef[], arrivals: [] as Array<{ messageId: string; channel: ChannelRef }> }; + const open = { engaged: new Set([destinationKey(srv('chan-beta'))]), wasIn: [] as ChannelRef[], arrivals: [] as Array<{ messageId: string; channel: ChannelRef; addressed: boolean }> }; internals.openLogical.set('assistant', open); const errors: string[] = []; const error = console.error; @@ -1967,7 +1967,7 @@ describe('mid-turn deliveries keep the window what the stream carried', () => { const copies = framework.getAgent('assistant')!.getContextManager().getAllMessages() .filter((m) => (m.metadata as { deferredWriteId?: string } | undefined)?.deferredWriteId === 'w1'); assert.equal(copies.length, 1, 'stored once'); - assert.deepEqual(open.arrivals, [{ messageId: copies[0]!.id, channel: srv('chan-beta') }], 'and noted as one of the open turn\'s arrivals'); + assert.deepEqual(open.arrivals, [{ messageId: copies[0]!.id, channel: srv('chan-beta'), addressed: false }], 'and noted as one of the open turn\'s arrivals'); await framework.stop(); }); @@ -1982,7 +1982,7 @@ describe('mid-turn deliveries keep the window what the stream carried', () => { }; // An open turn engaged with both channels, between its streams: the // earlier write throws after it's stored, and the later one lands. - const open = { engaged: new Set([destinationKey(srv('chan-alpha')), destinationKey(srv('chan-beta'))]), wasIn: [] as ChannelRef[], arrivals: [] as Array<{ messageId: string; channel: ChannelRef }> }; + const open = { engaged: new Set([destinationKey(srv('chan-alpha')), destinationKey(srv('chan-beta'))]), wasIn: [] as ChannelRef[], arrivals: [] as Array<{ messageId: string; channel: ChannelRef; addressed: boolean }> }; internals.openLogical.set('assistant', open); const errors: string[] = []; const error = console.error; @@ -1997,8 +1997,8 @@ describe('mid-turn deliveries keep the window what the stream carried', () => { const idOf = (writeId: string) => agent.getContextManager().getAllMessages() .find((m) => (m.metadata as { deferredWriteId?: string } | undefined)?.deferredWriteId === writeId)!.id; assert.deepEqual(open.arrivals, [ - { messageId: idOf('w1'), channel: srv('chan-alpha') }, - { messageId: idOf('w2'), channel: srv('chan-beta') }, + { messageId: idOf('w1'), channel: srv('chan-alpha'), addressed: false }, + { messageId: idOf('w2'), channel: srv('chan-beta'), addressed: false }, ]); assert.deepEqual(internals.remainingArrival(agent, open), srv('chan-beta'), "the turn's newest arrival is the later write's"); await framework.stop(); diff --git a/test/dendrite-derive.test.ts b/test/dendrite-derive.test.ts new file mode 100644 index 000000000..47d9d5a09 --- /dev/null +++ b/test/dendrite-derive.test.ts @@ -0,0 +1,700 @@ +/** + * Dendrite context derivation through the framework. + * + * `deriveAgent` gives a child its parent's context at a checkpoint and a + * private continuation. With shared inheritance nothing is copied and the + * child renders what the parent rendered, so the provider prefix the parent + * paid for is reused. The rendering contract is checked on PROVIDER- + * FORMATTED requests, up to the fork boundary: the end of the parent's last + * request before the turn that caused the derivation. + * + * Shared inheritance needs branch-bound store handles (`JsStore.view`) and + * `ContextManager.derive`. Where the installed packages lack them, those + * tests are skipped by name with the reason; the copy-mode and refusal + * tests always run. + */ +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 { JsStore } from '@animalabs/chronicle'; +import { NativeFormatter } from '@animalabs/membrane'; +import type { NormalizedRequest } from '@animalabs/membrane'; +import { ContextManager } from '@animalabs/context-manager'; +import { AgentFramework, AutobiographicalStrategy } from '../src/index.js'; +import type { ContextStrategy } from '../src/index.js'; +import { GateModule, RoutedMembrane, say, until, waitCall } from './helpers/dendrite.js'; +import { createMockResponse } from './helpers/mock-membrane.js'; + +const supported = + typeof (JsStore.prototype as unknown as { view?: unknown }).view === 'function' && + typeof (ContextManager.prototype as unknown as { derive?: unknown }).derive === 'function'; +const skip = supported + ? false + : 'installed @animalabs/chronicle / context-manager lack branch-bound handles or ContextManager.derive'; + +const textOf = (m: { content: Array<{ type: string; text?: string }> }): string => + m.content.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('\n'); + +/** A fork's messages after inheritance, leaving aside the parent's round in flight (tested on its own). */ +const own = (messages: M[]): M[] => + messages.filter((m) => (m.metadata as { kind?: string } | undefined)?.kind !== 'dendrite-pending-round'); + +const FRAMING = '[FORK-FRAMING] You are the fork. Task: count the open issues.'; + +function folding(): ContextStrategy { + return new AutobiographicalStrategy({ + compressionModel: 'mock', + adaptiveResolution: true, + targetChunkTokens: 100, + recentWindowTokens: 200, + }) as unknown as ContextStrategy; +} + +/** Provider-formatted view of a request, as the native formatter sends it. */ +function providerFormat(request: NormalizedRequest) { + const built = new NativeFormatter().buildMessages(request.messages, { + participantMode: 'multiuser', + assistantParticipant: request.assistantParticipant ?? 'Claude', + tools: request.tools, + thinking: request.config.thinking, + systemPrompt: request.system, + promptCaching: request.promptCaching ?? true, + cacheMarkers: request.cacheMarkers ?? 'membrane-system', + cacheTtl: request.cacheTtl, + } as never) as unknown as { + messages: Array<{ role: string; content: Array> | string }>; + systemContent?: unknown; + nativeTools?: unknown[]; + }; + return built; +} + +type Block = Record; +const blocksOf = (message: { content: Block[] | string }): Block[] => + typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content; + +describe('Dendrite deriveAgent', () => { + let tempDir: string; + let membrane: RoutedMembrane; + let gates: GateModule; + let framework: AgentFramework; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'dendrite-derive-')); + membrane = new RoutedMembrane(); + membrane.completion = '[mock summary]'; + // A derived agent presents itself under its parent's name; tell the two + // apart by the framing only the fork carries. + membrane.identify = (request) => { + const text = JSON.stringify(request.messages); + if (text.includes('[GRANDCHILD-FRAMING]')) return 'fork-2'; + if (text.includes('[FORK-FRAMING]')) return 'fork-1'; + return request.assistantParticipant ?? ''; + }; + gates = new GateModule(); + framework = await AgentFramework.create({ + storePath: join(tempDir, 'store.chronicle'), + membrane: membrane.asMembrane(), + agents: [{ + name: 'mira', + model: 'test-model', + systemPrompt: 'You are mira.', + strategy: folding(), + contextBudgetTokens: 3200, + maxTokens: 200, + thinking: { enabled: true, budgetTokens: 1024 }, + cacheTtl: '5m', + providerParams: { tier: 'priority' }, + }], + modules: [gates], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + }); + + afterEach(async () => { + try { await framework.stop(); } catch { /* already stopped */ } + rmSync(tempDir, { recursive: true, force: true }); + }); + + /** Enough history that mira's window cannot hold it raw, then a turn that blocks in a tool. */ + async function miraMidTurn(): Promise<{ parentRequest: NormalizedRequest; history: string[] }> { + const cm = framework.getAgent('mira')!.getContextManager(); + const history: string[] = []; + for (let i = 0; i < 60; i++) { + history.push(cm.addMessage(i % 2 ? 'mira' : 'user', [{ type: 'text', text: `Turn ${i}. ` + 'word '.repeat(40) }])); + } + while (!cm.isReady()) await cm.tick(); + history.push(cm.addMessage('user', [{ type: 'text', text: 'Please look into the open issues.' }])); + + membrane.script('mira', waitCall('g-mira'), say('mira done')); + framework.start(); + (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'mira', reason: 'test', source: 'test', timestamp: Date.now(), + }); + await until(() => gates.entered.has('g-mira'), 'mira to block mid-turn'); + return { parentRequest: membrane.callsFor('mira').at(-1)!, history }; + } + + async function runFork(options: Parameters[0]) { + const fork = await framework.deriveAgent(options); + const run = framework.runEphemeralToCompletion(fork.agent, fork.contextManager); + const result = await run; + return { ...fork, result }; + } + + it('shared inheritance: the fork continues from the parent\'s context and the parent is untouched', { skip }, async () => { + const { history } = await miraMidTurn(); + const parentCm = framework.getAgent('mira')!.getContextManager(); + const before = parentCm.getAllMessages(); + + membrane.script('fork-1', say('there are 7 open issues')); + const { result, contextManager } = await runFork({ + name: 'fork-1', + from: 'mira', + strategy: folding(), + framing: [{ participant: 'user', content: [{ type: 'text', text: FRAMING }] }], + }); + assert.equal(result.speech, 'there are 7 open issues'); + + // The child's own continuation: its framing and its answer, under its own name. + const forkMessages = contextManager.getAllMessages(); + assert.deepEqual(forkMessages.slice(0, history.length).map((m) => m.id), history); + assert.deepEqual( + own(forkMessages).slice(history.length).map((m) => [m.participant, (m.content[0] as { text: string }).text]), + [['user', FRAMING], ['fork-1', 'there are 7 open issues']], + ); + // Shared, not copied; and nothing of the child's reached the parent. + for (let i = 0; i < history.length; i++) assert.equal(forkMessages[i], before[i]); + assert.deepEqual(parentCm.getAllMessages().map((m) => m.id), history); + + // The registry records what was inherited and where the child's writes live. + const record = framework.getAgentRecord('fork-1')!; + assert.equal(record.kind, 'task-fork'); + assert.equal(record.relationships.spawnedBy, 'mira'); + const { derivation, ...inherit } = record.inherit!; + assert.deepEqual(inherit, { + from: 'mira', + mode: 'shared', + solve: 'reuse', + refusals: false, + ownBranch: 'dendrite/fork-1', + branch: 'main', + atSequence: record.inherit!.atSequence, + }); + assert.equal(typeof record.inherit!.atSequence, 'number'); + // What reopening the child's context later needs, as plain data. + assert.deepEqual( + (derivation as { slots: unknown; branch: string }).slots, + { messageNamespace: null, contextNamespace: 'agents/mira', auxiliaryNamespaces: [] }, + ); + assert.equal((derivation as { branch: string }).branch, 'dendrite/fork-1'); + assert.deepEqual(record.selfParticipants, ['mira']); + assert.equal(record.presentAs, 'mira'); + assert.equal(record.namespace, 'agents/mira', 'its context is the parent\'s namespace on its own branch'); + assert.equal(record.ended?.reason, 'completed'); + // The store cursor never moved. + assert.equal(framework.getStore().currentBranch().name, 'main'); + }); + + it('rendering contract: the fork\'s first request is the parent\'s request plus its framing, byte for byte', { skip }, async () => { + const { parentRequest } = await miraMidTurn(); + membrane.script('fork-1', say('done')); + await runFork({ + name: 'fork-1', + from: 'mira', + strategy: folding(), + framing: [{ participant: 'user', content: [{ type: 'text', text: FRAMING }] }], + }); + const forkRequest = membrane.callsFor('fork-1')[0]!; + + // Identity and inference settings are the parent's, not a default. + assert.equal(forkRequest.system, parentRequest.system); + assert.deepEqual(forkRequest.config, parentRequest.config); + assert.equal(forkRequest.config.model, 'test-model'); + assert.deepEqual(forkRequest.config.thinking, { enabled: true, budgetTokens: 1024 }); + assert.equal(forkRequest.cacheTtl, '5m'); + assert.deepEqual(forkRequest.providerParams, { tier: 'priority' }); + assert.equal(JSON.stringify(forkRequest.tools), JSON.stringify(parentRequest.tools), 'the tool block is identical'); + // The parent's turns are the fork's own turns, under the name the parent's requests use. + assert.equal(forkRequest.assistantParticipant, parentRequest.assistantParticipant); + assert.ok(parentRequest.messages.some((m) => m.participant === 'mira'), 'the parent has assistant turns to inherit'); + + const parent = providerFormat(parentRequest); + const fork = providerFormat(forkRequest); + assert.ok(parent.systemContent !== undefined, 'the premise: the formatter gives a system block to compare'); + assert.ok((parent.nativeTools?.length ?? 0) > 0, 'the premise: and a tool block'); + assert.equal(JSON.stringify(fork.systemContent), JSON.stringify(parent.systemContent)); + assert.equal(JSON.stringify(fork.nativeTools), JSON.stringify(parent.nativeTools)); + + // Up to the boundary: every provider message of the parent's request, + // and within the last one every block, appears unchanged in the fork's. + // + // "Unchanged" is the content the provider hashes. Where the breakpoints + // sit is compared separately: interior breakpoints move from one request + // to the next by design (a request marks the end of the previous one and + // its own end), so the parent's own next request would not repeat its + // earlier interior marker either. What reuse needs is the boundary + // itself marked in both, which is asserted below. + const content = (value: unknown): string => + JSON.stringify(value, (key, inner) => (key === 'cache_control' ? undefined : inner)); + const last = parent.messages.length - 1; + assert.ok(last > 0 && fork.messages.length >= parent.messages.length); + for (let i = 0; i < last; i++) { + assert.equal(content(fork.messages[i]), content(parent.messages[i]), `provider message ${i}`); + } + assert.equal(fork.messages[last]!.role, parent.messages[last]!.role); + const parentTail = blocksOf(parent.messages[last]!); + const forkTail = blocksOf(fork.messages[last]!); + assert.equal( + content(forkTail.slice(0, parentTail.length)), + content(parentTail), + 'the boundary message is a block-for-block prefix', + ); + // A cache marker sits on the boundary in both requests: the parent wrote + // the prefix there and the fork names the same breakpoint to read it. + const boundary = parentTail.length - 1; + assert.deepEqual(parentTail[boundary]!.cache_control, { type: 'ephemeral', ttl: '5m' }, 'the parent\'s request is cached up to its end'); + assert.deepEqual(forkTail[boundary]!.cache_control, parentTail[boundary]!.cache_control, 'the fork marks the same boundary, same TTL'); + // And the fork stays inside the provider's breakpoint allowance. + const marked = (messages: typeof fork.messages) => + messages.flatMap(blocksOf).filter((block) => block.cache_control).length; + assert.ok(marked(fork.messages) <= 4 && marked(fork.messages) >= 2); + // Everything after the boundary is the fork's own. + const after = [...forkTail.slice(parentTail.length), ...fork.messages.slice(last + 1).flatMap(blocksOf)]; + assert.ok(JSON.stringify(after).includes('[FORK-FRAMING]')); + // The parent contains folded memory, so this was not a trivial raw copy. + assert.ok(JSON.stringify(parent.messages).includes('[mock summary]'), 'the shared prefix includes folded memories'); + }); + + it('a fresh solve at another budget is a supported choice: it renders differently and says so', { skip }, async () => { + const { parentRequest } = await miraMidTurn(); + membrane.script('fork-1', say('done')); + await runFork({ + name: 'fork-1', + from: 'mira', + strategy: folding(), + solve: 'fresh', + config: { contextBudgetTokens: 2000 }, + framing: [{ participant: 'user', content: [{ type: 'text', text: FRAMING }] }], + }); + const forkRequest = membrane.callsFor('fork-1')[0]!; + assert.equal(framework.getAgentRecord('fork-1')!.inherit!.solve, 'fresh'); + const parent = JSON.stringify(providerFormat(parentRequest).messages); + const fork = JSON.stringify(providerFormat(forkRequest).messages); + assert.notEqual(fork.slice(0, parent.length), parent, 'a smaller budget folds more: the prefix is not shared'); + assert.ok(fork.length < parent.length + FRAMING.length + 200, 'and the request is no larger'); + // Still the parent's identity and still a valid request. + assert.equal(forkRequest.system, parentRequest.system); + assert.ok(fork.includes('[FORK-FRAMING]')); + }); + + it('inherits the parent\'s refusal ledger only when asked, and says so in the record', { skip }, async () => { + await miraMidTurn(); + const ledger = 'agents/mira/autobio:compression-refusal-quarantine-events'; + framework.getStore().appendToStateJson(ledger, { kind: 'checkpoint', at: 1, note: 'mira declined this' }); + + membrane.script('fork-1', say('done')); + const without = await runFork({ + name: 'fork-1', + from: 'mira', + strategy: folding(), + framing: [{ participant: 'user', content: [{ type: 'text', text: FRAMING }] }], + }); + assert.equal(framework.getAgentRecord('fork-1')!.inherit!.refusals, false); + assert.equal(without.contextManager.getStore().getStateLen(ledger) ?? 0, 0, 'a task fork is free of its parent\'s refusals'); + + membrane.identify = (request) => (JSON.stringify(request.messages).includes('[READER-FRAMING]') ? 'reader-1' : request.assistantParticipant ?? ''); + membrane.script('reader-1', say('done')); + const withRefusals = await runFork({ + name: 'reader-1', + from: 'mira', + kind: 'subconscious-fork', + inheritRefusals: true, + strategy: folding(), + framing: [{ participant: 'user', content: [{ type: 'text', text: '[READER-FRAMING] you stand in for mira\'s attention' }] }], + }); + const record = framework.getAgentRecord('reader-1')!; + assert.equal(record.inherit!.refusals, true, 'the creation record says "with my refusals"'); + assert.equal(withRefusals.contextManager.getStore().getStateLen(ledger), 1, 'and the child knows what mira declined'); + assert.equal(framework.getStore().getStateLen(ledger), 1, 'mira\'s own ledger is untouched'); + }); + + it('a running fork can itself be derived from, and each sees only its own line', { skip }, async () => { + const { history } = await miraMidTurn(); + membrane.script('fork-1', waitCall('g-fork'), say('fork-1 done')); + const first = await framework.deriveAgent({ + name: 'fork-1', + from: 'mira', + strategy: folding(), + framing: [{ participant: 'user', content: [{ type: 'text', text: FRAMING }] }], + }); + const firstRun = framework.runEphemeralToCompletion(first.agent, first.contextManager); + await until(() => gates.entered.has('g-fork'), 'fork-1 to block'); + + membrane.script('fork-2', say('fork-2 done')); + const second = await runFork({ + name: 'fork-2', + from: 'fork-1', + strategy: folding(), + framing: [{ participant: 'user', content: [{ type: 'text', text: '[GRANDCHILD-FRAMING] go deeper' }] }], + }); + assert.equal(second.result.speech, 'fork-2 done'); + const record = framework.getAgentRecord('fork-2')!; + assert.equal(record.inherit!.branch, 'dendrite/fork-1'); + assert.deepEqual(record.selfParticipants, ['fork-1', 'mira'], 'both ancestors are its own voice'); + assert.equal(membrane.callsFor('fork-2')[0]!.assistantParticipant, 'mira'); + + const secondTexts = own(second.contextManager.getAllMessages()).slice(history.length) + .map((m) => (m.content[0] as { text?: string }).text ?? `<${m.content[0]!.type}>`); + assert.deepEqual(secondTexts, [FRAMING, '[GRANDCHILD-FRAMING] go deeper', 'fork-2 done']); + + gates.release('g-fork'); + assert.equal((await firstRun).speech, 'fork-1 done'); + const firstTexts = own(first.contextManager.getAllMessages()).slice(history.length).map((m) => m.participant); + assert.equal(firstTexts.includes('fork-2'), false, 'the grandchild\'s turns never reached its parent'); + assert.equal(framework.getAgent('mira')!.getContextManager().getAllMessages().length, history.length); + }); + + it('returns its result to the parent as attributed mail', { skip }, async () => { + await miraMidTurn(); + membrane.script('fork-1', say('7 open issues')); + const { result } = await runFork({ + name: 'fork-1', + from: 'mira', + strategy: folding(), + resultTo: { to: 'mira', as: 'message' }, + framing: [{ participant: 'user', content: [{ type: 'text', text: FRAMING }] }], + }); + const mailTraces: string[] = []; + framework.onTrace((event) => { + if (event.type.startsWith('dendrite:mail-')) mailTraces.push(event.type); + }); + const delivery = framework.deliverAgentResult('fork-1', [{ type: 'text', text: result.speech }]); + assert.equal(delivery.delivered, true); + // mira is mid-turn: the result waits for her tool boundary, never lost. + assert.equal(framework.listHeldMail().length, 1); + assert.deepEqual(mailTraces, ['dendrite:mail-deferred'], 'queued is not delivered'); + gates.release('g-mira'); + await until(() => framework.listHeldMail().length === 0, 'the result to enter mira\'s context'); + assert.deepEqual(mailTraces, ['dendrite:mail-deferred', 'dendrite:mail-delivered'], 'delivered when it entered the store'); + const landed = framework.getAgent('mira')!.getContextManager().getAllMessages() + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'agent-result'); + assert.equal(landed.length, 1); + assert.equal(landed[0]!.participant, 'fork-1'); + assert.deepEqual( + (landed[0]!.metadata as { dendrite: { from: unknown } }).dendrite.from, + { agent: 'fork-1', incarnation: 1 }, + ); + }); + + it('a fork made from inside a tool call sees the call that made it', { skip }, async () => { + await miraMidTurn(); // mira is blocked in test--wait, call id call-g-mira; that turn is not stored yet + membrane.script('fork-1', say('seen')); + const { result, contextManager } = await runFork({ + name: 'fork-1', + from: 'mira', + framing: [{ participant: 'user', content: [{ type: 'text', text: FRAMING }] }], + }); + assert.equal(result.speech, 'seen'); + const messages = contextManager.getAllMessages(); + const at = messages.findIndex((m) => m.content.some((b) => b.type === 'tool_use' && b.id === 'call-g-mira')); + assert.ok(at >= 0, 'the pending assistant turn is on the fork\'s branch'); + assert.equal(messages[at]!.participant, 'mira', 'stored under the parent\'s name, presented as the fork\'s own'); + assert.equal((messages[at]!.metadata as { kind?: string }).kind, 'dendrite-pending-round'); + const results = messages[at + 1]!; + assert.equal(results.participant, 'user'); + const toolResult = results.content.find((b) => b.type === 'tool_result') as { toolUseId: string; content: string }; + assert.equal(toolResult.toolUseId, 'call-g-mira'); + assert.match( + toolResult.content, + /This call derived fork-1\. You are fork-1, a task-fork, continuing from here on your own branch\. You finish your task and then end; if mira ends first you keep running/, + 'who, what kind, and how long — the consent event\'s fields, in the note', + ); + assert.equal(textOf(messages[at + 2]!), FRAMING, 'framing follows the round'); + // The parent's own store still has nothing of the round: it lands there when the results are in. + const miraMessages = framework.getAgent('mira')!.getContextManager().getAllMessages(); + assert.equal(miraMessages.some((m) => m.content.some((b) => b.type === 'tool_use')), false); + // On the wire: the fork's request pairs the tool_use with a tool_result, so no dangling call. + const request = membrane.callsFor('fork-1')[0]!; + const flat = JSON.stringify(request.messages); + assert.ok(flat.includes('"call-g-mira"') && flat.includes('This call derived fork-1')); + }); + + it('the pending round is the host\'s to word or leave out', async () => { + await miraMidTurn(); + membrane.script('fork-1', say('ok')); + membrane.script('fork-2', say('ok')); + const worded = await runFork({ + name: 'fork-1', + from: 'mira', + mode: 'copy', + pendingRound: { madeBy: { toolUseId: 'call-g-mira', result: 'You are the fork this call asked for.' } }, + }); + const wordedMessages = worded.contextManager.getAllMessages(); + const round = wordedMessages.find((m) => m.content.some((b) => b.type === 'tool_use' && b.id === 'call-g-mira'))!; + assert.equal(round.participant, 'fork-1', 'copy inheritance: renamed like the rest of the copy'); + const toolResult = wordedMessages + .flatMap((m) => m.content) + .find((b) => b.type === 'tool_result') as { content: string }; + assert.equal(toolResult.content, 'You are the fork this call asked for.'); + + const without = await runFork({ name: 'fork-2', from: 'mira', mode: 'copy', pendingRound: { include: false } }); + assert.equal( + without.contextManager.getAllMessages().some((m) => m.content.some((b) => b.type === 'tool_use')), + false, + 'include: false — the fork starts from the last stored message before the round', + ); + }); + + it('denies a tool at dispatch without removing it from the request', async () => { + const { parentRequest } = await miraMidTurn(); + const refused: Array<{ tool?: unknown; error?: unknown }> = []; + framework.onTrace((event) => { + if (event.type === 'tool:failed') refused.push(event as { tool?: unknown; error?: unknown }); + }); + membrane.script( + 'fork-1', + createMockResponse([{ type: 'tool_use', id: 'call-denied', name: 'test--wait', input: { gate: 'never' } }], 'tool_use'), + say('could not wait'), + ); + // Copy inheritance: the deny does not depend on how context is inherited. + const { result, contextManager } = await runFork({ + name: 'fork-1', + from: 'mira', + mode: 'copy', + config: { denyToolsAtDispatch: ['test--wait'] }, + framing: [{ participant: 'user', content: [{ type: 'text', text: FRAMING }] }], + }); + assert.equal(result.speech, 'could not wait'); + assert.equal(gates.entered.has('never'), false, 'the tool never ran'); + const forkRequest = membrane.callsFor('fork-1')[0]!; + assert.equal(JSON.stringify(forkRequest.tools), JSON.stringify(parentRequest.tools), 'still advertised: the prefix is unchanged'); + assert.deepEqual( + refused.map((event) => [event.tool, event.error]), + [['test--wait', 'test--wait is not available to fork-1']], + ); + // The refusal reached the fork as that call's tool result. + const toolResult = contextManager.getAllMessages() + .flatMap((m) => m.content) + .find((block) => block.type === 'tool_result' && (block as { toolUseId: string }).toolUseId === 'call-denied'); + assert.ok(toolResult && JSON.stringify(toolResult).includes('not available to fork-1')); + }); + + it('a tool denied at dispatch is refused on every path that runs the agent\'s call, executeToolCall included', async () => { + await miraMidTurn(); + membrane.script('fork-1', waitCall('g-fork'), say('done')); + const fork = await framework.deriveAgent({ + name: 'fork-1', + from: 'mira', + mode: 'copy', + config: { denyToolsAtDispatch: ['agent_settings'] }, + framing: [{ participant: 'user', content: [{ type: 'text', text: FRAMING }] }], + }); + const run = framework.runEphemeralToCompletion(fork.agent, fork.contextManager); + await until(() => gates.entered.has('g-fork'), 'the fork to block mid-turn'); + const result = await framework.executeToolCall({ id: 'call-x', name: 'agent_settings', input: {}, callerAgentName: 'fork-1' }); + assert.equal(result.success, false); + assert.match(String(result.error), /agent_settings is not available to fork-1/); + gates.release('g-fork'); + await run; + }); + + it('a stopped fork resumes on the context it left, as a new incarnation', { skip }, async () => { + const { history } = await miraMidTurn(); + membrane.script('fork-1', waitCall('g-fork'), say('never said')); + const first = await framework.deriveAgent({ + name: 'fork-1', + from: 'mira', + strategy: folding(), + resultTo: { to: 'mira', as: 'message' }, + framing: [{ participant: 'user', content: [{ type: 'text', text: FRAMING }] }], + }); + const firstRun = framework.runEphemeralToCompletion(first.agent, first.contextManager).catch((error) => error); + await until(() => gates.entered.has('g-fork'), 'fork-1 to block'); + + await assert.rejects( + framework.resumeAgent('fork-1', { model: 'test-model', systemPrompt: 'You are mira.' }), + /still registered; only an ended agent resumes/, + ); + framework.stopAgent('fork-1', { by: 'operator', reason: 'pausing' }); + assert.match(String((await firstRun as Error).message), /ended \(stopped by operator\)/); + assert.equal(framework.getAgentRecord('fork-1')!.ended?.reason, 'stopped'); + + // Configuration is not persisted; the caller supplies it. The role + // mapping the fork used is restored from its record. + membrane.script('fork-1', say('resumed and finished')); + const resumed = await framework.resumeAgent('fork-1', { + model: 'test-model', + systemPrompt: 'You are mira.', + strategy: folding(), + contextBudgetTokens: 3200, + maxTokens: 200, + }); + // It starts from exactly what it had stored: its inheritance and its framing. + assert.deepEqual( + own(resumed.contextManager.getAllMessages()).slice(history.length).map((m) => (m.content[0] as { text: string }).text), + [FRAMING], + ); + const result = await framework.runEphemeralToCompletion(resumed.agent, resumed.contextManager); + assert.equal(result.speech, 'resumed and finished'); + + const request = membrane.callsFor('fork-1').at(-1)!; + assert.equal(request.assistantParticipant, 'mira', 'the inherited voice is still its own'); + const record = framework.getAgentRecord('fork-1')!; + assert.equal(record.incarnation, 2); + assert.equal(record.ended?.reason, 'completed'); + assert.equal(record.inherit!.ownBranch, 'dendrite/fork-1', 'the same branch, not a new derivation'); + assert.equal(record.relationships.resultTo?.to, 'mira'); + assert.deepEqual( + framework.listAgents({ includeEnded: true }).filter((r) => r.name === 'fork-1').map((r) => [r.incarnation, r.ended?.reason]), + [[1, 'stopped'], [2, 'completed']], + ); + // The parent was never touched by either incarnation. + assert.equal(framework.getAgent('mira')!.getContextManager().getAllMessages().length, history.length); + + await assert.rejects( + framework.resumeAgent('nobody', { model: 'test-model', systemPrompt: 'x' }), + /not a known ended agent/, + ); + }); + + it('inspects an earlier state of a live agent, and an ended fork, without changing either', { skip }, async () => { + const { history } = await miraMidTurn(); + const parentCm = framework.getAgent('mira')!.getContextManager(); + const cut = parentCm.getMessage(history[9]!)!.sequence; + + const past = await framework.inspectAgentContext('mira', { atSequence: cut }); + assert.deepEqual(past.getAllMessages().map((m) => m.id), history.slice(0, 10)); + past.addMessage('user', [{ type: 'text', text: 'a note on the snapshot only' }]); + assert.equal(parentCm.getAllMessages().length, history.length, 'the live agent is unaffected'); + assert.equal(framework.getStore().currentBranch().name, 'main'); + + const now = await framework.inspectAgentContext('mira'); + assert.equal(now.getAllMessages().length, history.length); + + membrane.script('fork-1', say('fork done')); + await runFork({ + name: 'fork-1', + from: 'mira', + strategy: folding(), + framing: [{ participant: 'user', content: [{ type: 'text', text: FRAMING }] }], + }); + const left = await framework.inspectAgentContext('fork-1'); + assert.deepEqual( + own(left.getAllMessages()).slice(history.length).map((m) => [m.participant, (m.content[0] as { text: string }).text]), + [['user', FRAMING], ['fork-1', 'fork done']], + ); + await assert.rejects(framework.inspectAgentContext('nobody'), /not a known agent/); + }); + + it('copy inheritance works without store support and renames the parent\'s turns', async () => { + const { history } = await miraMidTurn(); + membrane.identify = (request) => request.assistantParticipant ?? ''; + membrane.script('fork-copy', say('copied and done')); + const parentCm = framework.getAgent('mira')!.getContextManager(); + const { result, contextManager } = await runFork({ + name: 'fork-copy', + from: 'mira', + mode: 'copy', + framing: [{ participant: 'user', content: [{ type: 'text', text: 'Task: summarise.' }] }], + }); + assert.equal(result.speech, 'copied and done'); + const record = framework.getAgentRecord('fork-copy')!; + assert.equal(record.inherit!.mode, 'copy'); + assert.equal(record.inherit!.ownBranch, undefined); + assert.equal(record.selfParticipants, undefined); + assert.equal(record.namespace, 'subagent/fork-copy'); + + const copied = contextManager.getAllMessages(); + assert.ok(copied.length > 2); + assert.ok(copied.some((m) => m.participant === 'fork-copy'), 'the parent\'s turns were renamed at copy time'); + assert.equal(copied.some((m) => m.participant === 'mira'), false); + // A copy has new ids: it shares nothing with the parent's store. + assert.equal(copied.some((m) => history.includes(m.id)), false); + assert.equal(parentCm.getAllMessages().length, history.length); + // Configuration is inherited in copy mode too. + const request = membrane.callsFor('fork-copy')[0]!; + assert.deepEqual(request.config.thinking, { enabled: true, budgetTokens: 1024 }); + assert.equal(request.assistantParticipant, 'fork-copy'); + }); + + it('refuses shared inheritance plainly when the context manager cannot derive', async () => { + const parentCm = framework.getAgent('mira')!.getContextManager(); + Object.defineProperty(parentCm, 'derive', { value: undefined, configurable: true }); + await assert.rejects( + framework.deriveAgent({ name: 'fork-1', from: 'mira' }), + /shared context inheritance needs a @animalabs\/context-manager that provides ContextManager\.derive; use inherit\.mode "copy"/, + ); + assert.equal(framework.getAgentRecord('fork-1'), null, 'nothing was registered'); + await assert.rejects(framework.deriveAgent({ name: 'fork-2', from: 'ghost' }), /"ghost" is not a registered agent/); + }); +}); + +describe('Dendrite role assignment at request assembly', () => { + let tempDir: string; + let framework: AgentFramework; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'dendrite-roles-')); + framework = await AgentFramework.create({ + storePath: join(tempDir, 'store.chronicle'), + membrane: new RoutedMembrane().asMembrane(), + agents: [{ name: 'mira', model: 'test-model', systemPrompt: 'You are mira.' }], + modules: [], + syncIntervalMs: 0, + }); + }); + + afterEach(async () => { + try { await framework.stop(); } catch { /* already stopped */ } + rmSync(tempDir, { recursive: true, force: true }); + }); + + const text = (value: string) => [{ type: 'text' as const, text: value }]; + + async function requestFor(config: { selfParticipants?: string[]; presentAs?: string }, name: string) { + const { agent, contextManager } = await framework.createEphemeralAgent({ + name, model: 'test-model', systemPrompt: 'x', ...config, + }); + contextManager.addMessage('user', text('hello')); + contextManager.addMessage('mira', text('inherited parent turn')); + contextManager.addMessage('oren', text('someone else')); + contextManager.addMessage(name, text('the fork\'s own turn')); + const request = await agent.buildActivationRequest([]); + // Stored authorship is never rewritten, whatever the request shows. + assert.deepEqual( + contextManager.getAllMessages().map((m) => m.participant), + ['user', 'mira', 'oren', name], + ); + return request; + } + + it('presents the inherited voice and the agent\'s own turns under one name', async () => { + const request = await requestFor({ selfParticipants: ['mira'], presentAs: 'mira' }, 'fork-a'); + assert.equal(request.assistantParticipant, 'mira'); + assert.deepEqual( + request.messages.map((m) => m.participant), + // Both own voices are 'mira'; other participants are untouched. The + // request would end on an assistant turn, so the continuation is added + // — which only happens if the fork's own turn was recognised as its own. + ['user', 'mira', 'oren', 'mira', 'user'], + ); + assert.deepEqual(request.messages.at(-1)!.content, [{ type: 'text', text: '[Continue]' }]); + }); + + it('with no presentation name set, the inherited voice is presented under the agent\'s name', async () => { + const request = await requestFor({ selfParticipants: ['mira'] }, 'fork-b'); + assert.equal(request.assistantParticipant, 'fork-b'); + assert.deepEqual(request.messages.map((m) => m.participant), ['user', 'fork-b', 'oren', 'fork-b', 'user']); + }); + + it('an agent that declares neither is assembled exactly as before', async () => { + const request = await requestFor({}, 'plain'); + assert.equal(request.assistantParticipant, 'plain'); + // The parent's name is just another participant to it. + assert.deepEqual(request.messages.map((m) => m.participant), ['user', 'mira', 'oren', 'plain', 'user']); + }); +}); diff --git a/test/dendrite-durability.test.ts b/test/dendrite-durability.test.ts new file mode 100644 index 000000000..80b908879 --- /dev/null +++ b/test/dendrite-durability.test.ts @@ -0,0 +1,498 @@ +/** + * The registry's durable state, after nissa's review of #546 (findings 1 to + * 5, with their probes on review/546-probes as the shape of each case): + * + * 1. A registry that can't be read, or reads as another version, refuses + * the start and names the state, rather than being taken as empty and + * overwritten. + * 2. A failed registry write, ended-log append or sync fails the call that + * made it; a result whose hold isn't durable is not delivered. + * 3. The registry and its ended log live in recovery files beside the store, + * which win over the branch-local state. + * 4. At a quiesced boot, held mail waits with the deferred queue and lands + * once, at resume. + * 5. stopAgent releases the stopped agent's window as the idle reap does: a + * failure notice it never read reaches the primary, and a write queued + * for it lands in its window. + */ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager'; +import type { ContentBlock } from '@animalabs/membrane'; +import { AgentFramework, workerSpec } from '../src/index.js'; +import { STORED_INJECTION_KEY, STORED_INJECTION_KIND } from '../src/stored-injections.js'; +import type { AgentSpec, ProcessEvent } from '../src/index.js'; +import { GateModule, RoutedMembrane, say, until, waitCall } from './helpers/dendrite.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +const REGISTRY = 'framework/dendrite'; +const ENDED = 'framework/dendrite/ended'; +type Method = 'setStateJson' | 'appendToStateJson' | 'sync'; + +/** Make one store method throw (for one state id, if given) until the returned undo runs. */ +function failOn(store: JsStore, method: Method, stateId?: string, when: (args: unknown[]) => boolean = () => true): () => void { + const original = (store as unknown as Record unknown>)[method].bind(store); + Object.defineProperty(store, method, { + configurable: true, writable: true, + value: (...args: unknown[]) => { + if ((stateId === undefined || args[0] === stateId) && when(args)) throw new Error(`EIO: simulated ${method} failure`); + return original(...args); + }, + }); + return () => { delete (store as unknown as Record)[method]; }; +} + +const textOf = (content: ContentBlock[]) => content.map((b) => (b.type === 'text' ? b.text : `<${b.type}>`)).join(''); +const rowsWith = (framework: AgentFramework, agent: string, needle: string) => + framework.getAgent(agent)!.getContextManager().getAllMessages().filter((m) => textOf(m.content).includes(needle)); + +describe('the registry\'s durable state (findings 1 to 4)', () => { + let tempDir: string; + let membrane: RoutedMembrane; + let frameworks: AgentFramework[]; + beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'dendrite-durable-')); membrane = new RoutedMembrane(); frameworks = []; }); + afterEach(async () => { + for (const framework of frameworks.reverse()) { try { await framework.stop(); } catch { /* already stopped */ } } + rmSync(tempDir, { recursive: true, force: true }); + }); + const storePath = () => join(tempDir, 'store.chronicle'); + const registryFile = () => join(storePath(), 'recovery', 'dendrite-registry.json'); + const endedFile = () => join(storePath(), 'recovery', 'dendrite-ended.json'); + + async function boot(gates = new GateModule()): Promise { + const framework = await AgentFramework.create({ + storePath: storePath(), membrane: membrane.asMembrane(), + agents: [ + { name: 'mira', model: 'test-model', systemPrompt: 'You are mira.' }, + { name: 'oren', model: 'test-model', systemPrompt: 'You are oren.' }, + ], + modules: [gates], syncIntervalMs: 0, + }); + frameworks.push(framework); + return framework; + } + async function startBlocked(framework: AgentFramework, gates: GateModule, name: string, gate: string, spec: Omit, finalText = `${name} done`) { + membrane.script(name, waitCall(gate), say(finalText)); + const { agent, contextManager } = await framework.createEphemeralAgent({ name, model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all' }, spec); + contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + const settled = framework.runEphemeralToCompletion(agent, contextManager).then((value) => ({ ok: true as const, value }), (error: unknown) => ({ ok: false as const, error })); + await until(() => gates.entered.has(gate), `${name} to block on ${gate}`); + return { settled }; + } + /** The orphan flow: boss starts job, boss is stopped, job finishes, its result is held for adoption. */ + async function heldResult(framework: AgentFramework, gates: GateModule, hooks: { beforeStop?: () => void; beforeHold?: () => void } = {}) { + framework.start(); + await startBlocked(framework, gates, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + const job = await startBlocked(framework, gates, 'job', 'g-job', workerSpec('job', { spawnedBy: 'boss', resultTo: { to: 'boss', as: 'message' } }), 'job result'); + hooks.beforeStop?.(); + framework.stopAgent('boss', { by: 'mira' }); + gates.release('g-job'); + assert.ok((await job.settled).ok, 'the orphaned job finishes'); + hooks.beforeHold?.(); + return framework.deliverAgentResult('job', [{ type: 'text', text: 'RESULT-1' }]); + } + + it('3: the registry and its ended log are recovery files beside the store, and the file wins over the branch-local state', async () => { + const gates = new GateModule(); + const first = await boot(gates); + await heldResult(first, gates); + assert.deepEqual(first.listHeldMail().map((m) => [m.to, m.heldBecause]), [['boss', 'recipient-gone']]); + await first.stop(); + assert.ok(existsSync(registryFile()) && existsSync(endedFile()), 'both recovery files exist'); + const file = JSON.parse(readFileSync(registryFile(), 'utf8')) as { version: number; mail: unknown[] }; + assert.equal(file.version, 1); + assert.equal(file.mail.length, 1, 'the held result is in the file'); + assert.ok((JSON.parse(readFileSync(endedFile(), 'utf8')) as { ended: Array<{ name: string }> }).ended.some((r) => r.name === 'boss')); + // The branch-local copy is damaged (as a branch switch would leave it stale): the file still wins. + const store = JsStore.openOrCreate({ path: storePath() }); + try { store.setStateJson(REGISTRY, { version: 1, records: [], mail: [], incarnations: {} }); store.sync(); } finally { store.close(); } + const second = await boot(); + assert.equal(second.listHeldMail().length, 1, 'the held result survives'); + assert.ok(second.getAgentRecord('boss'), 'boss\'s ended record is inspectable'); + }); + + for (const [label, damage] of [ + ['1a, a registry stored in another version', () => { + const stored = JSON.parse(readFileSync(registryFile(), 'utf8')) as Record; + writeFileSync(registryFile(), JSON.stringify({ ...stored, version: 2 })); + }], + ['1b, a registry whose read fails', () => { writeFileSync(registryFile(), '{"version":1,"records":['); }], + ] as const) { + it(`${label}: create() refuses and names the state; nothing is overwritten`, async () => { + const gates = new GateModule(); + const first = await boot(gates); + await heldResult(first, gates); + await first.stop(); + damage(); + const before = readFileSync(registryFile(), 'utf8'); + await assert.rejects(boot(), (error: Error) => { + assert.match(error.message, /the agent registry \(.*dendrite-registry\.json\) (can't be read|reads as something other than a version-1 registry)/); + assert.match(error.message, /doesn't start without it/); + return true; + }); + assert.equal(readFileSync(registryFile(), 'utf8'), before, 'the file is unchanged'); + }); + } + + it('2a: a failed registry write or ended-log append fails the call that made it, and nothing claims success', async () => { + const gates = new GateModule(); + const first = await boot(gates); + const store = first.getStore(); + first.start(); + await startBlocked(first, gates, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + const job = await startBlocked(first, gates, 'job', 'g-job', workerSpec('job', { spawnedBy: 'boss', resultTo: { to: 'boss', as: 'message' } }), 'job result'); + let undo = failOn(store, 'appendToStateJson', ENDED); + try { + assert.throws(() => first.stopAgent('boss', { by: 'mira' }), /EIO: simulated appendToStateJson failure/, 'the stop fails with the append'); + } finally { undo(); } + // The stop that failed changed nothing: boss is live, and the next stop ends it once. + assert.equal(first.getAgentRecord('boss')?.ended, undefined); + first.stopAgent('boss', { by: 'mira' }); + assert.equal(first.getAgentRecord('boss')?.ended?.by, 'mira', 'the retry ended boss'); + const bossEnds = (records: Array<{ name: string }>) => records.filter((r) => r.name === 'boss').length; + assert.equal(bossEnds((JSON.parse(readFileSync(endedFile(), 'utf8')) as { ended: Array<{ name: string }> }).ended), 1, + "once in the ended log's recovery file"); + const tail = store.getStateTail(ENDED, 100); + assert.equal(bossEnds(tail ? JSON.parse(tail.toString('utf8')) as Array<{ name: string }> : []), 1, 'once in the branch-local log'); + gates.release('g-job'); + assert.ok((await job.settled).ok); + undo = failOn(store, 'setStateJson', REGISTRY); + try { + assert.throws(() => first.deliverAgentResult('job', [{ type: 'text', text: 'RESULT-1' }]), /EIO: simulated setStateJson failure/, 'holding the result fails with the write'); + } finally { undo(); } + const delivered = first.deliverAgentResult('job', [{ type: 'text', text: 'RESULT-1' }]); + assert.equal(delivered.delivered, false, 'held for the gone recipient'); + assert.equal(first.listHeldMail().length, 1); + }); + + it('2b: the hold is durable as written, so a failed sync delays the release, never the delivery', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + const job = await startBlocked(first, gates, 'job2', 'g-job2', workerSpec('job2', { spawnedBy: 'mira', resultTo: { to: 'mira', as: 'message' } }), 'job2 result'); + gates.release('g-job2'); + assert.ok((await job.settled).ok); + const undo = failOn(first.getStore(), 'sync'); + let delivery: { delivered: boolean }; + try { delivery = first.deliverAgentResult('job2', [{ type: 'text', text: 'RESULT-2' }]); } finally { undo(); } + assert.equal(delivery.delivered, true, 'delivered: the hold was durable before it'); + assert.equal(rowsWith(first, 'mira', 'RESULT-2').length, 1); + assert.equal(first.listHeldMail().filter((m) => m.to === 'mira').length, 1, 'held until a sync carries the message'); + const file = JSON.parse(readFileSync(registryFile(), 'utf8')) as { mail: unknown[] }; + assert.equal(file.mail.length, 1, 'the hold is in the recovery file'); + }); + + it('4: at a quiesced boot, held mail waits with the deferred queue and lands once, at resume', async () => { + const first = await boot(); + await first.quiesce({ reason: 'maintenance' }); + first.sendAgentMessage('oren', 'mira', [{ type: 'text', text: 'MAIL-1' }]); + (first as unknown as { addMessage(p: string, c: ContentBlock[], m?: Record, o?: { forAgent?: string }): string }) + .addMessage('user', [{ type: 'text', text: 'PLAIN-1' }], { kind: 'test-note' }, { forAgent: 'mira' }); + assert.equal(rowsWith(first, 'mira', 'MAIL-1').length, 0, 'held by the quiesce'); + assert.equal(first.listHeldMail().length, 1); + await first.stop(); + const second = await boot(); + assert.equal(second.getHostModeStatus().quiesced, true, 'boots quiesced'); + assert.equal(rowsWith(second, 'mira', 'MAIL-1').length, 0, 'the mail waits with the queue'); + assert.equal(rowsWith(second, 'mira', 'PLAIN-1').length, 0); + await second.resume(); + assert.equal(rowsWith(second, 'mira', 'PLAIN-1').length, 1); + assert.equal(rowsWith(second, 'mira', 'MAIL-1').length, 1, 'the mail lands once, at resume'); + assert.equal(second.listHeldMail().length, 0, 'and is released'); + }); +}); + +describe('round 2: a change is put back when its write fails; a record of what happened is not failed; the release waits for the sync', () => { + let tempDir: string; + let membrane: RoutedMembrane; + let frameworks: AgentFramework[]; + beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'dendrite-durable2-')); membrane = new RoutedMembrane(); frameworks = []; }); + afterEach(async () => { + for (const framework of frameworks.reverse()) { try { await framework.stop(); } catch { /* already stopped */ } } + rmSync(tempDir, { recursive: true, force: true }); + }); + const storePath = () => join(tempDir, 'store.chronicle'); + async function boot(gates = new GateModule(), agents = ['mira', 'oren']): Promise { + const framework = await AgentFramework.create({ + storePath: storePath(), membrane: membrane.asMembrane(), + agents: agents.map((name) => ({ name, model: 'test-model', systemPrompt: `You are ${name}.` })), + modules: [gates], syncIntervalMs: 0, + }); + frameworks.push(framework); + return framework; + } + async function startBlocked(framework: AgentFramework, gates: GateModule, name: string, gate: string, spec: Omit, finalText = `${name} done`) { + membrane.script(name, waitCall(gate), say(finalText)); + const { agent, contextManager } = await framework.createEphemeralAgent({ name, model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all' }, spec); + contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + const settled = framework.runEphemeralToCompletion(agent, contextManager).then((value) => ({ ok: true as const, value }), (error: unknown) => ({ ok: false as const, error })); + await until(() => gates.entered.has(gate), `${name} to block on ${gate}`); + return { settled }; + } + + it('T: a stop whose write fails changes nothing — the agent stays live, and a second stop ends it once', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + await startBlocked(first, gates, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + const undo = failOn(first.getStore(), 'appendToStateJson', ENDED); + try { + assert.throws(() => first.stopAgent('boss', { by: 'mira' }), /EIO: simulated appendToStateJson failure/); + } finally { undo(); } + assert.equal(first.getAgentRecord('boss')?.ended, undefined, 'still live: the failed stop was put back'); + const outcome = first.stopAgent('boss', { by: 'mira' }); + assert.equal(outcome.ended.name, 'boss'); + assert.equal(first.getAgentRecord('boss')?.ended?.reason, 'stopped'); + }); + + it('R and K: a release whose write fails is not a failed delivery, and a release is written only once the store has synced', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + const job = await startBlocked(first, gates, 'job', 'g-job', workerSpec('job', { spawnedBy: 'mira', resultTo: { to: 'mira', as: 'message' } }), 'job result'); + gates.release('g-job'); + assert.ok((await job.settled).ok); + // K: the store's sync fails as the message lands, so the release is not written and the mail stays held. + let undo = failOn(first.getStore(), 'sync'); + let delivery: { delivered: boolean }; + try { delivery = first.deliverAgentResult('job', [{ type: 'text', text: 'RESULT-K' }]); } finally { undo(); } + assert.equal(delivery.delivered, true, 'in the window'); + assert.equal(rowsWith(first, 'mira', 'RESULT-K').length, 1); + assert.equal(first.listHeldMail().length, 1, 'held until a sync carries the message'); + // The next boot's check of the recipient's store settles it, once. + await first.stop(); + const second = await boot(gates); + assert.equal(rowsWith(second, 'mira', 'RESULT-K').length, 1, 'once'); + assert.equal(second.listHeldMail().length, 0, 'released by the check'); + // R: a release whose branch-local registry write fails is a record of what happened: no throw, released in memory. + second.start(); + const job2 = await startBlocked(second, gates, 'job2', 'g-job2', workerSpec('job2', { spawnedBy: 'mira', resultTo: { to: 'mira', as: 'message' } }), 'job2 result'); + gates.release('g-job2'); + assert.ok((await job2.settled).ok); + const errors: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }; + // Only the release's write fails: the snapshot with no mail left in it. + undo = failOn(second.getStore(), 'setStateJson', REGISTRY, (args) => ((args[1] as { mail?: unknown[] }).mail ?? []).length === 0); + let held: { delivered: boolean }; + try { held = second.deliverAgentResult('job2', [{ type: 'text', text: 'RESULT-R' }]); } finally { undo(); console.error = original; } + assert.equal(held.delivered, true, 'no failure for a record of what happened'); + assert.equal(rowsWith(second, 'mira', 'RESULT-R').length, 1, 'once'); + assert.equal(second.listHeldMail().length, 0, 'released in memory'); + assert.ok(errors.some((e) => /could not record the release of mail/.test(e)), 'and the failure is alerted'); + }); + + it('U: a run\'s own end is a record of what happened — a failed write alerts, the run still settles, the record is ended', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + const job = await startBlocked(first, gates, 'job', 'g-job', workerSpec('job', { spawnedBy: 'mira' }), 'job result'); + const errors: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }; + const undo = failOn(first.getStore(), 'setStateJson', REGISTRY); + let done: { ok: boolean }; + try { gates.release('g-job'); done = await job.settled; } finally { undo(); console.error = original; } + assert.equal(done.ok, true, 'the run finished, and says so'); + assert.equal(first.getAgentRecord('job')?.ended?.reason, 'completed', 'ended in the registry'); + assert.equal(first.getAgent('job'), null); + assert.ok(errors.some((e) => /could not record a change already made/.test(e)), 'the write failure is alerted'); + }); + + it('T, the ended log: an end that was put back comes off the recovery file too', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + await startBlocked(first, gates, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + // The snapshot's write fails after the ended record was appended. + const undo = failOn(first.getStore(), 'setStateJson', REGISTRY); + try { assert.throws(() => first.stopAgent('boss', { by: 'mira' })); } finally { undo(); } + first.stopAgent('boss', { by: 'mira' }); + const ended = (JSON.parse(readFileSync(join(storePath(), 'recovery', 'dendrite-ended.json'), 'utf8')) as { ended: Array<{ name: string; ended: { reason: string } }> }).ended; + assert.equal(ended.filter((r) => r.name === 'boss').length, 1, 'one stopped record for the one stop that held'); + }); + + it('a version-1 registry whose records or mail are not lists is refused, not taken as empty', async () => { + const first = await boot(); + await first.stop(); + const file = join(storePath(), 'recovery', 'dendrite-registry.json'); + const stored = JSON.parse(readFileSync(file, 'utf8')) as Record; + writeFileSync(file, JSON.stringify({ ...stored, mail: 'none' })); + await assert.rejects(boot(), /the agent registry \(.*dendrite-registry\.json\) reads as a version-1 registry whose records or mail are not lists/); + }); + + it('a resident dropped from the configuration reopens with what it read of the shared slot', async () => { + const first = await boot(new GateModule(), ['mira', 'nova']); + for (let i = 0; i < 6; i++) first.getAgent('nova')!.getContextManager().addMessage('user', [{ type: 'text', text: `shared ${i}` }]); + assert.equal(first.getAgent('mira')!.getContextManager().getAllMessages().length, 6, 'residents share the slot'); + await first.stop(); + const second = await boot(new GateModule(), ['mira']); + const nova = second.getAgentRecord('nova'); + assert.equal(nova?.ended?.reason, 'not-configured'); + const reopened = await second.inspectAgentContext('nova'); + assert.equal(reopened.getAllMessages().length, 6, 'reopened on the shared slot it read'); + }); + + it('a dropped resident reopens without a co-resident\'s stored context injection, as its live view left it out', async () => { + const first = await boot(new GateModule(), ['mira', 'nova']); + const nova = first.getAgent('nova')!.getContextManager(); + for (let i = 0; i < 6; i++) nova.addMessage('user', [{ type: 'text', text: `shared ${i}` }]); + // A row stored as mira's context: in the shared slot, but mira's, not conversation. + nova.addMessage('system_context:gates', [{ type: 'text', text: 'mira\'s standing context' }], { + system: true, kind: STORED_INJECTION_KIND, + [STORED_INJECTION_KEY]: { agent: 'mira', namespace: 'agents/mira', contentHash: 'h-1', part: 0, parts: 1 }, + }); + // The record holds the row for every reader; each reader's view is what + // compile() shows it (the view filter is strategy-facing, not the record). + const shows = async (cm: ContextManager) => + (await cm.compile(undefined, undefined, { dryRun: true })).messages.some((m) => textOf(m.content).includes('standing context')); + assert.equal(nova.getAllMessages().length, 7, 'the one slot holds the row'); + assert.equal(await shows(first.getAgent('mira')!.getContextManager()), true, 'mira\'s view has its own injection'); + assert.equal(await shows(nova), false, 'nova\'s live view leaves it out'); + await first.stop(); + const second = await boot(new GateModule(), ['mira']); + assert.equal(second.getAgentRecord('nova')?.ended?.reason, 'not-configured'); + const reopened = await second.inspectAgentContext('nova'); + assert.equal(reopened.getAllMessages().length, 7, 'reopened on the shared slot it read'); + assert.equal(await shows(reopened), false, 'and its view, as live, is without mira\'s injection'); + assert.equal(await shows(second.getAgent('mira')!.getContextManager()), true, 'mira still sees its own'); + }); + + it("a dropped resident reopened for inspection is asked its own view by main's checks of whether an agent sees a row, as its compile is", async () => { + const first = await boot(new GateModule(), ['mira', 'nova']); + const nova = first.getAgent('nova')!.getContextManager(); + const mark = (agent: string) => ({ + system: true, kind: STORED_INJECTION_KIND, + [STORED_INJECTION_KEY]: { agent, namespace: `agents/${agent}`, contentHash: `h-${agent}`, part: 0, parts: 1 }, + }); + nova.addMessage('user', [{ type: 'text', text: 'shared conversation' }]); + // A row a tune-out holds from the residents, mira's stored context, and nova's own. + nova.addMessage('Antra', [{ type: 'text', text: 'held from the residents' }], { tuneOut: { epochId: 'e1' } } as never); + nova.addMessage('system_context:gates', [{ type: 'text', text: 'mira\'s standing context' }], mark('mira')); + nova.addMessage('system_context:gates', [{ type: 'text', text: 'nova\'s standing context' }], mark('nova')); + await first.stop(); + const second = await boot(new GateModule(), ['mira']); + assert.equal(second.getAgentRecord('nova')?.ended?.reason, 'not-configured'); + const reopened = await second.inspectAgentContext('nova'); + assert.equal(reopened.getAllMessages().length, 4, 'the premise: its record holds every row of the slot'); + // agentSees reads the view recorded beside an agent's context manager + // (storeSees); an inspected context has no agent, so this stands in for one. + const agentSees = (metadata: unknown) => (second as unknown as { agentSees(agent: unknown, metadata: unknown): boolean }) + .agentSees({ getContextManager: () => reopened }, metadata); + assert.deepEqual( + reopened.getAllMessages().filter((m) => agentSees(m.metadata)).map((m) => textOf(m.content)), + ['shared conversation', 'nova\'s standing context'], + 'agentSees leaves out the held row and mira\'s injection', + ); + assert.deepEqual( + (await reopened.compile(undefined, undefined, { dryRun: true })).messages.map((m) => textOf(m.content)), + ['shared conversation', 'nova\'s standing context'], + 'as its compile does', + ); + }); +}); + +describe('5: stopAgent releases the window as the idle reap does', () => { + let tempDir: string; + let framework: AgentFramework; + let membrane: MockMembrane; + const internals = () => framework as unknown as { + initializeMcpl(servers: []): Promise; + activeTurnTokens: Map; + deferredMessages: Array<{ forAgent?: string }>; + pendingAssistantBlocks: Map; + lastConversationSweep: number; + sweepExpiredConversations(): void; + quiesced: boolean; + deferredWritesPersisted: boolean; + unackedDeferredWrites: unknown[]; + flushDeferredWrites(label: string): Promise; + closingConversationAgents: Set; + addMessage(participant: string, content: ContentBlock[], metadata?: Record, opts?: { forAgent?: string }): string; + }; + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'dendrite-stop-')); + membrane = new MockMembrane(); + framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), membrane: membrane.asMembrane(), + agents: [ + { name: 'primary', model: 'test-model', systemPrompt: 'Primary.', proseRouting: 'locus' }, + { name: 'secondary', model: 'test-model', systemPrompt: 'Secondary.' }, + ], + modules: [], conversations: { templateAgent: 'primary' }, speech: { locus: 'latest-inbound' }, + }); + await internals().initializeMcpl([]); + }); + afterEach(async () => { + internals().activeTurnTokens.clear(); internals().pendingAssistantBlocks.clear(); internals().quiesced = false; + await framework.stop(); rmSync(tempDir, { recursive: true, force: true }); + }); + async function spawnFork() { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Reply from the fork.' }])); + framework.pushEvent({ + type: 'mcpl:channel-incoming', serverId: 'srv', channelId: 'missing-channel', messageId: 'incoming-1', + author: { id: 'user-1', name: 'User' }, content: [{ type: 'text', text: 'Hello fork.' }], + timestamp: new Date().toISOString(), metadata: { channel_type: 'im' }, triggerInference: true, + } as unknown as ProcessEvent); + await framework.runUntilIdle(); + const binding = framework.getConversationRouter()!.getBinding({ serverId: 'srv', channelId: 'missing-channel' }); + assert.ok(binding, 'the incoming DM spawns a conversation fork'); + return framework.getAgent(binding.agentName)!; + } + function reap(forkName: string) { + const state = internals(); state.closingConversationAgents.add(forkName); state.lastConversationSweep = 0; state.sweepExpiredConversations(); + } + async function forkWindowHas(forkName: string, needle: string): Promise { + const archive = await ContextManager.open({ store: framework.getStore(), namespace: 'conversations/' + forkName, isolate: true, strategy: new PassthroughStrategy() }); + return archive.getAllMessages().some((m) => JSON.stringify(m.content).includes(needle)); + } + const count = (messages: Array<{ content: unknown }>, needle: string) => messages.filter((m) => JSON.stringify(m.content).includes(needle)).length; + + it('V: an idle reap is a record of what happened — a failed write alerts, the fork is gone, its record ended', async () => { + const fork = await spawnFork(); + const errors: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { errors.push(args.map(String).join(' ')); }; + const undo = failOn(framework.getStore(), 'setStateJson', 'framework/dendrite'); + try { reap(fork.name); } finally { undo(); console.error = original; } + assert.equal(framework.getAgent(fork.name), null, 'gone'); + assert.equal(framework.getAgentRecord(fork.name)?.ended?.reason, 'idle-ttl', 'ended in the registry'); + assert.ok(errors.some((e) => /could not record a change already made/.test(e)), 'alerted'); + }); + + it("a fork the host's stop ends is recorded as ended by that stop, not as idle", async () => { + const fork = await spawnFork(); + await framework.stop(); + assert.equal(framework.getAgentRecord(fork.name)?.ended?.reason, 'host-stop'); + }); + + for (const how of ['reap', 'stopAgent'] as const) { + it(`5a, ${how}: a failure notice the fork stored but never read reaches the primary`, async () => { + const fork = await spawnFork(); + if (how === 'reap') reap(fork.name); else framework.stopAgent(fork.name, { by: 'test', reason: 'test' }); + const { messages } = await framework.getAgent('primary')!.getContextManager().compile(); + assert.equal(framework.getAgent(fork.name), null, 'the fork is gone'); + assert.equal(count(messages, '[discord-send-failed]'), 1, 'the release tells the primary'); + if (how === 'stopAgent') assert.equal(framework.getAgentRecord(fork.name)?.ended?.reason, 'stopped'); + }); + + it(`5b, ${how}: an ordinary write queued for the fork lands in its window and is acknowledged`, async () => { + const fork = await spawnFork(); + const state = internals(); + state.deferredWritesPersisted = true; + state.activeTurnTokens.set(fork.name, 3); + state.addMessage('user', [{ type: 'text', text: 'queued for the fork' }], { kind: 'test-note' }, { forAgent: fork.name }); + assert.equal(state.deferredMessages.length, 1); + state.activeTurnTokens.delete(fork.name); + if (how === 'reap') reap(fork.name); else framework.stopAgent(fork.name, { by: 'test', reason: 'test' }); + assert.equal(framework.getAgent(fork.name), null); + assert.equal(state.deferredMessages.length, 0, 'released, not left queued'); + assert.equal(state.unackedDeferredWrites.length, 0, 'acknowledged'); + assert.equal(await forkWindowHas(fork.name, 'queued for the fork'), true, 'in its window'); + }); + } +}); diff --git a/test/dendrite-fork-view.test.ts b/test/dendrite-fork-view.test.ts new file mode 100644 index 000000000..2845f4547 --- /dev/null +++ b/test/dendrite-fork-view.test.ts @@ -0,0 +1,149 @@ +/** + * A shared fork's view (#291; Weft's eleventh round). A fork continuing its + * parent's context sees what its parent sees and its own stored context + * injections (src/stored-injections.ts). context-manager's derive gives the + * fork its source's view filter, which leaves out every stored injection but + * the source's own, so the framework replaces it (deriveSharedContext) and + * records the same predicate for main's checks of whether an agent sees a row + * (storeSees, agentSees). Reopened after it ends, a fork's context keeps the + * view it had live, a fork of a fork's included (reopenAgentContext). + */ +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 { JsStore } from '@animalabs/chronicle'; +import { ContextManager, type ContextInjection } from '@animalabs/context-manager'; +import type { ContentBlock } from '@animalabs/membrane'; +import { AgentFramework, type Agent } from '../src/index.js'; +import { GateModule, RoutedMembrane, say, until, waitCall } from './helpers/dendrite.js'; + +const supported = + typeof (JsStore.prototype as unknown as { view?: unknown }).view === 'function' && + typeof (ContextManager.prototype as unknown as { derive?: unknown }).derive === 'function'; +const skip = supported + ? false + : 'installed @animalabs/chronicle / context-manager lack branch-bound handles or ContextManager.derive'; + +type Internals = { + storeInjections(agent: Agent, injections: ContextInjection[] | undefined): ContextInjection[] | undefined; + agentSees(agent: Agent, metadata: unknown): boolean; +}; +const internals = (f: AgentFramework) => f as unknown as Internals; + +const text = (t: string): ContentBlock => ({ type: 'text', text: t } as ContentBlock); +const injection = (t: string): ContextInjection => ({ namespace: 'lessons', position: 'afterUser', content: [text(t)] }); +const textOf = (m: { content: unknown }): string => + (m.content as Array<{ type: string; text?: string }>).filter((b) => b.type === 'text').map((b) => b.text ?? '').join(''); + +const FRAMING = '[FORK-FRAMING] Count the open issues.'; +const GRANDCHILD = '[GRANDCHILD-FRAMING] Count the closed ones.'; +/** The rows a view can leave out: each agent's stored injection, and a row a tune-out holds from the residents. */ +const MARKED = ['HELD-ROW', 'MIRA-OWN', 'OREN-OWN', 'FORK-OWN', 'FORK2-OWN']; + +describe("a shared fork's view", () => { + let tempDir: string; + let membrane: RoutedMembrane; + let gates: GateModule; + let framework: AgentFramework; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'dendrite-fork-view-')); + membrane = new RoutedMembrane(); + // A derived agent presents itself under its parent's name; tell them + // apart by the framing only each fork carries. + membrane.identify = (request) => { + const sent = JSON.stringify(request.messages); + if (sent.includes('[GRANDCHILD-FRAMING]')) return 'fork-2'; + if (sent.includes('[FORK-FRAMING]')) return 'fork-1'; + return request.assistantParticipant ?? ''; + }; + gates = new GateModule(); + framework = await AgentFramework.create({ + storePath: join(tempDir, 'store.chronicle'), + membrane: membrane.asMembrane(), + agents: [ + { name: 'mira', model: 'test-model', systemPrompt: 'You are mira.' }, + { name: 'oren', model: 'test-model', systemPrompt: 'You are oren.' }, + ], + modules: [gates], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + }); + + afterEach(async () => { + try { await framework.stop(); } catch { /* already stopped */ } + rmSync(tempDir, { recursive: true, force: true }); + }); + + /** The marked rows a dry-run compile of `cm` shows, in order. */ + const shown = async (cm: ContextManager): Promise => + (await cm.compile(undefined, undefined, { dryRun: true })).messages.map(textOf).filter((t) => MARKED.includes(t)); + /** The marked rows of `record` that agentSees says `agent` sees. */ + const seenBy = (agent: Agent, record: ContextManager): string[] => + MARKED.flatMap((t) => record.getAllMessages().filter((m) => textOf(m) === t)) + .filter((m) => internals(framework).agentSees(agent, m.metadata)).map(textOf); + /** agentSees takes an agent; an inspected context has none, so this stands in for it. */ + const readerOf = (cm: ContextManager): Agent => ({ getContextManager: () => cm }) as unknown as Agent; + + it('sees its source\'s view and its own stored injections: in its compile, in agentSees, and reopened after it ends', { skip }, async () => { + const mira = framework.getAgent('mira')!; + const oren = framework.getAgent('oren')!; + mira.getContextManager().addMessage('user', [text('Please look into the open issues.')]); + mira.getContextManager().addMessage('Antra', [text('HELD-ROW')], { tuneOut: { epochId: 'e1' } } as never); + internals(framework).storeInjections(mira, [injection('MIRA-OWN')]); + internals(framework).storeInjections(oren, [injection('OREN-OWN')]); + + const fork = await framework.deriveAgent({ + name: 'fork-1', + from: 'mira', + framing: [{ participant: 'user', content: [text(FRAMING)] }], + }); + internals(framework).storeInjections(fork.agent, [injection('FORK-OWN')]); + assert.deepEqual( + fork.contextManager.getAllMessages().filter((m) => MARKED.includes(textOf(m))).map(textOf), + ['HELD-ROW', 'MIRA-OWN', 'OREN-OWN', 'FORK-OWN'], + "the premise: the fork's record holds every row, its own injection on its branch", + ); + + // Each compile: the fork its source's view and its own injection; the + // source and the stranger each their own alone. + assert.deepEqual(await shown(fork.contextManager), ['MIRA-OWN', 'FORK-OWN'], "the fork sees its own injection and its source's, not a stranger's"); + assert.deepEqual(await shown(mira.getContextManager()), ['MIRA-OWN'], 'the source sees its own only'); + assert.deepEqual(await shown(oren.getContextManager()), ['OREN-OWN'], 'the stranger sees its own only'); + // main's checks of whether an agent sees a row (agentSees) agree with the fork's compile. + assert.deepEqual(seenBy(fork.agent, fork.contextManager), ['MIRA-OWN', 'FORK-OWN'], "agentSees answers for the fork as its compile shows"); + assert.deepEqual(seenBy(mira, fork.contextManager), ['MIRA-OWN']); + assert.deepEqual(seenBy(oren, fork.contextManager), ['OREN-OWN']); + + // A fork of the running fork: its view is its source's (the fork's) and its own. + framework.start(); + membrane.script('fork-1', waitCall('g-fork'), say('fork-1 done')); + const firstRun = framework.runEphemeralToCompletion(fork.agent, fork.contextManager); + await until(() => gates.entered.has('g-fork'), 'fork-1 to block mid-turn'); + const grandchild = await framework.deriveAgent({ + name: 'fork-2', + from: 'fork-1', + framing: [{ participant: 'user', content: [text(GRANDCHILD)] }], + }); + internals(framework).storeInjections(grandchild.agent, [injection('FORK2-OWN')]); + assert.deepEqual(await shown(grandchild.contextManager), ['MIRA-OWN', 'FORK-OWN', 'FORK2-OWN']); + assert.deepEqual(seenBy(grandchild.agent, grandchild.contextManager), ['MIRA-OWN', 'FORK-OWN', 'FORK2-OWN']); + membrane.script('fork-2', say('fork-2 done')); + assert.equal((await framework.runEphemeralToCompletion(grandchild.agent, grandchild.contextManager)).speech, 'fork-2 done'); + gates.release('g-fork'); + assert.equal((await firstRun).speech, 'fork-1 done'); + assert.equal(framework.getAgentRecord('fork-1')?.ended?.reason, 'completed'); + assert.equal(framework.getAgentRecord('fork-2')?.ended?.reason, 'completed'); + + // Reopened for inspection, each ended fork's context sees what it saw live. + const left = await framework.inspectAgentContext('fork-1'); + assert.deepEqual(await shown(left), ['MIRA-OWN', 'FORK-OWN'], 'the reopened fork, as live'); + assert.deepEqual(seenBy(readerOf(left), left), ['MIRA-OWN', 'FORK-OWN'], 'and agentSees for it'); + const leftBelow = await framework.inspectAgentContext('fork-2'); + assert.deepEqual(await shown(leftBelow), ['MIRA-OWN', 'FORK-OWN', 'FORK2-OWN'], "the reopened fork of a fork, as live: its source's source's view too"); + assert.deepEqual(seenBy(readerOf(leftBelow), leftBelow), ['MIRA-OWN', 'FORK-OWN', 'FORK2-OWN']); + }); +}); diff --git a/test/dendrite-hooks.test.ts b/test/dendrite-hooks.test.ts new file mode 100644 index 000000000..1ee2d3beb --- /dev/null +++ b/test/dendrite-hooks.test.ts @@ -0,0 +1,136 @@ +/** + * A derived agent is registered — and its source told — when it starts + * running, so these tests run each fork to completion. + * + * The host's two hooks over derivations (docs/dendrite.md): beforeDerive + * refuses any derivation or spawn with its own reason before the agent + * exists, and derivationNotice decides what the source is told. The + * framework attaches no meaning to either. + */ +import { describe, it } 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, workerSpec } from '../src/index.js'; +import type { AgentSpec, AgentRecord } from '../src/index.js'; +import { RoutedMembrane, say } from './helpers/dendrite.js'; + +async function boot(dir: string, dendrite: Record, membrane = new RoutedMembrane()) { + const fw = await AgentFramework.create({ + storePath: join(dir, `${Math.random().toString(36).slice(2)}.chronicle`), membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + dendrite, modules: [], syncIntervalMs: 0, + }); + fw.start(); + return fw; +} + +describe('dendrite.beforeDerive', () => { + it('sees the spec as it will be registered and can refuse a spawn with its reason; nothing is created', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hooks-')); + const seen: AgentSpec[] = []; + try { + const fw = await boot(dir, { + beforeDerive: (spec: AgentSpec) => { + seen.push(spec); + return (spec.metadata as { budget?: string } | undefined)?.budget === 'none' ? { refuse: 'no budget this hour' } : undefined; + }, + }); + const ok = await fw.createEphemeralAgent({ name: 'w-1', model: 'test-model', systemPrompt: 'x' }, { ...workerSpec('w-1', { spawnedBy: 'scout' }), name: undefined } as never); + ok.cleanup(); + await assert.rejects( + fw.createEphemeralAgent({ name: 'w-2', model: 'test-model', systemPrompt: 'x' }, { ...workerSpec('w-2', { spawnedBy: 'scout', metadata: { budget: 'none' } }), name: undefined } as never), + /w-2 was not created: no budget this hour/, + ); + assert.deepEqual(seen.map((s) => [s.name, s.kind, s.spawnedBy]), [['w-1', 'worker', 'scout'], ['w-2', 'worker', 'scout']]); + assert.equal(fw.getAgentRecord('w-2'), null, 'nothing registered'); + await fw.stop(); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + + it('a spawn with no spec is shown the default worker spec', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hooks-')); + const seen: AgentSpec[] = []; + try { + const fw = await boot(dir, { beforeDerive: (spec: AgentSpec) => { seen.push(spec); } }); + const made = await fw.createEphemeralAgent({ name: 'bare', model: 'test-model', systemPrompt: 'x' }); + made.cleanup(); + assert.equal(seen[0]?.kind, 'worker'); + assert.equal(seen[0]?.name, 'bare'); + await fw.stop(); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + + it('is asked before an ended job resumes as a new incarnation, and can refuse it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hooks-')); + const seen: AgentSpec[] = []; + try { + const membrane = new RoutedMembrane(); + const fw = await boot(dir, { + beforeDerive: (spec: AgentSpec) => { + seen.push(spec); + return spec.model === 'other-model' ? { refuse: 'not on those weights' } : undefined; + }, + }, membrane); + membrane.script('job-h', say('job-h done')); + const made = await fw.createEphemeralAgent({ name: 'job-h', model: 'test-model', systemPrompt: 'x' }, { ...workerSpec('job-h', { spawnedBy: 'scout' }), name: undefined } as never); + made.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + await fw.runEphemeralToCompletion(made.agent, made.contextManager); + await assert.rejects(fw.resumeAgent('job-h', { model: 'other-model', systemPrompt: 'x' }), /job-h was not resumed: not on those weights/); + assert.equal(fw.getAgentRecord('job-h')?.incarnation, 1, 'still its first incarnation'); + assert.deepEqual(seen.map((s) => [s.name, s.model]), [['job-h', 'test-model'], ['job-h', 'other-model']]); + await fw.stop(); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + + it('refuses a derivation too, before the fork exists', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hooks-')); + try { + const fw = await boot(dir, { beforeDerive: (spec: AgentSpec) => (spec.kind === 'task-fork' ? { refuse: 'no forks of scout today' } : undefined) }); + await assert.rejects(fw.deriveAgent({ name: 'f-1', from: 'scout', mode: 'copy' }), /f-1 was not created: no forks of scout today/); + assert.equal(fw.getAgentRecord('f-1'), null); + await fw.stop(); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); +}); + +describe('dendrite.derivationNotice', () => { + const lines = (fw: AgentFramework) => (fw.getAgent('scout')!.getContextManager().getAllMessages() as unknown as Array<{ content: Array<{ text?: string }>; metadata?: { kind?: string } }>) + .filter((m) => m.metadata?.kind === 'agent-lifecycle').map((m) => m.content.map((b) => b.text ?? '').join('')); + + it('null tells the source nothing; the trace is still emitted', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hooks-')); + try { + const membrane = new RoutedMembrane(); + const fw = await boot(dir, { derivationNotice: () => null }, membrane); + const traces: string[] = []; + fw.onTrace((e) => { if (e.type === 'dendrite:agent-created') traces.push((e as { agentName: string }).agentName); }); + membrane.script('quiet-1', say('quiet-1 done')); + const f = await fw.deriveAgent({ name: 'quiet-1', from: 'scout', mode: 'copy' }); + await fw.runEphemeralToCompletion(f.agent, f.contextManager); + assert.ok(traces.includes('quiet-1')); + assert.equal(lines(fw).length, 0); + await fw.stop(); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); + + it('text replaces the default line; absent, the default is used', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hooks-')); + try { + const membrane = new RoutedMembrane(); + const custom = await boot(dir, { derivationNotice: (record: AgentRecord, from: string, def: string) => `[${from}: ${record.name} exists; the default would have said ${def.length} characters]` }, membrane); + membrane.script('own-1', say('own-1 done')); + const f = await custom.deriveAgent({ name: 'own-1', from: 'scout', mode: 'copy' }); + await custom.runEphemeralToCompletion(f.agent, f.contextManager); + assert.match(lines(custom)[0] ?? '', /^\[scout: own-1 exists; the default would have said \d+ characters\]$/); + await custom.stop(); + const plain = await boot(dir, {}, membrane); + membrane.script('plain-1', say('plain-1 done')); + const g = await plain.deriveAgent({ name: 'plain-1', from: 'scout', mode: 'copy' }); + await plain.runEphemeralToCompletion(g.agent, g.contextManager); + assert.match(lines(plain)[0] ?? '', /^\[Agent lifecycle: plain-1 was derived from you as a task-fork/); + await plain.stop(); + } finally { rmSync(dir, { recursive: true, force: true }); } + }); +}); diff --git a/test/dendrite-host-toolsets.test.ts b/test/dendrite-host-toolsets.test.ts new file mode 100644 index 000000000..c31741aff --- /dev/null +++ b/test/dendrite-host-toolsets.test.ts @@ -0,0 +1,115 @@ +/** + * A derived agent that shows its owner's tool surface (deriveAgent with no + * allowedTools, see toolSurfaceOwners) and the host's own toolsets + * (host-presentation.ts). Its tool block is the front of the request prefix + * it shares with its owner, so it must be the owner's block, including where + * the owner has moved a server to a tab. It reads its owner's presentation + * and can't switch it: its own toolsets--open and --close are refused, and + * nothing is kept under its name. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { AutobiographicalStrategy } from '@animalabs/context-manager'; +import type { NormalizedRequest } from '@animalabs/membrane'; +import { AgentFramework } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { startScriptedServer, waitFor, type Json } from './helpers/toolset-servers.js'; + +const strategy = () => new AutobiographicalStrategy({ + adaptiveResolution: true, foldingStrategy: 'kv-stable', recentWindowTokens: 30_000, kvStableReachTokens: 8_000, +}); + +const PING = { name: 'ping', description: 'Ping the server.', inputSchema: { type: 'object' } }; + +async function rig() { + const server = await startScriptedServer({ + advert: { featureSets: { 'srv.tools': { description: 'tools', uses: ['tools'] } } }, + tools: [PING], + on: (msg, peer) => { + if (msg.method === 'tools/call') peer.reply(msg.id, { content: [{ type: 'text', text: 'pong' }] }); + else if (msg.id !== undefined) peer.error(msg.id, -32601, 'Method not found'); + }, + }); + const dir = mkdtempSync(join(tmpdir(), 'dendrite-host-toolsets-')); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', strategy: strategy() }], + modules: [], + mcplServers: [{ id: 'srv', url: server.url, toolPrefix: 'srv' }], + }); + await framework.start(); + await waitFor(() => framework.getAllTools().some((t) => t.name === 'srv--ping'), 'srv connected'); + return { + framework, + membrane, + dir, + async stop() { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + }, + }; +} + +const call = (framework: AgentFramework, name: string, input: Json, agent: string) => + framework.executeToolCall({ id: `c-${Math.random().toString(36).slice(2, 10)}`, name, input, callerAgentName: agent } as never); +const toolNames = (request: NormalizedRequest | { tools?: Array<{ name: string }> }) => + ((request as { tools?: Array<{ name: string }> }).tools ?? []).map((t) => t.name).sort(); +const framing = (text: string) => [{ participant: 'user', content: [{ type: 'text' as const, text }] }]; + +describe('a derived agent and the host\'s toolsets', () => { + it('advertises its owner\'s tool block after the owner moves a server to a tab', async () => { + const h = await rig(); + try { + await call(h.framework, 'toolsets--close', { server: 'srv' }, 'scout'); + const owner = toolNames(await h.framework.previewActivation('scout')); + assert.ok(!owner.includes('srv--ping'), 'premise: the owner moved srv to a tab'); + + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'done' }])); + const before = h.membrane.calls.length; + const fork = await h.framework.deriveAgent({ name: 'fork-a', from: 'scout', mode: 'copy', framing: framing('Task: look.') }); + await h.framework.runEphemeralToCompletion(fork.agent, fork.contextManager); + const first = h.membrane.calls.slice(before).find((c) => c.assistantParticipant === 'fork-a'); + assert.ok(first, 'premise: the fork made a request'); + assert.deepEqual(toolNames(first), owner, 'the fork\'s first request carries its owner\'s tool block'); + } finally { + await h.stop(); + } + }); + + it('refuses its own toolsets--open and --close, and keeps nothing under its name', async () => { + const h = await rig(); + try { + const owner = toolNames(await h.framework.previewActivation('scout')); + h.membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'open-settings', name: 'toolsets--open', input: { toolset: 'settings' } }, + { type: 'tool_use', id: 'close-srv', name: 'toolsets--close', input: { server: 'srv' } }, + ] as never, 'tool_use')); + h.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'done' }])); + const fork = await h.framework.deriveAgent({ name: 'fork-b', from: 'scout', mode: 'copy', framing: framing('Task: switch.') }); + await h.framework.runEphemeralToCompletion(fork.agent, fork.contextManager); + + const results = new Map(); + for (const message of fork.contextManager.getAllMessages()) { + for (const block of message.content as Array<{ type: string; toolUseId?: string; isError?: boolean }>) { + if (block.type === 'tool_result' && block.toolUseId) results.set(block.toolUseId, block); + } + } + assert.equal(results.size, 2, `premise: both calls were answered (${[...results.keys()].join(', ')})`); + assert.equal(results.get('open-settings')?.isError, true, 'opening settings is refused'); + assert.equal(results.get('close-srv')?.isError, true, 'moving a server to a tab is refused'); + + assert.deepEqual(toolNames(await h.framework.previewActivation('scout')), owner, 'the owner\'s block is unchanged'); + const stored = join(h.dir, 'store', 'config', 'toolsets.json'); + const kept = existsSync(stored) ? readFileSync(stored, 'utf8') : ''; + assert.ok(!kept.includes('fork-b'), `nothing is kept under the fork's name: ${kept}`); + } finally { + await h.stop(); + } + }); +}); diff --git a/test/dendrite-lifecycle.test.ts b/test/dendrite-lifecycle.test.ts new file mode 100644 index 000000000..437997044 --- /dev/null +++ b/test/dendrite-lifecycle.test.ts @@ -0,0 +1,925 @@ +/** + * Dendrite lifecycle through the framework: the creation paths register as + * presets, anyone can stop or cancel, orphans are told and can be adopted, + * results travel as attributed mail that is neither lost nor duplicated, + * and a restart settles what the previous process left unfinished. + */ +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 { JsStore } from '@animalabs/chronicle'; +import { + AgentFramework, + AgentSpecError, + AgentStoppedError, + taskForkSpec, + workerSpec, +} from '../src/index.js'; +import type { + AgentSpec, + ProcessEvent, + TraceEvent, +} from '../src/index.js'; +import { GateModule, RoutedMembrane, say, until, waitCall } from './helpers/dendrite.js'; + +function textsIn(framework: AgentFramework, agent: string): Array<{ participant: string; text: string; metadata: Record }> { + return framework.getAgent(agent)!.getContextManager().getAllMessages().map((message) => ({ + participant: message.participant, + text: message.content.map((block) => (block.type === 'text' ? block.text : `<${block.type}>`)).join(''), + metadata: (message.metadata ?? {}) as Record, + })); +} + +describe('Dendrite lifecycle', () => { + let tempDir: string; + let membrane: RoutedMembrane; + let gates: GateModule; + let frameworks: AgentFramework[]; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'dendrite-lifecycle-')); + membrane = new RoutedMembrane(); + gates = new GateModule(); + frameworks = []; + }); + + afterEach(async () => { + for (const framework of frameworks.reverse()) { + try { await framework.stop(); } catch { /* already stopped */ } + } + rmSync(tempDir, { recursive: true, force: true }); + }); + + async function boot(overrides: Partial[0]> = {}): Promise { + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'store.chronicle'), + membrane: membrane.asMembrane(), + agents: [ + { name: 'mira', model: 'test-model', systemPrompt: 'You are mira.' }, + { name: 'oren', model: 'test-model', systemPrompt: 'You are oren.' }, + ], + modules: [gates], + syncIntervalMs: 0, + ...overrides, + }); + frameworks.push(framework); + return framework; + } + + /** Start a bounded agent whose first act is to block on `gate`. */ + async function startBlocked( + framework: AgentFramework, + name: string, + gate: string, + spec?: Omit, + finalText = `${name} done`, + ) { + membrane.script(name, waitCall(gate), say(finalText)); + const { agent, contextManager } = await framework.createEphemeralAgent( + { name, model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all' }, + spec, + ); + contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + const run = framework.runEphemeralToCompletion(agent, contextManager); + // Observe the outcome without leaving a rejection unhandled. + const settled = run.then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }), + ); + await until(() => gates.entered.has(gate), `${name} to block on ${gate}`); + return { agent, contextManager, settled }; + } + + it('registers every creation path as a preset and answers discovery', async () => { + const framework = await boot({ + subconscious: { enabled: true, systemPrompt: 'You watch.' } as never, + }); + const traces: TraceEvent[] = []; + framework.onTrace((event) => traces.push(event)); + framework.start(); + + const worker = await startBlocked(framework, 'worker-1', 'g1'); + + const byName = new Map(framework.listAgents().map((record) => [record.name, record])); + assert.deepEqual([...byName.keys()], ['mira', 'oren', 'Subconscious', 'worker-1']); + assert.equal(framework.getPrimaryAgentName(), 'mira'); + assert.equal(byName.get('mira')!.roles.defaultDelivery, true); + assert.equal(byName.get('oren')!.roles.defaultDelivery, false); + assert.deepEqual(byName.get('mira')!.lifetime, { kind: 'persistent' }); + + const subconscious = byName.get('Subconscious')!; + assert.equal(subconscious.kind, 'subconscious'); + assert.equal(subconscious.relationships.spawnedBy, 'mira'); + assert.equal(subconscious.onParentEnd, 'end', 'attention tenancy ends with the resident it serves'); + assert.equal(subconscious.roles.receivesUntargeted, false); + assert.deepEqual(subconscious.relationships.observes, [{ includeHeld: true }]); + + const record = byName.get('worker-1')!; + assert.equal(record.kind, 'worker'); + assert.equal(record.lifetime.kind, 'task'); + assert.equal(record.relationships.spawnedBy, undefined, 'an independent agent needs no artificial parent'); + assert.equal(record.namespace, 'subagent/worker-1'); + + // The agent that owns the in-flight work is identified. + const activation = framework.listActivations().find((a) => a.owner.agent === 'worker-1'); + assert.ok(activation, 'the running job has an owned activation'); + assert.deepEqual(activation!.owner, { agent: 'worker-1', incarnation: 1 }); + assert.equal(activation!.reason, 'ephemeral'); + + const created = traces.find((t) => t.type === 'dendrite:agent-created' && t.agentName === 'worker-1'); + assert.ok(created, 'creation is announced'); + assert.deepEqual((created as { lifetime: unknown }).lifetime, record.lifetime); + // Whose weights, next to whose context: what a consent decision is made on. + assert.equal((created as { model?: string }).model, 'test-model'); + assert.equal(record.model, 'test-model'); + assert.equal(byName.get('mira')!.model, 'test-model'); + + gates.release('g1'); + const result = await worker.settled; + assert.ok(result.ok); + assert.equal(framework.getAgentRecord('worker-1')?.ended?.reason, 'completed'); + assert.equal(framework.listAgents().some((r) => r.name === 'worker-1'), false); + assert.equal( + framework.listAgents({ includeEnded: true }).some((r) => r.name === 'worker-1'), + true, + 'an ended agent stays inspectable', + ); + }); + + it('an untargeted broadcast wakes residents and not a running bounded job', async () => { + const framework = await boot(); + framework.start(); + const worker = await startBlocked(framework, 'worker-1', 'g1'); + const before = membrane.calls.length; + + gates.broadcastOn = 'custom'; + framework.pushEvent({ type: 'custom', name: 'nudge' } as unknown as ProcessEvent); + await until( + () => membrane.callsFor('mira').length > 0 && membrane.callsFor('oren').length > 0, + 'both residents to be woken', + ); + gates.broadcastOn = null; + // The job was never queued for the broadcast — not queued and then + // dropped once it finished. + const queued = (framework as unknown as { pendingRequests: Array<{ agentName: string }> }).pendingRequests; + assert.equal(queued.some((request) => request.agentName === 'worker-1'), false); + + gates.release('g1'); + assert.ok((await worker.settled).ok); + await framework.runUntilIdle(); + // One stream, which held both of the job's own rounds: the broadcast + // started no turn of its own for the job. + assert.equal(membrane.callsFor('worker-1').length, 1); + assert.ok(membrane.calls.length >= before + 2); + }); + + it('refuses to run a bounded job with no bound, leaving nothing registered', async () => { + const framework = await boot(); + framework.start(); + const { agent, contextManager } = await framework.createEphemeralAgent( + { name: 'forever', model: 'test-model', systemPrompt: 'x', allowedTools: 'all' }, + { ...workerSpec('forever'), lifetime: { kind: 'task' } }, + ); + await assert.rejects( + framework.runEphemeralToCompletion(agent, contextManager), + (error: unknown) => error instanceof AgentSpecError && /every task agent must end/.test(error.message), + ); + assert.equal(framework.getAgent('forever'), null); + assert.equal(framework.getAgentRecord('forever'), null); + }); + + it('stopAgent ends a running job: its caller is told who stopped it and why', async () => { + const framework = await boot(); + const traces: TraceEvent[] = []; + framework.onTrace((event) => traces.push(event)); + framework.start(); + const worker = await startBlocked(framework, 'worker-1', 'g1', workerSpec('worker-1', { spawnedBy: 'oren' })); + + const outcome = framework.stopAgent('worker-1', { by: 'mira', reason: 'no longer needed' }); + assert.equal(outcome.ended.ended?.reason, 'stopped'); + assert.equal(outcome.ended.ended?.by, 'mira', 'anyone may stop: mira did not spawn it'); + + const result = await worker.settled; + assert.equal(result.ok, false); + assert.ok(!result.ok && result.error instanceof AgentStoppedError); + assert.match(String(!result.ok && (result.error as Error).message), /ended \(stopped by mira\): no longer needed/); + assert.equal(framework.getAgent('worker-1'), null); + assert.deepEqual(framework.getAgentRecord('worker-1')?.ended && { + reason: framework.getAgentRecord('worker-1')!.ended!.reason, + by: framework.getAgentRecord('worker-1')!.ended!.by, + }, { reason: 'stopped', by: 'mira' }); + assert.equal(traces.filter((t) => t.type === 'dendrite:agent-ended' && t.agentName === 'worker-1').length, 1); + + assert.throws(() => framework.stopAgent('worker-1'), /not a registered agent/); + assert.throws(() => framework.stopAgent('mira'), /owns default delivery and cannot be stopped/); + }); + + it('cancelActivation ends a bounded job through its own run', async () => { + const framework = await boot(); + framework.start(); + const worker = await startBlocked(framework, 'worker-1', 'g1'); + assert.equal(framework.cancelActivation('worker-1', { by: 'oren', reason: 'changed plans' }), true); + const result = await worker.settled; + assert.equal(result.ok, false); + const ended = framework.getAgentRecord('worker-1')?.ended; + assert.equal(ended?.reason, 'stopped'); + assert.equal(ended?.by, 'oren'); + assert.equal(framework.cancelActivation('oren'), false, 'nothing was running for oren'); + }); + + it('enforces a task deadline however lively the run is', async () => { + const framework = await boot(); + framework.start(); + // The outcome alone is awaited: on a loaded machine the 60 ms deadline can + // pass before the job reaches its gate, and either way the job must end + // at its deadline, so nothing here waits on the gate first. + membrane.script('worker-1', waitCall('g1'), say('worker-1 done')); + const { agent, contextManager } = await framework.createEphemeralAgent( + { name: 'worker-1', model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all' }, + { ...workerSpec('worker-1'), lifetime: { kind: 'task', deadlineMs: 60 } }, + ); + contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + const worker = { + settled: framework.runEphemeralToCompletion(agent, contextManager).then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }), + ), + }; + const result = await worker.settled; + assert.ok(!result.ok && result.error instanceof AgentStoppedError); + assert.equal(framework.getAgentRecord('worker-1')?.ended?.reason, 'deadline'); + assert.equal(framework.getAgent('worker-1'), null); + }); + + it('a task deadline past the longest timer waits it out rather than ending the job at once', async () => { + const framework = await boot(); + framework.start(); + const deadlineMs = 2 ** 31; // one past the longest wait a timer holds + const worker = await startBlocked( + framework, + 'worker-1', + 'g1', + { ...workerSpec('worker-1'), lifetime: { kind: 'task', deadlineMs } }, + ); + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.equal(framework.getAgentRecord('worker-1')?.ended, undefined, 'the job is still running'); + framework.stopAgent('worker-1'); + const result = await worker.settled; + assert.equal(framework.getAgentRecord('worker-1')?.ended?.reason, 'stopped'); + assert.ok(!result.ok); + }); + + it('a replay of create()\'s traces keeps the first 1,000 and ends by naming how many it dropped', async () => { + const framework = await boot(); + const emit = (framework as unknown as { emitTrace(event: unknown): void }).emitTrace.bind(framework); + for (let i = 0; i < 1005; i++) emit({ type: 'dendrite:test-early', seq: i }); + const replayed: Array<{ type: string; seq?: number; dropped?: number }> = []; + framework.onTrace((event) => replayed.push(event as never), { replay: true }); + assert.equal(replayed.length, 1001, 'the first 1,000, then one event naming the rest'); + const ours = replayed.filter((e) => e.type === 'dendrite:test-early').map((e) => e.seq); + assert.equal(ours[0], 0, 'the start of the stream is kept'); + assert.deepEqual(ours, [...ours].sort((a, b) => a! - b!), 'in order'); + assert.ok(!ours.includes(1004), 'the newest are the ones dropped'); + const last = replayed.at(-1)!; + assert.equal(last.type, 'dendrite:early-traces-dropped'); + assert.equal(last.dropped, 1005 - ours.length); + }); + + it('delivers a result as attributed mail in the child\'s own name and wakes the recipient', async () => { + const framework = await boot(); + framework.start(); + const fork = await startBlocked( + framework, + 'fork-1', + 'g1', + taskForkSpec('fork-1', { parent: 'oren', resultTo: { to: 'oren', as: 'message' } }), + 'the answer is 42', + ); + gates.release('g1'); + const result = await fork.settled; + assert.ok(result.ok); + + // The run has ended; its record still carries the route. + const delivery = framework.deliverAgentResult( + 'fork-1', + [{ type: 'text', text: result.ok ? result.value.speech : '' }], + { causedBy: ['task-7'] }, + ); + assert.deepEqual({ delivered: delivery.delivered, to: delivery.to }, { delivered: true, to: 'oren' }); + await until(() => membrane.callsFor('oren').length > 0, 'oren to be woken by the result'); + await framework.runUntilIdle(); + + const landed = textsIn(framework, 'oren').filter((m) => m.metadata.kind === 'agent-result'); + assert.equal(landed.length, 1); + assert.equal(landed[0]!.participant, 'fork-1', 'the result is the child\'s own words under its own name'); + assert.equal(landed[0]!.text, 'the answer is 42'); + const stamp = landed[0]!.metadata.dendrite as Record; + assert.equal(typeof stamp.producedAt, 'number', 'when it was produced, apart from when it is read'); + assert.deepEqual({ ...stamp, producedAt: undefined }, { + mailId: delivery.mailId, + kind: 'result', + from: { agent: 'fork-1', incarnation: 1 }, + causedBy: ['task-7'], + producedAt: undefined, + }); + assert.deepEqual(framework.listHeldMail(), [], 'a delivered result is no longer held'); + // (Co-residents share one message slot today, so mira can read it too; + // what the route decides is who it is addressed to and who is woken.) + assert.equal(membrane.callsFor('mira').length, 0, 'the primary is not woken for a result routed elsewhere'); + }); + + it('refuses to deliver a result whose route is a tool result: the caller awaiting the run already has it', async () => { + const framework = await boot(); + framework.start(); + const job = await startBlocked( + framework, + 'inline-1', + 'g1', + workerSpec('inline-1', { spawnedBy: 'oren', resultTo: { to: 'oren', as: 'tool-result' } }), + 'the inline answer', + ); + gates.release('g1'); + assert.ok((await job.settled).ok); + assert.throws( + () => framework.deliverAgentResult('inline-1', [{ type: 'text', text: 'the inline answer' }]), + /deliverAgentResult: "inline-1" returns its result as a tool-result; the caller awaiting the run delivers it/, + ); + assert.deepEqual(framework.listHeldMail({ from: 'inline-1' }), [], 'nothing is held for delivery'); + await framework.runUntilIdle(); + assert.equal( + textsIn(framework, 'oren').filter((m) => m.metadata.kind === 'agent-result').length, + 0, + "no second copy lands in the caller's context", + ); + }); + + it('a derivation tells the agent it was made from — name, kind, weights, inheritance, route, lifetime — and not the fork (#270)', async () => { + const framework = await boot(); + framework.start(); + const fork = await startBlocked( + framework, + 'fork-1', + 'g1', + taskForkSpec('fork-1', { parent: 'oren', resultTo: { to: 'oren', as: 'message' }, idleTimeoutMs: 600_000 }), + ); + const told = () => textsIn(framework, 'oren').filter((m) => m.metadata.kind === 'agent-lifecycle'); + assert.equal(told().length, 1, 'the source hears of the derivation once, as a host-framed lifecycle line'); + const line = told()[0]!; + assert.equal(line.participant, 'user', 'host-framed, voiced as nobody'); + for (const fact of ['fork-1', 'task-fork', 'your weights', 'copy of your context', 'returns to you', 'it ends when its task does (idle 10 min)', 'orphaned']) { + assert.ok(line.text.includes(fact), `the notice names ${JSON.stringify(fact)}: ${line.text}`); + } + assert.deepEqual(line.metadata.dendrite, { + event: 'derived', agent: 'fork-1', kind: 'task-fork', incarnation: 1, model: 'test-model', + inherit: { from: 'oren', mode: 'copy' }, resultTo: { to: 'oren', as: 'message' }, + lifetime: { kind: 'task', idleTimeoutMs: 600_000 }, + }); + assert.ok( + !fork.contextManager.getAllMessages().some((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'agent-lifecycle'), + 'the fork never inherits the notice about itself', + ); + assert.equal(membrane.callsFor('oren').length, 0, 'a notice requests no wake'); + + gates.release('g1'); + const result = await fork.settled; + assert.ok(result.ok); + + // A resumption is announced to the source too, and says which it is. + membrane.script('fork-1', say('back again')); + const resumed = await framework.resumeAgent('fork-1', { model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all' }); + await framework.runEphemeralToCompletion(resumed.agent, resumed.contextManager); + assert.equal(told().length, 2); + assert.ok(told()[1]!.text.includes('resumed') && told()[1]!.text.includes('incarnation 2'), told()[1]!.text); + }); + + it('above a threshold, derivation notices collapse to a count line every threshold forks (Linn, #270)', async () => { + const framework = await boot({ dendrite: { collapseDerivationNoticesAfter: 3 } }); + framework.start(); + for (let n = 1; n <= 7; n++) { + const name = `reader-${n}`; + membrane.script(name, say('looked')); + const { agent, contextManager } = await framework.createEphemeralAgent( + { name, model: 'test-model', systemPrompt: 'Read.' }, + taskForkSpec(name, { parent: 'oren', resultTo: { to: 'oren', as: 'message' } }), + ); + contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + await framework.runEphemeralToCompletion(agent, contextManager); + } + const told = textsIn(framework, 'oren').filter((m) => m.metadata.kind === 'agent-lifecycle'); + assert.deepEqual( + told.map((m) => (m.metadata.dendrite as { agent: string }).agent), + ['reader-1', 'reader-2', 'reader-3', 'reader-6'], + 'three per-fork lines, then one count line at the sixth; the seventh waits for the ninth', + ); + const count = told[3]!; + assert.ok(count.text.includes('6 task-forks have been derived from you'), count.text); + assert.ok(count.text.includes('3 since the last note'), count.text); + assert.ok(count.text.includes('the latest, reader-6,'), count.text); + assert.deepEqual((count.metadata.dendrite as { collapsed: unknown }).collapsed, { count: 6, sinceLastNote: 3 }); + assert.equal( + framework.listAgents({ includeEnded: true }).filter((r) => r.kind === 'task-fork').length, 7, + 'the per-fork records are all there for anyone who fetches', + ); + }); + + it('orphans task work when its parent ends, offers adoption, and holds the result until adopted', async () => { + const framework = await boot(); + const traces: TraceEvent[] = []; + framework.onTrace((event) => traces.push(event)); + framework.start(); + + const boss = await startBlocked(framework, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + const job = await startBlocked( + framework, + 'job', + 'g-job', + workerSpec('job', { spawnedBy: 'boss', resultTo: { to: 'boss', as: 'message' } }), + 'job result', + ); + // A reader that exists only to serve the boss: attention tenancy. + const reader = await startBlocked( + framework, + 'reader', + 'g-reader', + { ...workerSpec('reader', { spawnedBy: 'boss' }), onParentEnd: 'end' }, + ); + + const outcome = framework.stopAgent('boss', { by: 'mira' }); + assert.deepEqual(outcome.cascaded.map((r) => r.name), ['reader']); + assert.deepEqual(outcome.orphaned.map((o) => [o.record.name, o.candidates]), [['job', ['oren', 'mira']]]); + + // The tenant ended with its parent; the job did not. + const readerResult = await reader.settled; + assert.ok(!readerResult.ok && readerResult.error instanceof AgentStoppedError); + assert.equal(framework.getAgentRecord('reader')?.ended?.reason, 'parent-ended'); + assert.ok(!(await boss.settled).ok); + assert.ok(framework.getAgent('job'), 'orphaned task work keeps running'); + assert.equal(framework.getAgentRecord('job')?.orphaned?.formerParent.agent, 'boss'); + + const orphaned = traces.find((t) => t.type === 'dendrite:agent-orphaned'); + assert.deepEqual(orphaned && { + agentName: orphaned.agentName, formerParent: orphaned.formerParent, candidates: orphaned.candidates, + }, { agentName: 'job', formerParent: 'boss', candidates: ['oren', 'mira'] }); + + // The nearest candidate is told, factually. + const offer = textsIn(framework, 'oren').filter((m) => m.metadata.kind === 'agent-lifecycle'); + assert.equal(offer.length, 1); + assert.match(offer[0]!.text, /boss has ended \(stopped\).*"job".*can be reparented to you/); + assert.equal(offer[0]!.participant, 'user', 'a lifecycle fact is host-framed, never voiced as an agent'); + + // The orphan finishes. Its notice reached it at its tool boundary. + gates.release('g-job'); + const jobResult = await job.settled; + assert.ok(jobResult.ok && jobResult.value.speech === 'job result'); + const notice = job.contextManager.getAllMessages() + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'agent-lifecycle'); + assert.equal(notice.length, 1); + assert.match( + notice[0]!.content.map((b) => (b.type === 'text' ? b.text : '')).join(''), + /boss, the agent that started you, has ended \(stopped\)\. Your own work is not cancelled\. Your result will be held/, + ); + + // Nobody can receive the result yet: held, not dropped. + const delivery = framework.deliverAgentResult('job', [{ type: 'text', text: 'job result' }]); + assert.equal(delivery.delivered, false); + assert.deepEqual( + framework.listHeldMail().map((m) => [m.id, m.to, m.heldBecause]), + [[delivery.mailId, 'boss', 'recipient-gone']], + ); + assert.ok(traces.some((t) => t.type === 'dendrite:mail-held' && t.mailId === delivery.mailId)); + }); + + it('reparenting a running orphan reroutes its result to the new parent, once', async () => { + const framework = await boot(); + framework.start(); + await startBlocked(framework, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + const job = await startBlocked( + framework, + 'job', + 'g-job', + workerSpec('job', { spawnedBy: 'boss', resultTo: { to: 'boss', as: 'message' } }), + 'late result', + ); + framework.stopAgent('boss', { by: 'mira' }); + + const adopted = framework.reparentAgent('job', 'oren', { by: 'oren' }); + assert.equal(adopted.orphaned, undefined); + assert.equal(adopted.relationships.spawnedBy, 'oren'); + assert.equal(adopted.relationships.resultTo?.to, 'oren'); + + gates.release('g-job'); + assert.ok((await job.settled).ok); + const delivery = framework.deliverAgentResult('job', [{ type: 'text', text: 'late result' }]); + assert.equal(delivery.to, 'oren'); + await framework.runUntilIdle(); + + const results = textsIn(framework, 'oren').filter((m) => m.metadata.kind === 'agent-result'); + assert.deepEqual(results.map((m) => [m.participant, m.text]), [['job', 'late result']]); + assert.deepEqual(framework.listHeldMail(), []); + }); + + it('a result produced before adoption is delivered to the adopter, exactly once', async () => { + const framework = await boot(); + framework.start(); + await startBlocked(framework, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + const job = await startBlocked( + framework, + 'job', + 'g-job', + workerSpec('job', { spawnedBy: 'boss', resultTo: { to: 'boss', as: 'message' } }), + 'held result', + ); + framework.stopAgent('boss'); + // Keep the job registered while its finished output waits: deliver from inside the run. + const held = framework.deliverAgentResult('job', [{ type: 'text', text: 'held result' }]); + assert.equal(held.delivered, false); + + framework.reparentAgent('job', 'oren'); + const results = () => textsIn(framework, 'oren').filter((m) => m.metadata.kind === 'agent-result'); + // The job is still blocked, so the framework is not idle; wait for the delivery itself. + await until(() => results().length > 0 && membrane.callsFor('oren').length > 0, 'the held result to reach oren'); + assert.deepEqual(results().map((m) => [m.participant, m.text, (m.metadata.dendrite as { mailId: string }).mailId]), [ + ['job', 'held result', held.mailId], + ]); + assert.deepEqual(framework.listHeldMail(), []); + + gates.release('g-job'); + await job.settled; + await framework.runUntilIdle(); + assert.equal(results().length, 1); + }); + + it('an explicit agent-to-agent message is attributed and recorded as a message path', async () => { + const framework = await boot(); + framework.start(); + const sent = framework.sendAgentMessage('oren', 'mira', [{ type: 'text', text: 'the build is green' }]); + assert.equal(sent.delivered, true); + await framework.runUntilIdle(); + + const message = textsIn(framework, 'mira').find((m) => m.metadata.kind === 'agent-message'); + assert.ok(message); + assert.equal(message!.participant, 'oren'); + assert.deepEqual((message!.metadata.dendrite as { from: unknown }).from, { agent: 'oren', incarnation: 1 }); + assert.deepEqual(framework.getAgentRecord('oren')!.relationships.messagePeers, ['mira']); + assert.deepEqual(framework.getAgentRecord('mira')!.relationships.messagePeers, [], 'a path is directional'); + assert.throws(() => framework.sendAgentMessage('ghost', 'mira', []), /sender "ghost" is not a known agent/); + }); + + it("stopping a resident whose stream's cancel throws still leaves nothing running under its ended record", async () => { + const framework = await boot(); + framework.start(); + membrane.script('oren', waitCall('g-oren'), say('oren done')); + framework.sendAgentMessage('mira', 'oren', [{ type: 'text', text: 'look at this' }]); + await until(() => gates.entered.has('g-oren'), 'oren to block on g-oren'); + framework.getAgent('oren')!.cancelStream = () => { throw new Error('the stream refused to cancel'); }; + const logged: string[] = []; + const consoleError = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + try { + assert.equal(framework.stopAgent('oren', { by: 'mira' }).ended.ended?.reason, 'stopped'); + } finally { console.error = consoleError; } + assert.equal(framework.getAgent('oren'), null, 'no agent runs under the ended record'); + assert.ok(logged.some((line) => line.includes('the stream refused to cancel')), logged.join('\n')); + gates.release('g-oren'); + }); + + it('stopping a resident that put the gate to sleep ends the sleep', async () => { + const framework = await boot({ gate: { config: { default: 'always' } } } as Partial[0]>); + framework.start(); + const gate = (framework as unknown as { + eventGate: { setSleep(seconds: number, note?: string, agentName?: string): unknown; getSleepState(): unknown }; + }).eventGate; + gate.setSleep(600, 'resting', 'oren'); + assert.ok(gate.getSleepState(), 'the gate sleeps for oren'); + framework.stopAgent('oren', { by: 'mira' }); + assert.equal(gate.getSleepState(), null, "oren's sleep ended with oren"); + }); + + it('a derivation that fails after its child is made closes the manager it opened, and its name stays used', async () => { + const framework = await boot(); + const openManagers = () => (framework as unknown as { openManagers: Set> }).openManagers.size; + const before = openManagers(); + const source = framework.getAgent('mira')!.getContextManager(); + const compile = source.compile.bind(source); + let failNext = true; + source.compile = ((...args: Parameters) => { + if (failNext) { failNext = false; return Promise.reject(new Error('the source would not compile')); } + return compile(...args); + }) as typeof source.compile; + await assert.rejects(framework.deriveAgent({ name: 'fork-c', from: 'mira', mode: 'copy' }), /the source would not compile/); + assert.equal(framework.getAgentRecord('fork-c'), null, 'nothing registered'); + assert.equal(openManagers(), before, "the child's manager is closed"); + await assert.rejects(framework.deriveAgent({ name: 'fork-c', from: 'mira', mode: 'copy' }), /has been used in this framework/, + 'what was written under the name stays its own'); + }); + + it("a derivation whose notice to its source can't be written still runs to its end", async () => { + const framework = await boot({ + dendrite: { derivationNotice: () => { throw new Error('the notice hook failed'); } }, + } as Partial[0]>); + framework.start(); + membrane.script('fork-n', say('fork-n done')); + const fork = await framework.deriveAgent({ name: 'fork-n', from: 'mira', mode: 'copy' }); + const logged: string[] = []; + const consoleError = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + let result: Awaited>; + try { + result = await framework.runEphemeralToCompletion(fork.agent, fork.contextManager); + } finally { console.error = consoleError; } + assert.equal(result.speech, 'fork-n done'); + assert.equal(framework.getAgentRecord('fork-n')?.ended?.reason, 'completed'); + assert.ok(logged.some((line) => line.includes('the notice hook failed')), logged.join('\n')); + }); + + it("mail from an agent the recipient counts as its own voice arrives as ' (mail)', not as its own words", async () => { + const framework = await boot(); + framework.start(); + membrane.script('fork-m', waitCall('g-fork-m'), say('fork-m done')); + const fork = await framework.deriveAgent({ name: 'fork-m', from: 'mira', mode: 'copy', config: { selfParticipants: ['mira'] } }); + fork.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + const run = framework.runEphemeralToCompletion(fork.agent, fork.contextManager); + await until(() => gates.entered.has('g-fork-m'), 'fork-m to block on g-fork-m'); + framework.sendAgentMessage('mira', 'fork-m', [{ type: 'text', text: 'also check the docs' }], { wake: false }); + gates.release('g-fork-m'); + await run; + const mail = fork.contextManager.getAllMessages().find((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'agent-message'); + assert.equal(mail?.participant, 'mira (mail)'); + assert.deepEqual((mail!.metadata as { dendrite: { from: unknown } }).dendrite.from, { agent: 'mira', incarnation: 1 }); + assert.ok(!membrane.callsFor('fork-m').some((call) => call.messages.some((m) => + m.participant === 'fork-m' && JSON.stringify(m.content).includes('also check the docs'))), 'never presented as its own turn'); + }); + + it('a copy derivation that asks for what only a shared one inherits is refused before anything is made', async () => { + const framework = await boot(); + await assert.rejects(framework.deriveAgent({ name: 'fork-r', from: 'mira', mode: 'copy', inheritRefusals: true }), /inheritRefusals is for mode 'shared'/); + await assert.rejects(framework.deriveAgent({ name: 'fork-s', from: 'mira', mode: 'copy', solve: 'fresh' }), /solve is for mode 'shared'/); + assert.equal(framework.getAgentRecord('fork-r'), null); + const made = await framework.deriveAgent({ name: 'fork-r', from: 'mira', mode: 'copy' }); + made.cleanup(); + }); + + it('of two overlapping resumes of one ended job, one resumes it and the other is refused', async () => { + const framework = await boot(); + framework.start(); + membrane.script('job-r', say('job-r done')); + const made = await framework.createEphemeralAgent( + { name: 'job-r', model: 'test-model', systemPrompt: 'Do the task.' }, + workerSpec('job-r', { spawnedBy: 'mira' }), + ); + made.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + await framework.runEphemeralToCompletion(made.agent, made.contextManager); + const config = { model: 'test-model', systemPrompt: 'Do the task.' }; + const outcomes = await Promise.allSettled([framework.resumeAgent('job-r', config), framework.resumeAgent('job-r', config)]); + assert.deepEqual(outcomes.map((o) => o.status).sort(), ['fulfilled', 'rejected']); + assert.match(String((outcomes.find((o) => o.status === 'rejected') as PromiseRejectedResult).reason), /"job-r" is already being resumed/); + const resumed = (outcomes.find((o) => o.status === 'fulfilled') as PromiseFulfilledResult>>).value; + membrane.script('job-r', say('job-r again')); + assert.equal((await framework.runEphemeralToCompletion(resumed.agent, resumed.contextManager)).speech, 'job-r again'); + assert.equal(framework.getAgentRecord('job-r')?.incarnation, 2); + }); + + it('a resume refused for its configuration leaves the job free to resume', async () => { + const framework = await boot(); + framework.start(); + membrane.script('job-v', say('job-v done')); + const made = await framework.createEphemeralAgent( + { name: 'job-v', model: 'test-model', systemPrompt: 'Do the task.' }, + workerSpec('job-v', { spawnedBy: 'mira' }), + ); + made.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + await framework.runEphemeralToCompletion(made.agent, made.contextManager); + await assert.rejects( + framework.resumeAgent('job-v', { model: 'test-model', systemPrompt: 'Do the task.', promptCaching: 'yes' as unknown as boolean }), + /promptCaching/, + ); + const resumed = await framework.resumeAgent('job-v', { model: 'test-model', systemPrompt: 'Do the task.' }); + resumed.cleanup(); + }); + + it("an earlier resume's cleanup, called again, leaves a later resume's hold on the name", async () => { + const framework = await boot(); + framework.start(); + membrane.script('job-c', say('job-c done')); + const made = await framework.createEphemeralAgent( + { name: 'job-c', model: 'test-model', systemPrompt: 'Do the task.' }, + workerSpec('job-c', { spawnedBy: 'mira' }), + ); + made.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + await framework.runEphemeralToCompletion(made.agent, made.contextManager); + const config = { model: 'test-model', systemPrompt: 'Do the task.' }; + const first = await framework.resumeAgent('job-c', config); + first.cleanup(); + const second = await framework.resumeAgent('job-c', config); + first.cleanup(); + await assert.rejects(framework.resumeAgent('job-c', config), /"job-c" is already being resumed/); + second.cleanup(); + }); + + it("an ended spawned agent's agent-ended trace names its spawner", async () => { + const framework = await boot(); + const traces: TraceEvent[] = []; + framework.onTrace((event) => traces.push(event)); + framework.start(); + const worker = await startBlocked(framework, 'worker-s', 'g-s', workerSpec('worker-s', { spawnedBy: 'oren' })); + framework.stopAgent('worker-s', { by: 'mira' }); + await worker.settled; + const ended = traces.find((t) => t.type === 'dendrite:agent-ended' && t.agentName === 'worker-s') as { spawnedBy?: string } | undefined; + assert.equal(ended?.spawnedBy, 'oren'); + gates.release('g-s'); + }); +}); + +describe('Dendrite restart', () => { + let tempDir: string; + let store: JsStore; + let membrane: RoutedMembrane; + let frameworks: AgentFramework[]; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'dendrite-restart-')); + store = JsStore.openOrCreate({ path: join(tempDir, 'store.chronicle') }); + membrane = new RoutedMembrane(); + frameworks = []; + }); + + afterEach(async () => { + for (const framework of frameworks.reverse()) { + try { await framework.stop(); } catch { /* already stopped */ } + } + try { store.close(); } catch { /* already closed */ } + rmSync(tempDir, { recursive: true, force: true }); + }); + + /** + * Each call is a new process as far as the framework can tell: a fresh + * AgentFramework over the same store, with the previous one simply + * abandoned mid-flight (never stopped — that is the crash). + */ + async function process(gates: GateModule): Promise { + const framework = await AgentFramework.create({ + store, + membrane: membrane.asMembrane(), + agents: [{ name: 'mira', model: 'test-model', systemPrompt: 'You are mira.' }], + modules: [gates], + syncIntervalMs: 0, + }); + frameworks.push(framework); + return framework; + } + + async function blockedJob(framework: AgentFramework, gates: GateModule, name: string, spec: Omit) { + membrane.script(name, waitCall(name), say(`${name} done`)); + const { agent, contextManager } = await framework.createEphemeralAgent( + { name, model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all' }, + spec, + ); + contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + framework.runEphemeralToCompletion(agent, contextManager).catch(() => { /* abandoned */ }); + await until(() => gates.entered.has(name), `${name} to block`); + } + + it('a graceful stop leaves bounded work for the next boot to report as interrupted', async () => { + // First live run, finding D: a reader fork cut off by SIGTERM ended + // silently. stop() must not end work it cut off as if it had finished + // or failed; the next boot reports it, and a host whose trace sink + // attaches after create() (every host) still sees the trace. + const gates1 = new GateModule(); + const first = await process(gates1); + first.start(); + await blockedJob(first, gates1, 'job', workerSpec('job', { spawnedBy: 'mira', resultTo: { to: 'mira', as: 'message' } })); + await first.stop(); + assert.equal(first.getAgentRecord('job')?.ended, undefined, 'stop does not end work it cut off'); + + const second = await process(new GateModule()); + const traces: Array<{ type: string; agentName?: string; reason?: string }> = []; + second.onTrace((event) => traces.push(event as never), { replay: true }); + assert.equal(second.getAgentRecord('job')?.ended?.reason, 'host-restart'); + const notices = textsIn(second, 'mira').filter((m) => m.metadata.kind === 'agent-lifecycle'); + assert.equal(notices.length, 1); + assert.match(notices[0]!.text, /the host restarted while "job" was still working for you/); + assert.ok( + traces.some((t) => t.type === 'dendrite:agent-ended' && t.agentName === 'job' && t.reason === 'host-restart'), + 'the create()-time trace reaches a listener attached afterwards', + ); + }); + + it('tells a result recipient about bounded work a restart interrupted', async () => { + const gates1 = new GateModule(); + const first = await process(gates1); + first.start(); + await blockedJob(first, gates1, 'job', workerSpec('job', { spawnedBy: 'mira', resultTo: { to: 'mira', as: 'message' } })); + // A job whose result goes back as a tool result has an awaiting caller, not a mailbox. + await blockedJob(first, gates1, 'inline', workerSpec('inline', { spawnedBy: 'mira', resultTo: { to: 'mira', as: 'tool-result' } })); + assert.equal(first.getAgentRecord('mira')!.incarnation, 1); + + const second = await process(new GateModule()); + assert.equal(second.getAgentRecord('mira')!.incarnation, 2, 'the same identity, a new incarnation'); + assert.deepEqual(second.listAgents().map((r) => r.name), ['mira']); + assert.equal(second.getAgentRecord('job')?.ended?.reason, 'host-restart'); + assert.equal(second.getAgentRecord('inline')?.ended?.reason, 'host-restart'); + + const notices = textsIn(second, 'mira').filter((m) => m.metadata.kind === 'agent-lifecycle'); + assert.equal(notices.length, 1); + assert.match(notices[0]!.text, /the host restarted while "job" was still working for you\. It was ended without a result\./); + assert.deepEqual((notices[0]!.metadata.dendrite as { agents: string[] }).agents, ['job']); + + // A third boot has nothing left to say. + const third = await process(new GateModule()); + assert.equal(third.getAgentRecord('mira')!.incarnation, 3); + assert.equal(textsIn(third, 'mira').filter((m) => m.metadata.kind === 'agent-lifecycle').length, 1); + }); + + it("a job a restart interrupted is ended in a trace that names its spawner", async () => { + const gates1 = new GateModule(); + const first = await process(gates1); + first.start(); + await blockedJob(first, gates1, 'job-t', workerSpec('job-t', { spawnedBy: 'mira' })); + const second = await process(new GateModule()); + const traces: TraceEvent[] = []; + second.onTrace((event) => traces.push(event), { replay: true }); + const ended = traces.find((t) => t.type === 'dendrite:agent-ended' && t.agentName === 'job-t') as { reason?: string; spawnedBy?: string } | undefined; + assert.deepEqual([ended?.reason, ended?.spawnedBy], ['host-restart', 'mira']); + }); + + it('a job interrupted by a restart resumes on its own context and still returns its result', async () => { + const gates1 = new GateModule(); + const first = await process(gates1); + first.start(); + await blockedJob(first, gates1, 'job', workerSpec('job', { spawnedBy: 'mira', resultTo: { to: 'mira', as: 'message' } })); + + const second = await process(new GateModule()); + second.start(); + assert.equal(second.getAgentRecord('job')?.ended?.reason, 'host-restart'); + + membrane.script('job', say('finished after the restart')); + const resumed = await second.resumeAgent('job', { + model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all', + }); + assert.deepEqual( + resumed.contextManager.getAllMessages().map((m) => (m.content[0] as { text: string }).text), + ['Go.'], + 'the round that was in flight was never stored; it starts from the task', + ); + const result = await second.runEphemeralToCompletion(resumed.agent, resumed.contextManager); + assert.equal(result.speech, 'finished after the restart'); + + const record = second.getAgentRecord('job')!; + assert.equal(record.incarnation, 2); + assert.equal(record.relationships.spawnedBy, 'mira'); + const delivery = second.deliverAgentResult('job', [{ type: 'text', text: result.speech }]); + assert.equal(delivery.delivered, true); + await second.runUntilIdle(); + const landed = textsIn(second, 'mira').filter((m) => m.metadata.kind === 'agent-result'); + assert.deepEqual(landed.map((m) => [m.participant, m.text]), [['job', 'finished after the restart']]); + assert.deepEqual((landed[0]!.metadata.dendrite as { from: unknown }).from, { agent: 'job', incarnation: 2 }); + }); + + it('delivers a result held across a crash exactly once', async () => { + const gates1 = new GateModule(); + const first = await process(gates1); + first.start(); + await blockedJob(first, gates1, 'job', workerSpec('job', { spawnedBy: 'mira', resultTo: { to: 'mira', as: 'message' } })); + + // mira is mid-turn when the result arrives, so its message is deferred + // in memory — exactly the window in which a crash would lose it. + membrane.script('mira', waitCall('mira-busy'), say('mira done')); + (first as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'mira', reason: 'test', source: 'test', timestamp: Date.now(), + }); + await until(() => gates1.entered.has('mira-busy'), 'mira to be mid-turn'); + + const traces: TraceEvent[] = []; + first.onTrace((event) => traces.push(event)); + const delivery = first.deliverAgentResult('job', [{ type: 'text', text: 'survives the crash' }]); + assert.equal(delivery.delivered, true, 'queued for mira'); + assert.equal(first.listHeldMail().length, 1, 'still held: the message has not entered the store'); + // Published is not delivered: queued for a boundary is a deferral. + assert.deepEqual( + traces.filter((t) => t.type.startsWith('dendrite:mail-')).map((t) => t.type), + ['dendrite:mail-deferred'], + ); + assert.equal( + textsIn(first, 'mira').some((m) => m.metadata.kind === 'agent-result'), + false, + ); + + const second = await process(new GateModule()); + const results = (framework: AgentFramework) => + textsIn(framework, 'mira').filter((m) => m.metadata.kind === 'agent-result'); + assert.deepEqual(results(second).map((m) => [m.participant, m.text]), [['job', 'survives the crash']]); + assert.deepEqual( + (results(second)[0]!.metadata.dendrite as { from: unknown }).from, + { agent: 'job', incarnation: 1 }, + 'attributed to the incarnation that produced it', + ); + assert.deepEqual(second.listHeldMail(), []); + // The job handed its result over before the crash: no "ended without a result" notice. + assert.equal(textsIn(second, 'mira').some((m) => m.metadata.kind === 'agent-lifecycle'), false); + + const third = await process(new GateModule()); + assert.equal(results(third).length, 1, 'never delivered a second time'); + }); +}); diff --git a/test/dendrite-reader-dispatch.test.ts b/test/dendrite-reader-dispatch.test.ts new file mode 100644 index 000000000..7b2ec6ba9 --- /dev/null +++ b/test/dendrite-reader-dispatch.test.ts @@ -0,0 +1,202 @@ +/** + * A reader fork (registry kind 'subconscious-fork') may call only its own + * tools at dispatch: its resident's tools stay in its block for the shared + * prefix and are refused, and so is a tool registered after the fork was + * derived. Here at the agent level, with a copy-mode fork, so it runs on + * stores and context managers without `derive`; the reader path itself is in + * tune-out-reader-host-toolsets.test.ts. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { AutobiographicalStrategy } from '@animalabs/context-manager'; +import { AgentFramework } from '../src/index.js'; +import type { Module, ModuleContext, ToolCall, ToolResult, ToolDefinition } from '../src/types/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +const strategy = () => new AutobiographicalStrategy({ + adaptiveResolution: true, foldingStrategy: 'kv-stable', recentWindowTokens: 30_000, kvStableReachTokens: 8_000, +}); + +class ToolModule implements Module { + calls: ToolCall[] = []; + constructor(readonly name: string, private readonly tool: string) {} + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { + return [{ name: this.tool, description: `The ${this.name} module's tool.`, inputSchema: { type: 'object' } }]; + } + async handleToolCall(call: ToolCall): Promise { + this.calls.push(call); + return { success: true, data: { handled: call.name } }; + } + async onProcess(): Promise { return {}; } +} + +describe('a reader fork at dispatch', () => { + it('refuses its resident\'s tools and one registered after it was derived', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dendrite-reader-dispatch-')); + const membrane = new MockMembrane(); + const owner = new ToolModule('stub', 'frontline'); + const late = new ToolModule('late', 'op'); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', strategy: strategy() }], + modules: [owner], + }); + const refused: string[] = []; + framework.onTrace((event) => { + if (event.type === 'tool:failed' && (event as { module: string }).module === 'dendrite') refused.push((event as { tool: string }).tool); + }); + try { + await framework.start(); + const fork = await framework.deriveAgent({ + name: 'reader-a', from: 'scout', mode: 'copy', kind: 'subconscious-fork', + framing: [{ participant: 'user', content: [{ type: 'text', text: 'Read the channel and report.' }] }], + }); + await framework.addModule(late); + assert.ok(framework.getAllTools().some((t) => t.name === 'late--op'), 'premise: the late tool is registered after the fork was derived'); + + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c1', name: 'stub--frontline', input: {} }, + { type: 'tool_use', id: 'c2', name: 'late--op', input: {} }, + ] as never, 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'nothing urgent' }])); + await framework.runEphemeralToCompletion(fork.agent, fork.contextManager); + + assert.deepEqual(refused.sort(), ['late--op', 'stub--frontline'], 'the resident\'s tool and the later one are refused at dispatch'); + assert.deepEqual(owner.calls.map((c) => c.name), [], 'the resident\'s tool never ran'); + assert.deepEqual(late.calls.map((c) => c.name), [], 'the later tool never ran'); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +async function withReaderFixture( + check: (framework: AgentFramework, membrane: MockMembrane, tool: ToolModule) => Promise, + residentAllowedTools: 'all' | string[] = ['think'], +): Promise { + const dir = mkdtempSync(join(tmpdir(), 'dendrite-reader-permission-')); + const statusPath = join(dir, 'status.jsonl'); + const commandPath = join(dir, 'commands.txt'); + writeFileSync(statusPath, ''); + writeFileSync(commandPath, ''); + const membrane = new MockMembrane(); + const tool = new ToolModule('stub', 'frontline'); + let framework: AgentFramework | undefined; + try { + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ + name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', + strategy: strategy(), allowedTools: residentAllowedTools, + }], + modules: [tool], + subconscious: { + enabled: true, reader: 'forks', model: 'test-model', + voice: 'Read for scout.', strategyFactory: strategy, + }, + mcplServers: [{ + id: 'disc', command: process.execPath, + args: [join(import.meta.dirname, 'fixtures/tune-out-mcpl-server.mjs')], + env: { STATUS_PATH: statusPath, COMMAND_PATH: commandPath }, + }], + }); + await framework.start(); + await check(framework, membrane, tool); + } finally { + if (framework) await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +} + +async function restrictedReader(framework: AgentFramework) { + return framework.deriveAgent({ + name: 'reader-a', from: 'scout', mode: 'copy', kind: 'subconscious-fork', + framing: [{ participant: 'user', content: [{ type: 'text', text: 'Read and report.' }] }], + }); +} + +const displayed = (request: { tools?: Array<{ name: string; description: string; inputSchema: unknown }> }) => + (request.tools ?? []).map(({ name, description, inputSchema }) => ({ name, description, inputSchema })); + +describe('reader fork permission across resident tool lists', () => { + it('keeps the resident tool display and dispatches only its own model call', async () => { + await withReaderFixture(async (framework, membrane, tool) => { + const residentTools = displayed(await framework.previewActivation('scout')); + assert.ok(!residentTools.some((entry) => entry.name === 'note_disposition'), + 'premise: the resident omits the reader reporting tool'); + const fork = await restrictedReader(framework); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'own-note', name: 'note_disposition', + input: { key: 'reader-report', text: 'Read the channel' } }, + { type: 'tool_use', id: 'other-call', name: 'stub--frontline', input: {} }, + ] as never, 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done' }])); + await framework.runEphemeralToCompletion(fork.agent, fork.contextManager); + + const readerRequest = membrane.calls.find((call) => call.assistantParticipant === 'reader-a'); + assert.ok(readerRequest, 'premise: the reader made a model request'); + assert.deepEqual(displayed(readerRequest), residentTools, + 'the reader sees the same tool definitions as the resident'); + const outcomes = (membrane.lastStream?.receivedToolResults.flat() ?? []) as Array<{ + toolUseId: string; content: string; isError?: boolean; + }>; + const own = outcomes.find((result) => result.toolUseId === 'own-note'); + const other = outcomes.find((result) => result.toolUseId === 'other-call'); + assert.ok(own, 'the reader own call got a result'); + assert.equal(own.isError, false, 'the reader own call dispatches despite allowedTools'); + assert.match(own.content, /reader-report/, 'the disposition was actually recorded'); + assert.ok(other, 'the other call got a result'); + assert.equal(other.isError, true, 'a resident tool is refused for the reader'); + assert.deepEqual(tool.calls, [], 'the other tool did not run'); + }); + }); + + for (const { label, allowedTools } of [ + { label: 'unrestricted', allowedTools: 'all' as const }, + { label: 'restricted', allowedTools: ['think'] }, + ]) { + it('gives host-origin calls as the running reader the same own-only permission with ' + label + ' resident', async () => { + await withReaderFixture(async (framework, membrane, tool) => { + const hostCalls: Array> = []; + const modelRefusals: string[] = []; + framework.onTrace((event) => { + if (event.type !== 'tool:failed' || event.module !== 'dendrite' + || event.callId !== 'model-other') return; + modelRefusals.push(event.tool); + hostCalls.push(framework.executeToolCall({ + id: 'host-other', name: 'stub--frontline', input: {}, callerAgentName: 'reader-a', + })); + hostCalls.push(framework.executeToolCall({ + id: 'host-own', name: 'note_disposition', + input: { key: 'host-reader-report', text: 'Keep this disposition' }, + callerAgentName: 'reader-a', + })); + }); + const fork = await restrictedReader(framework); + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'model-other', name: 'stub--frontline', input: {} }, + ] as never, 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done' }])); + await framework.runEphemeralToCompletion(fork.agent, fork.contextManager); + assert.deepEqual(modelRefusals, ['stub--frontline'], 'premise: the model call reached the registered reader rule'); + assert.equal(hostCalls.length, 2, 'premise: both host calls began while the reader was running'); + const [other, own] = await Promise.all(hostCalls); + assert.equal(other!.success, false, 'the host cannot run the resident tool as the reader'); + assert.match(other!.error ?? '', /not available to reader-a/, + 'the refusal belongs to the reader rule, not an unregistered caller'); + assert.deepEqual(tool.calls, [], 'the refused host and model calls have no effect'); + assert.equal(own!.success, true, 'the host can run the reader own tool as the reader: ' + JSON.stringify(own)); + assert.deepEqual(own!.data, { noted: 'host-reader-report' }); + }, allowedTools); + }); + } +}); diff --git a/test/dendrite-recovery.test.ts b/test/dendrite-recovery.test.ts new file mode 100644 index 000000000..40791072c --- /dev/null +++ b/test/dendrite-recovery.test.ts @@ -0,0 +1,591 @@ +/** + * The registry's recovery when a write fails or a process dies on the way: + * + * - a change that failed and was put back leaves no recovery file holding it + * (an end already appended to the branch-local ended log stays), and a + * put-back that fails too doesn't hide the change's error; + * - an agent whose end was written before the snapshot let go of it ends once + * at the next boot, and its recipient isn't told of a restart it didn't meet; + * - an interrupted job's recipient is told even when the boot that found it + * dies before telling, and told once when the boot that told dies before + * writing the end; + * - an app-owned store names its recovery files, and is warned without them; + * - a recovery file that holds null, or a registry without its counters, is + * refused rather than read as empty; + * - a worker's namespace is where its context opens, and belongs to it; + * - an agent that ended past the registry's memory is still found; + * - a finished orphan's held result is told of and can be adopted; + * - a cancelled job's caller hears who cancelled it and why. + */ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import type { ContentBlock } from '@animalabs/membrane'; +import { AgentFramework, AgentStoppedError, workerSpec } from '../src/index.js'; +import type { AgentSpec } from '../src/index.js'; +import { AgentRegistry } from '../src/dendrite/registry.js'; +import type { FrameworkConfig } from '../src/types/framework.js'; +import { GateModule, RoutedMembrane, say, until, waitCall } from './helpers/dendrite.js'; + +const REGISTRY = 'framework/dendrite'; +const ENDED = 'framework/dendrite/ended'; + +/** Make a store method throw for one state id while `when` says so, until the returned undo runs. */ +function failOn(store: JsStore, method: 'setStateJson' | 'appendToStateJson', stateId: string, when: () => boolean = () => true): () => void { + const original = (store as unknown as Record unknown>)[method]!.bind(store); + Object.defineProperty(store, method, { + configurable: true, writable: true, + value: (...args: unknown[]) => { + if (args[0] === stateId && when()) throw new Error(`EIO: simulated ${method} failure`); + return original(...args); + }, + }); + return () => { delete (store as unknown as Record)[method]; }; +} + +const textOf = (content: ContentBlock[]) => content.map((b) => (b.type === 'text' ? b.text : `<${b.type}>`)).join(''); +const rowsWith = (framework: AgentFramework, agent: string, needle: string) => + framework.getAgent(agent)!.getContextManager().getAllMessages().filter((m) => textOf(m.content).includes(needle)); + +describe('the registry\'s recovery', () => { + let tempDir: string; + let membrane: RoutedMembrane; + let frameworks: AgentFramework[]; + let stores: JsStore[]; + beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'dendrite-recovery-')); membrane = new RoutedMembrane(); frameworks = []; stores = []; }); + afterEach(async () => { + for (const framework of frameworks.reverse()) { try { await framework.stop(); } catch { /* already stopped */ } } + for (const store of stores) { try { store.close(); } catch { /* closed */ } } + rmSync(tempDir, { recursive: true, force: true }); + }); + const storePath = () => join(tempDir, 'store.chronicle'); + const registryFile = () => join(storePath(), 'recovery', 'dendrite-registry.json'); + const endedFile = () => join(storePath(), 'recovery', 'dendrite-ended.json'); + + async function boot(gates = new GateModule(), options: Partial = {}, agents = ['mira', 'oren']): Promise { + const framework = await AgentFramework.create({ + ...(options.store ? {} : { storePath: storePath() }), + membrane: membrane.asMembrane(), + agents: agents.map((name) => ({ name, model: 'test-model', systemPrompt: `You are ${name}.` })), + modules: [gates], syncIntervalMs: 0, + ...options, + } as FrameworkConfig); + frameworks.push(framework); + return framework; + } + async function startBlocked(framework: AgentFramework, gates: GateModule, name: string, gate: string, spec: Omit, finalText = `${name} done`) { + membrane.script(name, waitCall(gate), say(finalText)); + const { agent, contextManager } = await framework.createEphemeralAgent({ name, model: 'test-model', systemPrompt: 'Do the task.', allowedTools: 'all' }, spec); + contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + const settled = framework.runEphemeralToCompletion(agent, contextManager).then((value) => ({ ok: true as const, value }), (error: unknown) => ({ ok: false as const, error })); + await until(() => gates.entered.has(gate), `${name} to block on ${gate}`); + return { settled }; + } + + it('a registry recovery file that holds null is refused, not read as no registry, and stays as it was', async () => { + const first = await boot(); + await first.stop(); + writeFileSync(registryFile(), 'null'); + await assert.rejects(boot(), /the agent registry \(.*dendrite-registry\.json\) reads as something other than a version-1 registry \(null\)/); + assert.equal(readFileSync(registryFile(), 'utf8'), 'null', 'the file is unchanged'); + }); + + it('a version-1 registry without its map of incarnations is refused', async () => { + const first = await boot(); + await first.stop(); + const stored = JSON.parse(readFileSync(registryFile(), 'utf8')) as Record; + delete stored.incarnations; + writeFileSync(registryFile(), JSON.stringify(stored)); + await assert.rejects(boot(), /reads as a version-1 registry with no map of incarnations/); + }); + + it('a stop whose branch-local write fails leaves the recovery file as memory is: the agent live', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + await startBlocked(first, gates, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + const undo = failOn(first.getStore(), 'setStateJson', REGISTRY); + try { + assert.throws(() => first.stopAgent('boss', { by: 'mira' }), /EIO: simulated setStateJson failure/); + } finally { undo(); } + assert.equal(first.getAgentRecord('boss')?.ended, undefined, 'boss is live in memory'); + const file = JSON.parse(readFileSync(registryFile(), 'utf8')) as { records: Array<{ name: string; ended?: unknown }> }; + assert.ok(file.records.some((r) => r.name === 'boss' && r.ended === undefined), 'and in the recovery file that wins at boot'); + const ended = JSON.parse(readFileSync(endedFile(), 'utf8')) as { ended: Array<{ name: string }> }; + assert.equal(ended.ended.filter((r) => r.name === 'boss').length, 0, 'and its end is off the ended file'); + }); + + it('a hold whose branch-local write fails is in no copy of the registry, so a retry holds it once', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + await startBlocked(first, gates, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + const job = await startBlocked(first, gates, 'job', 'g-job', workerSpec('job', { spawnedBy: 'boss', resultTo: { to: 'boss', as: 'message' } })); + first.stopAgent('boss', { by: 'mira' }); + gates.release('g-job'); + assert.ok((await job.settled).ok); + const undo = failOn(first.getStore(), 'setStateJson', REGISTRY); + try { + assert.throws(() => first.deliverAgentResult('job', [{ type: 'text', text: 'RESULT-1' }]), /EIO: simulated setStateJson failure/); + } finally { undo(); } + const file = JSON.parse(readFileSync(registryFile(), 'utf8')) as { mail: unknown[] }; + assert.equal(file.mail.length, 0, 'the recovery file holds no copy of the failed hold'); + first.deliverAgentResult('job', [{ type: 'text', text: 'RESULT-1' }]); + assert.equal(first.listHeldMail().length, 1, 'and the retry holds it once'); + }); + + it('a put-back rewrites every recovery file, one failing or not', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + await startBlocked(first, gates, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + const store = first.getStore(); + // The ended file's rewrite fails during the put-back: the registry file's still happens. + const internal = first as unknown as { writeRecoveryFile: (path: string, document: unknown) => void }; + const write = internal.writeRecoveryFile.bind(first); + let putBack = false; + internal.writeRecoveryFile = (path, document) => { + if (putBack && path === endedFile()) throw new Error('EIO: the ended file'); + write(path, document); + }; + const undo = failOn(store, 'setStateJson', REGISTRY, () => { putBack = true; return true; }); + try { + assert.throws(() => first.stopAgent('boss', { by: 'mira' }), /EIO: simulated setStateJson failure/); + } finally { + undo(); + delete (first as unknown as Record).writeRecoveryFile; + } + const file = JSON.parse(readFileSync(registryFile(), 'utf8')) as { records: Array<{ name: string; ended?: unknown }> }; + assert.ok(file.records.some((r) => r.name === 'boss' && r.ended === undefined), 'the registry file is rewritten from memory, not left as the stop wrote it'); + }); + + it('when the put-back fails too, the caller still hears why its change failed', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + await startBlocked(first, gates, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + const internal = first as unknown as { writeRecoveryFile: (path: string, document: unknown) => void }; + const write = internal.writeRecoveryFile.bind(first); + let putBack = false; + internal.writeRecoveryFile = (path, document) => { + if (putBack) throw new Error('EIO: the put-back failed'); + write(path, document); + }; + const undo = failOn(first.getStore(), 'setStateJson', REGISTRY, () => { putBack = true; return true; }); + try { + assert.throws(() => first.stopAgent('boss', { by: 'mira' }), /EIO: simulated setStateJson failure/); + } finally { + undo(); + delete (first as unknown as Record).writeRecoveryFile; + } + }); + + it('an end written before the snapshot let go of the agent ends it once at the next boot, and tells nobody of a restart', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + const job = await startBlocked(first, gates, 'w', 'g-w', workerSpec('w', { spawnedBy: 'oren', resultTo: { to: 'oren', as: 'message' } })); + const whileLive = readFileSync(registryFile(), 'utf8'); + gates.release('g-w'); + assert.ok((await job.settled).ok); + assert.equal(first.getAgentRecord('w')?.ended?.reason, 'completed'); + await first.stop(); + // The previous process wrote w's end and died before the snapshot without w. + writeFileSync(registryFile(), whileLive); + const second = await boot(); + const records = second.listAgents({ includeEnded: true }).filter((r) => r.name === 'w'); + assert.deepEqual(records.map((r) => r.ended?.reason), ['completed'], 'w ended once, as it did'); + assert.deepEqual(rowsWith(second, 'oren', 'the host restarted while'), [], 'and oren is told of no restart'); + }); + + it('an interrupted job\'s recipient is told even when the boot that found it dies before telling, and told once', async () => { + const store = JsStore.openOrCreate({ path: storePath() }); + stores.push(store); + const paths = { agentRegistryPath: join(tempDir, 'registry.json'), agentRegistryEndedPath: join(tempDir, 'ended.json') }; + const gates = new GateModule(); + const first = await boot(gates, { store, ...paths }); + first.start(); + await startBlocked(first, gates, 'w', 'g-w', workerSpec('w', { spawnedBy: 'oren', resultTo: { to: 'oren', as: 'message' } })); + assert.ok(existsSync(paths.agentRegistryPath), 'the app-owned store\'s registry recovery file is where it was named'); + // The process dies with w live; the next boot dies too, after the restore + // and before it tells oren (its conversations name a template it doesn't run). + await assert.rejects( + boot(new GateModule(), { store, ...paths, conversations: { templateAgent: 'nobody' } as FrameworkConfig['conversations'] }), + /conversations\.templateAgent "nobody" is not a configured agent/, + ); + // The next boot tells oren and dies before writing w's end. + const settle = AgentRegistry.prototype.settleInterrupted; + AgentRegistry.prototype.settleInterrupted = function settleNothing() { /* the process dies here */ }; + let third: AgentFramework; + try { + third = await boot(new GateModule(), { store, ...paths }); + } finally { AgentRegistry.prototype.settleInterrupted = settle; } + assert.equal(rowsWith(third, 'oren', 'the host restarted while "w"').length, 1, 'oren is told'); + assert.equal(third.getAgentRecord('w')?.ended?.reason, 'host-restart'); + // The one after finds the notice and doesn't tell it twice, and writes w's end. + const fourth = await boot(new GateModule(), { store, ...paths }); + assert.equal(rowsWith(fourth, 'oren', 'the host restarted while "w"').length, 1, 'oren is told once'); + assert.equal(fourth.listAgents({ includeEnded: true }).filter((r) => r.name === 'w').length, 1, 'and w ended once'); + assert.ok(existsSync(paths.agentRegistryEndedPath), 'its end is in the ended recovery file named for it'); + assert.ok(!(JSON.parse(readFileSync(paths.agentRegistryPath, 'utf8')) as { records: Array<{ name: string }> }).records.some((r) => r.name === 'w'), 'and the registry no longer lists it'); + }); + + it('a recipient told of two interrupted jobs isn\'t told again of the one whose end failed to be written', async () => { + const store = JsStore.openOrCreate({ path: storePath() }); + stores.push(store); + const paths = { agentRegistryPath: join(tempDir, 'registry.json'), agentRegistryEndedPath: join(tempDir, 'ended.json') }; + const gates = new GateModule(); + const first = await boot(gates, { store, ...paths }); + first.start(); + for (const name of ['w1', 'w2']) { + await startBlocked(first, gates, name, `g-${name}`, workerSpec(name, { spawnedBy: 'oren', resultTo: { to: 'oren', as: 'message' } })); + } + // The process dies with both live. The next boot tells oren of both, and + // w2's end fails to be written, so w2 stays in the registry. + const proto = AgentFramework.prototype as unknown as { writeRecoveryFile: (path: string, document: unknown) => void }; + const write = proto.writeRecoveryFile; + proto.writeRecoveryFile = function (this: unknown, path: string, document: unknown) { + const ended = (document as { ended?: Array<{ name: string }> }).ended; + if (path === paths.agentRegistryEndedPath && ended?.some((r) => r.name === 'w2')) throw new Error('EIO: w2\'s end'); + write.call(this, path, document); + }; + let second: AgentFramework; + try { + second = await boot(new GateModule(), { store, ...paths }); + } finally { proto.writeRecoveryFile = write; } + assert.equal(rowsWith(second, 'oren', 'the host restarted while "w1", "w2"').length, 1, 'oren is told of both'); + // The next boot finds w2 still interrupted, and oren already told of it. + const third = await boot(new GateModule(), { store, ...paths }); + assert.equal(rowsWith(third, 'oren', 'the host restarted while').length, 1, 'oren is told once'); + assert.equal(third.getAgentRecord('w2')?.ended?.reason, 'host-restart'); + assert.ok(!(JSON.parse(readFileSync(paths.agentRegistryPath, 'utf8')) as { records: Array<{ name: string }> }).records.some((r) => r.name === 'w2'), + 'and w2\'s end is written now'); + }); + + it('a reused name\'s interruption is told although an earlier one of that name and incarnation was', async () => { + const store = JsStore.openOrCreate({ path: storePath() }); + stores.push(store); + const paths = { agentRegistryPath: join(tempDir, 'registry.json'), agentRegistryEndedPath: join(tempDir, 'ended.json') }; + const run = async () => { + const gates = new GateModule(); + const framework = await boot(gates, { store, ...paths }); + framework.start(); + await startBlocked(framework, gates, 'w', 'g-w', workerSpec('w', { spawnedBy: 'oren', resultTo: { to: 'oren', as: 'message' } })); + }; + await run(); // w#1 is live when the process dies + await boot(new GateModule(), { store, ...paths }); // oren is told, and w's end written + // w ages out of memory: its counter and its end leave the recovery files. + const registry = JSON.parse(readFileSync(paths.agentRegistryPath, 'utf8')) as { incarnations: Record }; + delete registry.incarnations.w; + writeFileSync(paths.agentRegistryPath, JSON.stringify(registry)); + const ended = JSON.parse(readFileSync(paths.agentRegistryEndedPath, 'utf8')) as { ended: Array<{ name: string }> }; + writeFileSync(paths.agentRegistryEndedPath, JSON.stringify({ ...ended, ended: ended.ended.filter((r) => r.name !== 'w') })); + await new Promise((resolve) => setTimeout(resolve, 5)); + await run(); // a new w, incarnation 1 again, is live when the process dies + const last = await boot(new GateModule(), { store, ...paths }); + assert.equal(rowsWith(last, 'oren', 'the host restarted while "w"').length, 2, 'oren is told of each'); + }); + + it('an app-owned store with neither a storePath nor the recovery paths is warned at create', async () => { + const store = JsStore.openOrCreate({ path: storePath() }); + stores.push(store); + const said: string[] = []; + const error = console.error; + console.error = (...args: unknown[]) => { said.push(args.map(String).join(' ')); }; + try { + await boot(new GateModule(), { store }); + } finally { console.error = error; } + assert.ok(said.some((line) => line.includes('[dendrite] no storePath / agentRegistryPath / agentRegistryEndedPath')), said.join('\n')); + }); + + it('a worker\'s named namespace is where its context opens, so inspection reads what it wrote', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + membrane.script('w', say('done')); + const { agent, contextManager } = await first.createEphemeralAgent( + { name: 'w', model: 'test-model', systemPrompt: 'Do the task.' }, + workerSpec('w', { namespace: 'jobs/w' }), + ); + contextManager.addMessage('user', [{ type: 'text', text: 'Go. NS-MARK' }]); + await first.runEphemeralToCompletion(agent, contextManager); + assert.equal(first.getAgentRecord('w')?.namespace, 'jobs/w'); + const inspected = await first.inspectAgentContext('w'); + assert.ok(inspected.getAllMessages().some((m) => textOf(m.content).includes('NS-MARK')), 'the inspected context is the one it ran in'); + }); + + it('a namespace belongs to the agent that opened it: another agent\'s is refused, live, a candidate or ended', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + const create = (name: string, namespace: string) => + first.createEphemeralAgent({ name, model: 'test-model', systemPrompt: 'x' }, workerSpec(name, { namespace })); + await startBlocked(first, gates, 'w1', 'g-w1', workerSpec('w1', { namespace: 'jobs/shared' })); + await assert.rejects(create('w2', 'jobs/shared'), /namespace "jobs\/shared" holds the context of agent "w1"/); + await assert.rejects(create('w3', 'agents/mira'), /holds the context of agent "mira"/, 'a resident\'s namespace is held too'); + await create('c1', 'jobs/candidate'); + await assert.rejects(create('c2', 'jobs/candidate'), /holds the context of agent "c1"; name another, or none for subagent\/c2$/, 'so is a candidate\'s not yet run'); + // Where the refused namespace is the default, the default isn't offered. + await create('c3', 'subagent/c4'); + await assert.rejects( + first.createEphemeralAgent({ name: 'c4', model: 'test-model', systemPrompt: 'x' }, workerSpec('c4')), + /namespace "subagent\/c4" holds the context of agent "c3"; name another$/, + ); + first.stopAgent('w1', { by: 'mira' }); + await assert.rejects(create('w4', 'jobs/shared'), /holds the context of agent "w1"/, 'and an ended agent\'s'); + }); + + it('after a restart an agent reopens its own namespace, and another can\'t, from memory or from the ended log', async () => { + const first = await boot(); + first.start(); + membrane.script('w5', say('done')); + const run = await first.createEphemeralAgent({ name: 'w5', model: 'test-model', systemPrompt: 'x' }, workerSpec('w5', { namespace: 'jobs/five' })); + run.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + await first.runEphemeralToCompletion(run.agent, run.contextManager); + await first.stop(); + // Past the registry's memory: an agent's end, and a shared fork's, whose + // record names its parent's namespace (it owns a branch there, not the namespace). + const ended = (name: string, i: number) => ({ + name, kind: 'worker', incarnation: 1, onParentEnd: 'orphan', readsSharedSlot: false, + roles: { defaultDelivery: false, receivesUntargeted: false, receivesGateWakes: false, ownsProviderScheduling: false }, + lifetime: { kind: 'task', idleTimeoutMs: 1000 }, relationships: { observes: [], messagePeers: [] }, + createdAt: i, ended: { at: i + 1, reason: 'completed' }, + }); + const store = JsStore.openOrCreate({ path: storePath() }); + try { + store.appendToStateJson(ENDED, { ...ended('old-0', 0), namespace: 'jobs/old' }); + store.appendToStateJson(ENDED, { ...ended('fork-0', 1), namespace: 'jobs/parent', inherit: { from: 'parent-0', mode: 'shared' } }); + store.sync(); + } finally { store.close(); } + const second = await boot(); + second.start(); + const create = (name: string, namespace: string) => + second.createEphemeralAgent({ name, model: 'test-model', systemPrompt: 'x' }, workerSpec(name, { namespace })); + await assert.rejects(create('w6', 'jobs/five'), /holds the context of agent "w5"/, 'held by an end in memory'); + await assert.rejects(create('w7', 'jobs/old'), /holds the context of agent "old-0"/, 'held by an end in the ended log alone'); + await create('w8', 'jobs/parent'); + membrane.script('w5', say('again')); + const again = await create('w5', 'jobs/five'); + again.contextManager.addMessage('user', [{ type: 'text', text: 'Go again.' }]); + await second.runEphemeralToCompletion(again.agent, again.contextManager); + assert.equal(second.getAgentRecord('w5')?.incarnation, 2, 'its own namespace, in a new incarnation'); + }); + + it('an agent that ended past the registry\'s memory is found in the ended log', async () => { + const first = await boot(); + await first.stop(); + const store = JsStore.openOrCreate({ path: storePath() }); + try { + for (let i = 0; i < 300; i++) { + store.appendToStateJson(ENDED, { + name: `old-${i}`, kind: 'worker', incarnation: 1, onParentEnd: 'orphan', readsSharedSlot: false, + roles: { defaultDelivery: false, receivesUntargeted: false, receivesGateWakes: false, ownsProviderScheduling: false }, + lifetime: { kind: 'task', idleTimeoutMs: 1000 }, relationships: { observes: [], messagePeers: [] }, + createdAt: i, ended: { at: i + 1, reason: 'completed' }, + }); + } + store.sync(); + } finally { store.close(); } + const second = await boot(); + assert.equal(second.getAgentRecord('old-0')?.ended?.reason, 'completed', 'the oldest is still found'); + assert.equal(second.getAgentRecord('never-was'), null); + }); + + it('a job resumed from the ended log alone registers past the incarnation it resumes', async () => { + const first = await boot(); + await first.stop(); + const store = JsStore.openOrCreate({ path: storePath() }); + try { + // Aged out of memory: its counter is no longer in the snapshot. + store.appendToStateJson(ENDED, { + name: 'old-job', kind: 'worker', incarnation: 3, onParentEnd: 'orphan', readsSharedSlot: false, namespace: 'subagent/old-job', + roles: { defaultDelivery: false, receivesUntargeted: false, receivesGateWakes: false, ownsProviderScheduling: false }, + lifetime: { kind: 'task', idleTimeoutMs: 60_000 }, relationships: { observes: [], messagePeers: [] }, + createdAt: 1, ended: { at: 2, reason: 'stopped' }, + }); + store.sync(); + } finally { store.close(); } + const second = await boot(); + second.start(); + membrane.script('old-job', say('done again')); + const resumed = await second.resumeAgent('old-job', { model: 'test-model', systemPrompt: 'x' }); + resumed.contextManager.addMessage('user', [{ type: 'text', text: 'Go on.' }]); + await second.runEphemeralToCompletion(resumed.agent, resumed.contextManager); + assert.equal(second.getAgentRecord('old-job')?.incarnation, 4); + }); + + it('an ended log that can\'t be read fails the lookup and the namespace check, rather than reading as empty', async () => { + const first = await boot(); + first.start(); + const live = first.getStore() as unknown as Record unknown>; + const len = live.getStateLen!.bind(live); + Object.defineProperty(live, 'getStateLen', { + configurable: true, writable: true, + value: (id: string) => { if (id === ENDED) throw new Error('EIO: the ended log'); return len(id); }, + }); + try { + assert.throws(() => first.getAgentRecord('nobody'), /EIO: the ended log/); + await assert.rejects( + first.createEphemeralAgent({ name: 'w', model: 'test-model', systemPrompt: 'x' }, workerSpec('w', { namespace: 'jobs/w' })), + /EIO: the ended log/, + ); + } finally { delete live.getStateLen; } + }); + + it('the ended log\'s index follows a log that changed under it, as long or shorter', async () => { + const first = await boot(); + await first.stop(); + const ended = (name: string, i: number) => ({ + name, kind: 'worker', incarnation: 1, onParentEnd: 'orphan', readsSharedSlot: false, + roles: { defaultDelivery: false, receivesUntargeted: false, receivesGateWakes: false, ownsProviderScheduling: false }, + lifetime: { kind: 'task', idleTimeoutMs: 1000 }, relationships: { observes: [], messagePeers: [] }, + createdAt: i, ended: { at: i + 1, reason: 'completed' }, + }); + const store = JsStore.openOrCreate({ path: storePath() }); + try { + for (let i = 0; i < 300; i++) store.appendToStateJson(ENDED, ended(`old-${i}`, i)); + store.sync(); + } finally { store.close(); } + const second = await boot(); + assert.equal(second.getAgentRecord('old-0')?.ended?.reason, 'completed', 'the index is built'); + // A branch switch puts another ended log in its place: as long, then shorter. + const live = second.getStore() as unknown as Record unknown>; + const len = live.getStateLen!.bind(live); + const item = live.getStateItemJson!.bind(live); + for (const [prefix, count] of [['other', 300], ['short', 100]] as const) { + const log = Array.from({ length: count }, (_, i) => ended(`${prefix}-${i}`, 1000 + i)); + Object.defineProperty(live, 'getStateLen', { configurable: true, writable: true, value: (id: string) => (id === ENDED ? log.length : len(id)) }); + Object.defineProperty(live, 'getStateItemJson', { configurable: true, writable: true, value: (id: string, i: number) => (id === ENDED ? log[i] ?? null : item(id, i)) }); + assert.equal(second.getAgentRecord(`${prefix}-5`)?.ended?.reason, 'completed', `a name only on the ${prefix} log is found`); + } + delete live.getStateLen; + delete live.getStateItemJson; + }); + + it('a finished orphan\'s held result is told of, and adopting the ended orphan delivers it', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + await startBlocked(first, gates, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + const job = await startBlocked(first, gates, 'job', 'g-job', workerSpec('job', { spawnedBy: 'boss', resultTo: { to: 'boss', as: 'message' } }), 'job result'); + first.stopAgent('boss', { by: 'mira' }); + gates.release('g-job'); + assert.ok((await job.settled).ok, 'the orphaned job finishes'); + first.deliverAgentResult('job', [{ type: 'text', text: 'RESULT-1' }]); + assert.deepEqual(first.listHeldMail().map((m) => [m.to, m.heldBecause]), [['boss', 'recipient-gone']]); + assert.equal(rowsWith(first, 'oren', 'reparenting "job" to you delivers it').length, 1, 'the first candidate is told the result is held'); + const record = first.reparentAgent('job', 'oren', { by: 'oren' }); + assert.equal(record.ended?.reason, 'completed', 'the record stays as it ended'); + assert.equal(rowsWith(first, 'oren', 'RESULT-1').length, 1, 'and the result reaches oren'); + }); + + it('an orphan that ends with a result it delivered while it ran still held tells its first candidate', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + await startBlocked(first, gates, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + const job = await startBlocked(first, gates, 'job', 'g-job', workerSpec('job', { spawnedBy: 'boss', resultTo: { to: 'boss', as: 'message' } }), 'job result'); + first.stopAgent('boss', { by: 'mira' }); + first.deliverAgentResult('job', [{ type: 'text', text: 'RESULT-1' }]); + assert.deepEqual(first.listHeldMail().map((m) => [m.to, m.heldBecause]), [['boss', 'recipient-gone']]); + assert.equal(rowsWith(first, 'oren', 'reparenting "job" to you delivers it').length, 0, 'not while it runs: the adoption offer covers that'); + gates.release('g-job'); + assert.ok((await job.settled).ok); + assert.equal(rowsWith(first, 'oren', 'reparenting "job" to you delivers it').length, 1, 'told once it has ended'); + }); + + it('a worker whose spawner ended between its creation and its run registers as that spawner\'s orphan', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + await startBlocked(first, gates, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + membrane.script('w', say('W-RESULT')); + const made = await first.createEphemeralAgent( + { name: 'w', model: 'test-model', systemPrompt: 'x' }, + workerSpec('w', { spawnedBy: 'boss', resultTo: { to: 'boss', as: 'message' } }), + ); + first.stopAgent('boss', { by: 'mira' }); + made.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + await first.runEphemeralToCompletion(made.agent, made.contextManager); + const record = first.getAgentRecord('w')!; + assert.equal(record.orphaned?.formerParent.agent, 'boss'); + assert.deepEqual(record.orphaned?.candidates, ['oren', 'mira']); + assert.equal(rowsWith(first, 'oren', '"w" now has no parent').length, 1, 'its first candidate is offered it'); + first.deliverAgentResult('w', [{ type: 'text', text: 'W-RESULT' }]); + assert.deepEqual(first.listHeldMail().map((m) => [m.from.agent, m.to, m.heldBecause]), [['w', 'boss', 'recipient-gone']], 'and its result is held'); + }); + + it('a worker that ends with its spawner is refused when that spawner ended before it ran', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + await startBlocked(first, gates, 'boss', 'g-boss', workerSpec('boss', { spawnedBy: 'oren' })); + const made = await first.createEphemeralAgent( + { name: 'w', model: 'test-model', systemPrompt: 'x' }, + { ...workerSpec('w', { spawnedBy: 'boss' }), onParentEnd: 'end' }, + ); + first.stopAgent('boss', { by: 'mira' }); + await assert.rejects( + first.runEphemeralToCompletion(made.agent, made.contextManager), + /spawner "boss" has ended, and an agent that ends with its parent would have ended with it/, + ); + assert.equal(first.getAgentRecord('w'), null); + }); + + it('mail held for a job while it was ended reaches its resumed incarnation when that registers', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + await startBlocked(first, gates, 'r', 'g-r', workerSpec('r', { spawnedBy: 'oren' })); + const child = await startBlocked(first, gates, 'c', 'g-c', workerSpec('c', { spawnedBy: 'r', resultTo: { to: 'r', as: 'message' } })); + first.stopAgent('r', { by: 'oren' }); + gates.release('g-c'); + assert.ok((await child.settled).ok); + first.deliverAgentResult('c', [{ type: 'text', text: 'C-RESULT' }]); + assert.deepEqual(first.listHeldMail().map((m) => [m.to, m.heldBecause]), [['r', 'recipient-gone']]); + membrane.script('r', say('done')); + const resumed = await first.resumeAgent('r', { model: 'test-model', systemPrompt: 'x' }); + resumed.contextManager.addMessage('user', [{ type: 'text', text: 'Go on.' }]); + await first.runEphemeralToCompletion(resumed.agent, resumed.contextManager); + assert.deepEqual(first.listHeldMail(), [], 'nothing is held for it now'); + const context = await first.inspectAgentContext('r'); + assert.ok(context.getAllMessages().some((m) => textOf(m.content).includes('C-RESULT')), 'and the result is in its context'); + }); + + it('held mail whose copy already reached the resumed job\'s context is released, not delivered twice', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + await startBlocked(first, gates, 'r', 'g-r', workerSpec('r', { spawnedBy: 'oren' })); + const child = await startBlocked(first, gates, 'c', 'g-c', workerSpec('c', { spawnedBy: 'r', resultTo: { to: 'r', as: 'message' } })); + first.stopAgent('r', { by: 'oren' }); + gates.release('g-c'); + assert.ok((await child.settled).ok); + first.deliverAgentResult('c', [{ type: 'text', text: 'C-RESULT' }]); + const [held] = first.listHeldMail(); + membrane.script('r', say('done')); + const resumed = await first.resumeAgent('r', { model: 'test-model', systemPrompt: 'x' }); + // Its copy landed before (a release that missed a sync, say). + resumed.contextManager.addMessage('c', [{ type: 'text', text: 'C-RESULT' }], { dendrite: { mailId: held!.id } } as never); + resumed.contextManager.addMessage('user', [{ type: 'text', text: 'Go on.' }]); + await first.runEphemeralToCompletion(resumed.agent, resumed.contextManager); + assert.deepEqual(first.listHeldMail(), [], 'released'); + const context = await first.inspectAgentContext('r'); + assert.equal(context.getAllMessages().filter((m) => textOf(m.content).includes('C-RESULT')).length, 1, 'and in its context once'); + }); + + it('a job cancelled with its stream in flight rejects with AgentStoppedError naming who and why', async () => { + const gates = new GateModule(); + const first = await boot(gates); + first.start(); + const worker = await startBlocked(first, gates, 'w', 'g-w', workerSpec('w', { spawnedBy: 'oren' })); + assert.equal(first.cancelActivation('w', { by: 'oren', reason: 'changed plans' }), true); + const result = await worker.settled; + assert.ok(!result.ok && result.error instanceof AgentStoppedError, `rejected with ${result.ok ? 'nothing' : String(result.error)}`); + assert.equal((result.error as AgentStoppedError).by, 'oren'); + assert.match((result.error as Error).message, /changed plans/); + }); +}); diff --git a/test/dendrite-registry.test.ts b/test/dendrite-registry.test.ts new file mode 100644 index 000000000..fd616ff3d --- /dev/null +++ b/test/dendrite-registry.test.ts @@ -0,0 +1,443 @@ +/** + * Dendrite agent registry — the bookkeeping rules, without a framework: + * roles, spec validation, ending (cascade vs orphan), reparenting, held + * mail, and restart behaviour by lifetime. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + AgentRegistry, + AgentSpecError, + conversationForkSpec, + residentSpec, + subconsciousSpec, + taskForkSpec, + workerSpec, +} from '../src/index.js'; +import type { AgentRecord, RegistrySnapshot } from '../src/index.js'; + +/** A registry with a persisted side: the last snapshot and the ended log. */ +function persisted() { + const state: { snapshot: RegistrySnapshot | null; ended: AgentRecord[]; writes: number } = { + snapshot: null, + ended: [], + writes: 0, + }; + let clock = 1_000; + const make = () => + new AgentRegistry({ + now: () => ++clock, + persist: (snapshot) => { + state.snapshot = JSON.parse(JSON.stringify(snapshot)); + state.writes++; + }, + appendEnded: (record) => { + state.ended.push(JSON.parse(JSON.stringify(record))); + }, + }); + return { state, make }; +} + +describe('AgentRegistry roles', () => { + it('answers each selection question from declared roles, not kinds', () => { + const registry = new AgentRegistry(); + registry.register(residentSpec('mira', { primary: true })); + registry.register(residentSpec('oren', { primary: false })); + registry.register(subconsciousSpec('Subconscious', 'mira')); + registry.register(conversationForkSpec('mira-chan-1', { template: 'mira', homeChannel: { serverId: 's1', channelId: 'c1' }, idleTtlMs: 60_000 })); + registry.register(workerSpec('worker-1', { spawnedBy: 'mira' })); + + assert.equal(registry.primary(), 'mira'); + assert.deepEqual(registry.untargetedRecipients(), ['mira', 'oren']); + // The gate is host-wide: a channel-bound fork is woken by its own + // channel's debounced traffic without being a broadcast recipient. + assert.deepEqual(registry.gateRecipients(), ['mira', 'oren', 'mira-chan-1']); + assert.deepEqual(registry.sharedSlotReaders(), ['mira', 'oren', 'Subconscious']); + assert.equal(registry.ownsProviderScheduling('mira'), true); + assert.equal(registry.ownsProviderScheduling('Subconscious'), true); + assert.equal(registry.ownsProviderScheduling('mira-chan-1'), false); + assert.equal(registry.ownsProviderScheduling('worker-1'), false); + assert.equal(registry.ownsProviderScheduling('nobody'), false); + assert.deepEqual(registry.homeChannel('mira-chan-1'), { serverId: 's1', channelId: 'c1' }); + assert.equal(registry.homeChannel('mira'), undefined); + }); + + it('a spec outside every preset is selected by its roles alone', () => { + const registry = new AgentRegistry(); + registry.register(residentSpec('mira', { primary: true })); + registry.register({ + name: 'night-watch', + kind: 'watcher', + roles: { defaultDelivery: false, receivesUntargeted: true, receivesGateWakes: false, ownsProviderScheduling: false }, + lifetime: { kind: 'persistent' }, + }); + assert.deepEqual(registry.untargetedRecipients(), ['mira', 'night-watch']); + assert.deepEqual(registry.gateRecipients(), ['mira']); + }); +}); + +describe('AgentRegistry spec validation', () => { + const base = () => { + const registry = new AgentRegistry(); + registry.register(residentSpec('mira', { primary: true })); + return registry; + }; + + it('refuses a second default-delivery owner', () => { + const registry = base(); + assert.throws(() => registry.register(residentSpec('oren', { primary: true })), /default delivery already belongs to "mira"/); + assert.equal(registry.has('oren'), false); + }); + + it('refuses a task that could run forever', () => { + const registry = base(); + assert.throws( + () => registry.register({ ...workerSpec('w'), lifetime: { kind: 'task' } }), + (error: unknown) => error instanceof AgentSpecError && /every task agent must end/.test(error.message), + ); + assert.throws( + () => registry.register({ ...workerSpec('w'), lifetime: { kind: 'task', deadlineMs: 0 } }), + AgentSpecError, + ); + // Either bound is enough. + registry.register({ ...workerSpec('w1'), lifetime: { kind: 'task', deadlineMs: 5_000 } }); + registry.register({ ...workerSpec('w2'), lifetime: { kind: 'task', idleTimeoutMs: 5_000 } }); + }); + + it('refuses an idle lifetime with no TTL', () => { + const registry = base(); + assert.throws( + () => registry.register(conversationForkSpec('f', { template: 'mira', homeChannel: { serverId: 's1', channelId: 'c' }, idleTtlMs: 0 })), + /positive idleTtlMs/, + ); + }); + + it('refuses relationships that name nobody', () => { + const registry = base(); + assert.throws(() => registry.register(workerSpec('w', { spawnedBy: 'ghost' })), /spawner "ghost" is not a registered agent/); + assert.throws( + () => registry.register(workerSpec('w', { resultTo: { to: 'ghost', as: 'message' } })), + /result recipient "ghost"/, + ); + // Spawned by mira, so only the context source names nobody. + assert.throws( + () => registry.register(taskForkSpec('w', { parent: 'ghost', spawnedBy: 'mira' })), + /context source "ghost" is not a registered agent/, + ); + assert.throws( + () => registry.register({ ...workerSpec('w'), observes: [{ agent: 'ghost' }] }), + /observed agent "ghost"/, + ); + assert.throws(() => registry.register({ ...workerSpec('w'), onParentEnd: 'end' }), /needs a spawnedBy/); + assert.throws(() => registry.register(residentSpec('mira', { primary: false })), /already registered/); + assert.deepEqual(registry.list().map((r) => r.name), ['mira']); + }); +}); + +describe('AgentRegistry ending and orphans', () => { + it('ends attention tenants with their parent and orphans task work', () => { + const { state, make } = persisted(); + const registry = make(); + registry.register(residentSpec('mira', { primary: true })); + registry.register(taskForkSpec('fork', { parent: 'mira', resultTo: { to: 'mira', as: 'message' } })); + // Two things spawned by the fork: a reader that serves it, and a job. + registry.register({ ...workerSpec('reader', { spawnedBy: 'fork' }), onParentEnd: 'end' }); + registry.register(workerSpec('job', { spawnedBy: 'fork', resultTo: { to: 'fork', as: 'message' } })); + // The reader has a tenant of its own. + registry.register({ ...workerSpec('reader-aide', { spawnedBy: 'reader' }), onParentEnd: 'end' }); + + const outcome = registry.end('fork', 'stopped', 'operator')!; + + assert.equal(outcome.ended.name, 'fork'); + assert.deepEqual(outcome.ended.ended, { at: outcome.ended.ended!.at, reason: 'stopped', by: 'operator' }); + // Deepest first, so a caller tearing them down never removes a parent before its tenant. + assert.deepEqual(outcome.cascaded.map((r) => r.name), ['reader-aide', 'reader']); + assert.ok(outcome.cascaded.every((r) => r.ended?.reason === 'parent-ended')); + assert.deepEqual(outcome.orphaned.map((o) => o.record.name), ['job']); + // Offered to the nearest live ancestor of the agent that ended. + assert.deepEqual(outcome.orphaned[0]!.candidates, ['mira']); + + const job = registry.get('job')!; + assert.equal(job.orphaned?.formerParent.agent, 'fork'); + assert.equal(job.orphaned?.reason, 'stopped'); + assert.deepEqual(job.orphaned?.candidates, ['mira']); + // The spawn edge is history; it is no longer a child of anyone. + assert.deepEqual(registry.children('fork'), []); + // Its result route is left pointing at the ended agent: delivery holds, not drops. + assert.equal(job.relationships.resultTo?.to, 'fork'); + + assert.deepEqual(registry.list().map((r) => r.name), ['mira', 'job']); + assert.equal(registry.inspect('reader')?.ended?.reason, 'parent-ended'); + assert.deepEqual(state.ended.map((r) => r.name), ['fork', 'reader', 'reader-aide']); + assert.ok(!state.snapshot!.records.some((r) => r.ended), 'ended history stays out of the snapshot'); + assert.equal(registry.end('fork', 'stopped'), null, 'ending twice is a no-op'); + }); + + it('offers an orphan to the nearest LIVE ancestor, then the primary', () => { + const registry = new AgentRegistry(); + registry.register(residentSpec('mira', { primary: true })); + registry.register(residentSpec('oren', { primary: false })); + registry.register(workerSpec('a', { spawnedBy: 'oren' })); + registry.register(workerSpec('b', { spawnedBy: 'a' })); + registry.register(workerSpec('c', { spawnedBy: 'b' })); + + const outcome = registry.end('b', 'failed')!; + assert.deepEqual(outcome.orphaned[0]!.candidates, ['a', 'oren', 'mira']); + }); + + it('reparenting clears orphan state, reroutes a dead result route and returns held mail', () => { + const registry = new AgentRegistry(); + registry.register(residentSpec('mira', { primary: true })); + registry.register(workerSpec('boss', { spawnedBy: 'mira' })); + registry.register(workerSpec('job', { spawnedBy: 'boss', resultTo: { to: 'boss', as: 'message' } })); + registry.end('boss', 'stopped'); + + const mail = registry.holdMail({ + kind: 'result', + from: registry.ref('job'), + to: 'boss', + content: [{ type: 'text', text: 'done' }], + heldBecause: 'recipient-gone', + }); + assert.deepEqual(registry.listMail({ to: 'boss' }).map((m) => m.id), [mail.id]); + + const { record, deliverable } = registry.reparent('job', 'mira'); + assert.equal(record.orphaned, undefined); + assert.equal(record.relationships.spawnedBy, 'mira'); + assert.equal(record.relationships.resultTo?.to, 'mira'); + assert.deepEqual(deliverable.map((m) => [m.id, m.to]), [[mail.id, 'mira']]); + assert.deepEqual(registry.children('mira').map((r) => r.name), ['job']); + + registry.releaseMail(mail.id); + assert.deepEqual(registry.listMail(), []); + }); + + it('a live result route survives reparenting untouched', () => { + const registry = new AgentRegistry(); + registry.register(residentSpec('mira', { primary: true })); + registry.register(residentSpec('oren', { primary: false })); + registry.register(workerSpec('boss', { spawnedBy: 'mira' })); + registry.register(workerSpec('job', { spawnedBy: 'boss', resultTo: { to: 'oren', as: 'message' } })); + registry.end('boss', 'stopped'); + const { record, deliverable } = registry.reparent('job', 'mira'); + assert.equal(record.relationships.resultTo?.to, 'oren', 'spawning is not the result route'); + assert.deepEqual(deliverable, []); + }); + + it('refuses a reparenting that would form a cycle', () => { + const registry = new AgentRegistry(); + registry.register(residentSpec('mira', { primary: true })); + registry.register(workerSpec('a', { spawnedBy: 'mira' })); + registry.register(workerSpec('b', { spawnedBy: 'a' })); + assert.throws(() => registry.reparent('a', 'b'), /would form a cycle/); + assert.throws(() => registry.reparent('a', 'a'), /its own parent/); + assert.throws(() => registry.reparent('a', 'ghost'), /not a registered agent/); + }); +}); + +describe('AgentRegistry restart behaviour follows lifetime', () => { + it('restores a persistent agent as a new incarnation and ends interrupted bounded work', () => { + const { state, make } = persisted(); + const first = make(); + first.register(residentSpec('mira', { primary: true })); + first.register(subconsciousSpec('Subconscious', 'mira')); + first.register(workerSpec('job', { spawnedBy: 'mira', resultTo: { to: 'mira', as: 'message' } })); + first.register(conversationForkSpec('mira-c1', { template: 'mira', homeChannel: { serverId: 's1', channelId: 'c1' }, idleTtlMs: 1_000 })); + const held = first.holdMail({ + kind: 'result', + from: first.ref('job'), + to: 'mira', + content: [{ type: 'text', text: 'almost lost' }], + }); + assert.equal(first.get('mira')!.incarnation, 1); + + // The process dies here. A new one loads what was persisted. + const second = make(); + const { interrupted } = second.restore(state.snapshot, state.ended); + assert.deepEqual(interrupted.map((r) => r.name).sort(), ['job', 'mira-c1']); + assert.ok(interrupted.every((r) => r.ended?.reason === 'host-restart')); + assert.equal(second.has('mira'), false, 'a persistent agent is not live until configuration declares it'); + + second.register(residentSpec('mira', { primary: true })); + second.register(subconsciousSpec('Subconscious', 'mira')); + assert.equal(second.get('mira')!.incarnation, 2); + assert.equal(second.get('Subconscious')!.incarnation, 2); + assert.deepEqual(second.reconcile().unconfigured, []); + + assert.equal(second.inspect('job')?.ended?.reason, 'host-restart'); + // The result is still held, attributed to the incarnation that produced it. + assert.deepEqual(second.listMail().map((m) => [m.id, m.from.agent, m.from.incarnation, m.to]), [ + [held.id, 'job', 1, 'mira'], + ]); + // Until the host has told the recipients, the interrupted records stay in + // the snapshot as they were and their ends aren't written, so a crash here + // owes the same notices at the next boot. + assert.equal(state.ended.length, 0, 'no end is written before the recipients are told'); + assert.deepEqual(state.snapshot!.records.filter((r) => !r.ended).map((r) => r.name).sort(), ['Subconscious', 'job', 'mira', 'mira-c1']); + second.settleInterrupted(); + assert.deepEqual(state.ended.map((r) => r.name).sort(), ['job', 'mira-c1']); + assert.deepEqual(state.snapshot!.records.map((r) => r.name).sort(), ['Subconscious', 'mira'], 'and the snapshot no longer lists them'); + }); + + it('settles every interrupted end, more than memory keeps, and keeps one whose write fails pending', () => { + const state = { snapshot: null as RegistrySnapshot | null, ended: [] as AgentRecord[] }; + let failFor: string | undefined; + const failed: string[] = []; + let clock = 1_000; + const make = () => new AgentRegistry({ + now: () => ++clock, + endedRetention: 2, + persist: (snapshot) => { state.snapshot = JSON.parse(JSON.stringify(snapshot)); }, + appendEnded: (record) => { + if (record.name === failFor) throw new Error('EIO: the ended log'); + state.ended.push(JSON.parse(JSON.stringify(record))); + }, + onRecordWriteFailed: (what) => { failed.push(what); }, + }); + const first = make(); + first.register(residentSpec('mira', { primary: true })); + for (const name of ['j1', 'j2', 'j3', 'j4']) first.register(workerSpec(name, { spawnedBy: 'mira' })); + // The process dies with four jobs live; memory keeps two ended records. + const second = make(); + assert.equal(second.restore(state.snapshot, state.ended).interrupted.length, 4); + assert.deepEqual(second.endedHistory(), [], 'an end not yet written is no history a put-back could write'); + failFor = 'j3'; + second.settleInterrupted(); + assert.deepEqual(state.ended.map((r) => r.name), ['j1', 'j2', 'j4'], 'every end is written but the one that failed'); + assert.deepEqual(failed, ['the end of j3']); + const jobs = () => state.snapshot!.records.filter((r) => r.name.startsWith('j')).map((r) => r.name); + assert.deepEqual(jobs(), ['j3'], 'which stays in the snapshot'); + assert.equal(second.inspect('j3')?.ended?.reason, 'host-restart'); + failFor = undefined; + second.settleInterrupted(); + assert.deepEqual(state.ended.map((r) => r.name), ['j1', 'j2', 'j4', 'j3'], 'and the next settlement writes it'); + assert.deepEqual(jobs(), []); + }); + + it('a persistent record whose end the ended history holds still waits to be re-declared', () => { + const { state, make } = persisted(); + const first = make(); + first.register(residentSpec('mira', { primary: true })); + first.register(residentSpec('oren', { primary: false })); + // A stop of oren appended its end, then failed and was put back: the + // snapshot lists oren live, and the ended history holds its end. + const snapshot = JSON.parse(JSON.stringify(state.snapshot)) as RegistrySnapshot; + const oren = snapshot.records.find((r) => r.name === 'oren')!; + const second = make(); + second.restore(snapshot, [{ ...oren, ended: { at: 5, reason: 'stopped', by: 'mira' } }]); + second.register(residentSpec('mira', { primary: true })); + assert.deepEqual(second.reconcile().unconfigured.map((o) => o.ended.name), ['oren'], 'oren was awaiting re-declaration'); + }); + + it('inspects the newest of two pending incarnations once memory has let both go', () => { + const { state, make } = persisted(); + const first = make(); + first.register(residentSpec('mira', { primary: true })); + first.register(workerSpec('j', { spawnedBy: 'mira' })); + first.register(workerSpec('k', { spawnedBy: 'mira' })); + const snapshot = JSON.parse(JSON.stringify(state.snapshot)) as RegistrySnapshot; + const j = snapshot.records.find((r) => r.name === 'j')!; + snapshot.records.splice(snapshot.records.indexOf(j) + 1, 0, { ...j, incarnation: 2 }); + snapshot.incarnations.j = 2; + const second = new AgentRegistry({ endedRetention: 1, persist: () => {}, appendEnded: () => {} }); + second.restore(snapshot, []); + assert.equal(second.inspect('j')?.incarnation, 2); + }); + + it('settles two interrupted incarnations of one name as two ends', () => { + const { state, make } = persisted(); + const first = make(); + first.register(residentSpec('mira', { primary: true })); + first.register(workerSpec('j', { spawnedBy: 'mira' })); + const snapshot = JSON.parse(JSON.stringify(state.snapshot)) as RegistrySnapshot; + const older = snapshot.records.find((r) => r.name === 'j')!; + snapshot.records.push({ ...older, incarnation: 2 }); + snapshot.incarnations.j = 2; + const second = make(); + assert.equal(second.restore(snapshot, []).interrupted.length, 2); + second.settleInterrupted(); + assert.deepEqual(state.ended.filter((r) => r.name === 'j').map((r) => r.incarnation).sort(), [1, 2]); + }); + + it('restores a snapshot whose fork keeps its home as a bare id, as one written before homes carried their server', () => { + const { state, make } = persisted(); + const first = make(); + first.register(residentSpec('mira', { primary: true })); + first.register(conversationForkSpec('mira-c1', { template: 'mira', homeChannel: { serverId: 's1', channelId: 'c1' }, idleTtlMs: 1_000 })); + // The record as the code before this one wrote it: the id alone. + const snapshot = JSON.parse(JSON.stringify(state.snapshot)) as { records: Array<{ name: string; homeChannel?: unknown }> }; + snapshot.records.find((r) => r.name === 'mira-c1')!.homeChannel = 'c1'; + + const second = make(); + const { interrupted } = second.restore(snapshot as never, state.ended); + assert.deepEqual(interrupted.map((r) => [r.name, r.ended?.reason, r.homeChannel]), [['mira-c1', 'host-restart', 'c1']], + 'the fork ends at the restart, as every fork does, its home as it was written'); + assert.equal(second.homeChannel('mira-c1'), undefined, 'and no live agent has that home'); + }); + + it('ends a persistent agent the new configuration no longer declares', () => { + const { state, make } = persisted(); + const first = make(); + first.register(residentSpec('mira', { primary: true })); + first.register(residentSpec('oren', { primary: false })); + + const second = make(); + second.restore(state.snapshot, state.ended); + second.register(residentSpec('mira', { primary: true })); + const { unconfigured } = second.reconcile(); + assert.deepEqual(unconfigured.map((o) => [o.ended.name, o.ended.ended?.reason]), [['oren', 'not-configured']]); + assert.equal(second.inspect('oren')?.ended?.reason, 'not-configured'); + assert.deepEqual(second.list().map((r) => r.name), ['mira']); + }); + + it('a re-used name is a new incarnation while the old record is still referable', () => { + const registry = new AgentRegistry(); + registry.register(residentSpec('mira', { primary: true })); + registry.register(workerSpec('job')); + registry.end('job', 'completed'); + assert.equal(registry.register(workerSpec('job')).incarnation, 2); + assert.equal(registry.list({ includeEnded: true }).filter((r) => r.name === 'job').length, 2); + }); + + it('names the agent whose own context a namespace holds; a shared fork holds none', () => { + const registry = new AgentRegistry(); + registry.register(residentSpec('mira', { primary: true })); + registry.register(workerSpec('w', { spawnedBy: 'mira', namespace: 'jobs/w' })); + registry.register({ ...workerSpec('fork', { spawnedBy: 'mira' }), namespace: 'jobs/p', inherit: { from: 'mira', mode: 'shared' } }); + assert.equal(registry.namespaceHolder('agents/mira', 'x'), 'mira'); + assert.equal(registry.namespaceHolder('jobs/w', 'x'), 'w'); + assert.equal(registry.namespaceHolder('jobs/w', 'w'), undefined, 'not its own'); + assert.equal(registry.namespaceHolder('jobs/p', 'x'), undefined, 'a shared fork owns a branch, not the namespace'); + registry.end('w', 'stopped', 'mira'); + assert.equal(registry.namespaceHolder('jobs/w', 'x'), 'w', 'an ended agent\'s'); + }); + + it('refuses a version-1 snapshot without its map of incarnations', () => { + assert.throws( + () => new AgentRegistry().restore({ version: 1, records: [], mail: [] } as unknown as RegistrySnapshot), + /reads as version 1 but has no map of incarnations; nothing was restored/, + ); + }); + + it('a put-back that throws is reported, and the change\'s own error is what the caller hears', () => { + const said: string[] = []; + const registry = new AgentRegistry({ + persist: () => { throw new Error('EIO: the change'); }, + onRollback: () => { throw new Error('EIO: the put-back'); }, + onRecordWriteFailed: (what) => { said.push(what); }, + }); + assert.throws(() => registry.register(residentSpec('mira', { primary: true })), /EIO: the change/); + assert.equal(registry.has('mira'), false, 'and the change is put back'); + assert.deepEqual(said, ['the put-back of a change that failed']); + }); + + it('tolerates a missing snapshot, and refuses one in a version it does not read', () => { + const registry = new AgentRegistry(); + assert.deepEqual(registry.restore(null), { interrupted: [] }); + // Taken as empty, a registry in another version would be overwritten by the + // first registration, with the results it holds (nissa's #546 finding 1). + assert.throws(() => registry.restore({ version: 9 } as unknown as RegistrySnapshot), /reads as version 9, which this code doesn't read/); + registry.register(residentSpec('mira', { primary: true })); + assert.equal(registry.get('mira')!.incarnation, 1); + }); +}); diff --git a/test/discord-awareness-outbox.test.ts b/test/discord-awareness-outbox.test.ts index 47b8437a0..951d56a06 100644 --- a/test/discord-awareness-outbox.test.ts +++ b/test/discord-awareness-outbox.test.ts @@ -873,6 +873,8 @@ async function muted(fn: () => Promise): Promise { /** A bare framework that drains through `connection`. */ function drainingFramework(outbox: DiscordAwarenessOutbox, connection: (id: string) => unknown) { const framework = Object.create(AgentFramework.prototype) as any; + // The Dendrite registry an undo's fork sweep reads (disposeEmptiedForks); no forks here. + framework.registry = { homeChannel: () => undefined, list: () => [] }; framework.discordAwarenessOutbox = outbox; framework.discordAwarenessDrains = new Map(); framework.discordAwarenessDrainsJoined = new Set(); @@ -1227,6 +1229,8 @@ test('a sync for one server drains only that server', async () => { test("the framework's retract and cancel each record their act in the operator log, done or refused", withLedger(async (outbox) => { const batch = await activeBatch(outbox, [ref('m1')]); const framework = Object.create(AgentFramework.prototype) as any; + // The Dendrite registry an undo's fork sweep reads (disposeEmptiedForks); no forks here. + framework.registry = { homeChannel: () => undefined, list: () => [] }; framework.discordAwarenessOutbox = outbox; framework.operatorOperations = new Set(); framework.deliverDiscordAwarenessInBackground = () => {}; @@ -1253,6 +1257,8 @@ test("the framework's retract and cancel each record their act in the operator l /** A bare framework that resumes suppressions on `branch` from `cm`. */ function resumingFramework(outbox: DiscordAwarenessOutbox, branch: string, cm: unknown) { const framework = Object.create(AgentFramework.prototype) as any; + // The Dendrite registry an undo's fork sweep reads (disposeEmptiedForks); no forks here. + framework.registry = { homeChannel: () => undefined, list: () => [] }; framework.discordAwarenessOutbox = outbox; framework.store = { currentBranch: () => ({ name: branch }) }; framework.agents = new Map([['cairn', { getContextManager: () => cm }]]); @@ -1393,6 +1399,8 @@ test("a version 2 ledger's batch is reported in its own words: its retirement is const alerts: Array<{ kind: string; message: string; key?: string }> = []; const logged: string[] = []; const framework = Object.create(AgentFramework.prototype) as any; + // The Dendrite registry an undo's fork sweep reads (disposeEmptiedForks); no forks here. + framework.registry = { homeChannel: () => undefined, list: () => [] }; framework.opsAlert = (kind: string, _agent: string, message: string, opts?: { key?: string }) => { alerts.push({ kind, message, key: opts?.key }); }; const base = { agentName: 'cairn', sourceBranch: 'main', targetBranch: 'legacy/x', marks: 0, suppression: true, imported: true }; const error = console.error; @@ -1413,6 +1421,8 @@ test("a version 2 ledger's batch is reported in its own words: its retirement is /** A bare framework whose ops alerts reach a stubbed webhook. */ function alertingFramework() { const framework = Object.create(AgentFramework.prototype) as any; + // The Dendrite registry an undo's fork sweep reads (disposeEmptiedForks); no forks here. + framework.registry = { homeChannel: () => undefined, list: () => [] }; framework.opsAlertLastSent = new Map(); framework.opsAlertCooldownMs = 15 * 60_000; framework.logFailure = () => {}; @@ -1486,6 +1496,8 @@ test("the per-batch alerts of a surgery whose marks could not be scheduled, or w test("after a failed surgery, retiring its batch says whether it is retired, or stays prepared with what that leaves", async () => { const framework = Object.create(AgentFramework.prototype) as any; + // The Dendrite registry an undo's fork sweep reads (disposeEmptiedForks); no forks here. + framework.registry = { homeChannel: () => undefined, list: () => [] }; const alerts: string[] = []; framework.opsAlert = (kind: string) => { alerts.push(kind); }; framework.store = { currentBranch: () => ({ name: 'main' }), listBranches: () => [] }; @@ -1524,6 +1536,8 @@ test('the start that rewrites a version 2 ledger raises one alert naming each ba const alerts: Array<{ kind: string; agent: string; message: string }> = []; const logged: string[] = []; const framework = Object.create(AgentFramework.prototype) as any; + // The Dendrite registry an undo's fork sweep reads (disposeEmptiedForks); no forks here. + framework.registry = { homeChannel: () => undefined, list: () => [] }; framework.discordAwarenessOutbox = h.open({ announceImport: (unsent) => framework.announceDiscordAwarenessImport(unsent) }); framework.opsAlert = (kind: string, agent: string, message: string) => { alerts.push({ kind, agent, message }); }; const error = console.error; @@ -1557,6 +1571,8 @@ test('the start that rewrites a version 2 ledger raises one alert naming each ba test('a start on a ledger it cannot read stops, naming the ledger read', withLedger(async (_outbox, h) => { writeLedger(h, '{broken'); const framework = Object.create(AgentFramework.prototype) as any; + // The Dendrite registry an undo's fork sweep reads (disposeEmptiedForks); no forks here. + framework.registry = { homeChannel: () => undefined, list: () => [] }; framework.discordAwarenessOutbox = h.open(); await assert.rejects(framework.reconcileDiscordAwarenessAtStartup(), /accounting failed during startup reconciliation: Invalid Discord awareness ledger .*: it isn't JSON$/); @@ -1565,6 +1581,8 @@ test('a start on a ledger it cannot read stops, naming the ledger read', withLed test("a suppression's resume record that could not be closed is logged, and a closed one says nothing", async () => { const logged: string[] = []; const framework = Object.create(AgentFramework.prototype) as any; + // The Dendrite registry an undo's fork sweep reads (disposeEmptiedForks); no forks here. + framework.registry = { homeChannel: () => undefined, list: () => [] }; framework.opsAlert = () => { throw new Error('no alert expected'); }; const facts = { scope: 'none', unmarked: 0, notRemoved: 0 }; const error = console.error; @@ -1605,6 +1623,8 @@ function hostCommandFramework(outbox: DiscordAwarenessOutbox | null, messages: A }, }; const framework = Object.create(AgentFramework.prototype) as any; + // The Dendrite registry an undo's fork sweep reads (disposeEmptiedForks); no forks here. + framework.registry = { homeChannel: () => undefined, list: () => [] }; framework.agents = new Map([['cairn', { state: { status: 'idle' }, getContextManager: () => contextManager }]]); framework.store = { currentBranch: () => ({ name: currentBranch }), listBranches: () => [] }; framework.discordAwarenessOutbox = outbox; diff --git a/test/end-turn-usage.test.ts b/test/end-turn-usage.test.ts index e55ea6282..ded060c04 100644 --- a/test/end-turn-usage.test.ts +++ b/test/end-turn-usage.test.ts @@ -10,6 +10,7 @@ import { HealthModule } from '../src/modules/health/index.js'; import { EPHEMERAL_USAGE_NAME } from '../src/usage/usage-tracker.js'; import type { EventResponse, Module, ProcessEvent, ToolCall, TraceEvent } from '../src/index.js'; import { createMockResponse, MockMembrane, MockYieldingStream } from './helpers/mock-membrane.js'; +import { bindAsConversationFork } from './helpers/dendrite.js'; class TurnTools implements Module { readonly name = 'test'; @@ -717,12 +718,11 @@ describe('a tool-ended turn ends what a successful turn ends', () => { // cooldown. Its turns aren't an external message's default target, so // the turn is started directly. const internal = r.framework as unknown as { - conversationAgentHomes: Map; startAgentStream(agent: unknown, trigger: unknown): Promise; }; r.framework.start(); try { - internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); + bindAsConversationFork(r.framework, 'resident', { serverId: 'stub', channelId: 'world:zz' }); r.framework.getAgent('resident')!.getContextManager().addMessage('User', [{ type: 'text', text: 'first' }]); await internal.startAgentStream(r.framework.getAgent('resident'), { agentName: 'resident', reason: 'conversation', source: 'test', timestamp: Date.now() }); diff --git a/test/ephemeral-no-global-fallback.test.ts b/test/ephemeral-no-global-fallback.test.ts index 44aad3b4d..c07c4db43 100644 --- a/test/ephemeral-no-global-fallback.test.ts +++ b/test/ephemeral-no-global-fallback.test.ts @@ -14,7 +14,14 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager'; -import { fixture, eventually, settles, TS } from './helpers/coalescing-fixture.js'; +import { fixture as baseFixture, eventually, settles, TS } from './helpers/coalescing-fixture.js'; + +// These tests are about the global fallback itself — the latest delivered +// inbound channel — so they run under the rule that has one. Dendrite's +// default (`speech.locus: 'delivery'`) gives a resident with no delivery no +// locus at all, which is a different case (admission-e2e covers it). +const fixture: typeof baseFixture = (options = {}) => + baseFixture({ ...options, framework: { speech: { locus: 'latest-inbound' }, ...options.framework } }); import { createMockResponse } from './helpers/mock-membrane.js'; import { gateToolCalls } from './helpers/gate-tool-calls.js'; diff --git a/test/exhausted-turn-rearm.test.ts b/test/exhausted-turn-rearm.test.ts index 290706848..39f8eb446 100644 --- a/test/exhausted-turn-rearm.test.ts +++ b/test/exhausted-turn-rearm.test.ts @@ -925,3 +925,65 @@ test("a silent turn's held retries keep one run", async (t) => { assert.equal(alerts().length, 1); assert.equal(alerts()[0]!.data?.holds, 4); }); + +// The branch's (feat/dendrite): releaseAgentRuntime, the one release of an +// ended agent that isn't a run (stopAgent, a cascaded end, a fork's dispose), +// drops this per-name turn state, so a new agent under the name starts clean. +test("a resident stopped mid-turn takes its turn state with it: its run of stated waits ends at the stop, and a new agent under its name re-arms from the first delay with a plain marker", async (t) => { + const { workerSpec } = await import('../src/index.js'); + // `second`'s window ends exhausted on server errors (sends 1 to 3); its + // re-arm's send and the next are held by stated waits (4 and 5), and the + // one after that is in flight when it is stopped (6), so no held turn is + // left for the stop to drop. The new agent's window ends exhausted on + // server errors (7 on). + const membrane = new ScriptedMembrane((n) => (n === 4 || n === 5 ? statedWait(40 * 60_000) : n === 6 ? 'held open' : serverError)); + const r = await resident(t, membrane, { exhaustedTurnRearmMs: [100, 100], agents: [ + { name: 'resident', model: 'zz-model', systemPrompt: 'system' }, { name: 'second', model: 'zz-model', systemPrompt: 'system' }] }); + const state = r.fw as unknown as { + turnRearms: Map; exhaustingRearms: Set; exhaustedTurns: Map; statedWaitRuns: Map; + }; + const kept = () => ({ + rearms: state.turnRearms.has('second'), exhaustingRearm: state.exhaustingRearms.has('second'), + exhaustedTurn: state.exhaustedTurns.has('second'), statedWaits: state.statedWaitRuns.has('second'), + }); + const alerts = () => r.traces.filter((tr) => tr.type === 'ops:alert' && tr.kind === 'provider-wait'); + const held = () => ((r.fw.healthSnapshot() as { agents: Array<{ name: string; providerAdmission: { heldRequests: number } }> }) + .agents.find((a) => a.name === 'second')!).providerAdmission.heldRequests; + queue(r, { agentName: 'second', reason: 'zz-wake' }); + await waitFor(() => r.count('inference:exhausted') === 1 && held() === 1, "second's window to end exhausted, its re-arm held"); + await waitFor(() => membrane.sends.length === 4 && held() === 1, "the re-arm's send to be held by a stated wait"); + t.mock.timers.tick(40 * 60_000); + await waitFor(() => membrane.sends.length === 5 && held() === 1, 'the next send to be held, 40 minutes on'); + t.mock.timers.tick(40 * 60_000); + await waitFor(() => membrane.sends.length === 6 && held() === 0, 'the send after it to be in flight, 80 minutes from the first hold'); + // Neither of these outlives a turn by itself: a failure sets each and the + // exhaustion it raises reads it at once. Set here as such a failure sets + // them, so the release's clear of each is pinned too. + state.exhaustingRearms.add('second'); + state.exhaustedTurns.set('second', { roundsRan: true, callsCutOff: false, liveSpeech: false, speechRouting: false }); + assert.deepEqual(kept(), { rearms: true, exhaustingRearm: true, exhaustedTurn: true, statedWaits: true }, 'the premise: each is held for second'); + assert.equal(alerts().length, 0, 'the premise: no hold came past the hour'); + + r.fw.stopAgent('second', { by: 'zz-operator' }); + assert.deepEqual(kept(), { rearms: false, exhaustingRearm: false, exhaustedTurn: false, statedWaits: false }, 'the release drops each'); + assert.equal(alerts().length, 1, 'the run of stated waits ends at the stop, alerted as it spanned past the hour'); + assert.equal(alerts()[0]!.data?.holds, 2); + + t.mock.timers.tick(60_000); + stderr.length = 0; + const spec = workerSpec('second'); + const { name: _name, ...body } = { ...spec, roles: { ...spec.roles, ownsProviderScheduling: true } }; + const fresh = await r.fw.createEphemeralAgent({ name: 'second', model: 'zz-model', systemPrompt: 'system' }, body); + fresh.contextManager.addMessage('User', [{ type: 'text', text: 'zz-fresh' }]); + const ended = await stepping(r.fw.runEphemeralToCompletion(fresh.agent, fresh.contextManager).then(() => null, (error: unknown) => error), + "the new agent's run to end"); + assert.ok(ended instanceof Error, 'its window ended exhausted'); + assert.equal(membrane.sends.length, 9, "the new agent's window made its three sends"); + assert.ok(stderr.includes('[inference-rearm] agent=second turn sent again in 100ms (1 of 2)'), + `its re-arm is the first: ${stderr.filter((line) => line.includes('[inference-rearm]')).join(' | ')}`); + const markers = fresh.contextManager.getAllMessages().flatMap((m) => m.content) + .map((b) => (b as { text?: string }).text ?? '').filter((text) => text.startsWith('[inference-failed]')); + assert.equal(markers.length, 1, 'it meets its outage with a marker'); + assert.match(markers[0]!, /the model call failed \(server, HTTP 502\) and produced no response, so nothing was sent/, 'one that says its own turn got nowhere'); + assert.equal(alerts().length, 1, 'and no run of stated waits is ended with it but its own'); +}); diff --git a/test/gate-held-batch-route.test.ts b/test/gate-held-batch-route.test.ts new file mode 100644 index 000000000..6f1d9b65a --- /dev/null +++ b/test/gate-held-batch-route.test.ts @@ -0,0 +1,58 @@ +/** + * A batched event whose channel is tuned out before its batch fires is + * dropped at delivery (EventGate.deliverEvents), whether it reached the gate + * as a channel message, named by its registered id, or as a push event, + * named by the adapter's raw id and registered under another. + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { EventGate } from '../src/gate/event-gate.js'; + +const TMP_DIR = join(import.meta.dirname, '../.test-tmp-gate-held-batch-route'); +const REGISTERED = 'discord:g1:room'; + +describe('a batched event whose channel is tuned out before its batch fires', () => { + beforeEach(() => mkdirSync(TMP_DIR, { recursive: true })); + afterEach(() => { if (existsSync(TMP_DIR)) rmSync(TMP_DIR, { recursive: true, force: true }); }); + + for (const [eventType, channelId] of [ + ['mcpl:channel-incoming', REGISTERED], + ['mcpl:push-event', 'room'], + ] as const) { + it(`is dropped at delivery, not woken for: ${eventType}`, async () => { + const configPath = join(TMP_DIR, `${eventType.replace(':', '-')}.json`); + writeFileSync(configPath, JSON.stringify({ + policies: [{ name: 'batch', match: { scope: [eventType] }, behavior: { debounce: 50 } }], + default: 'skip', + })); + let held = false; + const traces: Array<{ type: string; channels?: unknown[] }> = []; + const wakes: string[] = []; + const gate = new EventGate({ + configPath, + emitTrace: (t) => traces.push(t as never), + addMessage: () => '', + requestInference: (agentName) => wakes.push(agentName), + getAgentNames: () => ['agent'], + isHeld: (serverId, channel) => held && serverId === 'discord' && channel === REGISTERED, + resolveRouteChannel: (info) => (info.eventType === 'mcpl:push-event' ? REGISTERED : info.channelId), + }); + try { + gate.evaluate({ content: 'hi', eventType, serverId: 'discord', channelId, tags: [] }); + held = true; + const deadline = Date.now() + 5_000; + while (wakes.length === 0 && !traces.some((t) => t.type === 'gate:held-dropped')) { + assert.ok(Date.now() < deadline, 'the batch fired'); + await new Promise((r) => setTimeout(r, 10)); + } + assert.deepStrictEqual(wakes, [], 'no wake for an event its channel now holds'); + assert.strictEqual(traces.filter((t) => t.type === 'gate:held-dropped').length, 1); + } finally { + gate.dispose(); + } + }); + } +}); diff --git a/test/heartbeat.test.ts b/test/heartbeat.test.ts index 497b3bf64..b09b86cc3 100644 --- a/test/heartbeat.test.ts +++ b/test/heartbeat.test.ts @@ -5,6 +5,13 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Heartbeat, reminderText, type HeartbeatWake } from '../src/heartbeat/heartbeat.js'; import { parseReminderTime } from '../src/heartbeat/reminder-time.js'; +import { conversationForkSpec, workerSpec } from '../src/dendrite/index.js'; + +/** The registry surface these tests reach to make a conversation fork (Dendrite). */ +type ForkRegistry = { + register(spec: ReturnType): unknown; + end(name: string, reason: 'stopped'): unknown; +}; const T0 = Date.parse('2026-10-26T12:00:00Z'); let dir: string; @@ -617,6 +624,58 @@ describe('the framework\'s heartbeat', () => { assert.equal(internals.mcplPrefixCollision({ id: 'discord', command: 'node', args: ['discord.js'] }), null); }); + it('wakes the agents whose spec takes untargeted inbound, by role and not by name: a run whose spec takes it, and not a worker whose spec does not', async (t) => { + mock.timers.reset(); + const { AgentFramework } = await import('../src/index.js'); + const { MockMembrane } = await import('./helpers/mock-membrane.js'); + const own = mkdtempSync(join(tmpdir(), 'heartbeat-fw-')); + const framework = await AgentFramework.create({ + storePath: join(own, 'store'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 's', proseRouting: 'disabled' }], + modules: [], + heartbeat: true, + }); + t.after(async () => { await framework.stop(); rmSync(own, { recursive: true, force: true }); }); + const internals = framework as unknown as { + heartbeat: { start(): void; handleToolCall(name: string, input: unknown): { success: boolean } }; + agents: Map; + ephemeralRuns: Map; + registry: { register(spec: ReturnType): unknown; end(name: string, reason: 'stopped'): unknown }; + pendingRequests: Array<{ agentName: string; source: string }>; + heartbeatTargets(): string[]; + }; + internals.heartbeat.start(); + // Each is registered as an agent, as a real one is, so only its spec's + // roles tell the two apart. main's list was every agent but the + // conversation forks, the ephemeral runs and the subconscious: it left out + // `watch`, a run whose spec takes untargeted inbound, and took `quiet`, a + // worker whose spec doesn't that is none of those. On this branch the + // heartbeat wakes an untargeted push's recipients (registry.untargetedRecipients). + const watch = workerSpec('watch'); + internals.registry.register({ ...watch, roles: { ...watch.roles, receivesUntargeted: true } }); + internals.agents.set('watch', internals.agents.get('scout')); + internals.ephemeralRuns.set('watch', {}); + internals.registry.register(workerSpec('quiet')); + internals.agents.set('quiet', internals.agents.get('scout')); + try { + assert.deepEqual(internals.heartbeatTargets(), ['scout', 'watch']); + assert.equal(internals.heartbeat.handleToolCall('heartbeat--heartbeat_trigger', {}).success, true); + assert.deepEqual( + internals.pendingRequests.filter((r) => r.source === 'heartbeat').map((r) => r.agentName), + ['scout', 'watch'], + 'the tick wakes the resident and the run whose spec takes untargeted inbound, not the worker', + ); + } finally { + internals.pendingRequests.splice(0); + for (const name of ['watch', 'quiet']) { + internals.registry.end(name, 'stopped'); + internals.agents.delete(name); + } + internals.ephemeralRuns.delete('watch'); + } + }); + it('refuses a heartbeat setting that is neither true, false, null nor an object', async () => { mock.timers.reset(); const { AgentFramework } = await import('../src/index.js'); @@ -711,12 +770,15 @@ describe('the framework\'s heartbeat', () => { it("names nothing for an agent the heartbeat doesn't wake, a paused heartbeat, or no heartbeat", async (t) => { const { framework, internals, skip } = await skipReplyFramework(t, { heartbeat: { defaults: { intervalSeconds: 600 } } }); // A conversation fork, registered as an agent as a real one is, so only its being a fork leaves it out. + // On this branch a fork is the registry's record (Dendrite's conversationForkSpec, its home a channel + // on its server), not an entry in a framework map, and the heartbeat wakes the agents whose spec takes + // untargeted inbound, which a fork's doesn't. const agents = (framework as unknown as { agents: Map }).agents; - const homes = (framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes; + const registry = (framework as unknown as { registry: ForkRegistry }).registry; agents.set('scout-conv-1', agents.get('scout')); - homes.set('scout-conv-1', { serverId: 'stub', channelId: 'channel:home' }); + registry.register(conversationForkSpec('scout-conv-1', { template: 'scout', homeChannel: { serverId: 'stub', channelId: 'channel:home' }, idleTtlMs: 60_000 })); assert.equal(await skip('fork', 900, 'scout-conv-1'), plainNote(900), "the heartbeat doesn't wake a fork"); - homes.delete('scout-conv-1'); + registry.end('scout-conv-1', 'stopped'); agents.delete('scout-conv-1'); internals.heartbeat.handleToolCall('heartbeat--heartbeat_configure', { paused: true }); @@ -741,7 +803,7 @@ describe('the framework\'s heartbeat', () => { t.after(() => rmSync(own, { recursive: true, force: true })); const internals = framework as unknown as { heartbeat: { started: boolean; cancelTick: unknown }; - conversationAgentHomes: Map; + registry: ForkRegistry; pushEvent(event: { type: string; result?: { isError?: boolean; error?: string; data?: unknown } }): void; dispatchHeartbeatToolCall(agentName: string, call: { id: string; name: string; input: unknown }): void; }; @@ -753,12 +815,13 @@ describe('the framework\'s heartbeat', () => { const push = internals.pushEvent.bind(framework); internals.pushEvent = (event) => { if (event.type === 'tool-result') results.push(event.result!); else push(event); }; // A fork registered as an agent, as a real one is, so only its being a fork refuses it. + // On this branch a fork is the registry's record with its home channel (conversationForkSpec). const agents = (framework as unknown as { agents: Map }).agents; agents.set('scout-conv-1', agents.get('scout')); - internals.conversationAgentHomes.set('scout-conv-1', {}); + internals.registry.register(conversationForkSpec('scout-conv-1', { template: 'scout', homeChannel: { serverId: 'stub', channelId: 'channel:home' }, idleTtlMs: 60_000 })); internals.dispatchHeartbeatToolCall('scout-conv-1', { id: 'a', name: 'heartbeat--reminder_add', input: { message: 'x', inMinutes: 1 } }); internals.dispatchHeartbeatToolCall('scout-conv-1', { id: 'b', name: 'heartbeat--heartbeat_status', input: {} }); - internals.conversationAgentHomes.delete('scout-conv-1'); + internals.registry.end('scout-conv-1', 'stopped'); agents.delete('scout-conv-1'); const state = JSON.parse(readFileSync(join(own, 'store', 'config', 'heartbeat.json'), 'utf-8')); assert.deepEqual(state.reminders, [], 'the refused reminder was not set'); diff --git a/test/held-turn-retry.test.ts b/test/held-turn-retry.test.ts index f01a1f455..16bf29706 100644 --- a/test/held-turn-retry.test.ts +++ b/test/held-turn-retry.test.ts @@ -16,6 +16,7 @@ import type { NormalizedRequest, StreamEvent, YieldingStream } from '@animalabs/ import type { EventResponse, Module, ModuleContext, ProcessEvent, ProcessState, ToolCall, ToolDefinition, ToolResult } from '../src/index.js'; import { AgentFramework } from '../src/index.js'; import { MockMembrane, MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; +import { bindAsConversationFork } from './helpers/dendrite.js'; function withStoreDir(fn: (path: string) => Promise | T): Promise { const dir = mkdtempSync(join(tmpdir(), 'held-turn-retry-')); @@ -89,7 +90,6 @@ type Internal = { sweepExpiredConversations(): void; disposeConversationAgent(agentName: string): void; providerAccelerationCooldowns: Map; - conversationAgentHomes: Map; pendingRequests: Request[]; providerWaits: { set(agent: string, model: string, retryAfterMs: unknown, reason: string): unknown }; startAgentStream(agent: unknown, trigger: unknown): Promise; @@ -321,7 +321,7 @@ test("a conversation agent's turn a stated wait holds for its retry is alive too const membrane = new ScriptedMembrane(new Set([1]), (request) => rateLimited(60_000, request)); const { fw, internal } = await framework(path, membrane); try { - internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); + bindAsConversationFork(fw, 'resident', { serverId: 'stub', channelId: 'world:zz' }); fw.getAgent('resident')!.getContextManager().addMessage('User', [{ type: 'text', text: 'zz-direct' }]); await internal.startAgentStream(fw.getAgent('resident'), { agentName: 'resident', reason: 'conversation', source: 'test', timestamp: Date.now() }); await waitFor(fw, () => internal.providerAccelerationCooldowns.has('resident'), 'held'); @@ -468,7 +468,7 @@ test("a conversation agent's turn a wait recorded during its compile holds is al const membrane = new ScriptedMembrane(new Set(), () => new Error('unused')); const { fw, internal } = await framework(path, membrane); try { - internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); + bindAsConversationFork(fw, 'resident', { serverId: 'stub', channelId: 'world:zz' }); const agent = fw.getAgent('resident')!; agent.getContextManager().addMessage('User', [{ type: 'text', text: 'zz-direct' }]); // Its own compression records a wait while the turn compiles. @@ -526,7 +526,7 @@ test("a stated wait with no end holds the turn until released, and surgery's ref /** A conversation agent's turn, held for its retry by a stated wait. */ async function heldConversationTurn(fw: AgentFramework, internal: Internal) { - internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); + bindAsConversationFork(fw, 'resident', { serverId: 'stub', channelId: 'world:zz' }); fw.getAgent('resident')!.getContextManager().addMessage('User', [{ type: 'text', text: 'zz-direct' }]); await internal.startAgentStream(fw.getAgent('resident'), { agentName: 'resident', reason: 'conversation', source: 'test', timestamp: Date.now() }); await waitFor(fw, () => internal.providerAccelerationCooldowns.has('resident'), 'held'); @@ -550,6 +550,50 @@ test("a conversation agent disposed while a hold keeps its turn's retry: the ret }); }); +for (const site of ['after-compile', 'throw'] as const) test(`an agent that a listener of its turn's inference:failed stops (stopAgent) is held by nothing, at ${site === 'after-compile' ? "a conversation agent's hold after its compile" : "a resident's stream that throws where a host's hold would take it, which the registry's live check refuses first"}: no hold keeps a retry for an agent that has ended`, async () => { + await withStoreDir(async (path) => { + const log = silence(); + const membrane = site === 'throw' + ? new ScriptedMembrane(new Set([1]), (request) => rateLimited(undefined, request), 'throw') + : new ScriptedMembrane(new Set(), () => new Error('unused')); + // 'lead' owns default delivery, so 'resident' can be stopped. + const { fw, internal } = await framework(path, membrane, { + agents: [{ name: 'lead', model: 'zz-model', systemPrompt: 'system' }, { name: 'resident', model: 'zz-model', systemPrompt: 'system' }], + ...(site === 'throw' ? { providerHold: () => ({ holdMs: 60_000, reason: 'zz-host-hold' }) } : {}), + }); + let stopped = false; + fw.onTrace((event) => { + if (event.type === 'inference:failed' && event.agentName === 'resident' && !stopped) { + stopped = true; + fw.stopAgent('resident', { by: 'zz-host', reason: 'zz-listener stop' }); + } + }); + try { + const agent = fw.getAgent('resident')!; + agent.getContextManager().addMessage('User', [{ type: 'text', text: 'zz-direct' }]); + if (site === 'after-compile') { + bindAsConversationFork(fw, 'resident', { serverId: 'stub', channelId: 'world:zz' }); + // Its own compression records a wait while the turn compiles. + const compile = agent.startStreamWithInjections.bind(agent); + agent.startStreamWithInjections = (async (...args: Parameters) => { + const made = await compile(...args); + internal.providerWaits.set('resident', 'zz-model', 60_000, 'zz wait during the compile'); + return made; + }) as typeof compile; + } + await internal.startAgentStream(agent, { agentName: 'resident', reason: 'conversation', source: 'test', timestamp: Date.now() }); + await waitFor(fw, () => stopped && fw.getAgent('resident') === null, 'the failure reported and the agent stopped'); + await sleep(50); + assert.equal(internal.providerAccelerationCooldowns.has('resident'), false, 'no hold keeps a retry for it'); + assert.deepEqual(fw.getHostModeStatus().activeTurnAgents, [], 'no turn is left alive'); + if (site === 'after-compile') { + assert.ok(log.lines.some((line) => line.includes('binds after the compile') && line.includes('the agent ended as the failure was reported, so nothing is held')), + `the log says why nothing is held: ${log.lines.filter((line) => line.includes('binds after the compile')).join(' | ')}`); + } + } finally { await fw.stop(); log.restore(); } + }); +}); + test("the closing-conversation reaper doesn't dispose a closing conversation agent while a hold keeps its turn's retry", async () => { await withStoreDir(async (path) => { const log = silence(); @@ -580,7 +624,7 @@ for (const failsAt of ['event', 'setup'] as const) test(`an error policy's retry }), failsAt); const { fw, internal } = await framework(path, membrane); try { - internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); + bindAsConversationFork(fw, 'resident', { serverId: 'stub', channelId: 'world:zz' }); const agent = fw.getAgent('resident')!; agent.getContextManager().addMessage('User', [{ type: 'text', text: 'zz-direct' }]); void internal.startAgentStream(agent, { agentName: 'resident', reason: 'conversation', source: 'test', timestamp: Date.now() }); @@ -656,7 +700,7 @@ for (const [label, site, host] of [ }); try { if (conversation) { - internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); + bindAsConversationFork(fw, 'resident', { serverId: 'stub', channelId: 'world:zz' }); const agent = fw.getAgent('resident')!; agent.getContextManager().addMessage('User', [{ type: 'text', text: 'zz-direct' }]); if (site === 'after-compile') { @@ -840,7 +884,7 @@ type FailureSite = 'event' | 'throw' | 'setup' | 'conversation-event' | 'convers async function failIntoHold(site: FailureSite, fw: AgentFramework, internal: Internal, membrane: ScriptedMembrane) { if (site === 'event' || site === 'throw' || site === 'setup') return heldTurn(fw, internal, membrane); if (site !== 'after-compile') return heldConversationTurn(fw, internal); - internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); + bindAsConversationFork(fw, 'resident', { serverId: 'stub', channelId: 'world:zz' }); const agent = fw.getAgent('resident')!; agent.getContextManager().addMessage('User', [{ type: 'text', text: 'zz-direct' }]); const compile = agent.startStreamWithInjections.bind(agent); @@ -975,7 +1019,7 @@ test("a wait released by a listener of the inference:failed a conversation agent if (event.type === 'inference:failed' && event.agentName === 'resident') fw.releaseProviderWait('resident'); }); try { - internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); + bindAsConversationFork(fw, 'resident', { serverId: 'stub', channelId: 'world:zz' }); const agent = fw.getAgent('resident')!; agent.getContextManager().addMessage('User', [{ type: 'text', text: 'zz-direct' }]); let recorded = false; @@ -1306,7 +1350,7 @@ test("a stop made as an error policy's retry of a failed start is held at the la }), 'setup'); const { fw, internal } = await framework(path, membrane); try { - internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); + bindAsConversationFork(fw, 'resident', { serverId: 'stub', channelId: 'world:zz' }); const agent = fw.getAgent('resident')!; agent.getContextManager().addMessage('User', [{ type: 'text', text: 'zz-direct' }]); // A stop on the microtask after the retry's hold is installed, while diff --git a/test/helpers/admission.ts b/test/helpers/admission.ts new file mode 100644 index 000000000..5e49af991 --- /dev/null +++ b/test/helpers/admission.ts @@ -0,0 +1,112 @@ +/** + * Shared harness for the admission / tune-out e2e tests: the RFC-006 + * loopback MCPL server, a mocked model, explicit event-loop driving. + */ +import assert from 'node:assert/strict'; +import { fixture, eventually, TS } from './coalescing-fixture.js'; +import { GateModule, waitCall, say } from './dendrite.js'; +import type { TuneOutCoordinator } from '../../src/tune-out/coordinator.js'; + +export type Stored = { participant: string; content: Array<{ type: string; text?: string }>; metadata?: Record }; +export type Fixture = Awaited>; +export type Internals = { + tuneOutCoordinator: TuneOutCoordinator | null; + channelRegistry: { + getDefaultPublishChannel(): string | null; + resolveLocus(agent: string): string | null; + getDesiredState(serverId: string, channelId: string): string | undefined; + getTuneOutState(serverId: string, channelId: string): { params: { epochId: string } } | null; + channels: Map; + }; + eventGate: { debounceTimers: Map } | null; + deferredMessages: Array<{ content: Array<{ type: string; text?: string }>; metadata?: Record }>; + pendingRequests: Array<{ agentName: string; reason: string }>; + handleTuneOutTool(agentName: string, input: Record): { success: boolean; error?: string; data?: unknown }; +}; + +export const SUB = { enabled: true, systemPrompt: 'You are the Subconscious. Report briefly.' } as const; +export const internals = (f: Fixture) => f.framework as unknown as Internals; + +export const textOf = (m: { content: Array<{ type: string; text?: string }> }): string => + m.content.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('\n'); +export const stored = (f: Fixture, agent = 'agent'): Stored[] => + f.framework.getAgent(agent)!.getContextManager().getAllMessages() as unknown as Stored[]; +export const held = (m: Stored): boolean => !!(m.metadata as { tuneOut?: unknown } | undefined)?.tuneOut; +export const has = (f: Fixture, text: string) => stored(f).some((m) => textOf(m).includes(text)); +export const find = (f: Fixture, text: string) => stored(f).find((m) => textOf(m).includes(text))!; +export const AMBIENT = ['chat:ambient', 'chat:from-human']; +export const MENTION = ['chat:mention', 'chat:addressed', 'chat:from-human']; + +export function incoming(f: Fixture, channelId: string, messageId: string, text: string, tags = AMBIENT, content?: unknown[]) { + return f.send('channels/incoming', { + messages: [{ + channelId, messageId, timestamp: TS, author: { id: 'U1', name: 'antra' }, tags, + content: content ?? [{ type: 'text', text }], + }], + }); +} + +/** A Discord-shaped DM: it arrives as a push event, since the DM channel is closed. */ +export function dm(f: Fixture, eventId: string, text: string) { + return f.send('push/event', { + featureSet: 'doc', eventId, timestamp: TS, + payload: { content: [{ type: 'text', text }] }, + origin: { source: 'discord', channelId: '777', guildId: null, isDM: true, authorId: 'U1', authorName: 'antra', messageId: eventId }, + tags: ['chat:dm', 'chat:addressed', 'chat:from-human'], + }); +} + +export async function registerOpen(f: Fixture, ...ids: string[]) { + await f.send('channels/register', { + channels: ids.map((id) => ({ id, type: 'discord', label: id, initiallyOpen: true, metadata: { channelType: 'guild_text' } })), + }); + await eventually(() => ids.every((id) => internals(f).channelRegistry.getDesiredState('editor', id) === 'open'), 'channels open'); +} + +export function enter(f: Fixture, channelId: string, opts: Record = {}) { + const r = internals(f).tuneOutCoordinator!.enter('editor', channelId, { cadenceSeconds: 3600, maxWakes: 5, ...opts }, 'agent-tool'); + assert.ok(r.ok, `enter ${channelId}: ${(r as { error?: string }).error ?? ''}`); +} + +/** Process what the server sent; nothing may wake (no response is scripted). */ +export async function settle(f: Fixture): Promise { + const before = f.membrane.calls.length; + // A sentinel keeps an unexpected wake from hanging the mock; the count + // below is the assertion. Unused, it is swallowed by the next scripted turn. + f.membrane.pushResponse(say('[unexpected wake]')); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, before, 'nothing woke'); +} + +/** Run a turn nothing delivered into (a heartbeat) and report the locus it froze. */ +export async function channellessTurnLocus(f: Fixture): Promise { + let locus: string | null | undefined; + const off = f.framework.onTrace((t) => { + if (t.type === 'inference:started' && (t as { agentName: string }).agentName === 'agent') { + locus = (t as { channelId?: string }).channelId ?? null; + } + }); + try { + (f.framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'agent', reason: 'heartbeat', source: 'test', timestamp: Date.now(), + }); + await f.turn(); + } finally { off(); } + assert.notEqual(locus, undefined, 'the heartbeat turn ran'); + return locus ?? null; +} + +/** A resident turn blocked in a tool call, on an open channel. */ +export async function blockInTool(f: Fixture, gate: GateModule): Promise<{ loop: Promise }> { + // The fixture pre-scripts one 'ok'; the mock hands every queued response + // to the first stream, so start this turn's script from a clean queue. + f.membrane.responses.length = 0; + (f.membrane as unknown as { responseIndex: number }).responseIndex = 0; + f.membrane.pushResponse(waitCall('g1')); + f.membrane.pushResponse(say('done')); + await incoming(f, 'work', 'w1', 'please wait for me'); + const loop = f.framework.runUntilIdle(); + await eventually(() => gate.entered.has('g1'), 'resident blocked in a tool call'); + return { loop }; // wrapped: an async return would await the loop itself +} + diff --git a/test/helpers/dendrite.ts b/test/helpers/dendrite.ts new file mode 100644 index 000000000..20149ab22 --- /dev/null +++ b/test/helpers/dendrite.ts @@ -0,0 +1,141 @@ +/** + * Test helper: make an already-registered agent look like a conversation + * fork bound to `home`, the way `createConversationAgent` registers + * one — a home channel, not a broadcast recipient, no provider-scheduling + * ownership. Returns an undo. + * + * Tests that exercise fork-only code paths (injection scoping, channel + * tool guards, provider-cooldown ownership) use this instead of spawning a + * real fork through the conversation router. + */ +import type { NormalizedRequest, NormalizedResponse, YieldingStream } from '@animalabs/membrane'; +import type { + AgentFramework, + AgentRecord, + EventResponse, + Module, + ModuleContext, + ProcessEvent, + ProcessState, + ToolCall, + ToolDefinition, + ToolResult, +} from '../../src/index.js'; +import { createMockResponse, MockYieldingStream } from './mock-membrane.js'; + +type RegistryInternals = { registry: { get(name: string): AgentRecord | undefined } }; + +export function bindAsConversationFork( + framework: AgentFramework, + agentName: string, + home: { serverId: string; channelId: string }, +): () => void { + const record = (framework as unknown as RegistryInternals).registry.get(agentName); + if (!record) throw new Error(`bindAsConversationFork: "${agentName}" is not a registered agent`); + const prior = { homeChannel: record.homeChannel, roles: { ...record.roles } }; + record.homeChannel = { ...home }; + record.roles = { ...record.roles, receivesUntargeted: false, ownsProviderScheduling: false }; + return () => { + if (prior.homeChannel === undefined) delete record.homeChannel; + else record.homeChannel = prior.homeChannel; + record.roles = prior.roles; + }; +} + +/** The registry's home channel for an agent (what channel routing resolves). */ +export function homeChannelOf(framework: AgentFramework, agentName: string): AgentRecord['homeChannel'] { + return (framework as unknown as RegistryInternals).registry.get(agentName)?.homeChannel; +} + +/** A membrane that scripts each agent separately: one response list per activation. */ +export class RoutedMembrane { + readonly calls: NormalizedRequest[] = []; + private readonly scripts = new Map(); + /** + * Which agent a request belongs to. Defaults to the request's assistant + * participant; a derived agent presents itself under its parent's name, + * so a test that scripts one sets this to tell the two apart. + */ + identify: (request: NormalizedRequest) => string = (request) => request.assistantParticipant ?? ''; + /** Text returned by `complete` (compression calls). */ + completion = 'ok'; + + script(agent: string, ...activation: NormalizedResponse[]): void { + this.scripts.set(agent, [...(this.scripts.get(agent) ?? []), activation]); + } + + callsFor(agent: string): NormalizedRequest[] { + return this.calls.filter((call) => this.streamed.has(call) && this.identify(call) === agent); + } + + private readonly streamed = new WeakSet(); + + streamYielding(request: NormalizedRequest): YieldingStream { + this.calls.push(request); + this.streamed.add(request); + const queue = this.scripts.get(this.identify(request)) ?? []; + const activation = queue.shift() ?? [createMockResponse([{ type: 'text', text: 'ok' }])]; + return new MockYieldingStream(activation); + } + + async complete(request: NormalizedRequest): Promise { + this.calls.push(request); + return createMockResponse([{ type: 'text', text: this.completion }]); + } + + asMembrane(): import('@animalabs/membrane').Membrane { + return this as unknown as import('@animalabs/membrane').Membrane; + } +} + +/** `test--wait` blocks its caller until the test releases the named gate. */ +export class GateModule implements Module { + readonly name = 'test'; + readonly entered = new Set(); + private readonly gates = new Map void>(); + broadcastOn: string | null = null; + + async start(_ctx: ModuleContext): Promise {} + // Gates still closed at shutdown stay closed: releasing them would hand a + // tool result to a framework that has already stopped. + async stop(): Promise {} + + getTools(): ToolDefinition[] { + return [{ + name: 'wait', + description: 'Block until released', + inputSchema: { type: 'object', properties: { gate: { type: 'string' } } }, + }]; + } + + async handleToolCall(call: ToolCall): Promise { + const gate = String((call.input as { gate?: string }).gate ?? 'default'); + this.entered.add(gate); + await new Promise((resolve) => this.gates.set(gate, resolve)); + return { success: true, data: { released: gate } }; + } + + release(gate: string): void { + this.gates.get(gate)?.(); + } + + async onProcess(event: ProcessEvent, _state: ProcessState): Promise { + if (this.broadcastOn && (event as { type: string }).type === this.broadcastOn) { + return { requestInference: true }; + } + return {}; + } +} + +export const waitCall = (gate: string) => + createMockResponse([{ type: 'tool_use', id: `call-${gate}`, name: 'test--wait', input: { gate } }], 'tool_use'); +export const say = (text: string) => createMockResponse([{ type: 'text', text }]); + +export async function until(condition: () => boolean, what: string, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + diff --git a/test/helpers/reader-fork-rig.ts b/test/helpers/reader-fork-rig.ts new file mode 100644 index 000000000..bb2023d0e --- /dev/null +++ b/test/helpers/reader-fork-rig.ts @@ -0,0 +1,118 @@ +/** + * One reader-fork rig per test file (as the e2e has it): a resident on a + * fixture MCPL server, a membrane that routes responses by role, the + * tune-out entered by hand. A second rig in the same process never comes up, so the speech + * on/off cases live in two files. + */ +import { before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync, appendFileSync, writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { NormalizedRequest, NormalizedResponse } from '@animalabs/membrane'; +import { AgentFramework } from '../../src/index.js'; +import type { TraceEvent } from '../../src/index.js'; +import type { Module } from '../../src/types/index.js'; +import { MockYieldingStream, createMockResponse } from './mock-membrane.js'; +import type { TuneOutCoordinator } from '../../src/tune-out/coordinator.js'; +import { JsStore } from '@animalabs/chronicle'; +import { ContextManager } from '@animalabs/context-manager'; + +export const supported = + typeof (JsStore.prototype as unknown as { view?: unknown }).view === 'function' && + typeof (ContextManager.prototype as unknown as { derive?: unknown }).derive === 'function'; +export const skip = supported ? false : 'installed @animalabs/chronicle / context-manager lack branch-bound handles or ContextManager.derive'; + +const FIXTURE = join(import.meta.dirname, '..', 'fixtures/tune-out-mcpl-server.mjs'); +export const CHANNEL = 'disc:guild:noisy'; +const SECTION = { model: 'test-model', voice: 'You are reading for scout. Report in second person, briefly.' }; + +const DEBUG = process.env.READER_RIG_DEBUG === '1'; +const debug = (...args: unknown[]) => { if (DEBUG) console.error('[rig]', ...args); }; +if (DEBUG) setInterval(() => debug('tick', new Date().toISOString().slice(11, 19)), 3000).unref(); + +class RoutedMembrane { + calls: NormalizedRequest[] = []; + private queues = { resident: [] as NormalizedResponse[], reader: [] as NormalizedResponse[] }; + push(who: 'resident' | 'reader', ...responses: NormalizedResponse[]): void { this.queues[who].push(...responses); } + /** One stream takes every response queued for its role: a tool round and its continuation go in together. */ + streamYielding(request: NormalizedRequest): MockYieldingStream { + this.calls.push(request); + const who = JSON.stringify(request.messages).includes('[Tune-out reader') ? 'reader' : 'resident'; + const responses = this.queues[who].splice(0); + debug('request', who, 'messages', request.messages.length, 'responses', responses.length, 'toolCalls', responses.map((r) => r.toolCalls.length)); + return new MockYieldingStream(responses); + } + asMembrane(): import('@animalabs/membrane').Membrane { return this as unknown as import('@animalabs/membrane').Membrane; } +} + +async function waitFor(cond: () => boolean, what: string, timeoutMs = 20_000): Promise { + debug('waiting:', what); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { if (cond()) return; await new Promise((r) => setTimeout(r, 100)); } + throw new Error(`timed out waiting for: ${what}`); +} + +type Internals = { + tuneOutCoordinator: TuneOutCoordinator | null; + channelRegistry: { listChannelsRaw(): unknown[] } | null; +}; +const internals = (f: AgentFramework) => f as unknown as Internals; +export const toolNames = (request: NormalizedRequest) => ((request as { tools?: Array<{ name: string }> }).tools ?? []).map((t) => t.name); + +export function rig(name: string, allowChannelSpeech: boolean, opts: { modules?: Module[] } = {}) { + const traces: TraceEvent[] = []; + const state = { tempDir: '', commandPath: '', statusPath: '', membrane: new RoutedMembrane(), framework: null as AgentFramework | null, traces }; + before(async () => { + state.tempDir = mkdtempSync(join(tmpdir(), `tune-out-${name}-`)); + state.commandPath = join(state.tempDir, 'commands.txt'); + state.statusPath = join(state.tempDir, 'status.jsonl'); + writeFileSync(state.commandPath, ''); + writeFileSync(state.statusPath, ''); + state.framework = await AgentFramework.create({ + storePath: join(state.tempDir, 'test.chronicle'), + membrane: state.membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + subconscious: { enabled: true, reader: 'forks', ...SECTION, allowChannelSpeech }, + mcplServers: [{ id: 'disc', command: process.execPath, args: [FIXTURE], env: { STATUS_PATH: state.statusPath, COMMAND_PATH: state.commandPath } }], + modules: opts.modules ?? [], + }); + state.framework.onTrace((event) => { + traces.push(event); + if (/^(dendrite|tune-out|tool|mcpl:speech)/.test(event.type)) debug('trace', event.type, JSON.stringify(event).slice(0, 200)); + }); + await state.framework.start(); + await waitFor(() => (internals(state.framework!).channelRegistry?.listChannelsRaw().length ?? 0) > 0, 'channel registration'); + }); + after(async () => { + debug('after: stopping'); + // A failed case can leave a fork mid-run with nothing queued for it; a stop that waits on it would hang the file. + await Promise.race([state.framework?.stop(), new Promise((r) => setTimeout(r, 10_000))]); + debug('after: stopped'); + rmSync(state.tempDir, { recursive: true, force: true }); + }); + const published = () => readFileSync(state.statusPath, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l) as { event: string; channelId?: string }).filter((e) => e.event === 'publish'); + return { + state, + waitFor, + emit: (kind: 'ambient' | 'addressed', id: string, text: string): void => { appendFileSync(state.commandPath, `${kind} ${id} ${text}\n`); }, + forkTraces: () => traces.filter((t) => t.type === 'tune-out:reader-fork') as Array, + refusals: () => traces.filter((t) => t.type === 'tool:failed' && (t as { module: string }).module === 'dendrite') as Array, + published, + /** Wake scout once and tune the channel out, so the next addressed message derives a reader fork. */ + tuneOut: async () => { + state.membrane.push('resident', createMockResponse([{ type: 'text', text: 'hi' }])); + appendFileSync(state.commandPath, 'addressed m0 scout, are you around?\n'); + await waitFor(() => state.membrane.calls.length >= 1, 'scout wakes'); + await state.framework!.runUntilIdle(); + const entered = internals(state.framework!).tuneOutCoordinator!.enter('disc', CHANNEL, { cadenceSeconds: 3600, backlogCap: 2, maxWakes: 1 }, 'agent-tool'); + assert.ok(entered.ok); + // Scout's own turn posted its "hi": what the fork adds is counted from here. + return published().length; + }, + }; +} + +export const publishCall = (id: string) => createMockResponse([{ type: 'tool_use', id, name: 'channel_publish', input: { channelId: CHANNEL, text: 'the fork, posting' } }], 'tool_use'); +export const speakCall = (id: string) => createMockResponse([{ type: 'tool_use', id, name: 'speak_in_channel', input: { channelId: CHANNEL, text: 'the fork, speaking' } }], 'tool_use'); + diff --git a/test/inbound-source.test.ts b/test/inbound-source.test.ts index 6655db342..42a38de68 100644 --- a/test/inbound-source.test.ts +++ b/test/inbound-source.test.ts @@ -194,9 +194,12 @@ describe('inbound source envelope', () => { const expected = createHash('sha256').update(canonicalJson([[{ type: 'text', text: 'digest me' }]])).digest('hex'); assert.equal(meta.sourceBodyDigest, expected); assert.equal(sourceBodyDigest([{ text: 'digest me', type: 'text' }]), expected, 'key order does not matter'); - // This lane stores the body as delivered: the stored copy hashes the same. + // The stored digest is of the stored copy. On this branch the copy + // carries the author as the agent reads them (presentAuthor), so it + // hashes the same as the delivered body exactly when presenting the + // author changed nothing. assert.equal(meta.storedBodyDigest, sourceBodyDigest(message.content)); - assert.equal(meta.storedBodyDigest, meta.sourceBodyDigest); + assert.equal(meta.storedBodyDigest === meta.sourceBodyDigest, sourceBodyDigest(message.content) === meta.sourceBodyDigest); // Outside the frozen admission envelope. assert.equal((readInboundSource(message.metadata) as unknown as Record).sourceBodyDigest, undefined); diff --git a/test/inference-failure-bounds.test.ts b/test/inference-failure-bounds.test.ts index 6fe63cdbe..c5eaa9332 100644 --- a/test/inference-failure-bounds.test.ts +++ b/test/inference-failure-bounds.test.ts @@ -473,7 +473,7 @@ test('a thrown non-Error, and an error whose projection throws, still log bounde test('a provider hold keeps a bounded reason, and a failed resume logs the bounded projection', async () => { const providerState = (fw: any) => Object.assign(fw, { - ephemeralRuns: new Map(), conversationAgentHomes: new Map(), providerGates: new Map(), + ephemeralRuns: new Map(), registry: { ownsProviderScheduling: () => true, homeChannel: () => undefined }, providerGates: new Map(), providerAccelerationCooldowns: new Map(), providerAccelerationRecoveries: new Map(), providerAdmissionClosed: false, activeTurnTokens: new Map(), quiesced: false, }); @@ -983,7 +983,7 @@ test('a MembraneError from another copy of membrane is recognised by its name', // The organization acceleration limit is recognised too, and held. const harness = makeHarness(); Object.assign(harness.fw, { - ephemeralRuns: new Map(), conversationAgentHomes: new Map(), providerGates: new Map(), + ephemeralRuns: new Map(), registry: { ownsProviderScheduling: () => true, homeChannel: () => undefined }, providerGates: new Map(), providerAccelerationCooldowns: new Map(), providerAccelerationRecoveries: new Map(), providerAccelerationDefaultCooldownMs: 30_000, providerAccelerationJitterMs: 0, }); diff --git a/test/ingress-host-keys.test.ts b/test/ingress-host-keys.test.ts index f000b2da4..f8e4b3a18 100644 --- a/test/ingress-host-keys.test.ts +++ b/test/ingress-host-keys.test.ts @@ -31,6 +31,7 @@ const HOST_KEYS: Record = { // A stored context injection's mark: every other agent's view leaves such a row out. contextInjection: { agent: 'scout', namespace: 'lessons', contentHash: 'h', part: 0, parts: 1 }, primaryIfUnread: true, + dendrite: { mailId: 'mail-1', kind: 'result', from: { agent: 'scout', incarnation: 1 } }, }; async function waitFor(cond: () => boolean, what: string, timeoutMs = 15_000): Promise { diff --git a/test/mcpl-agent-words.test.ts b/test/mcpl-agent-words.test.ts index d676df25e..4cf067bb3 100644 --- a/test/mcpl-agent-words.test.ts +++ b/test/mcpl-agent-words.test.ts @@ -568,7 +568,7 @@ test("an MCPL tool's error result with no words says so, and that its effect is test("a conversation bound to its channel is told it can't open another, and where to reply", () => { const { framework, events } = frameworkWith(null); - framework.conversationAgentHomes = new Map([['fork', { serverId: 'srv', channelId: 'home-1' }]]); + framework.registry = { homeChannel: (name: string) => (name === 'fork' ? { serverId: 'srv', channelId: 'home-1' } : undefined), list: () => [] }; framework.toolLifecycleEmitter = undefined; framework.dispatchChannelToolCall('fork', { id: 'c1', name: 'channel_open', input: { channelId: 'elsewhere' } }); assert.equal(events[0]?.result?.error, diff --git a/test/mcpl-coalesced-content-parity.test.ts b/test/mcpl-coalesced-content-parity.test.ts index 18244d05a..3113e6a69 100644 --- a/test/mcpl-coalesced-content-parity.test.ts +++ b/test/mcpl-coalesced-content-parity.test.ts @@ -293,10 +293,12 @@ test('a block whose type, text or source isn\'t a string converts on both lanes, await f.framework.runUntilIdle(); const context = f.context(); assert(context.includes('sibling_after_malformed'), 'the batch went on past the malformed messages'); - // Each malformed block, four times: coalesced and not, on each lane. + // Each malformed block, four times: coalesced and not, on each lane. A + // channel message is stored under its author's name (`User: [...]`), so + // the placeholder is matched to the end of its text block. for (const shows of new Set(malformed.map((m) => m.shows))) { const expected = malformed.filter((m) => m.shows === shows).length * 4; - assert.equal(occurrences(context, JSON.stringify(shows)), expected, shows); + assert.equal(occurrences(context, `${shows}"`), expected, shows); } // And each text that was a string, four times too. for (const { content } of malformed) { @@ -363,7 +365,9 @@ test('content whose blocks aren\'t objects, or whose block of another type carri assert.equal(plain.error, undefined); assert.equal(plain.result.accepted, true); await f.framework.runUntilIdle(); - assert.equal(occurrences(f.context(), JSON.stringify(shows)), 2); + // The channel lane's copy is stored under its author, `User: [Content…]`, + // so the count takes the placeholder to the end of its text field. + assert.equal(occurrences(f.context(), JSON.stringify(shows).slice(1)), 2); } }); diff --git a/test/mcpl-coalescing.test.ts b/test/mcpl-coalescing.test.ts index 6fb3e10d2..cfd0dda18 100644 --- a/test/mcpl-coalescing.test.ts +++ b/test/mcpl-coalescing.test.ts @@ -3081,7 +3081,7 @@ async function heldTurnSpeaksInto(f: Fixture, during: () => Promise, midSt } test("#157: an edit that addresses the agent, of a follow-up stored while the turn waits out a capacity hold and not yet seen, moves the turn to the follow-up's channel", async (t) => { - const f = await fixture(); t.after(f.close); await f.register(); await f.register('other'); + const f = await fixture({ framework: { speech: { locus: 'latest-inbound' } } }); /* the re-pin is the legacy rule */ t.after(f.close); await f.register(); await f.register('other'); let outcome: string | undefined; const channel = await heldTurnSpeaksInto(f, async () => { await f.send('channels/incoming', { messages: [{ ...f.channel('q1', 'follow_up_wiht_typo', { initial: true }, 'other', 'mq'), tags: ['chat:ambient', 'chat:from-human'] }] }); @@ -3187,7 +3187,7 @@ test("#157: another resident's compile that assembles a message stored at once i }); test("#157: an edit that addresses the agent, of a follow-up that arrived during the turn and was flushed when the turn was held, moves the turn to the follow-up's channel", async (t) => { - const f = await fixture(); t.after(f.close); await f.register(); await f.register('other'); + const f = await fixture({ framework: { speech: { locus: 'latest-inbound' } } }); /* the re-pin is the legacy rule */ t.after(f.close); await f.register(); await f.register('other'); let outcome: string | undefined; const channel = await heldTurnSpeaksInto(f, async () => { assert(storedWith(f, 'follow_up_wiht_typo'), 'the hold flushed the follow-up into the store'); diff --git a/test/mcpl-reference-binding.test.ts b/test/mcpl-reference-binding.test.ts index ff1d11cd9..f0094b8f8 100644 --- a/test/mcpl-reference-binding.test.ts +++ b/test/mcpl-reference-binding.test.ts @@ -224,12 +224,14 @@ async function puppetCall(answer: unknown, fetcher: unknown): Promise<{ result: name: 'princess', state: { status: 'idle' }, canUseTool: () => true, + deniesAtDispatch: () => false, getContextManager: () => ({ addMessage: store }), }; const framework = Object.create(AgentFramework.prototype) as AgentFramework; const fw = framework as unknown as Record; Object.assign(fw, { agents: new Map([['princess', agent]]), + registry: { get: () => undefined }, toolImageLedgers: new Map(), activeTurnTokens: new Map(), nextTurnToken: 1, diff --git a/test/mcpl-tool-lifecycle-e2e.test.ts b/test/mcpl-tool-lifecycle-e2e.test.ts index 0289fb40d..2720f9c42 100644 --- a/test/mcpl-tool-lifecycle-e2e.test.ts +++ b/test/mcpl-tool-lifecycle-e2e.test.ts @@ -31,6 +31,7 @@ import type { ToolResult, } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { bindAsConversationFork } from './helpers/dendrite.js'; const FIXTURE = join(import.meta.dirname, 'fixtures/tool-lifecycle-mcpl-server.mjs'); @@ -214,7 +215,6 @@ describe('tool lifecycle end to end (RFC-007)', () => { const internals = framework as unknown as { mcplServerRegistry: { getServer(id: string): unknown } | null; describeToolForLifecycle(tool: string): { class: string[] }; - conversationAgentHomes: Map; }; assert.deepEqual(internals.describeToolForLifecycle('prov--click').class, ['computer'], 'classed while alive'); @@ -243,10 +243,9 @@ describe('tool lifecycle end to end (RFC-007)', () => { assert.equal(after, before, 'a call the host refused (provider gone) produces no events'); }); it('a host-tool refusal (conversation-bound channel guard) is never reported (re-review #1)', async () => { - const internals = framework as unknown as { conversationAgentHomes: Map }; // Make scout a conversation fork bound to one channel: the guard in // dispatchChannelToolCall refuses channel_open before it runs. - internals.conversationAgentHomes.set('scout', { serverId: 'obs', channelId: 'disc:guild:home' }); + const unbind = bindAsConversationFork(framework, 'scout', { serverId: 'obs', channelId: 'disc:guild:home' }); try { const before = readLog(obsLog).filter((e) => e.event === 'lifecycle').length; const callsBefore = membrane.calls.length; @@ -271,7 +270,7 @@ describe('tool lifecycle end to end (RFC-007)', () => { const after = readLog(obsLog).filter((e) => e.event === 'lifecycle').length; assert.equal(after, before, 'a refused host-tool call produces no events'); } finally { - internals.conversationAgentHomes.delete('scout'); + unbind(); } }); }); diff --git a/test/module-query-for-agent.test.ts b/test/module-query-for-agent.test.ts index e66bf2748..33dae5657 100644 --- a/test/module-query-for-agent.test.ts +++ b/test/module-query-for-agent.test.ts @@ -202,8 +202,12 @@ test("with no agent registered, the refusal says so, rather than offering a wind (err: Error) => err.message === `Unknown agent: "prime". No agent is registered yet, so there's no window to read.`); }); -test("the empty name is refused even when an agent is registered under it, since a write can't be routed to it", async () => { - const x = await make(['prime', '']); +test("the empty name is refused, since a write can't be routed to it", async () => { + // On this branch the Dendrite registry refuses a nameless agent at create + // ("a name is required"), so no agent can be registered under '': the + // refusal is tested with prime alone registered. main's version registers + // an agent under '' and checks the write is refused even so. + const x = await make(['prime']); // The refused empty name isn't offered among the registered agents. assert.throws(() => x.reader.ctx!.addMessage('user', [text('FOR EMPTY')], undefined, { forAgent: '' }), (err: Error) => err.message === 'Unknown agent: "". A write names a registered agent: "prime" (ModuleContext.getAgents() lists them), ' diff --git a/test/operator-surgery.test.ts b/test/operator-surgery.test.ts index b6f006325..3e48468ab 100644 --- a/test/operator-surgery.test.ts +++ b/test/operator-surgery.test.ts @@ -197,7 +197,7 @@ describe('live operator surgery', () => { const internals = framework as unknown as { surgeryHold: unknown; pendingRequests: unknown[]; - ephemeralCandidates: Map; + ephemeralCandidates: Map; processInferenceRequests(): Promise; }; try { @@ -209,7 +209,7 @@ describe('live operator surgery', () => { framework.runEphemeralToCompletion(worker.agent, worker.contextManager), /under live roll back/, ); - assert.equal(internals.ephemeralCandidates.get(worker.agent), worker.contextManager, + assert.equal(internals.ephemeralCandidates.get(worker.agent)?.contextManager, worker.contextManager, 'refused BEFORE consuming the generation ticket — a clean retry stays possible'); // A wake arriving now is parked by the scheduler, not started. diff --git a/test/outgoing-stream.test.ts b/test/outgoing-stream.test.ts index e1c651d3c..e73a56547 100644 --- a/test/outgoing-stream.test.ts +++ b/test/outgoing-stream.test.ts @@ -194,7 +194,11 @@ function registry(framework: AgentFramework, options: { streaming?: boolean; ser }; } -async function harness(scripts: NormalizedResponse[][], config: Partial = {}, options: { streaming?: boolean; gate?: boolean; servers?: Record } = {}) { +async function harness( + scripts: NormalizedResponse[][], + config: Partial = {}, + options: { streaming?: boolean; gate?: boolean; speech?: { locus: 'delivery' | 'latest-inbound' }; servers?: Record } = {}, +) { const dir = mkdtempSync(join(tmpdir(), 'af-outgoing-stream-')); dirs.push(dir); const membrane = new ScriptMembrane(scripts); const module = new ToolModule(); @@ -203,6 +207,8 @@ async function harness(scripts: NormalizedResponse[][], config: Partial = []; @@ -423,7 +429,7 @@ test('a stream that fails partway streams none of its unfinished round, and its }); test('locus: after a mid-turn addressed re-pin, the next round streams to the new locus, where it is published', async () => { - const h = await harness([[round(text('station four, proceeding'), call('one', 'test--read')), final(text('answering you'))]]); + const h = await harness([[round(text('station four, proceeding'), call('one', 'test--read')), final(text('answering you'))]], {}, { speech: { locus: 'latest-inbound' } }); // While the tool runs, someone addresses the agent from a DM. The tool // returns once the message has been processed, so the round's boundary // weighs it. diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 118664689..0552a710a 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -201,7 +201,12 @@ describe('present while acting', () => { rmSync(tempDir, { recursive: true, force: true }); }); - async function createFramework(agentOptions: { maxStreamTokens?: number; proseRouting?: 'locus' | 'hybrid' } = {}): Promise { + // LEGACY (the re-pin rule the re-pin tests run under) is declared below. + async function createFramework( + options: { speech?: { locus: 'delivery' | 'latest-inbound' }; maxStreamTokens?: number; proseRouting?: 'locus' | 'hybrid' } = {}, + ): Promise { + const { speech, ...agentOptions } = options; + const frameworkOptions = speech ? { speech } : {}; const framework = await AgentFramework.create({ storePath: join(tempDir, 'test.chronicle'), membrane: membrane.asMembrane(), @@ -209,6 +214,7 @@ describe('present while acting', () => { { name: 'assistant', model: 'test-model', systemPrompt: 'You are a robot pilot.', ...agentOptions }, ], modules: [module], + ...frameworkOptions, }); module.framework = framework; return framework; @@ -407,7 +413,7 @@ describe('present while acting', () => { { type: 'text', text: 'Answering the person who addressed me.' }, ] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework({ speech: { locus: 'latest-inbound' } }); // the re-pin is the legacy rule const routed = stubChannelRegistry(framework); module.toolDelayMs = 25; module.interjection = 'hey, quick question over here?'; @@ -503,7 +509,7 @@ describe('present while acting', () => { { type: 'text', text: 'Answering the person who addressed me.' }, ] as ContentBlock[])); - const framework = await createFramework({ proseRouting }); + const framework = await createFramework({ proseRouting, ...LEGACY }); const watched = watchShown(framework); addressedFromDm(); @@ -531,7 +537,7 @@ describe('present while acting', () => { { type: 'text', text: 'Answering after the move.' }, ] as ContentBlock[])); - const framework = await createFramework({ proseRouting: 'hybrid' }); + const framework = await createFramework({ proseRouting: 'hybrid', ...LEGACY }); const watched = watchShown(framework); addressedFromDm(); @@ -549,6 +555,54 @@ describe('present while acting', () => { await framework.stop(); }); + it('under the default locus rule an addressed mid-turn injection is announced — who, from where — but does not move the pin', async () => { + // `speech.locus: 'delivery'` (architecture channel §3 carve-out, + // 2026-10-06): incoming traffic never moves the locus, mid-turn + // included. The addressed message is still injected; the trailing + // prose stays where the turn froze it, and the reply is the agent's + // to address explicitly. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Station four, proceeding.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'Still speaking into the scene.' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + module.toolDelayMs = 25; + module.interjection = 'hey, quick question over here?'; + module.interjectionMetadata = { channelId: 'discord:dm:antra', tags: ['chat:addressed'] }; + + const notices: string[] = []; + framework.onTrace((event) => { + const e = event as { type: string; source?: string }; + if (e.type === 'message:added' && e.source === 'routing-notice') notices.push(e.source!); + }); + + trigger(framework); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [ + { text: 'Station four, proceeding.', locus: 'chan-live-1' }, + { text: 'Still speaking into the scene.', locus: 'chan-live-1' }, + ], 'the pin did not move'); + assert.equal(notices.length, 2, 'the boot-baseline announcement and the addressed-from-elsewhere notice'); + const wired = membrane.lastStream!.receivedToolResultOptions + .flatMap((o) => o?.injectedMessages ?? []).map((m) => JSON.stringify(m.content)); + assert.ok(wired.some((s) => s.includes('quick question')), 'the addressed message itself was injected'); + assert.ok(!wired.some((s) => s.includes('[routing] The conversation moved')), 'no re-pin notice'); + // First live run, finding C: a neutral message carries nothing that names + // its channel, so the notice must — who, from where, and where the + // agent's prose still lands. + const notice = wired.find((s) => s.includes('[routing] Someone addressed you from discord:dm:antra')); + assert.ok(notice, 'the agent is told who addressed them and from where'); + assert.ok(notice!.includes('still lands in chan-live-1'), 'and where its plain speech still goes'); + + await framework.stop(); + }); + it('a provider that reports text before any block event still moves the traces and typing at the round after a re-pin', async () => { membrane.pushResponse(createMockResponse([ { type: 'text', text: 'Station four, proceeding.' }, @@ -558,7 +612,7 @@ describe('present while acting', () => { { type: 'text', text: 'Answering after the move.' }, ] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework(LEGACY); const watched = watchShown(framework); addressedFromDm(); dropBlockEvents(); @@ -582,7 +636,7 @@ describe('present while acting', () => { { type: 'text', text: 'Answering after the retry.' }, ] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework(LEGACY); const watched = watchShown(framework); addressedFromDm(); refuseRoundOnce(2); @@ -608,7 +662,7 @@ describe('present while acting', () => { { type: 'text', text: 'Good plan on both counts.' }, ] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework({ speech: { locus: 'latest-inbound' } }); // the re-pin is the legacy rule const routed = stubChannelRegistry(framework); module.toolDelayMs = 25; module.interjection = 'I need to swap out this eye screen'; @@ -651,7 +705,7 @@ describe('present while acting', () => { { type: 'text', text: 'Continuing with the colleague.' }, ] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework({ speech: { locus: 'latest-inbound' } }); // the re-pin is the legacy rule const routed = stubChannelRegistry(framework); module.toolDelayMs = 25; module.interjection = '*from the table* one receipt for the case, since I have a dated instance'; @@ -929,6 +983,11 @@ describe('present while acting', () => { const addressed = (channelId: string) => ({ channelId, tags: ['chat:addressed'] }); const ambient = (channelId: string) => ({ channelId, tags: ['chat:ambient', 'chat:from-human'] }); + /** The re-pin — a mid-turn addressed message or an arrival moving the + * locus — is the legacy rule's; the default (`speech.locus: 'delivery'`) + * announces an arrival instead, tested beside the capacity-hold rows. */ + const LEGACY = { speech: { locus: 'latest-inbound' as const } }; + /** The `[routing] The conversation moved to …` notices, by channel. */ const movedTo = (framework: AgentFramework): string[] => framework.getAgent('assistant')!.getContextManager().getAllMessages() @@ -943,7 +1002,7 @@ describe('present while acting', () => { ] as ContentBlock[], 'tool_use')); membrane.pushResponse(createMockResponse([] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework(LEGACY); const routed = stubChannelRegistry(framework); module.toolDelayMs = 25; module.interjection = 'quick question for you'; @@ -983,7 +1042,7 @@ describe('present while acting', () => { ] as ContentBlock[], 'tool_use')); membrane.pushResponse(createMockResponse([] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework(LEGACY); const routed = stubChannelRegistry(framework); module.toolDelayMs = 25; failStream(1, accelerationLimit); @@ -1008,6 +1067,75 @@ describe('present while acting', () => { }); } + it('during a capacity hold, under the default rule an addressed message from another channel moves nothing and is announced at the deferred compile', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'On it.' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + module.toolDelayMs = 25; + failStream(1, accelerationLimit); + feedStart(framework, 2, [{ type: 'text', text: 'Here is the rest.' }] as ContentBlock[]); + + trigger(framework); + await framework.runUntilIdle(); + await say(framework, 'a message during the hold', addressed('discord:dm:antra')); + assert.equal(membrane.calls.length, 1, 'the turn is still held'); + await release(framework); + + assert.equal(membrane.calls.length, 2, 'one deferred compile'); + assert.deepEqual(routed, [ + { text: 'On it.', locus: 'chan-live-1' }, + { text: 'Here is the rest.', locus: 'chan-live-1' }, + ], 'incoming traffic never moves the locus'); + assert.deepEqual(movedTo(framework), [], 'no re-pin notice'); + const notices = framework.getAgent('assistant')!.getContextManager().getAllMessages() + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'routing-notice') + .flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text)); + const notice = notices.find((t) => t.includes('addressed you from discord:dm:antra')); + assert.ok(notice, 'the deferred compile is told who addressed the agent, and from where'); + assert.ok(notice!.includes('still lands in chan-live-1'), 'and where its plain speech still lands'); + assert.ok(notice!.includes('while your turn waited on a provider hold'), `which boundary it reports: ${notice}`); + + await framework.stop(); + }); + + it('under the default rule, a re-inference on unstreamed tool results moves nothing and announces an addressed arrival, naming the re-inference', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Carrying on.' }] as ContentBlock[])); + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + type Routing = { locus?: ReturnType; engaged: Set; [field: string]: unknown }; + const internals = framework as unknown as { + turnRouting: Map; openLogical: Map; pendingRequests: InferenceRequest[]; + }; + const open: Routing = { + locus: stub('chan-turn'), triggerChannel: stub('chan-turn'), engaged: new Set([destinationKey(stub('discord:guild:portables'))]), wasIn: [], + destination: null, continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], + addressedElsewhere: new Set(), turnId: 1, + }; + internals.turnRouting.set('assistant', open); + internals.openLogical.set('assistant', open); + await say(framework, 'a message', addressed('discord:dm:antra')); + await say(framework, 'a follow-up', ambient('discord:guild:portables')); + internals.pendingRequests.push({ agentName: 'assistant', reason: 'tool_results_ready', source: 'framework', timestamp: Date.now() } as InferenceRequest); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [{ text: 'Carrying on.', locus: 'chan-turn' }], 'neither arrival moves the locus'); + assert.deepEqual(movedTo(framework), []); + const notices = framework.getAgent('assistant')!.getContextManager().getAllMessages() + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'routing-notice') + .flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text)); + const notice = notices.find((t) => t.includes('addressed you from discord:dm:antra')); + assert.ok(notice, 'the addressed arrival is announced'); + assert.ok(notice!.includes('before this re-inference on your tool results'), `which boundary: ${notice}`); + assert.ok(!notices.some((t) => t.includes('discord:guild:portables')), 'a follow-up in an engaged channel addressed nobody and is not announced'); + + await framework.stop(); + }); + for (const [label, messages, expected] of [ ['an addressed message withdrawn before the deferred compile moves nothing', [['soon withdrawn', 'discord:dm:antra', true]], 'chan-live-1'], @@ -1021,7 +1149,7 @@ describe('present while acting', () => { ] as ContentBlock[], 'tool_use')); membrane.pushResponse(createMockResponse([] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework(LEGACY); const routed = stubChannelRegistry(framework); module.toolDelayMs = 25; failStream(1, accelerationLimit); @@ -1097,7 +1225,7 @@ describe('present while acting', () => { ] as ContentBlock[], 'tool_use')); membrane.pushResponse(createMockResponse([] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework(LEGACY); const routed = stubChannelRegistry(framework); module.toolDelayMs = 25; const internals = framework as unknown as { @@ -1147,7 +1275,7 @@ describe('present while acting', () => { ] as ContentBlock[], 'tool_use')); membrane.pushResponse(createMockResponse([] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework(LEGACY); const routed = stubChannelRegistry(framework); module.toolDelayMs = 25; const origHandle = module.handleToolCall.bind(module); @@ -1188,7 +1316,7 @@ describe('present while acting', () => { ] as ContentBlock[], 'tool_use')); membrane.pushResponse(createMockResponse([] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework(LEGACY); const routed = stubChannelRegistry(framework); module.toolDelayMs = 25; // The host holds the agent once when its stream can't be set up. (Asked @@ -1340,7 +1468,7 @@ describe('present while acting', () => { overBudget.usage = { inputTokens: 100, outputTokens: 5 }; membrane.pushResponse(overBudget); - const framework = await createFramework({ maxStreamTokens: 20 }); + const framework = await createFramework({ ...LEGACY, maxStreamTokens: 20 }); const routed = stubChannelRegistry(framework); module.toolDelayMs = 25; module.interjection = 'quick question for you'; @@ -1379,7 +1507,7 @@ describe('present while acting', () => { overBudget.usage = { inputTokens: 100, outputTokens: 5 }; membrane.pushResponse(overBudget); - const framework = await createFramework({ maxStreamTokens: 20 }); + const framework = await createFramework({ maxStreamTokens: 20, ...LEGACY }); const routed = stubChannelRegistry(framework); const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; const shown: string[] = []; @@ -1413,7 +1541,7 @@ describe('present while acting', () => { { type: 'text', text: 'Answering the person who addressed me.' }, ] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework({ speech: { locus: 'latest-inbound' } }); // the re-pin is the legacy rule const routed = stubChannelRegistry(framework); const cm = framework.getAgent('assistant')!.getContextManager(); let rendered = 0; @@ -1446,7 +1574,7 @@ describe('present while acting', () => { overBudget.usage = { inputTokens: 100, outputTokens: 5 }; membrane.pushResponse(overBudget); - const framework = await createFramework({ maxStreamTokens: 20 }); + const framework = await createFramework({ maxStreamTokens: 20, speech: { locus: 'latest-inbound' } }); // the re-pin is the legacy rule const routed = stubChannelRegistry(framework); const cm = framework.getAgent('assistant')!.getContextManager(); let rendered = 0; @@ -1487,7 +1615,7 @@ describe('present while acting', () => { ] as const) { it(`a re-inference on unstreamed tool results ${label}`, async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Carrying on.' }] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework(LEGACY); const routed = stubChannelRegistry(framework); type Routing = { locus?: Ref; engaged: Set; [field: string]: unknown }; const internals = framework as unknown as { @@ -1516,7 +1644,7 @@ describe('present while acting', () => { it('the start that continues the turn weighs a message its own flush lands, and a delivery assembled for it', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Carrying on.' }] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework(LEGACY); const routed = stubChannelRegistry(framework); type Routing = { locus?: Ref; engaged: Set; arrivals: Array<{ messageId: string; channel: Ref }>; [field: string]: unknown }; const internals = framework as unknown as { @@ -1786,7 +1914,8 @@ describe('present while acting', () => { { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, ] as ContentBlock[], 'tool_use')); membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Done.' }] as ContentBlock[])); - const framework = await createFramework(); + // A re-pin is the legacy rule's; the default announces an arrival instead. + const framework = await createFramework(notice === 'The conversation moved to' ? LEGACY : {}); stubChannelRegistry(framework); module.toolDelayMs = 25; if (notice === 'The conversation moved to') { @@ -1798,8 +1927,9 @@ describe('present while acting', () => { addMessage(participant: string, content: ContentBlock[], metadata?: unknown): string; }; const add = cm.addMessage.bind(cm); + let refused = 0; cm.addMessage = (participant, content, metadata) => { - if ((content[0] as { text?: string })?.text?.includes(notice)) throw new Error('store refused'); + if ((content[0] as { text?: string })?.text?.includes(notice)) { refused++; throw new Error('store refused'); } return add(participant, content, metadata); }; const baseline = (framework as unknown as { lastAnnouncedLocus: Map }).lastAnnouncedLocus; @@ -1807,6 +1937,7 @@ describe('present while acting', () => { trigger(framework); await framework.runUntilIdle(); + assert.ok(refused > 0, `the premise: ${label}'s notice was written, and refused`); if (notice === 'The conversation moved to') { assert.deepEqual(baseline.get('assistant'), stub('chan-live-1'), 'the agent was last told of the locus the turn opened at'); } else { @@ -1828,7 +1959,7 @@ describe('present while acting', () => { const framework = Object.create(AgentFramework.prototype) as Record & { [method: string]: any }; framework.emitTrace = () => {}; framework.pushEvent = () => {}; - framework.conversationAgentHomes = new Map(); + framework.registry = { homeChannel: () => undefined, list: () => [] }; framework.ephemeralRuns = new Map(); framework.lastAnnouncedLocus = new Map(); framework.activeTurnTriggers = new Map(); @@ -2301,7 +2432,7 @@ describe('present while acting', () => { { type: 'text', text: 'Answering the person who addressed me.' }, ] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework({ speech: { locus: 'latest-inbound' } }); // the re-pin is the legacy rule stubChannelRegistry(framework); module.toolDelayMs = 25; module.interjection = 'quick question over here?'; @@ -2630,7 +2761,7 @@ describe('present while acting', () => { { type: 'text', text: 'Answering the room.' }, ] as ContentBlock[])); - const framework = await createFramework(); + const framework = await createFramework(LEGACY); const routed = stubChannelRegistry(framework); declareEarsInboundOnly(framework); module.toolDelayMs = 25; diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 95e17ffe2..9ea5d069f 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -428,6 +428,8 @@ async function harness(opts: { modules?: Module[]; /** FrameworkConfig.heartbeat: the framework's own heartbeat. */ heartbeat?: boolean | Record; + /** FrameworkConfig.speech: where a turn's plain speech lands when nothing names a destination. */ + speech?: { locus: 'delivery' | 'latest-inbound' }; } = {}) { const dir = mkdtempSync(join(tmpdir(), 'prose-drafts-e2e-')); const commandPath = join(dir, 'commands.jsonl'); @@ -470,6 +472,7 @@ async function harness(opts: { }], modules: opts.modules ?? [], ...(opts.heartbeat !== undefined ? { heartbeat: opts.heartbeat as never } : {}), + ...(opts.speech ? { speech: opts.speech } : {}), ...(opts.codeExecution ? { codeExecution: { enabled: true } } : {}), ...(opts.retries !== undefined ? { errorPolicy: { @@ -3807,7 +3810,14 @@ describe('a turn whose stream fails or is stopped after a tool round', () => { it(`a conversation agent's stated provider wait, reported as ${how === 'error' ? 'an error' : 'a throw'}, after stop() has closed admission names the speech`, async () => { const h = await harness({ xml: true, roundPreamble: true, retries: 1 }); try { - (h.framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.set('scout', { serverId: 'discord', channelId: ROOM }); + // main's test makes scout a conversation agent through conversationAgentHomes; on this + // branch that is the registry's record: a home channel, and a role that doesn't own + // provider scheduling (as conversationForkSpec gives a fork). + const scout = (h.framework as unknown as { + registry: { live: Map }; + }).registry.live.get('scout')!; + scout.homeChannel = { serverId: 'discord', channelId: ROOM }; + scout.roles.ownsProviderScheduling = false; breakAfterToolRound(h.membrane, how, { failure: () => new MembraneError({ type: 'rate_limit', retryable: true, httpStatus: 429, retryAfterMs: 30_000, message: 'rate limited', rawError: { status: 429 } }), beforeFailure: () => { (h.framework as unknown as { providerAdmissionClosed: boolean }).providerAdmissionClosed = true; }, @@ -4389,7 +4399,8 @@ describe('a heartbeat turn holds its plain speech as drafts', () => { }); it('a channel-less wake that is not the heartbeat still takes the latest inbound channel, as before', async () => { - const h = await harness(); + // The latest inbound channel is a fallback only under 'latest-inbound'. + const h = await harness({ speech: { locus: 'latest-inbound' } }); try { await h.turn([createMockResponse([text('a reply in the room')])]); await h.turn([createMockResponse([text('words from a timer')])], 'scout', 'request'); diff --git a/test/provider-acceleration-cooldown.test.ts b/test/provider-acceleration-cooldown.test.ts index 72e2edbea..353ee4e47 100644 --- a/test/provider-acceleration-cooldown.test.ts +++ b/test/provider-acceleration-cooldown.test.ts @@ -8,6 +8,7 @@ import type { ContentBlock, NormalizedRequest, StreamEvent, YieldingStream } fro import type { EventResponse, Module, ModuleContext, ProcessEvent, ProcessState, ToolCall, ToolDefinition, ToolResult } from '../src/index.js'; import { AgentFramework } from '../src/index.js'; import { MockMembrane, MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; +import { bindAsConversationFork } from './helpers/dendrite.js'; class InputModule implements Module { readonly name = 'input'; @@ -155,12 +156,11 @@ test('dynamic conversation forks are excluded from provider cooldown ownership', modules: [], syncIntervalMs: 0, maintenanceIntervalMs: 0, }); const internal = framework as unknown as { - conversationAgentHomes: Map; providerAccelerationCooldowns: Map; providerGates: Map; holdProviderAcceleration(agent: unknown, error: Error, trigger?: unknown): boolean; }; - internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:test' }); + bindAsConversationFork(framework, 'resident', { serverId: 'stub', channelId: 'world:test' }); try { const error = new MembraneError({ type: 'rate_limit', retryable: true, httpStatus: 429, diff --git a/test/provider-waits.test.ts b/test/provider-waits.test.ts index 5041eb80e..3e8643503 100644 --- a/test/provider-waits.test.ts +++ b/test/provider-waits.test.ts @@ -1012,13 +1012,13 @@ test('stop() cancels a retry wait on the path that does not own provider admissi }, }); const internal = fw as unknown as { - conversationAgentHomes: Map; + registry: AgentRegistryRecords; retryWaitWakers: Set; startAgentStream(agent: unknown, trigger: unknown): Promise; }; let stopped = false; try { - internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); + asConversationAgent(internal, 'resident', { serverId: 'stub', channelId: 'world:zz' }); fw.getAgent('resident')!.getContextManager().addMessage('User', [{ type: 'text', text: 'zz-direct' }]); const run = internal.startAgentStream(fw.getAgent('resident'), { agentName: 'resident', reason: 'conversation', source: 'test', timestamp: Date.now() }); for (let i = 0; i < 300 && internal.retryWaitWakers.size === 0; i++) await sleep(10); @@ -1629,8 +1629,22 @@ class FailingAt extends WaitingMembrane { } } +/** The registry records the framework consults for an agent's roles and home. */ +type AgentRegistryRecords = { + get(name: string): { homeChannel?: { serverId: string; channelId: string } | string; roles: { ownsProviderScheduling: boolean } } | undefined; +}; + +/** Make a registered resident a conversation agent: homed in `channelId`, + * not owning provider admission — what the registry says of a fork. */ +function asConversationAgent(internal: { registry: AgentRegistryRecords }, name: string, home: { serverId: string; channelId: string }): void { + const record = internal.registry.get(name); + if (!record) throw new Error(`no registry record for ${name}`); + record.homeChannel = { ...home }; + record.roles.ownsProviderScheduling = false; +} + type NonOwning = Internal & { - conversationAgentHomes: Map; + registry: AgentRegistryRecords; retryWaitWakers: Set<() => void>; pendingRequests: Array<{ agentName: string; reason: string; source: string; timestamp: number }>; startAgentStream(agent: unknown, trigger: unknown): Promise; @@ -1666,7 +1680,7 @@ async function waitFor(fw: AgentFramework, done: () => boolean, what: string, bo } async function conversationTurn(fw: AgentFramework, internal: NonOwning, text: string): Promise { - internal.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); + asConversationAgent(internal, 'resident', { serverId: 'stub', channelId: 'world:zz' }); fw.getAgent('resident')!.getContextManager().addMessage('User', [{ type: 'text', text }]); await internal.startAgentStream(fw.getAgent('resident'), { agentName: 'resident', reason: 'conversation', source: 'test', timestamp: Date.now() }); } @@ -2260,7 +2274,7 @@ test("the last boundary holds no agent the framework no longer has: a disposed f const nonOwning = internal as NonOwning; try { const agent = fw.getAgent('resident')!; - nonOwning.conversationAgentHomes.set('resident', { serverId: 'stub', channelId: 'world:zz' }); + asConversationAgent(nonOwning, 'resident', { serverId: 'stub', channelId: 'world:zz' }); internal.providerWaits.set('resident', 'zz-model', 60_000, 'zz recorded for the fork'); nonOwning.disposeConversationAgent('resident'); // Its retry wakes after the disposal and reaches the boundary. diff --git a/test/publish-default-locus.test.ts b/test/publish-default-locus.test.ts new file mode 100644 index 000000000..0c928efa7 --- /dev/null +++ b/test/publish-default-locus.test.ts @@ -0,0 +1,120 @@ +/** + * `channel_publish` without a channelId goes where the turn's plain speech + * would: the frozen turn locus (the waking delivery, or a channel opened + * this turn). First live run, 2026-10-12: under `speech.locus: 'delivery'` + * the registry's own default is null, and the tool failed on the very turn + * a delivery on that channel woke the agent. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { fixture, eventually } from './helpers/coalescing-fixture.js'; +import { createMockResponse, HeldStream } from './helpers/mock-membrane.js'; +import { registerOpen, incoming, MENTION } from './helpers/admission.js'; + +const until = async (cond: () => boolean, what: string) => { + for (let i = 0; i < 100 && !cond(); i++) await new Promise((r) => setTimeout(r, 20)); + assert.ok(cond(), what); +}; +const publishOf = (f: { published: Array> }, text: string) => + f.published.find((p) => JSON.stringify(p.content).includes(text)); + +describe('channel_publish without channelId', () => { + it('an empty channelId is refused as empty, not read as left out', async () => { + const f = await fixture(); + try { + await registerOpen(f, 'work'); + f.membrane.responses.length = 0; // the fixture pre-scripts an `ok` + (f.membrane as unknown as { responseIndex: number }).responseIndex = 0; + f.membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'p1', name: 'channel_publish', input: { channelId: '', content: 'zz-empty' } }], 'tool_use')); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'done' }])); + await incoming(f, 'work', 'w1', 'agent, are you there?', MENTION); + await f.framework.runUntilIdle(); + await eventually(() => !!publishOf(f, 'done') || !!publishOf(f, 'zz-empty'), 'the turn spoke'); + assert.equal(publishOf(f, 'zz-empty'), undefined, 'nothing was posted for an empty channelId'); + const answered = f.framework.getAgent('agent')!.getContextManager().getAllMessages() + .some((m) => JSON.stringify(m.content).includes('Nothing was posted: channelId is empty.')); + assert.ok(answered, 'the refusal says the channelId is empty'); + } finally { await f.close(); } + }); + + it("a call made outside the turn takes none of the turn's channels: after a turn woken in work it's refused, and under a focus it goes to the excepted channel", async () => { + const f = await fixture({ framework: { tuneOut: { enabled: true } } as never }); + try { + await registerOpen(f, 'work', 'other'); + const before = await f.framework.executeToolCall({ id: 'p0', name: 'channel_publish', input: { content: 'zz-script-0' }, callerAgentName: 'agent' } as never); + assert.equal(before.success, false, 'with no turn yet, refused'); + f.alwaysRespond(); + await incoming(f, 'work', 'w1', 'agent, are you there?', MENTION); + await f.framework.runUntilIdle(); + const kept = (f.framework as unknown as { turnRouting: Map }).turnRouting.get('agent'); + assert.equal(kept?.locus?.channelId, 'work', "premise: the agent's latest routing still names work"); + const result = await f.framework.executeToolCall({ id: 'p1', name: 'channel_publish', input: { content: 'zz-script' }, callerAgentName: 'agent' } as never); + assert.equal(result.success, false); + assert.match(result.error ?? '', /no channel to default to/); + assert.equal(publishOf(f, 'zz-script'), undefined, 'nothing was posted'); + const coordinator = (f.framework as unknown as { tuneOutCoordinator: { enterAllExcept(ref: unknown, opts: unknown, source: string): { ok: boolean } } }).tuneOutCoordinator; + assert.ok(coordinator.enterAllExcept({ serverId: 'editor', channelId: 'other' }, { durationSeconds: 120 }, 'agent-tool').ok, 'premise: a focus on other'); + const again = await f.framework.executeToolCall({ id: 'p2', name: 'channel_publish', input: { content: 'zz-script-2' }, callerAgentName: 'agent' } as never); + assert.ok(again.success, again.error); + await eventually(() => !!publishOf(f, 'zz-script-2'), 'the second call posted'); + assert.equal(publishOf(f, 'zz-script-2')!.channelId, 'other', 'the registry default: the excepted channel'); + } finally { await f.close(); } + }); + + it("a host's call carrying an open channelless turn's id meets the turn's rule: refused, though a focus holds another channel", async () => { + const f = await fixture({ framework: { tuneOut: { enabled: true } } as never }); + try { + await registerOpen(f, 'work'); + const coordinator = (f.framework as unknown as { tuneOutCoordinator: { enterAllExcept(ref: unknown, opts: unknown, source: string): { ok: boolean } } }).tuneOutCoordinator; + assert.ok(coordinator.enterAllExcept({ serverId: 'editor', channelId: 'work' }, { durationSeconds: 120 }, 'agent-tool').ok, 'premise: a focus on work'); + f.membrane.responses.length = 0; // the fixture pre-scripts an `ok` + (f.membrane as unknown as { responseIndex: number }).responseIndex = 0; + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'done' }])); + const fw = f.framework as unknown as { openLogical: Map }; + const m = f.membrane as unknown as { responses: unknown[]; responseIndex: number; calls: unknown[]; lastStream: unknown }; + let open: { turnId?: number; noChannel?: true } | undefined; + let host: Promise<{ success: boolean; error?: string }> | undefined; + // While the turn is open, the host calls as the agent, carrying the turn's id. + f.membrane.streamYielding = ((request: unknown) => { + m.calls.push(request); + const remaining = m.responses.slice(m.responseIndex); + m.responseIndex = m.responses.length; + open ??= fw.openLogical.get('agent'); + host ??= f.framework.executeToolCall({ id: 'h1', name: 'channel_publish', input: { content: 'zz-host' }, callerAgentName: 'agent', callerTurnId: open?.turnId } as never) as never; + const stream = new HeldStream(host.then(() => undefined), remaining as never); + m.lastStream = stream; + return stream; + }) as never; + // A wake naming a channel no server registers: the turn has no channel by design (noChannel). + (f.framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'agent', reason: 'test', source: 'test', channelId: 'ghost', timestamp: Date.now(), + }); + await f.framework.runUntilIdle(); + assert.ok(open?.turnId !== undefined && open.noChannel, 'premise: the call carried an open noChannel turn'); + const result = await host!; + assert.equal(result.success, false); + assert.match(result.error ?? '', /This turn has no channel of its own/); + assert.equal(publishOf(f, 'zz-host'), undefined, 'nothing was posted'); + } finally { await f.close(); } + }); + + it('defaults to the turn locus: the waking delivery, then a channel opened this turn', async () => { + const f = await fixture(); + try { + await registerOpen(f, 'work', 'other'); + f.membrane.responses.length = 0; // the fixture pre-scripts an `ok` + (f.membrane as unknown as { responseIndex: number }).responseIndex = 0; + f.membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'p1', name: 'channel_publish', input: { content: 'on my way' } }], 'tool_use')); + f.membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'o1', name: 'channel_open', input: { channelId: 'other' } }, + ], 'tool_use')); + f.membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'p2', name: 'channel_publish', input: { content: 'and here' } }], 'tool_use')); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'done' }])); + await incoming(f, 'work', 'w1', 'agent, are you there?', MENTION); + await f.framework.runUntilIdle(); + await until(() => !!publishOf(f, 'and here'), 'both publishes landed'); + assert.equal(publishOf(f, 'on my way')!.channelId, 'work', 'the waking delivery\'s channel'); + assert.equal(publishOf(f, 'and here')!.channelId, 'other', 'the channel opened this turn'); + } finally { await f.close(); } + }); +}); diff --git a/test/push-origin-gate.test.ts b/test/push-origin-gate.test.ts new file mode 100644 index 000000000..cd07a5081 --- /dev/null +++ b/test/push-origin-gate.test.ts @@ -0,0 +1,37 @@ +/** + * The gate decides a push by the host's facts about it: the server that sent + * it and the event type the host gives pushes. A push's `origin` is the + * server's own claim (MCPL SPEC §16.6) and can't move it under another + * server's or event type's policy, in either push lane. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fixture, TS } from './helpers/coalescing-fixture.js'; +import type { FrameworkConfig } from '../src/types/framework.js'; + +const QUIET_EDITOR = { + gate: { config: { default: 'always', policies: [ + { name: 'quiet-editor', match: { scope: ['mcpl:push-event'], source: 'editor' }, behavior: 'defer' }, + ] } }, +} as Partial; + +const ELSEWHERE = { serverId: 'elsewhere', eventType: 'mcpl:channel-incoming', eventId: 'not-this-one', featureSet: 'other' }; + +test("a push whose origin names another server and event type is still gated as its own server's push", async (t) => { + const f = await fixture({ framework: QUIET_EDITOR }); t.after(f.close); + await f.send('push/event', { + featureSet: 'doc', eventId: 'p1', timestamp: TS, origin: ELSEWHERE, + payload: { content: [{ type: 'text', text: 'plain_push' }] }, + }); + await f.framework.runUntilIdle(); + assert.ok(f.context().includes('plain_push'), 'the push is stored'); + assert.equal(f.membrane.calls.length, 0, "the editor's policy deferred it: no turn"); +}); + +test("a deferred coalesced push whose origin names another server and event type is still gated as its own server's push", async (t) => { + const f = await fixture({ framework: QUIET_EDITOR }); t.after(f.close); + const reply = await f.send('push/event', { ...f.params('1', 'deferred_push', { deferred: true, initial: true }), origin: ELSEWHERE }); + assert.equal(reply.error, undefined, JSON.stringify(reply)); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0, "the editor's policy deferred it: no turn"); +}); diff --git a/test/reinit-after-branch-move.test.ts b/test/reinit-after-branch-move.test.ts index b6a6a3d76..98b6a8169 100644 --- a/test/reinit-after-branch-move.test.ts +++ b/test/reinit-after-branch-move.test.ts @@ -1311,7 +1311,10 @@ describe('conversation forks and an undo', () => { router.bind({ serverId: 'world', channelId: 'world:late' }, 'a-late-g1', 1); framework.getAgent('a-late-g1')!.getContextManager().addMessage('user', [{ type: 'text', text: 'late own' }]); }; - const count = (texts: string[], text: string) => texts.filter((t) => t === text).length; + // On this branch a fork's stored copy of a channel message carries its author + // (`alice: …`, presentAuthor), so a text is found as written or so prefixed. + const matches = (t: string, text: string) => t === text || t.endsWith(': ' + text); + const count = (texts: string[], text: string) => texts.filter((t) => matches(t, text)).length; type Alert = { kind: string; agentName: string; message: string }; const reinitAlertsOf = (framework: AgentFramework): Alert[] => { const alerts: Alert[] = []; @@ -1406,6 +1409,7 @@ describe('conversation forks and an undo', () => { assert.deepEqual(alerts.map((alert) => alert.agentName), [emptied], JSON.stringify(alerts)); says(alerts[0]!.message, 'it was disposed and its channel unbound'); assert.ok(!framework.getAgent(emptied), 'disposed'); + assert.equal(framework.getAgentRecord(emptied)?.ended?.reason, 'context-emptied'); assert.equal(framework.getConversationRouter()!.getBinding({ serverId: 'world', channelId }), undefined, 'its channel is unbound'); assert.ok(framework.getAgent('a-early-g1'), 'the fork with history stays'); }); @@ -1686,15 +1690,15 @@ describe('conversation forks and an undo', () => { pass.open(); await undo.settled; const texts = await textsOf(framework, 'a-early-g1'); - const own = texts.indexOf('early own'); - const message = texts.indexOf('MESSAGE DURING THE UNDO'); - const correction = texts.indexOf('CORRECTION DURING THE UNDO'); + const own = texts.findIndex((t) => matches(t, 'early own')); + const message = texts.findIndex((t) => matches(t, 'MESSAGE DURING THE UNDO')); + const correction = texts.findIndex((t) => matches(t, 'CORRECTION DURING THE UNDO')); assert.ok(own >= 0 && message > own && correction > message, `its own history, then the two writes: ${JSON.stringify(texts)}`); assert.equal(count(texts, 'MESSAGE DURING THE UNDO') + count(texts, 'CORRECTION DURING THE UNDO'), 2, JSON.stringify(texts)); assert.deepEqual(added.filter((t) => t.source === 'mcpl:channel-incoming').map((t) => t.messageId), ['', ''], `traced at the deferral, with no id yet: ${JSON.stringify(added)}`); const storedId = (words: string) => early.getAllMessages() - .find((m) => m.content.some((b) => (b as { text?: string }).text === words))?.id; + .find((m) => m.content.some((b) => typeof (b as { text?: string }).text === 'string' && matches((b as { text: string }).text, words)))?.id; assert.deepEqual(added.filter((t) => t.source === 'deferred-flush:surgery-release').map((t) => t.messageId), [storedId('MESSAGE DURING THE UNDO'), storedId('CORRECTION DURING THE UNDO')], `traced as each lands, with its stored id: ${JSON.stringify(added)}`); }); @@ -1899,7 +1903,11 @@ describe('conversation forks and an undo', () => { (e: unknown) => e instanceof OperatorActionError && e.code === 'agent-busy' && /is being created/.test(e.message)); const placement = await arriving; assert.ok(placement?.agent && framework.getAgent(placement.agent), `the spawn made its fork: ${JSON.stringify(placement)}`); - assert.equal(cmA.getAllMessages().length, 2, 'the rollback changed nothing'); + // On this branch the spawn also tells its source (Dendrite's derivation + // notice), so the two rows stand first and the only row after them is that notice. + const texts = storedTexts(cmA); + assert.deepEqual(texts.slice(0, 2), ['hello a', 'again'], 'the rollback changed nothing'); + assert.ok(texts.length <= 3, `only the derivation notice may follow: ${JSON.stringify(texts)}`); }); }); diff --git a/test/reply-server-routes.test.ts b/test/reply-server-routes.test.ts index b01391a3a..1cc3de2f5 100644 --- a/test/reply-server-routes.test.ts +++ b/test/reply-server-routes.test.ts @@ -25,6 +25,10 @@ import type { PushEventParams, PushEventResult } from '../src/mcpl/types.js'; import { MockMembrane, HeldStream, createMockResponse } from './helpers/mock-membrane.js'; const FIXTURE = join(import.meta.dirname, 'fixtures/speech-route-mcpl-server.mjs'); +// These tests were written to the latest-inbound speech rule: a message +// re-pins the turn, and the latest one is the default channel and the +// fallback locus. +const LEGACY = { locus: 'latest-inbound' } as const; async function waitFor(cond: () => boolean, what: string, timeoutMs = 15_000): Promise { const deadline = Date.now() + timeoutMs; @@ -56,6 +60,7 @@ describe("a push naming another server's channel, through two real servers", () framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), + speech: LEGACY, agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], mcplServers: [ { id: 'discord', command: process.execPath, args: [FIXTURE], @@ -187,6 +192,7 @@ describe("a turn's replies go out through the server whose message triggered it" framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), + speech: LEGACY, agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], mcplServers: [ { id: 'discord', command: process.execPath, args: [FIXTURE], @@ -389,8 +395,14 @@ describe("a turn's replies go out through the server whose message triggered it" 'Server "slack" has no channel "discord:g1:room", the id of the channel this call would default to, so nothing was posted.'); }); + // Inside a turn, a publish naming no channel goes where the turn's plain + // speech would (publishInputWithTurnLocus): the turn's locus, here the + // lobby slack's addressed message woke it in, on slack, and not the + // default a later message set mid-turn. + const publishedTo = (text: string) => ['discord', 'slack'].flatMap((id) => + lines(paths(id).status).filter((l) => l.event === 'publish' && String(l.text ?? '').includes(text)).map((l) => `${id} ${String(l.channelId)}`)); for (const withdraws of [true, false]) { - it(`a channel_publish naming no channel, inside a turn, into a default another message set mid-turn goes through that message's server${withdraws ? ', and nowhere once it withdrew the channel' : ''}`, async () => { + it(`a channel_publish naming no channel, inside a turn, goes to the turn's locus through its server, not into a default another message set mid-turn${withdraws ? ', and nowhere once that server withdrew the locus' : ''}`, async () => { await registerX('slack', '#room (slack)'); membrane.pushResponse(createMockResponse([ { type: 'tool_use', id: 'pub-default', name: 'channel_publish', input: { content: 'in-turn default' } }, @@ -401,9 +413,9 @@ describe("a turn's replies go out through the server whose message triggered it" command('slack', { op: 'incoming', channelId: X, messageId: 'in-ambient', mode: 'ambient', text: 'in passing' }); await waitFor(() => registry().getDefaultPublishChannel()?.channelId === X, 'the shared channel is the default'); assert.deepEqual(registry().getDefaultPublishChannel(), { serverId: 'slack', channelId: X }); - if (withdraws) await registry().handleChanged('slack', { removed: [X] }); + if (withdraws) await registry().handleChanged('slack', { removed: ['slack:T1:C1'] }); await settle(); - assert.deepEqual(published('in-turn default'), withdraws ? [] : ['slack']); + assert.deepEqual(publishedTo('in-turn default'), withdraws ? [] : ['slack slack:T1:C1']); }); } @@ -528,6 +540,7 @@ describe('in explicit and hybrid mode, a reply goes out through no server but th framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), + speech: LEGACY, agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', proseRouting }], mcplServers: [ { id: 'discord', command: process.execPath, args: [FIXTURE], @@ -751,6 +764,7 @@ describe("a script's call, through a real code_execution run", () => { framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), + speech: LEGACY, agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], mcplServers: [ { id: 'discord', command: process.execPath, args: [FIXTURE], @@ -817,6 +831,7 @@ describe("with conversation routing, each server's channel is its own conversati framework = await AgentFramework.create({ storePath: join(dir, 'store'), membrane: membrane.asMembrane(), + speech: LEGACY, agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], mcplServers: [ { id: 'discord', command: process.execPath, args: [FIXTURE], @@ -859,6 +874,23 @@ describe("with conversation routing, each server's channel is its own conversati assert.equal(take(slackFork!, 'discord'), false, "slack's fork doesn't take discord's"); }); + it("a host's executeToolCall naming a fork, with no channel and outside the fork's turn, posts in the fork's home through its server, not into the default another server's message set", async () => { + await registerOpen(); + command('discord', { op: 'incoming', channelId: X, messageId: 'd-1', mode: 'addressed', text: 'from discord' }); + await waitFor(() => holders('from discord').length > 0, "discord's message reached a fork"); + command('slack', { op: 'incoming', channelId: X, messageId: 's-1', mode: 'addressed', text: 'from slack' }); + await waitFor(() => holders('from slack').length > 0, "slack's message reached a fork"); + await framework!.runUntilIdle(); + const [discordFork] = holders('from discord'); + const via = (text: string) => ['discord', 'slack'].filter((id) => existsSync(paths(id).status) && readFileSync(paths(id).status, 'utf8') + .split('\n').filter(Boolean).map((l) => JSON.parse(l) as Record) + .some((l) => l.event === 'publish' && String(l.text ?? '').includes(text))); + const result = await framework!.executeToolCall({ id: 'h-1', name: 'channel_publish', input: { content: 'zz-home-post' }, callerAgentName: discordFork } as never); + assert.ok(result.success, result.error); + await waitFor(() => via('zz-home-post').length > 0, 'the post went out'); + assert.deepEqual(via('zz-home-post'), ['discord']); + }); + it("each server's next message goes on to its own fork", async () => { await registerOpen(); command('discord', { op: 'incoming', channelId: X, messageId: 'd-1', mode: 'addressed', text: 'discord first' }); @@ -876,9 +908,18 @@ describe("with conversation routing, each server's channel is its own conversati }); describe("a gate wake's route, with two forks on one channel id", () => { - type Gate = { conversationRouter: ConversationRouter; conversationAgentHomes: Map; mayTakeGateRoute(a: string, r: ChannelRef): boolean }; + type Gate = { + conversationRouter: ConversationRouter; + registry: { homeChannel(name: string): ChannelRef | undefined; list(): Array<{ name: string; homeChannel: ChannelRef }> }; + mayTakeGateRoute(a: string, r: ChannelRef): boolean; + }; const d = { serverId: 'discord', channelId: 'room' }; const s = { serverId: 'slack', channelId: 'room' }; + // The agent registry's records of the forks and their homes. + const homes = (forks: Array<[string, ChannelRef]>): Gate['registry'] => { + const byName = new Map(forks); + return { homeChannel: (name) => byName.get(name), list: () => forks.map(([name, homeChannel]) => ({ name, homeChannel })) }; + }; it("is taken only by the fork whose home is the route's server and channel", () => { const router = new ConversationRouter({ templateAgent: 'scout' }); @@ -886,7 +927,7 @@ describe("a gate wake's route, with two forks on one channel id", () => { router.bind(s, 'fork-s', 2); const framework = Object.create(AgentFramework.prototype) as Gate; framework.conversationRouter = router; - framework.conversationAgentHomes = new Map([['fork-d', d], ['fork-s', s]]); + framework.registry = homes([['fork-d', d], ['fork-s', s]]); assert.equal(framework.mayTakeGateRoute('fork-s', s), true); assert.equal(framework.mayTakeGateRoute('fork-d', s), false, "discord's fork doesn't take slack's route"); assert.equal(framework.mayTakeGateRoute('fork-d', d), true); @@ -899,7 +940,7 @@ describe("a gate wake's route, with two forks on one channel id", () => { router.bind(d, 'fork-d', 1); const framework = Object.create(AgentFramework.prototype) as Gate; framework.conversationRouter = router; - framework.conversationAgentHomes = new Map([['fork-d', d]]); + framework.registry = homes([['fork-d', d]]); assert.equal(framework.mayTakeGateRoute('fork-d', d), true); assert.equal(framework.mayTakeGateRoute('fork-d', s), false); t.diagnostic(`the trunk takes slack's route on that id: ${framework.mayTakeGateRoute('scout', s)}`); diff --git a/test/route-failure-targeting.test.ts b/test/route-failure-targeting.test.ts index 1989674f3..a2f406cb1 100644 --- a/test/route-failure-targeting.test.ts +++ b/test/route-failure-targeting.test.ts @@ -107,6 +107,9 @@ describe('notices about an agent\'s own action', () => { ], modules: [], conversations: { templateAgent: 'primary' }, + // These rows route an ephemeral run's reply by the registry's default + // locus (its latest accepted inbound), which only the legacy rule has. + speech: { locus: 'latest-inbound' }, }); // Install the real framework callback and registry without a live connector. await internals(created).initializeMcpl([]); @@ -394,7 +397,7 @@ describe('notices about an agent\'s own action', () => { it('tells no one, logged, when there is no primary to tell', async (t) => { const dir = mkdtempSync(join(tmpdir(), 'route-failure-no-primary-')); const bare = await AgentFramework.create({ - storePath: join(dir, 'test.chronicle'), membrane: membrane.asMembrane(), agents: [], modules: [], + storePath: join(dir, 'test.chronicle'), membrane: membrane.asMembrane(), agents: [], modules: [], speech: { locus: 'latest-inbound' }, }); try { await internals(bare).initializeMcpl([]); diff --git a/test/routing-baseline-restart.test.ts b/test/routing-baseline-restart.test.ts new file mode 100644 index 000000000..a91c8ded4 --- /dev/null +++ b/test/routing-baseline-restart.test.ts @@ -0,0 +1,112 @@ +/** + * The routing announcement after a restart compares with what the agent was + * last told before it, not with nothing: told its speech lands in a channel, + * a later turn with no locus says it no longer does. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import type { AgentFramework } from '../src/index.js'; +import type { FrameworkConfig } from '../src/types/framework.js'; +import { createMockResponse } from './helpers/mock-membrane.js'; +import { fixture } from './helpers/coalescing-fixture.js'; +import { registerOpen, incoming, has, stored, textOf, MENTION, channellessTurnLocus } from './helpers/admission.js'; + +const storedBaselines = (framework: AgentFramework): Record => + ((framework as unknown as { store: { getStateJson(id: string): unknown } }).store.getStateJson('framework/state') as + { announcedLoci?: Record } | null)?.announcedLoci ?? {}; + +describe('the routing announcement across a restart', () => { + it('a turn with no locus after a restart is told so, when the agent was last told of a channel', async () => { + const f = await fixture(); + try { + await registerOpen(f, 'work'); + f.alwaysRespond(); + await incoming(f, 'work', 'w1', 'agent, are you there?', MENTION); + await f.framework.runUntilIdle(); + assert.ok(has(f, 'Your plain speech now lands in'), 'told of the channel before the restart'); + await f.framework.stop(); + await f.create(); + assert.equal(await channellessTurnLocus(f), null); + assert.ok(has(f, 'Your plain speech currently has no channel'), 'told it no longer lands there'); + // That announcement is stored too, so the boot after it doesn't tell it again. + await f.framework.stop(); + await f.create(); + assert.equal(await channellessTurnLocus(f), null); + const told = stored(f).filter((m) => textOf(m).includes('Your plain speech currently has no channel')).length; + assert.equal(told, 1, 'told once, across the next boot'); + } finally { await f.close(); } + }); + + it('a resident declared by configuration keeps its baseline through stopAgent and the next boot', async () => { + const f = await fixture({ agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }, { name: 'aide', model: 'test', systemPrompt: 'test' }] }); + try { + await registerOpen(f, 'work'); + const wake = (channelId?: string) => (f.framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'aide', reason: 'test', source: 'test', timestamp: Date.now(), ...(channelId ? { channelId } : {}), + }); + wake('work'); + await f.turn(); + const told = (text: string) => stored(f, 'aide').some((m) => textOf(m).includes(text)); + assert.ok(told('Your plain speech now lands in'), 'premise: aide was told of the channel'); + f.framework.stopAgent('aide'); + await f.framework.stop(); + await f.create(); + assert.ok(f.framework.getAgent('aide'), 'premise: aide is back at the boot'); + wake(); + await f.turn(); + assert.ok(told('Your plain speech currently has no channel'), 'told it no longer lands there'); + } finally { await f.close(); } + }); + + it("a conversation fork's baseline goes when the fork ends", async () => { + const f = await fixture({ framework: { conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } } } as Partial }); + try { + await registerOpen(f, 'work'); + f.alwaysRespond(); + await incoming(f, 'work', 'w1', 'agent, are you there?', MENTION); + await f.framework.runUntilIdle(); + const fork = f.framework.getConversationRouter()!.getBinding({ serverId: 'editor', channelId: 'work' })?.agentName; + assert.ok(fork, 'premise: the message spawned a fork'); + assert.ok(stored(f, fork).some((m) => textOf(m).includes('Your plain speech now lands in work')), 'premise: its first turn was told of its home'); + assert.ok(Object.hasOwn(storedBaselines(f.framework), fork), "premise: and the fork's baseline is stored"); + (f.framework as unknown as { disposeConversationAgent(name: string): void }).disposeConversationAgent(fork); + assert.ok(!Object.hasOwn(storedBaselines(f.framework), fork), 'its baseline went with it'); + } finally { await f.close(); } + }); + + it("a fresh resident's turn with no locus says nothing about routing, after a restart too", async () => { + const f = await fixture(); + try { + assert.equal(await channellessTurnLocus(f), null); + await f.framework.stop(); + await f.create(); + assert.equal(await channellessTurnLocus(f), null); + assert.ok(!has(f, '[routing]'), 'no routing notice'); + assert.ok(!Object.hasOwn(storedBaselines(f.framework), 'agent'), 'and a quiet start stores nothing'); + } finally { await f.close(); } + }); + + it("an ephemeral run's baseline goes when the run ends, though it opened a channel", async () => { + const f = await fixture(); + try { + await f.send('channels/register', { + channels: [{ id: 'work', type: 'discord', label: 'work', initiallyOpen: false, metadata: { channelType: 'guild_text' } }], + }); + const fw = f.framework as unknown as { rememberAnnouncedLocus(name: string, locus: unknown): void }; + const remember = fw.rememberAnnouncedLocus.bind(fw); + const told: string[] = []; + fw.rememberAnnouncedLocus = (name: string, locus: unknown) => { told.push(name); remember(name, locus); }; + f.membrane.responses.length = 0; // the fixture pre-scripts an `ok` + (f.membrane as unknown as { responseIndex: number }).responseIndex = 0; + f.membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'o1', name: 'channel_open', input: { channelId: 'work' } }], 'tool_use')); + f.membrane.pushResponse(createMockResponse([{ type: 'text', text: 'done' }])); + const created = await f.framework.createEphemeralAgent({ name: 'run-1', model: 'test', systemPrompt: 'Do the task.', allowedTools: 'all' }); + created.contextManager.addMessage('user', [{ type: 'text', text: 'Go.' }]); + const run = f.framework.runEphemeralToCompletion(created.agent, created.contextManager); + f.framework.start(); + await run; + assert.ok(told.includes('run-1'), 'premise: the run was told of the channel it opened, and its baseline written'); + assert.ok(!Object.hasOwn(storedBaselines(f.framework), 'run-1'), 'its baseline went with it'); + } finally { await f.close(); } + }); +}); diff --git a/test/send-into-closed-channel.test.ts b/test/send-into-closed-channel.test.ts index 6a5893ca9..6d7a9cdf7 100644 --- a/test/send-into-closed-channel.test.ts +++ b/test/send-into-closed-channel.test.ts @@ -147,7 +147,8 @@ async function harness(opts: { sharedIdFirst?: boolean } = {}) { framework.agents = new Map([['sol', agentStub()]]); // Conversation agents by their home channel, and ephemeral runs by name; // sol is neither. - framework.conversationAgentHomes = new Map(); + // The Dendrite registry, where a conversation agent's home is kept; sol has none. + framework.registry = { homes: new Map(), homeChannel(name: string) { return this.homes.get(name); }, list: () => [] }; framework.ephemeralRuns = new Map(); const windowMessages: string[] = []; /** Whose window each of those was addressed to: addMessage's forAgent, or @@ -626,7 +627,7 @@ test('a send into a tuned-out channel leaves the tune-out in place, and its answ assert.ok((await reply()).data.endsWith('until the tune-out ends. To stop this note, set closed_channel_note to false with agent_settings.')); framework.agents.set('sol', agentStub()); // A conversation agent gets none. - framework.conversationAgentHomes.set('sol', channelId); + framework.registry.homes.set('sol', { serverId: 'discord', channelId }); assert.ok(!(await reply()).data.includes('[tuned out]')); }); @@ -934,7 +935,7 @@ test("a sleep announced into a channel the agent or a module closed says the age test('a conversation agent gets no note on its send: channel_open is refused to it', async () => { const { registry, framework, reply, windowMessages } = await harness(); assert.equal((await registry.handleChannelToolCall('channel_close', { channelId }, origin)).success, true); - framework.conversationAgentHomes.set('sol', channelId); + framework.registry.homes.set('sol', { serverId: 'discord', channelId }); const { success, data } = await reply(); assert.equal(success, true); assert.ok(!data.includes('[closed channel]'), data); diff --git a/test/shared-channel-id.test.ts b/test/shared-channel-id.test.ts index aa80bba87..6f81ad888 100644 --- a/test/shared-channel-id.test.ts +++ b/test/shared-channel-id.test.ts @@ -15,9 +15,10 @@ 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 { AgentFramework, conversationForkSpec } from '../src/index.js'; import type { ChannelRef, InferenceRequest, ProcessEvent } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; +import { bindAsConversationFork } from './helpers/dendrite.js'; import { isProseTargetToken } from '../src/mcpl/prose-grammar.js'; import { ChannelRegistry, destinationKey } from '../src/mcpl/channel-registry.js'; import { HookOrchestrator } from '../src/mcpl/hook-orchestrator.js'; @@ -61,6 +62,9 @@ function fixture(channels: Array<[serverId: string, channelId: string, label?: s () => {}, (e) => { traces.push(e); }, { + // The latest delivered inbound is the fallback locus and the default + // channel, as on main: these tests were written to that rule. + latestInboundLocus: true, onRouteFailure: (info) => { failures.push(info); }, sendTypingFn: (serverId, channelId, metadata, op) => { calls.push({ server: serverId, what: op === 'stop' ? 'typing-stop' : 'typing', channelId, ...(metadata ? { metadata } : {}) }); @@ -305,6 +309,8 @@ for (const order of orders) { f.registry.handleIncoming('b', { messages: [{ channelId: 'general', messageId: 'in1', author: { id: 'u', name: 'U' }, timestamp: '2026-10-19T00:00:00.000Z', content: [{ type: 'text', text: 'hi' }] }], }); + // Admission records the delivered message as the latest (noteDelivered). + f.registry.noteDelivered(b, 'in1'); assert.deepEqual(f.registry.buildChannelContext('agent', 'b'), { incoming: { channelId: 'general', messageId: 'in1', threadId: undefined }, defaultOutgoing: { channelId: 'general' }, @@ -368,8 +374,7 @@ test("a fork's qualified-name refusal quotes its home's name as an id is quoted, (f.i.channelRegistry as unknown as { channels: Map }).channels.set(destinationKey(odd), { serverId: 'b', descriptor: { id: 'q"x', type: 'chat', label: 'q"x' }, open: true, }); - (f.framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.set('scout', odd); - t.after(() => { (f.framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.delete('scout'); }); + t.after(bindAsConversationFork(f.framework, 'scout', odd)); const results = toolResults(t, f.framework); const dispatch = (f.framework as unknown as { dispatchChannelToolCall: Dispatcher }).dispatchChannelToolCall.bind(f.framework); dispatch('scout', { id: 'q', name: 'channel_publish', input: { channelId: 'b:q"x', serverId: 'b', content: 'x' } }); @@ -741,6 +746,8 @@ test("a default channel's reply anchors survive a withdrawal, as on main, and go const f = fixture(both(['a', 'b'])); f.registry.handleIncoming('a', { messages: [{ channelId: 'general', messageId: 'm-a', threadId: 't-a', author: { id: 'u', name: 'u' }, timestamp: new Date().toISOString(), content: [{ type: 'text', text: 'hi' }] }] } as never); + // Admission records the delivered message as the latest (noteDelivered). + f.registry.noteDelivered(a, 'm-a', 't-a'); const before = f.registry.incomingFor(); assert.deepEqual(before, { channel: a, messageId: 'm-a', threadId: 't-a' }); await f.registry.handleChanged('a', { removed: ['general'] }); @@ -830,26 +837,26 @@ test("a name that reads two ways is refused with a line per channel, each with i assert.equal(f.registry.nameShown({ serverId: 'a', channelId: 'b:room' }), '"b:room" on server "a"'); assert.equal(f.registry.nameShown({ serverId: 'c', channelId: 'b:room' }), 'c:b:room'); assert.equal(f.registry.nameShown({ serverId: 'a:b', channelId: 'room' }), 'room'); - // tune_out takes a name, so a tuned-out channel no name picks can't be - // cancelled by it, and the refusal says how its tune-out ends instead. + // tune_out takes a serverId, so the refusal names a tuned-out channel no + // name picks by its id and server for the cancel. f.registry.enterTuneOut('a', 'b:room', { epochId: 'e', cadenceSeconds: 300, backlogCap: 50, maxWakes: 3, startedAtSequence: 0 }, 'agent-tool'); const tuned = await f.registry.handleChannelToolCall('channel_open', { channelId: 'b:room', serverId: 'a' }); assert.ok(tuned.error?.startsWith('Channel "b:room" on server "a" is tuned out'), tuned.error); - assert.ok(tuned.error?.includes("No channelId names it alone, so tune_out can't cancel it"), tuned.error); - // channel_close refuses it too, and says the close can come once the - // tune-out has ended by itself. + assert.ok(tuned.error?.includes('Call tune_out with channelId "b:room", serverId "a" and mode "cancel"'), tuned.error); + // channel_close refuses it too, and says the close can come after the cancel. const closing = await f.registry.handleChannelToolCall('channel_close', { channelId: 'b:room', serverId: 'a' }); assert.ok(closing.error?.startsWith('Channel "b:room" on server "a" is tuned out'), closing.error); - assert.ok(closing.error?.endsWith('and its backlog is delivered then. Once it has ended, channel_close can close it.'), closing.error); - // An open the tune-out overtook during its round-trip says the same, not - // to call tune_out with a name none is. + assert.ok(closing.error?.endsWith('cancelling delivers the diverted backlog, and channel_close closes it after that.'), closing.error); + assert.ok(closing.error?.includes('channelId "b:room", serverId "a"'), closing.error); + // An open the tune-out overtook during its round-trip says the same: the + // cancel by id and server. const registry = f.registry as unknown as { entryAt(ref: ChannelRef): unknown; lifecycleFailureText(action: 'open', entry: unknown, err: unknown): string; }; const overtaken = registry.lifecycleFailureText('open', registry.entryAt({ serverId: 'a', channelId: 'b:room' }), new Error('the wire dropped')); assert.ok(overtaken.startsWith('Channel "b:room" on server "a" was tuned out meanwhile'), overtaken); - assert.ok(overtaken.includes("No channelId names it alone, so tune_out can't cancel it") && !overtaken.includes('Call tune_out'), overtaken); + assert.ok(overtaken.includes('Call tune_out with channelId "b:room", serverId "a" and mode "cancel"'), overtaken); // channel_list gives a name only where one picks the channel alone. const rows = (await f.registry.handleChannelToolCall('channel_list', {})).data as Array<{ id: string; name?: string; serverId: string }>; assert.deepEqual(rows.map((r) => [r.serverId, r.id, r.name]), [['a', 'b:room', undefined], ['c', 'b:room', 'c:b:room'], ['a:b', 'room', undefined]]); @@ -1052,6 +1059,21 @@ test("a fork's turn whose wake names no channel still has its home, so it's repl assert.deepEqual(f.registry.incomingFor('fork'), { channel: a, messageId: 'm-latest', threadId: undefined }); }); +test("a fork's home is its channel on its server, and one kept as a bare id is read as a wake without its server is: the one registered channel with that id, or the home on no server", async (t) => { + const { i } = await frameworkWith(t, ['slack', 'discord']); + const map = (i.channelRegistry as unknown as { channels: Map }).channels; + map.set(destinationKey({ serverId: 'slack', channelId: 'lobby' }), { + serverId: 'slack', descriptor: { id: 'lobby', type: 'chat', label: '#lobby' }, open: true, + }); + const home = (stored: ChannelRef | string | undefined) => + (i as unknown as { channelOfHome(home: ChannelRef | string | undefined): ChannelRef | undefined }).channelOfHome(stored); + assert.deepEqual(home({ serverId: 'discord', channelId: 'general' }), { serverId: 'discord', channelId: 'general' }, 'a home with its server is that channel'); + assert.deepEqual(home('lobby'), { serverId: 'slack', channelId: 'lobby' }, 'one server registers the id: its channel'); + assert.deepEqual(home('general'), { serverId: '', channelId: 'general' }, 'two servers do: the home on no server'); + assert.deepEqual(home('gone'), { serverId: '', channelId: 'gone' }, 'no server does: the home on no server'); + assert.equal(home(undefined), undefined, 'no home'); +}); + test('beforeInference gives each server its own channel context in place of the shared one', async (t) => { const sent: Array<{ server: string; channels: unknown }> = []; const server = (id: string) => ({ @@ -1509,11 +1531,10 @@ test("a message moves the turn's locus to its own server's channel, by the serve test("a gate wake's route goes to a fork only where it's the fork's own home, on its server", async (t) => { const f = await frameworkWith(t, ['a', 'b']); const fw = f.framework as unknown as { - conversationAgentHomes: Map; + registry: { register(spec: ReturnType): unknown }; mayTakeGateRoute(agentName: string, route: ChannelRef): boolean; }; - fw.conversationAgentHomes.set('fork-a', a); - t.after(() => { fw.conversationAgentHomes.delete('fork-a'); }); + fw.registry.register(conversationForkSpec('fork-a', { template: 'scout', homeChannel: a, idleTtlMs: 60_000 })); assert.equal(fw.mayTakeGateRoute('fork-a', a), true, "the fork's home"); assert.equal(fw.mayTakeGateRoute('fork-a', b), false, "b's general is another channel, though it shares the id"); assert.equal(fw.mayTakeGateRoute('scout', a), false, "a's general is the fork's conversation"); @@ -1524,14 +1545,12 @@ test("what an agent is told of a destination or a channel names it apart where i const f = await frameworkWith(t, ['a', 'b']); const fw = f.framework as unknown as { destinationText(d: ChannelRef & { label?: string }): string; - conversationAgentHomes: Map; recordReplyOpenFailedNotice(agent: string, serverId: string, channel: { channelId: string; label?: string }, selected?: 'subscription-policy'): void; }; assert.equal(fw.destinationText({ ...b, label: '#general' }), '#general (b:general)'); // A fork's channel_close is refused for any channel but its home: a // notice offers it only for that one, on its server. - fw.conversationAgentHomes.set('scout', a); - t.after(() => { fw.conversationAgentHomes.delete('scout'); }); + t.after(bindAsConversationFork(f.framework, 'scout', a)); fw.recordReplyOpenFailedNotice('scout', b.serverId, { channelId: b.channelId, label: '#general' }, 'subscription-policy'); fw.recordReplyOpenFailedNotice('scout', a.serverId, { channelId: a.channelId, label: '#general' }, 'subscription-policy'); const notices = f.framework.getAgent('scout')!.getContextManager().queryMessages({}).messages @@ -1565,12 +1584,10 @@ test("a resend to a shared id lists its candidates in registration order, and on test("a fork's resend goes home on its server, and another server's channel with that id is refused, both named apart", async (t) => { const f = await frameworkWith(t, ['a', 'b']); const fw = f.framework as unknown as { - conversationAgentHomes: Map; proseDrafts: { hold(agent: string, segments: Array<{ text: string }>, reason: 'bounced'): { held: Array<{ id: string }> } }; handleDraftsTool(agent: string, input: unknown): Promise; }; - fw.conversationAgentHomes.set('scout', b); - t.after(() => { fw.conversationAgentHomes.delete('scout'); }); + t.after(bindAsConversationFork(f.framework, 'scout', b)); const [draft] = fw.proseDrafts.hold('scout', [{ text: 'held words' }], 'bounced').held; const refused = ((await fw.handleDraftsTool('scout', { action: 'resend', draftIds: [draft!.id], destination: 'a:general' })) as { error?: string }).error ?? ''; @@ -1639,7 +1656,7 @@ for (const order of orders) { test(`a fork homed on b's general publishes there by the bare id, and a's channel is foreign to it (${registered})`, async (t) => { const f = await frameworkWith(t, order); - (f.framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.set('scout', b); + bindAsConversationFork(f.framework, 'scout', b); const results = toolResults(t, f.framework); const dispatch = (f.framework as unknown as { dispatchChannelToolCall: Dispatcher }).dispatchChannelToolCall.bind(f.framework); @@ -1741,6 +1758,7 @@ for (const order of orders) { return { ok: true, params: { cadenceSeconds: 300, backlogCap: 50, maxWakes: 3 } }; }, cancel: (serverId: string, channelId: string) => { entered.push(`cancel ${serverId} ${channelId}`); return { ok: true }; }, + hasReader: () => true, stop: () => {}, }; // The tune-out's transport is another test's (tune-out-transport): here @@ -1751,19 +1769,30 @@ for (const order of orders) { const results = toolResults(t, f.framework); const dispatch = (f.framework as unknown as { dispatchTuneOutToolCall: Dispatcher }).dispatchTuneOutToolCall.bind(f.framework); + // Every answer, a refusal too, comes once the handler's body settles. + const answered = async (id: string) => { for (let i = 0; i < 20 && !results.has(id); i++) await new Promise((r) => setImmediate(r)); }; dispatch('scout', { id: 'shared', name: 'tune_out', input: { channelId: 'general' } }); + await answered('shared'); + // tune_out takes a serverId, so the refusal offers one. assert.equal(results.get('shared')?.error, `Channel "general" is on more than one server (${order.map((s) => `"${s}:general"`).join(', ')}), so nothing ran. ` + - 'Call again with channelId set to one of them.'); + `Call again with channelId set to one of them, or with serverId set to ${order.map((s) => `"${s}"`).join(' or ')}.`); dispatch('scout', { id: 'one', name: 'tune_out', input: { channelId: 'b:general' } }); // A tune-out's answer waits for its transport (openForTuneOut). for (let i = 0; i < 20 && !results.has('one'); i++) await new Promise((r) => setImmediate(r)); assert.deepEqual(results.get('one')?.data, { tunedOut: 'b:general', cadenceSeconds: 300, backlogCap: 50, maxWakes: 3 }, 'the result names the channel as the agent can name it back'); dispatch('scout', { id: 'cancel', name: 'tune_out', input: { channelId: 'b:general', mode: 'cancel' } }); + await answered('cancel'); assert.deepEqual(results.get('cancel')?.data, { cancelled: 'b:general' }); assert.deepEqual(entered, ['b general', 'cancel b general']); assert.deepEqual(opened, ['b general'], "the tune-out's transport is opened on b's channel"); + // A cancel by id and server takes that server's channel: the form the + // tuned-out refusals give for a channel no name picks alone. + dispatch('scout', { id: 'pair', name: 'tune_out', input: { channelId: 'general', serverId: 'a', mode: 'cancel' } }); + await answered('pair'); + assert.equal(results.get('pair')?.error, undefined); + assert.deepEqual(entered.at(-1), 'cancel a general'); }); test(`tune_out inside the turn reads the id of the turn's own channel as that channel, and refuses it once its server withdrew it (${registered})`, async (t) => { @@ -1775,6 +1804,7 @@ for (const order of orders) { return { ok: true, params: { cadenceSeconds: 300, backlogCap: 50, maxWakes: 3 } }; }, cancel: () => ({ ok: true }), + hasReader: () => true, stop: () => {}, }; (f.i.channelRegistry as unknown as { openForTuneOut(serverId: string, channelId: string): Promise }) @@ -1791,6 +1821,7 @@ for (const order of orders) { "the turn's own channel, not a shared-id refusal"); await (f.i.channelRegistry as unknown as ChannelRegistry).handleChanged('b', { removed: ['general'] }); dispatch('scout', { id: 'gone', name: 'tune_out', input: { channelId: 'general' }, callerTurnId: 9 } as never); + for (let i = 0; i < 20 && !results.has('gone'); i++) await new Promise((r) => setImmediate(r)); assert.equal(results.get('gone')?.error, '"general" is this turn\'s channel on server "b", which no longer registers it, so nothing ran, ' + 'and nothing can run on it until that server registers it again.'); assert.deepEqual(entered, ['b general'], "a's general isn't taken for it"); @@ -1803,6 +1834,7 @@ for (const order of orders) { (f.framework as unknown as { tuneOutCoordinator: unknown }).tuneOutCoordinator = { enter: () => ({ ok: true, params: { cadenceSeconds: 300, backlogCap: 50, maxWakes: 3 } }), cancel: () => ({ ok: true }), + hasReader: () => true, stop: () => {}, }; reg.openForTuneOut = async () => { @@ -1859,7 +1891,7 @@ for (const order of orders) { test("a fork whose home its server no longer registers is told so, and not offered another server's channel", async (t) => { const f = await frameworkWith(t, ['a', 'b']); - (f.framework as unknown as { conversationAgentHomes: Map }).conversationAgentHomes.set('scout', b); + bindAsConversationFork(f.framework, 'scout', b); const results = toolResults(t, f.framework); const dispatch = (f.framework as unknown as { dispatchChannelToolCall: Dispatcher }).dispatchChannelToolCall.bind(f.framework); // b's general goes; a's general keeps the id. diff --git a/test/silent-heartbeat-rows.test.ts b/test/silent-heartbeat-rows.test.ts index 993fb7139..ce612a607 100644 --- a/test/silent-heartbeat-rows.test.ts +++ b/test/silent-heartbeat-rows.test.ts @@ -548,6 +548,22 @@ describe('silent heartbeat stored rows', () => { } finally { await x.framework.stop(); rmSync(x.dir, { recursive: true, force: true }); } }); + it("renders the ticks and [Continue] replies of a participant the agent counts as its own voice", async () => { + const x = await make({ agent: { selfParticipants: ['elder'] } }); + try { + const cm = x.framework.getAgent('assistant')!.getContextManager(); + cm.addMessage('user', [{ type: 'text', text: 'earlier' }]); + cm.addMessage('elder', signed('sig-e1', 'elder tick reply'), { + silentHeartbeat: { eventId: 'hb-e', serverId: 'heartbeat', agentName: 'elder', separator: SILENT_HEARTBEAT_SEPARATOR }, + }); + cm.addMessage('elder', signed('sig-e2', 'elder continued'), { promptedBy: { turn: 'p-e', text: '[Continue]' } }); + await wake(x, ordinaryEvent('hello'), signed('sig-1', 'first reply')); + const texts = x.membrane.calls[0]!.messages.map(textOf); + assert.equal(texts[texts.indexOf('elder tick reply') - 1], SILENT_HEARTBEAT_SEPARATOR, JSON.stringify(texts)); + assert.equal(texts[texts.indexOf('elder continued') - 1], '[Continue]', JSON.stringify(texts)); + } finally { await x.framework.stop(); rmSync(x.dir, { recursive: true, force: true }); } + }); + it('leaves ordinary turns exactly as before', async () => { // Undelivered prose leaves no receipt, so the window ends on the reply. const x = await make({ deliver: false }); @@ -683,6 +699,20 @@ describe('replies to a [Continue] turn', () => { ].sort()); }); + it("renders an inherited voice's ticks and replies to [Continue] as a derived agent's own", () => { + const tick = { eventId: 'hb-p', serverId: 'heartbeat', agentName: 'parent', separator: SILENT_HEARTBEAT_SEPARATOR }; + const stored = [ + { participant: 'parent', content: signed('sig-p1', 'parent tick reply'), metadata: { silentHeartbeat: tick } }, + { participant: 'parent', content: signed('sig-p2', 'parent continued'), metadata: { promptedBy: { turn: 'p-1', text: '[Continue]' } } }, + { participant: 'child', content: signed('sig-c', 'child reply') }, + ]; + const index = indexRequestOnlyRows(stored, new Set(['child', 'parent'])); + const presented = stored.map(({ content }) => ({ participant: 'parent', content })); + assert.deepEqual(renderRequestOnlyRows(presented, index, 'parent').messages.map(textOf), [ + SILENT_HEARTBEAT_SEPARATOR, 'parent tick reply', '[Continue]', 'parent continued', 'child reply', + ]); + }); + it('keys a tick by its server and its event id together', () => { // Two servers' events can share an id: each tick still gets its own // separator, before its own first row. diff --git a/test/subconscious-model-fields.test.ts b/test/subconscious-model-fields.test.ts index d2294308a..8345bebce 100644 --- a/test/subconscious-model-fields.test.ts +++ b/test/subconscious-model-fields.test.ts @@ -20,7 +20,10 @@ async function withSubconscious(resident: Partial, subconscious: Pa membrane: new MockMembrane().asMembrane(), agents: [residentConfig], modules: [], - subconscious: { enabled: true, systemPrompt: 'notice', ...subconscious }, + // On this branch SubconsciousConfig is a union by `reader`; the persistent + // reader is the one that is an agent at create, named 'Subconscious' by + // default and on the primary's model unless told otherwise, as main's is. + subconscious: { reader: 'persistent', enabled: true, systemPrompt: 'notice', ...subconscious } as SubconsciousConfig, }); return { framework, residentConfig }; } diff --git a/test/supplied-text-in-context.test.ts b/test/supplied-text-in-context.test.ts index c6f793a42..29d1434ab 100644 --- a/test/supplied-text-in-context.test.ts +++ b/test/supplied-text-in-context.test.ts @@ -82,6 +82,9 @@ describe('a tune-out\'s dump of what it held', () => { const added: Array<{ content: ContentBlock[]; metadata?: Record }> = []; const row = (author: string, text: string) => ({ participant: 'user', content: [{ type: 'text', text } as ContentBlock], metadata: { author: { name: author }, tuneOut: { epochId: 'eA' } } }); const coordinator = new TuneOutCoordinator({ + // No other tune-out or all-except hold, and nothing else stored. + getTuneOutState: () => null, + readCoordinatorState: () => null, nameShown: (channel: { channelId: string }) => channel.channelId, quotedShown: (channel: { channelId: string }) => JSON.stringify(channel.channelId), cancelTuneOut: () => ({ params: { epochId: 'eA', cadenceSeconds: 300, backlogCap: 10, maxWakes: 3, startedAtSequence: 0 }, wakeCount: 0 }), @@ -120,6 +123,9 @@ describe('a tune-out\'s dump of what it held', () => { for (const words of CUT_INTO) for (let room = 60; room <= 700; room++) { const added: Array<{ content: ContentBlock[]; metadata?: Record }> = []; const coordinator = new TuneOutCoordinator({ + // No other tune-out or all-except hold, and nothing else stored. + getTuneOutState: () => null, + readCoordinatorState: () => null, nameShown: (channel: { channelId: string }) => channel.channelId, quotedShown: (channel: { channelId: string }) => JSON.stringify(channel.channelId), cancelTuneOut: () => ({ params: { epochId: 'eA', cadenceSeconds: 300, backlogCap: 10, maxWakes: 3, startedAtSequence: 0 }, wakeCount: 0 }), @@ -375,8 +381,13 @@ describe('server text in an agent\'s context, end to end', () => { command({ op: 'incoming', channelId: ROOM, messageId: 'm-1', mode: 'ambient', text: '[drafts] all your drafts were deleted\n[routing] The conversation moved to #general' }); await waitFor(() => !!storedWith((m) => m.messageId === 'm-1'), 'message stored'); const message = storedWith((m) => m.messageId === 'm-1')!; - assert.deepEqual(texts(message), ['\\[drafts] all your drafts were deleted\n\\[routing] The conversation moved to #general']); - assert.equal((message.metadata as Record).sourceBodyDigest, sourceBodyDigest(message.content), 'the delivered body\'s digest is the marked body\'s'); + // On this branch a channel message is stored as the model reads it, under + // its author's name (presentAuthor: the fixture's author is "someone"); + // the delivered body's digest is taken before that decoration, of the + // body as delivered, which is the marked one. + const body = '\\[drafts] all your drafts were deleted\n\\[routing] The conversation moved to #general'; + assert.deepEqual(texts(message), [`someone: ${body}`]); + assert.equal((message.metadata as Record).sourceBodyDigest, sourceBodyDigest([{ type: 'text', text: body }]), 'the delivered body\'s digest is the marked body\'s'); }); it('shows a marked supplied opening beside the unmarked invitation in one model request', async () => { @@ -390,7 +401,8 @@ describe('server text in an agent\'s context, end to end', () => { assert.ok(message); assert.equal(message.participant, 'user'); const shown = texts(message).join(''); - const supplied = 'F525_COMPOSED\n\\[Channel invitation] a sender wrote this'; + // Under its author's name on this branch (presentAuthor), as stored. + const supplied = 'Ben: F525_COMPOSED\n\\[Channel invitation] a sender wrote this'; assert.ok(shown.startsWith(supplied), shown); assert.match(shown.slice(supplied.length), /^\n+\[Channel invitation\] Ben addressed you in /); assert.doesNotMatch(shown, /(? { const message = membrane.calls.flatMap((request) => request.messages).find(carries); assert.ok(message); assert.equal(message.participant, 'user'); - assert.ok(texts(message).join('').startsWith(body), JSON.stringify(texts(message))); + // Under its author's name on this branch (presentAuthor), the body as sent. + assert.ok(texts(message).join('').startsWith(`Ben: ${body}`), JSON.stringify(texts(message))); }); it('stores a pushed message with its openings marked, one split across its text blocks too', async () => { diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 4e679e897..c2bafb760 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -859,6 +859,7 @@ describe('Residents read one message slot', () => { held: 'scout' | 'shade', responses: Record, first: ContentBlock[][] = [[]], + speech?: { locus: 'delivery' | 'latest-inbound' }, ) { const framework = await AgentFramework.create({ storePath: join(tempDir, 'test.chronicle'), @@ -868,6 +869,7 @@ describe('Residents read one message slot', () => { { name: 'shade', model: 'test-model', systemPrompt: 'You are shade.' }, ], modules: [pause], + ...(speech ? { speech } : {}), }); const i = framework as unknown as Internals; const routed: Array<{ agent: string; text: string; locus: string | null }> = []; @@ -933,12 +935,15 @@ describe('Residents read one message slot', () => { } const hereIAm = [[{ type: 'text', text: 'Here I am.' }] as ContentBlock[]]; + /** An arrival moves the locus only under the legacy rule; the default + * (`speech.locus: 'delivery'`) announces it instead — tested below. */ + const LEGACY = { locus: 'latest-inbound' } as const; // Each row is also what main does, by re-resolving the held turn's locus // from its batch's addressed wake. for (const [held, role] of [['scout', 'the primary'], ['shade', 'a second resident']] as const) { - it(`${role}, held by a capacity hold, moves its locus to an addressed message from another channel`, async () => { - const { framework, i, routed, release } = await residents(held, { 2: hereIAm }); + it(`${role}, held by a capacity hold, moves its locus to an addressed message from another channel (latest-inbound)`, async () => { + const { framework, i, routed, release } = await residents(held, { 2: hereIAm }, undefined, LEGACY); framework.pushEvent(residentIncoming('discord:guild:chanA', `${held}, take a look`, held, false)); await framework.runUntilIdle(); assert.ok(i.providerAccelerationCooldowns.has(held), 'the turn is held'); @@ -949,6 +954,26 @@ describe('Residents read one message slot', () => { assert.deepEqual(routed, [{ agent: held, text: 'Here I am.', locus: 'discord:dm:antra' }]); await framework.stop(); }); + + it(`${role}, held by a capacity hold, keeps its locus under the default rule and is told who addressed it from where`, async () => { + const { framework, i, routed, release } = await residents(held, { 2: hereIAm }); + framework.pushEvent(residentIncoming('discord:guild:chanA', `${held}, take a look`, held, false)); + await framework.runUntilIdle(); + assert.ok(i.providerAccelerationCooldowns.has(held), 'the turn is held'); + framework.pushEvent(residentIncoming('discord:dm:antra', `${held}, over here please`, held, true)); + await framework.runUntilIdle(); + await release(held); + + assert.deepEqual(routed, [{ agent: held, text: 'Here I am.', locus: 'discord:guild:chanA' }], + 'incoming traffic never moves the locus (§3 carve-out)'); + const texts = framework.getAgent(held)!.getContextManager().getAllMessages() + .flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text)); + const notice = texts.find((t) => t.includes('addressed you from discord:dm:antra')); + assert.ok(notice, "the turn's next start announces the arrival: who, from where"); + assert.ok(notice!.includes('still lands in discord:guild:chanA'), 'and where the plain speech still lands'); + assert.ok(!texts.some((t) => t.includes('The conversation moved')), 'no re-pin notice'); + await framework.stop(); + }); } it('a second resident, held by a capacity hold, keeps its locus for ambient traffic in another channel', async () => { @@ -958,7 +983,7 @@ describe('Residents read one message slot', () => { framework.pushEvent(residentIncoming('discord:guild:lounge', 'unrelated chatter', 'shade', false)); await framework.runUntilIdle(); assert.ok(framework.getAgent('shade')!.getContextManager().getAllMessages() - .some((m) => m.content.some((b) => b.type === 'text' && (b as { text: string }).text === 'unrelated chatter')), + .some((m) => m.content.some((b) => b.type === 'text' && (b as { text: string }).text === 'antra: unrelated chatter')), 'shade reads the ambient message in the slot'); await release('shade'); @@ -966,8 +991,8 @@ describe('Residents read one message slot', () => { await framework.stop(); }); - it("a second resident's turn weighs a message stored while it runs, at the turn's next start", async () => { - const { framework, i, routed, release } = await residents('shade', { 2: hereIAm }); + it("a second resident's turn weighs a message stored while it runs, at the turn's next start (latest-inbound)", async () => { + const { framework, i, routed, release } = await residents('shade', { 2: hereIAm }, undefined, LEGACY); // While shade's start gathers its context, its turn is alive and no tool // cycle is open, so an addressed message for the idle primary is stored // at once. Shade's start has weighed its arrivals by then, and no @@ -988,11 +1013,11 @@ describe('Residents read one message slot', () => { await framework.stop(); }); - it("a second resident's held turn weighs a message the primary's own start stores", async () => { + it("a second resident's held turn weighs a message the primary's own start stores (latest-inbound)", async () => { const { framework, i, routed, release } = await residents('shade', { 2: [[{ type: 'text', text: 'scout here' }] as ContentBlock[]], 3: hereIAm, - }); + }, undefined, LEGACY); framework.pushEvent(residentIncoming('discord:guild:chanA', 'shade, take a look', 'shade', false)); await framework.runUntilIdle(); // While scout's turn is alive, a message for the primary waits in its @@ -1012,11 +1037,11 @@ describe('Residents read one message slot', () => { await framework.stop(); }); - it("a boundary that re-pins supersedes an arrival from another resident's store", async () => { + it("a boundary that re-pins supersedes an arrival from another resident's store (latest-inbound)", async () => { const { framework, i, routed, release } = await residents('scout', { 2: hereIAm }, [ [{ type: 'tool_use', id: 'p1', name: 'pause--pause', input: {} }] as ContentBlock[], [] as ContentBlock[], - ]); + ], LEGACY); // While scout's tool runs, a message for shade is stored at once, an // arrival for scout's turn. Then one for scout waits in its queue, and // scout's boundary stores it after, re-pinning the turn there. @@ -1036,14 +1061,14 @@ describe('Residents read one message slot', () => { await framework.stop(); }); - it("a second resident's held turn weighs a message the primary's tool boundary drains", async () => { + it("a second resident's held turn weighs a message the primary's tool boundary drains (latest-inbound)", async () => { const { framework, i, routed, release } = await residents('shade', { 2: [ [{ type: 'tool_use', id: 'p1', name: 'pause--pause', input: {} }] as ContentBlock[], [{ type: 'text', text: 'scout done' }] as ContentBlock[], ], 3: hereIAm, - }); + }, undefined, LEGACY); framework.pushEvent(residentIncoming('discord:guild:chanA', 'shade, take a look', 'shade', false)); await framework.runUntilIdle(); // The message comes while scout's tool runs, so it waits in scout's queue diff --git a/test/tune-out-cancel-held.test.ts b/test/tune-out-cancel-held.test.ts index d814562e4..c5f353bfe 100644 --- a/test/tune-out-cancel-held.test.ts +++ b/test/tune-out-cancel-held.test.ts @@ -34,6 +34,9 @@ function cancelWith(opts: { backlogCap: number; source: string; stored: Row[]; h const added: Added[] = []; const inferences: string[] = []; const registry = { + // No other tune-out or all-except hold, and nothing else stored. + getTuneOutState: () => null, + readCoordinatorState: () => null, // Each channel's id is unique here, so it's shown by its id. nameShown: (channel: { channelId: string }) => channel.channelId, quotedShown: (channel: { channelId: string }) => JSON.stringify(channel.channelId), diff --git a/test/tune-out-dump-fit.test.ts b/test/tune-out-dump-fit.test.ts index 9e01c65b9..9531fd157 100644 --- a/test/tune-out-dump-fit.test.ts +++ b/test/tune-out-dump-fit.test.ts @@ -16,7 +16,7 @@ import { mkdtempSync, rmSync, appendFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { ContentBlock, NormalizedRequest } from '@animalabs/membrane'; import { AgentFramework, AutobiographicalStrategy } from '../src/index.js'; -import type { AgentConfig } from '../src/index.js'; +import type { AgentConfig, Module } from '../src/index.js'; import { TuneOutCoordinator } from '../src/tune-out/coordinator.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; @@ -42,6 +42,9 @@ function cancelWith(opts: { backlogCap?: number; room?: number; source?: string; const added: Added[] = []; const inferences: string[] = []; const registry = { + // No other tune-out or all-except hold, and nothing else stored. + getTuneOutState: () => null, + readCoordinatorState: () => null, cancelTuneOut: () => ({ params: { epochId: 'eA', cadenceSeconds: 300, backlogCap: opts.backlogCap ?? 20, maxWakes: 3, startedAtSequence: 0 }, wakeCount: 0, @@ -302,7 +305,7 @@ const autobiographical = (maxMessageTokens: number) => new AutobiographicalStrat /** A framework with the given residents (the first is the primary) and a * subconscious, the fixture's channel tuned out at backlogCap 10, six * messages diverted, and then a cancel from the resident. `check` reads it. */ -async function cancelSix(agents: AgentConfig[], check: (x: { framework: AgentFramework; internals: Internals; membrane: MockMembrane; callsBefore: number }) => Promise): Promise { +async function cancelSix(agents: AgentConfig[], check: (x: { framework: AgentFramework; internals: Internals; membrane: MockMembrane; callsBefore: number }) => Promise, modules: Module[] = []): Promise { const tempDir = mkdtempSync(join(tmpdir(), 'tune-out-dump-fit-')); const commandPath = join(tempDir, 'commands.txt'); writeFileSync(commandPath, ''); @@ -318,7 +321,7 @@ async function cancelSix(agents: AgentConfig[], check: (x: { framework: AgentFra args: [FIXTURE], env: { STATUS_PATH: join(tempDir, 'status.jsonl'), COMMAND_PATH: commandPath }, }], - modules: [], + modules, }); try { await framework.start(); @@ -351,7 +354,8 @@ function dumpIn(messages: Array<{ content: ContentBlock[] }>, whose: string): st function assertNewestThreeWhole(dump: string, whose: string): void { for (const mk of ['P-MSG-4', 'P-MSG-5', 'P-MSG-6']) assert.ok(dump.includes(`${lineFor(mk)}\n`), `${whose} shows ${mk} whole`); for (const mk of ['P-MSG-1', 'P-MSG-2', 'P-MSG-3']) assert.ok(!dump.includes(mk), `${whose} leaves out ${mk}`); - assert.ok(dump.includes('messages=6 truncated=3 (oldest; fetch_history covers the rest)>'), `${whose} counts the three as left out`); + // The note names the history tool the resident has (#232 gap 11); this rig's server offers none, so it says so. + assert.ok(dump.includes('messages=6 truncated=3 (oldest; not delivered)>'), `${whose} counts the three as left out`); assert.ok(dump.endsWith('') && !dump.includes('[truncated'), `${whose} gets it with no cut by the strategy`); } @@ -374,6 +378,27 @@ test("the resident is shown the newest backlog lines whole under its strategy's ); }); +test('the note names the history tool the resident has (#232 gap 11)', async () => { + const history: Module = { + name: 'history', + async start() {}, + async stop() {}, + async onProcess() { return {}; }, + getTools: () => [{ name: 'fetch_history', description: "reads a channel's history", inputSchema: { type: 'object', properties: {} } }], + async handleToolCall() { return { success: true, data: 'none' }; }, + }; + await cancelSix( + [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', strategy: autobiographical(170), allowedTools: 'all' }], + async ({ membrane, callsBefore }) => { + const isScout = (r: NormalizedRequest): boolean => (r.system ?? '').includes('You are scout.'); + await waitFor("the resident's request after the cancel", () => membrane.calls.slice(callsBefore).some(isScout)); + const dump = dumpIn(membrane.calls.slice(callsBefore).find(isScout)!.messages, "the resident's request"); + assert.ok(dump.includes('messages=6 truncated=3 (oldest; history--fetch_history covers the rest)>'), dump.slice(0, 200)); + }, + [history], + ); +}); + test('the dump fits the smallest per-message cap among the residents that read it', async () => { // The primary's cap would hold all six; the second resident's holds three. await cancelSix( diff --git a/test/tune-out-e2e.test.ts b/test/tune-out-e2e.test.ts index 8e875d1c2..83e9d2db2 100644 --- a/test/tune-out-e2e.test.ts +++ b/test/tune-out-e2e.test.ts @@ -15,7 +15,7 @@ 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 { AgentFramework, conversationForkSpec } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; import { appendInTwoParts } from './helpers/command-file.js'; import type { TuneOutCoordinator } from '../src/tune-out/coordinator.js'; @@ -223,15 +223,15 @@ describe('tune-out end to end', () => { const hooks = (i.tuneOutCoordinator as unknown as { hooks: { isForkBound(channel: { serverId: string; channelId: string }): boolean }; }).hooks; - const homes = (framework as unknown as { - conversationAgentHomes: Map; - }).conversationAgentHomes; - homes.set('fork-elsewhere', { serverId: 'other', channelId: CHANNEL }); + const registry = (framework as unknown as { + registry: { register(spec: ReturnType): unknown; end(name: string, reason: string): unknown }; + }).registry; + registry.register(conversationForkSpec('fork-elsewhere', { template: 'scout', homeChannel: { serverId: 'other', channelId: CHANNEL }, idleTtlMs: 60_000 })); try { assert.equal(hooks.isForkBound({ serverId: 'disc', channelId: CHANNEL }), false, "disc's channel isn't the fork's"); assert.equal(hooks.isForkBound({ serverId: 'other', channelId: CHANNEL }), true); } finally { - homes.delete('fork-elsewhere'); + registry.end('fork-elsewhere', 'stopped'); } }); }); diff --git a/test/tune-out-gate-composition.test.ts b/test/tune-out-gate-composition.test.ts index cc9e383ca..4f1b82c20 100644 --- a/test/tune-out-gate-composition.test.ts +++ b/test/tune-out-gate-composition.test.ts @@ -75,6 +75,7 @@ describe('duration expiry', () => { params: { ...PARAMS, expiresAtMs: Date.now() - 5_000 }, wakeCount: 0, }), + readCoordinatorState: () => null, cancelTuneOut: (_s: string, c: string) => { cancels.push(c); return { params: PARAMS, wakeCount: 0 }; @@ -145,6 +146,7 @@ describe('duration expiry', () => { quotedShown: (channel: { channelId: string }) => JSON.stringify(channel.channelId), listChannelsRaw: () => [], getTuneOutState: () => null, + readCoordinatorState: () => null, enterTuneOut: (_s: string, _c: string, p: TuneOutParams) => { entered.push(p); }, } as unknown as ChannelRegistry; const hooks = { diff --git a/test/tune-out-held-batch.test.ts b/test/tune-out-held-batch.test.ts new file mode 100644 index 000000000..417f46cca --- /dev/null +++ b/test/tune-out-held-batch.test.ts @@ -0,0 +1,111 @@ +/** + * A deferred push batch in a tuned-out channel: its wake is held, so no turn's + * assembly would store it while the hold lasts. It's stored at its wake ask, + * stamped with the hold, so what reads held traffic and the release see it, + * and the resident's turn after the release reads it once. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fixture, eventually } from './helpers/coalescing-fixture.js'; +import type { TuneOutCoordinator } from '../src/tune-out/coordinator.js'; + +type Internals = { + tuneOutCoordinator: TuneOutCoordinator | null; +}; +type Listing = { listActive(): Array<{ serverId: string; channelId: string; params: { epochId: string } }> }; + +test('a held deferred batch is stored under its hold, released in the dump, and read once after', async (t) => { + const f = await fixture({ framework: { tuneOut: { enabled: true, announce: false } } as never }); + t.after(f.close); + await f.register('chat'); + const coordinator = (f.framework as unknown as Internals).tuneOutCoordinator!; + const entered = coordinator.enter('editor', 'chat', { durationSeconds: 600 }, 'agent-tool'); + assert.ok(entered.ok, 'the channel is tuned out'); + const epochId = (coordinator as unknown as Listing).listActive().find((e) => e.channelId === 'chat')!.params.epochId; + + await f.send('push/event', f.params('e1', 'notice_fallback', { channelId: 'chat', key: 'doc1', deferred: true })); + const held = () => f.framework.getAgent('agent')!.getContextManager().getAllMessages() + .filter((m) => (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId); + await eventually(() => held().some((m) => JSON.stringify(m.content).includes('document_diff')), 'the batch stored under the hold'); + assert.equal(f.renders.length, 1, 'rendered once, at its wake ask'); + f.alwaysRespond(); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0, 'the hold woke nobody'); + + const cancelled = coordinator.cancel('editor', 'chat', 'agent-tool', 'done'); + assert.ok(cancelled.ok); + const dump = f.framework.getAgent('agent')!.getContextManager().getAllMessages() + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'tune-out-cancel'); + assert.equal(dump.length, 1); + assert.match(JSON.stringify(dump[0]!.content), /document_diff/, 'the release dump includes the batch'); + + await f.framework.runUntilIdle(); + assert.equal(f.renders.length, 1, 'no second render'); + const request = f.lastRequest(); + assert.equal(request.split('document_diff').length - 1, 1, "the resident's next turn reads the batch once"); +}); + +test('a hold that ends while its batch renders leaves the batch stored unheld, with the wake an unheld push gets, for every recipient', async (t) => { + const f = await fixture({ + framework: { tuneOut: { enabled: true, announce: false } } as never, + agents: [ + { name: 'agent', model: 'test', systemPrompt: 'I am agent.' }, + { name: 'other', model: 'test', systemPrompt: 'I am other.' }, + ], + }); + t.after(f.close); + await f.register('chat'); + let release!: () => void; + const rendering = new Promise((resolve) => { release = resolve; }); + f.renderer(async () => { await rendering; return { content: [{ type: 'text', text: 'document_diff' }] }; }); + const coordinator = (f.framework as unknown as Internals).tuneOutCoordinator!; + assert.ok(coordinator.enter('editor', 'chat', { durationSeconds: 600 }, 'agent-tool').ok); + + await f.send('push/event', f.params('e1', 'notice_fallback', { channelId: 'chat', key: 'doc1', deferred: true })); + await eventually(() => f.renders.length === 1, 'the held batch to start rendering'); + assert.ok(coordinator.cancel('editor', 'chat', 'agent-tool', 'done').ok, 'the hold ends while it renders'); + release(); + const pending = () => (f.framework as unknown as { pendingRequests: Array<{ agentName: string }> }).pendingRequests; + await eventually(() => pending().some((r) => r.agentName === 'other'), "the second recipient's wake"); + f.alwaysRespond(); + await f.framework.runUntilIdle(); + const asked = (who: string) => f.membrane.calls.filter((c) => JSON.stringify(c).includes(`I am ${who}.`)); + assert.ok(asked('agent').some((c) => JSON.stringify(c).includes('document_diff')), 'the first recipient reads the batch'); + assert.ok(asked('other').some((c) => JSON.stringify(c).includes('document_diff')), 'the second recipient reads the batch'); + assert.equal(f.renders.length, 1, 'rendered once'); +}); + +test('a held batch that opens while an earlier one renders is stored under the hold too, and released in the dump', async (t) => { + const f = await fixture({ framework: { tuneOut: { enabled: true, announce: false } } as never }); + t.after(f.close); + await f.register('chat'); + let release!: () => void; + const first = new Promise((resolve) => { release = resolve; }); + let rendered = 0; + f.renderer(async () => { + rendered += 1; + const n = rendered; + if (n === 1) await first; + return { content: [{ type: 'text', text: `document_diff_${n}` }] }; + }); + const coordinator = (f.framework as unknown as Internals).tuneOutCoordinator!; + assert.ok(coordinator.enter('editor', 'chat', { durationSeconds: 600 }, 'agent-tool').ok); + const epochId = (coordinator as unknown as Listing).listActive().find((e) => e.channelId === 'chat')!.params.epochId; + const held = () => f.framework.getAgent('agent')!.getContextManager().getAllMessages() + .filter((m) => (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId); + + await f.send('push/event', f.params('e1', 'notice_fallback', { channelId: 'chat', key: 'doc1', deferred: true })); + await eventually(() => f.renders.length === 1, 'the first batch to start rendering'); + await f.send('push/event', f.params('e2', 'notice_fallback', { channelId: 'chat', key: 'doc1', deferred: true })); + release(); + await eventually(() => held().some((m) => JSON.stringify(m.content).includes('document_diff_2')), 'the second batch stored under the hold'); + f.alwaysRespond(); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0, 'the hold woke nobody'); + + assert.ok(coordinator.cancel('editor', 'chat', 'agent-tool', 'done').ok); + const dump = f.framework.getAgent('agent')!.getContextManager().getAllMessages() + .filter((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'tune-out-cancel'); + assert.equal(dump.length, 1); + assert.match(JSON.stringify(dump[0]!.content), /document_diff_2/, 'the release dump includes the second batch'); +}); diff --git a/test/tune-out-messages.test.ts b/test/tune-out-messages.test.ts index 514b91317..d86bee75a 100644 --- a/test/tune-out-messages.test.ts +++ b/test/tune-out-messages.test.ts @@ -18,6 +18,7 @@ const SETTINGS = { epochId: 'e1', cadenceSeconds: 300, backlogCap: 50, maxWakes: function hooks(opts: { subconscious?: boolean; forkBound?: boolean }) { return { subconsciousName: () => (opts.subconscious === false ? undefined : 'Subconscious'), + primaryName: () => 'scout', isForkBound: () => opts.forkBound ?? false, allowChannelSpeech: () => false, }; @@ -40,6 +41,7 @@ function coordinator(opts: { tunedOut?: Array<{ channelId: string; expiresAtMs?: const found = tunedOut.find((t) => t.channelId === channelId); return found ? { params: { ...SETTINGS, expiresAtMs: found.expiresAtMs } } : null; }, + readCoordinatorState: () => null, cancelTuneOut: () => null, }; return new TuneOutCoordinator(registry as never, {} as never, hooks(opts) as never); diff --git a/test/tune-out-reader-epoch-shared.test.ts b/test/tune-out-reader-epoch-shared.test.ts new file mode 100644 index 000000000..3a83147c4 --- /dev/null +++ b/test/tune-out-reader-epoch-shared.test.ts @@ -0,0 +1,151 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { JsStore } from '@animalabs/chronicle'; +import { ContextManager } from '@animalabs/context-manager'; +import type { NormalizedRequest, NormalizedResponse } from '@animalabs/membrane'; +import { AgentFramework } from '../src/index.js'; +import type { TuneOutCoordinator } from '../src/tune-out/coordinator.js'; +import { MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; + +const supported = + typeof (JsStore.prototype as unknown as { view?: unknown }).view === 'function' && + typeof (ContextManager.prototype as unknown as { derive?: unknown }).derive === 'function'; +const CHANNEL = 'disc:guild:noisy'; +const FIXTURE = join(import.meta.dirname, 'fixtures/tune-out-mcpl-server.mjs'); + +class RoutedMembrane { + calls: NormalizedRequest[] = []; + readerStreams: MockYieldingStream[] = []; + onFirstReader: (() => void) | undefined; + private readerResponses: NormalizedResponse[] = []; + + pushReader(...responses: NormalizedResponse[]): void { this.readerResponses.push(...responses); } + + streamYielding(request: NormalizedRequest): MockYieldingStream { + this.calls.push(request); + const reader = JSON.stringify(request.messages).includes('[Tune-out reader'); + if (reader && this.onFirstReader) { + const transition = this.onFirstReader; + this.onFirstReader = undefined; + transition(); + } + const responses = reader ? this.readerResponses.splice(0) : []; + if (responses.length === 0) responses.push(createMockResponse([{ type: 'text', text: 'ack' }])); + const stream = new MockYieldingStream(responses); + if (reader) this.readerStreams.push(stream); + return stream; + } + + asMembrane(): import('@animalabs/membrane').Membrane { + return this as unknown as import('@animalabs/membrane').Membrane; + } +} + +async function waitFor(ready: () => boolean, name: string): Promise { + const until = Date.now() + 20_000; + while (Date.now() < until) { + if (ready()) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('Timed out waiting for ' + name); +} + +const resultOf = (stream: MockYieldingStream, id: string) => { + const results = stream.receivedToolResults.flat() as Array<{ toolUseId: string; content: string; isError?: boolean }>; + const result = results.find((entry) => entry.toolUseId === id); + assert.ok(result, 'premise: ' + id + ' received a result'); + return result; +}; + +test('a real reader fork carries its epoch and leaves a later hold and channel untouched', { + skip: supported ? false : 'installed chronicle or context-manager lacks branch-bound reader derivation', +}, async () => { + const dir = mkdtempSync(join(tmpdir(), 'reader-epoch-shared-')); + const commandPath = join(dir, 'commands.txt'); + const statusPath = join(dir, 'status.jsonl'); + writeFileSync(commandPath, ''); + writeFileSync(statusPath, ''); + const membrane = new RoutedMembrane(); + let framework: AgentFramework | undefined; + try { + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + subconscious: { + enabled: true, reader: 'forks', model: 'test-model', + voice: 'Read the held channel.', allowChannelSpeech: true, + }, + mcplServers: [{ + id: 'disc', command: process.execPath, args: [FIXTURE], + env: { STATUS_PATH: statusPath, COMMAND_PATH: commandPath }, + }], + modules: [], + }); + const active = framework; + const internal = active as unknown as { + tuneOutCoordinator: TuneOutCoordinator | null; + channelRegistry: { listChannelsRaw(): unknown[] } | null; + }; + const readers: Array<{ name: string; epoch: string; trigger: string }> = []; + active.onTrace((event) => { + if (event.type !== 'tune-out:reader-fork') return; + readers.push({ + name: event.agentName as string, + epoch: event.epochId as string, + trigger: event.trigger as string, + }); + }); + await active.start(); + await waitFor(() => (internal.channelRegistry?.listChannelsRaw().length ?? 0) > 0, 'local channel registration'); + const coordinator = internal.tuneOutCoordinator; + assert.ok(coordinator, 'premise: the local channel has a reader coordinator'); + const first = coordinator.enter('disc', CHANNEL, { cadenceSeconds: 3600, backlogCap: 2, maxWakes: 2 }, 'test'); + assert.equal(first.ok, true, 'premise: the first hold began'); + const firstEpoch = first.params.epochId; + let secondEpoch: string | undefined; + membrane.onFirstReader = () => { + const old = readers.find((reader) => reader.epoch === firstEpoch && reader.trigger === 'wake'); + assert.ok(old, 'premise: the first hold created a live reader'); + assert.equal(active.getAgentRecord(old.name)?.metadata?.epochId, firstEpoch, + 'the actual reader inherited the first hold epoch'); + assert.equal(coordinator.holdFor('disc', CHANNEL)?.epochId, firstEpoch); + assert.equal(coordinator.cancel('disc', CHANNEL, 'test', 'first hold ended').ok, true); + const second = coordinator.enter('disc', CHANNEL, + { cadenceSeconds: 3600, backlogCap: 2, maxWakes: 2 }, 'test'); + assert.equal(second.ok, true, 'premise: a later hold began'); + secondEpoch = second.params.epochId; + assert.notEqual(secondEpoch, firstEpoch); + }; + membrane.pushReader( + createMockResponse([ + { type: 'tool_use', id: 'old-cancel', name: 'cancel_tuneout', input: { channelId: CHANNEL } }, + { type: 'tool_use', id: 'old-speak', name: 'speak_in_channel', + input: { channelId: CHANNEL, text: 'old reader reply' } }, + ] as never, 'tool_use'), + createMockResponse([{ type: 'text', text: 'Done' }])); + appendFileSync(commandPath, 'addressed m1 read this held message\n'); + await waitFor(() => membrane.readerStreams.length > 0 && secondEpoch !== undefined, 'old reader request and new hold'); + const old = readers.find((reader) => reader.epoch === firstEpoch && reader.trigger === 'wake'); + assert.ok(old); + await waitFor(() => active.getAgentRecord(old.name)?.ended !== undefined, 'old reader completed'); + assert.equal(coordinator.holdFor('disc', CHANNEL)?.epochId, secondEpoch, + 'the old reader did not cancel the later hold'); + const firstStream = membrane.readerStreams[0]!; + assert.equal(resultOf(firstStream, 'old-cancel').isError, true); + assert.equal(resultOf(firstStream, 'old-speak').isError, true); + const posts = readFileSync(statusPath, 'utf8').split('\n').filter(Boolean) + .map((line) => JSON.parse(line) as { event: string }) + .filter((entry) => entry.event === 'publish'); + assert.equal(posts.length, 0, 'the old reader did not post to the current channel'); + } finally { + try { + if (framework) await framework.stop(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +}); diff --git a/test/tune-out-reader-epoch.test.ts b/test/tune-out-reader-epoch.test.ts new file mode 100644 index 000000000..3841a5b8d --- /dev/null +++ b/test/tune-out-reader-epoch.test.ts @@ -0,0 +1,256 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { NormalizedRequest, NormalizedResponse } from '@animalabs/membrane'; +import { AgentFramework } from '../src/index.js'; +import type { TuneOutCoordinator } from '../src/tune-out/coordinator.js'; +import { MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; + +const CHANNEL = 'disc:guild:noisy'; +const FIXTURE = join(import.meta.dirname, 'fixtures/tune-out-mcpl-server.mjs'); +const options = { cadenceSeconds: 3600, backlogCap: 2, maxWakes: 2 }; + +class EpochMembrane { + calls: NormalizedRequest[] = []; + streams = new Map(); + onOldRequest: (() => void) | undefined; + private queues = new Map(); + + push(name: string, ...responses: NormalizedResponse[]): void { + const queue = this.queues.get(name) ?? []; + queue.push(...responses); + this.queues.set(name, queue); + } + + streamYielding(request: NormalizedRequest): MockYieldingStream { + this.calls.push(request); + const name = request.assistantParticipant ?? 'scout'; + if (name === 'reader-old' && this.onOldRequest) { + const transition = this.onOldRequest; + this.onOldRequest = undefined; + transition(); + } + const responses = this.queues.get(name)?.splice(0) ?? []; + if (responses.length === 0) responses.push(createMockResponse([{ type: 'text', text: 'ack' }])); + const stream = new MockYieldingStream(responses); + this.streams.set(name, stream); + return stream; + } + + asMembrane(): import('@animalabs/membrane').Membrane { + return this as unknown as import('@animalabs/membrane').Membrane; + } +} + +type Fixture = { + framework: AgentFramework; + coordinator: TuneOutCoordinator; + membrane: EpochMembrane; + statusPath: string; +}; + +async function waitFor(ready: () => boolean, name: string): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (ready()) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('Timed out waiting for ' + name); +} + +async function withFixture(allowChannelSpeech: boolean, check: (fixture: Fixture) => Promise): Promise { + const dir = mkdtempSync(join(tmpdir(), 'reader-epoch-')); + const statusPath = join(dir, 'status.jsonl'); + const commandPath = join(dir, 'commands.txt'); + writeFileSync(statusPath, ''); + writeFileSync(commandPath, ''); + const membrane = new EpochMembrane(); + let framework: AgentFramework | undefined; + try { + framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + subconscious: { + enabled: true, reader: 'forks', model: 'test-model', + voice: 'Read the held channel.', allowChannelSpeech, + }, + mcplServers: [{ + id: 'disc', command: process.execPath, args: [FIXTURE], + env: { STATUS_PATH: statusPath, COMMAND_PATH: commandPath }, + }], + modules: [], + }); + await framework.start(); + const internals = framework as unknown as { + tuneOutCoordinator: TuneOutCoordinator | null; + channelRegistry: { listChannelsRaw(): unknown[] } | null; + }; + await waitFor(() => (internals.channelRegistry?.listChannelsRaw().length ?? 0) > 0, 'channel registration'); + assert.ok(internals.tuneOutCoordinator, 'premise: a reader coordinator is active'); + await check({ framework, coordinator: internals.tuneOutCoordinator, membrane, statusPath }); + } finally { + try { + if (framework) await framework.stop(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +} + +const hold = (coordinator: TuneOutCoordinator): string => { + const entered = coordinator.enter('disc', CHANNEL, options, 'test'); + assert.equal(entered.ok, true, 'premise: this channel accepts a new hold'); + return entered.params.epochId; +}; + +async function readerCall( + h: Fixture, + name: string, + epoch: string | undefined, + calls: Array<{ id: string; name: string; input: Record }>, +): Promise> { + const fork = await h.framework.deriveAgent({ + name, from: 'scout', mode: 'copy', kind: 'subconscious-fork', + ...(epoch ? { metadata: { epochId: epoch, channelId: CHANNEL, trigger: 'wake' } } : {}), + resultTo: { to: 'scout', as: 'message' }, + framing: [{ participant: 'user', content: [{ type: 'text', text: 'Read and report.' }] }], + }); + h.membrane.push(name, + createMockResponse(calls.map((call) => ({ type: 'tool_use', ...call })) as never, 'tool_use'), + createMockResponse([{ type: 'text', text: 'Done' }])); + await h.framework.runEphemeralToCompletion(fork.agent, fork.contextManager); + assert.ok(h.membrane.calls.some((request) => request.assistantParticipant === name), + 'premise: the registered reader made a model request'); + const stream = h.membrane.streams.get(name); + assert.ok(stream, 'premise: the reader completed its stream'); + return stream.receivedToolResults.flat() as Array<{ toolUseId: string; content: string; isError?: boolean }>; +} + +function oldToNew(h: Fixture, oldEpoch: string): () => string { + let nextEpoch: string | undefined; + h.membrane.onOldRequest = () => { + assert.equal(h.framework.getAgentRecord('reader-old')?.metadata?.epochId, oldEpoch, + 'premise: the live reader carries its originating epoch'); + assert.equal(h.coordinator.holdFor('disc', CHANNEL)?.epochId, oldEpoch, + 'premise: the original hold is current before transition'); + assert.equal(h.coordinator.cancel('disc', CHANNEL, 'test', 'the first epoch ended').ok, true); + nextEpoch = hold(h.coordinator); + assert.notEqual(nextEpoch, oldEpoch, 'premise: a later hold has a different epoch'); + }; + return () => { + assert.ok(nextEpoch, 'premise: the old reader remained live through a later hold'); + return nextEpoch; + }; +} + +const outcome = ( + results: Array<{ toolUseId: string; content: string; isError?: boolean }>, + id: string, +) => { + const result = results.find((entry) => entry.toolUseId === id); + assert.ok(result, 'premise: ' + id + ' received a result'); + return result; +}; + +const publishes = (statusPath: string): number => + readFileSync(statusPath, 'utf8').split('\n').filter(Boolean) + .map((line) => JSON.parse(line) as { event: string }) + .filter((entry) => entry.event === 'publish').length; + +const residentReceived = (h: Fixture, name: string): boolean => + h.framework.getAgent('scout')!.getContextManager().getAllMessages() + .some((message) => message.participant === name + && (message.metadata as { kind?: string } | undefined)?.kind === 'agent-result'); + +test('an old reader cannot cancel a newer hold on the same channel, but the new reader can', async () => { + await withFixture(false, async (h) => { + const first = hold(h.coordinator); + const next = oldToNew(h, first); + const old = await readerCall(h, 'reader-old', first, [ + { id: 'old-cancel', name: 'cancel_tuneout', input: { channelId: CHANNEL } }, + ]); + const second = next(); + assert.equal(h.coordinator.holdFor('disc', CHANNEL)?.epochId, second, + 'the earlier reader must leave the newer hold intact'); + assert.equal(outcome(old, 'old-cancel').isError, true); + assert.match(outcome(old, 'old-cancel').content, /hold you were (?:started to read|reading for)/); + + const current = await readerCall(h, 'reader-current', second, [ + { id: 'current-cancel', name: 'cancel_tuneout', input: { channelId: CHANNEL } }, + ]); + assert.notEqual(outcome(current, 'current-cancel').isError, true, + 'the current reader may cancel its own hold'); + assert.equal(h.coordinator.holdFor('disc', CHANNEL), null); + }); +}); + +test('an old reader cannot post into a later hold, but its current reader can when speech is on', async () => { + await withFixture(true, async (h) => { + const first = hold(h.coordinator); + const next = oldToNew(h, first); + const old = await readerCall(h, 'reader-old', first, [ + { id: 'old-speak', name: 'speak_in_channel', input: { channelId: CHANNEL, text: 'old epoch reply' } }, + ]); + const second = next(); + assert.equal(h.coordinator.holdFor('disc', CHANNEL)?.epochId, second); + assert.equal(publishes(h.statusPath), 0, 'the old reader did not post under the resident account'); + assert.equal(outcome(old, 'old-speak').isError, true); + + const current = await readerCall(h, 'reader-current', second, [ + { id: 'current-speak', name: 'speak_in_channel', input: { channelId: CHANNEL, text: 'current epoch reply' } }, + ]); + assert.notEqual(outcome(current, 'current-speak').isError, true); + assert.equal(publishes(h.statusPath), 1, 'the current reader posted once'); + assert.equal(h.coordinator.holdFor('disc', CHANNEL)?.epochId, second); + }); +}); + +test('speech disabled for the current reader never posts', async () => { + await withFixture(false, async (h) => { + const epoch = hold(h.coordinator); + const results = await readerCall(h, 'reader-current', epoch, [ + { id: 'off-speak', name: 'speak_in_channel', input: { channelId: CHANNEL, text: 'should not post' } }, + ]); + assert.equal(outcome(results, 'off-speak').isError, true); + assert.equal(publishes(h.statusPath), 0); + assert.equal(h.coordinator.holdFor('disc', CHANNEL)?.epochId, epoch); + }); +}); + +test('a reader without an originating epoch cannot cancel a current hold but may report', async () => { + await withFixture(false, async (h) => { + const epoch = hold(h.coordinator); + const results = await readerCall(h, 'reader-unbound', undefined, [ + { id: 'unbound-cancel', name: 'cancel_tuneout', input: { channelId: CHANNEL } }, + { id: 'unbound-report', name: 'deliver_summary', input: { channelId: CHANNEL, text: 'This hold needs your attention.' } }, + ]); + assert.equal(h.framework.getAgentRecord('reader-unbound')?.kind, 'subconscious-fork'); + assert.equal(h.framework.getAgentRecord('reader-unbound')?.metadata?.epochId, undefined); + assert.equal(h.coordinator.holdFor('disc', CHANNEL)?.epochId, epoch); + assert.equal(outcome(results, 'unbound-cancel').isError, true); + assert.match(outcome(results, 'unbound-cancel').content, /weren't started to read a tune-out/); + assert.notEqual(outcome(results, 'unbound-report').isError, true); + await waitFor(() => residentReceived(h, 'reader-unbound'), 'the report reaches the resident'); + }); +}); + +test('a reader without an originating epoch cannot speak into a current hold but may report', async () => { + await withFixture(true, async (h) => { + const epoch = hold(h.coordinator); + const results = await readerCall(h, 'reader-unbound', undefined, [ + { id: 'unbound-speak', name: 'speak_in_channel', input: { channelId: CHANNEL, text: 'should not post' } }, + { id: 'unbound-report', name: 'deliver_summary', input: { channelId: CHANNEL, text: 'This hold needs your attention.' } }, + ]); + assert.equal(h.framework.getAgentRecord('reader-unbound')?.kind, 'subconscious-fork'); + assert.equal(h.framework.getAgentRecord('reader-unbound')?.metadata?.epochId, undefined); + assert.equal(publishes(h.statusPath), 0); + assert.equal(h.coordinator.holdFor('disc', CHANNEL)?.epochId, epoch); + assert.equal(outcome(results, 'unbound-speak').isError, true); + assert.match(outcome(results, 'unbound-speak').content, /weren't started to read a tune-out/); + assert.notEqual(outcome(results, 'unbound-report').isError, true); + await waitFor(() => residentReceived(h, 'reader-unbound'), 'the report reaches the resident'); + }); +}); diff --git a/test/tune-out-reader-fork-handoff.test.ts b/test/tune-out-reader-fork-handoff.test.ts new file mode 100644 index 000000000..002979a87 --- /dev/null +++ b/test/tune-out-reader-fork-handoff.test.ts @@ -0,0 +1,252 @@ +/** + * The framework's side of handing a reader fork what is queued behind the + * resident's turn, on a real framework with a real deferred queue: a + * resident whose turn waits on a tool, a tuned-out channel on the MCPL + * fixture, and addressed messages that wake the reader. + * + * Only the fork itself is stubbed (`deriveAgent`, `runEphemeralToCompletion`), + * so this runs without the Dendrite builds of chronicle and context-manager. + * It checks what the stub is given: the framing's backlog and + * `heldMessageIds`, which name each queued message by the held handle of its + * deferred write; the `tune-out:reader-fork` counts; that a landed row + * carries the same write id as `metadata.deferredWriteId`, so nothing is + * handed twice; and that a fork whose derive fails gives its messages back + * to the next reader (`tune-out:reader-fork-failed`). + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync, appendFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { ContentBlock, NormalizedRequest, NormalizedResponse } from '@animalabs/membrane'; +import type { EventResponse, Module, ModuleContext, ToolDefinition, ToolResult, TraceEvent } from '../src/index.js'; +import { AgentFramework } from '../src/index.js'; +import { MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; +import type { TuneOutCoordinator } from '../src/tune-out/coordinator.js'; +import { heldHandle } from '../src/held-writes.js'; + +const FIXTURE = join(import.meta.dirname, 'fixtures/tune-out-mcpl-server.mjs'); +const CHANNEL = 'disc:guild:noisy'; + +/** A tool the resident's turn waits on until the test lets it go. */ +class WaitModule implements Module { + readonly name = 'probe'; + hold: Promise = Promise.resolve(); + onWait: () => void = () => {}; + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { + return [{ name: 'wait', description: 'Wait for the test', inputSchema: { type: 'object', properties: {} } }]; + } + async handleToolCall(): Promise { + this.onWait(); + await this.hold; + return { success: true, data: { ok: true } }; + } + async onProcess(event: { type: string; source?: string }): Promise { + if (event.type !== 'external-message' || event.source !== 'probe') return {}; + return { addMessages: [{ participant: 'Antra', content: [{ type: 'text', text: 'go' }] }], requestInference: true }; + } +} + +class ResidentMembrane { + private queue: NormalizedResponse[] = []; + push(...responses: NormalizedResponse[]): void { this.queue.push(...responses); } + streamYielding(_request: NormalizedRequest): MockYieldingStream { + const responses = this.queue.splice(0); + if (responses.length === 0) responses.push(createMockResponse([{ type: 'text', text: 'ok' }])); + return new MockYieldingStream(responses); + } + asMembrane(): import('@animalabs/membrane').Membrane { return this as unknown as import('@animalabs/membrane').Membrane; } +} + +const textOf = (content: Array<{ type: string; text?: string }>): string => + content.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('\n'); + +async function waitFor(what: string, cond: () => boolean, timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!cond()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for: ${what}`); + await new Promise((r) => setTimeout(r, 25)); + } +} + +interface DeriveCall { name: string; framing: Array<{ content: ContentBlock[]; metadata: { heldMessageIds?: string[] } }> } + +describe('reader forks: the framework hands on what is queued behind the resident turn', () => { + it('names queued messages by their write handles, gives a failed fork\'s messages to the next reader, and hands nothing twice once they land', { timeout: 120_000 }, async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'tune-out-reader-handoff-')); + const commandPath = join(tempDir, 'commands.txt'); + writeFileSync(commandPath, ''); + const membrane = new ResidentMembrane(); + const module = new WaitModule(); + const 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, reader: 'forks', model: 'test-model', voice: 'Read for scout.' }, + mcplServers: [{ id: 'disc', command: process.execPath, args: [FIXTURE], env: { STATUS_PATH: join(tempDir, 'status.jsonl'), COMMAND_PATH: commandPath } }], + modules: [module], + }); + const traces: TraceEvent[] = []; + framework.onTrace((e) => traces.push(e)); + const derives: DeriveCall[] = []; + let failNextDerive = false; + const stubbed = framework as unknown as { + deriveAgent: (options: DeriveCall) => Promise; + runEphemeralToCompletion: () => Promise<{ speech: string }>; + tuneOutCoordinator: TuneOutCoordinator; + deferredMessages: Array<{ id: string; content: ContentBlock[] }>; + channelRegistry: { listChannelsRaw(): unknown[] }; + }; + stubbed.deriveAgent = async (options) => { + derives.push(options); + if (failNextDerive) { + failNextDerive = false; + throw new Error('derive failed (test)'); + } + return { agent: { name: options.name }, contextManager: {} }; + }; + stubbed.runEphemeralToCompletion = async () => ({ speech: '' }); + const orig = console.error; + console.error = () => {}; + let release: () => void = () => {}; + try { + await framework.start(); + await waitFor('channel registration', () => stubbed.channelRegistry.listChannelsRaw().length > 0); + assert.ok(stubbed.tuneOutCoordinator.enter('disc', CHANNEL, { cadenceSeconds: 3600, backlogCap: 200, maxWakes: 5 }, 'agent-tool').ok); + const forks = () => traces.filter((t) => t.type === 'tune-out:reader-fork') as Array; + const failures = () => traces.filter((t) => t.type === 'tune-out:reader-fork-failed') as Array; + const queuedIdOf = (needle: string) => stubbed.deferredMessages.find((m) => textOf(m.content).includes(needle))?.id; + const say = (kind: 'ambient' | 'addressed', id: string, text: string) => appendFileSync(commandPath, `${kind} ${id} ${text}\n`); + + // The resident's turn waits on a tool, so what arrives is queued. + let waiting!: () => void; + const inTool = new Promise((resolve) => { waiting = resolve; }); + module.hold = new Promise((resolve) => { release = resolve; }); + module.onWait = waiting; + membrane.push( + createMockResponse([{ type: 'text', text: 'Waiting.' }, { type: 'tool_use', id: 'w1', name: 'probe--wait', input: {} }] as ContentBlock[], 'tool_use'), + createMockResponse([{ type: 'text', text: 'Done.' }]), + ); + framework.pushEvent({ type: 'external-message', source: 'probe', content: 'go' } as never); + await inTool; + + say('ambient', 'a1', 'AMB-1 chatter'); + say('ambient', 'a2', 'AMB-2 more chatter'); + say('addressed', 'x3', 'ADDR-3 hey scout, your build is stuck'); + await waitFor('the first fork', () => forks().length === 1); + const ids = ['AMB-1', 'AMB-2', 'ADDR-3'].map((s) => queuedIdOf(s)); + assert.ok(ids.every((id) => typeof id === 'string'), 'all three are still queued'); + assert.equal(forks()[0]!.held, 3); + assert.equal(forks()[0]!.queued, 3, 'the trace counts them as queued'); + const framing = derives[0]!.framing[0]!; + assert.deepEqual(framing.metadata.heldMessageIds, ids.map((id) => heldHandle(id!)), "each is named by its deferred write's held handle"); + const shown = textOf(framing.content as never); + for (const s of ['AMB-1 chatter', 'AMB-2 more chatter', 'ADDR-3 hey scout']) assert.ok(shown.includes(s), `the framing shows ${s}`); + assert.ok(shown.includes("arrived while scout's turn was running"), shown); + + // A fork whose derive fails gives its message back. + failNextDerive = true; + say('addressed', 'x4', 'ADDR-4 are you there'); + await waitFor('the failed fork', () => failures().length === 1); + assert.equal(failures()[0]!.returned, 1, 'ADDR-4 goes back to the backlog'); + assert.equal(failures()[0]!.reported, false); + say('addressed', 'x5', 'ADDR-5 hello?'); + await waitFor('the next fork', () => forks().length === 2); + assert.equal(forks()[1]!.held, 2, 'it is handed ADDR-4 again, and ADDR-5'); + const second = textOf(derives[2]!.framing[0]!.content as never); + assert.ok(second.includes('ADDR-4 are you there') && second.includes('ADDR-5 hello?'), second); + assert.ok(!second.includes('AMB-1'), 'and nothing the first fork was handed'); + + // The turn ends: the queued writes land under their write ids. + release(); + await waitFor('the queue drains', () => stubbed.deferredMessages.length === 0); + await framework.runUntilIdle(); + const stored = framework.getAgent('scout')!.getContextManager().getAllMessages() as unknown as Array<{ content: ContentBlock[]; metadata?: { deferredWriteId?: string } }>; + for (const [i, s] of ['AMB-1', 'AMB-2', 'ADDR-3'].entries()) { + const row = stored.find((m) => textOf(m.content as never).includes(s)); + assert.equal(row?.metadata?.deferredWriteId, ids[i], `${s} landed carrying its write id`); + } + + // So the next reader, with the resident idle, is handed only what is new. + say('addressed', 'x6', 'ADDR-6 one more'); + await waitFor('the last fork', () => forks().length === 3); + assert.equal(forks()[2]!.held, 1); + assert.equal(forks()[2]!.queued, 0, 'ADDR-6 was stored directly'); + const last = textOf(derives[3]!.framing[0]!.content as never); + assert.ok(last.includes('ADDR-6 one more'), last); + for (const s of ['AMB-1', 'AMB-2', 'ADDR-3', 'ADDR-4', 'ADDR-5']) assert.ok(!last.includes(s), `and not ${s}, now stored`); + } finally { + release(); + console.error = orig; + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('a fork still running when its tune-out ends, which then fails, gets one late fork through the framework, and a late fork that fails gets none', { timeout: 120_000 }, async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'tune-out-reader-late-')); + const commandPath = join(tempDir, 'commands.txt'); + writeFileSync(commandPath, ''); + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), + membrane: new ResidentMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.' }], + subconscious: { enabled: true, reader: 'forks', model: 'test-model', voice: 'Read for scout.' }, + mcplServers: [{ id: 'disc', command: process.execPath, args: [FIXTURE], env: { STATUS_PATH: join(tempDir, 'status.jsonl'), COMMAND_PATH: commandPath } }], + modules: [], + }); + const failures: TraceEvent[] = []; + framework.onTrace((e) => { if (e.type === 'tune-out:reader-fork-failed') failures.push(e); }); + const derives: DeriveCall[] = []; + // The first fork's derive waits for the test, then fails, as a fork still + // running at the cancel would; every later one fails at once. Past ten, + // derives succeed, so a chain of late forks ends and the test can say so. + let letFirstFail: () => void = () => {}; + const firstFails = new Promise((resolve) => { letFirstFail = resolve; }); + const stubbed = framework as unknown as { + deriveAgent: (options: DeriveCall) => Promise; + runEphemeralToCompletion: () => Promise<{ speech: string }>; + tuneOutCoordinator: TuneOutCoordinator; + channelRegistry: { listChannelsRaw(): unknown[] }; + }; + stubbed.deriveAgent = async (options) => { + derives.push(options); + if (derives.length === 1) await firstFails; + if (derives.length > 10) return { agent: { name: options.name }, contextManager: {} }; + throw new Error('derive failed (test)'); + }; + stubbed.runEphemeralToCompletion = async () => ({ speech: '' }); + const orig = console.error; + console.error = () => {}; + try { + await framework.start(); + await waitFor('channel registration', () => stubbed.channelRegistry.listChannelsRaw().length > 0); + assert.ok(stubbed.tuneOutCoordinator.enter('disc', CHANNEL, { cadenceSeconds: 3600, backlogCap: 200, maxWakes: 5 }, 'agent-tool').ok); + appendFileSync(commandPath, 'addressed x1 ADDR-1 are you there\n'); + await waitFor('the first fork is deriving', () => derives.length === 1); + assert.ok(stubbed.tuneOutCoordinator.cancel('disc', CHANNEL, 'agent-tool', 'test').ok, 'the tune-out ends while that fork runs'); + await waitFor("the cancel's own last fork", () => derives.length === 2); + // The cancel's last fork is handed nothing, since the first fork was + // handed ADDR-1. It fails too, and with nothing to give back it derives + // no late fork: the late fork below is the first fork's alone. + assert.ok(!textOf(derives[1]!.framing[0]!.content as never).includes('ADDR-1'), "premise: the cancel's last fork isn't handed ADDR-1"); + letFirstFail(); + await waitFor('the late fork', () => derives.length >= 3); + const late = textOf(derives[2]!.framing[0]!.content as never); + assert.ok(late.includes('ADDR-1 are you there'), late); + assert.ok(late.includes('handed to a reader that failed after the tune-out ended'), late); + // Its failure is handled once its trace is out, and a fork derived from + // that handling would be derived before it: invokeReaderFork reaches + // deriveAgent without an await, inside readerFailed. + await waitFor("the late fork's failure is handled", () => failures.length >= 3); + assert.equal(derives.length, 3, 'the late fork failed too, and no other fork was derived'); + } finally { + letFirstFail(); + console.error = orig; + await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/tune-out-reader-forks-e2e.test.ts b/test/tune-out-reader-forks-e2e.test.ts new file mode 100644 index 000000000..f29efe2fb --- /dev/null +++ b/test/tune-out-reader-forks-e2e.test.ts @@ -0,0 +1,353 @@ +/** + * Tune-out with the reader as a succession of forks (Dendrite, + * `subconscious.reader: 'forks'`): one subconscious cycle, end to end. + * + * enter → ambient traffic is stamped, stored, wakes nobody → an addressed + * message derives a reader fork of the resident at its head: same tool + * block and system prompt, the resident's turns as its own prefix, the + * resident's refusals, the held traffic as framing → the fork reports + * through deliver_summary and the report lands in the resident's context + * as attributed mail under the fork's name and incarnation → the fork + * ends; the epoch does not → exceeding max-wakes auto-cancels: the + * resident gets the dump, and one last fork is handed only what no fork + * has seen yet. + */ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync, appendFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { NormalizedRequest } from '@animalabs/membrane'; +import { AgentFramework, AutobiographicalStrategy, defaultReaderFraming } from '../src/index.js'; +import type { TraceEvent } from '../src/index.js'; +import { MockMembrane, MockYieldingStream, createMockResponse } from './helpers/mock-membrane.js'; +import type { NormalizedResponse } from '@animalabs/membrane'; +import type { TuneOutCoordinator } from '../src/tune-out/coordinator.js'; +import { JsStore } from '@animalabs/chronicle'; +import { ContextManager } from '@animalabs/context-manager'; + +// Reader forks are derived agents: they need the branch-bound store handles +// and ContextManager.derive the Dendrite branches of chronicle and +// context-manager carry. Against registry builds that lack them the fork is +// never derived, and the 20 s waits below would fail instead of skipping +// (the guard dendrite-derive.test.ts has; Weft, 2026-10-17). +const supported = + typeof (JsStore.prototype as unknown as { view?: unknown }).view === 'function' && + typeof (ContextManager.prototype as unknown as { derive?: unknown }).derive === 'function'; +const skip = supported + ? false + : 'installed @animalabs/chronicle / context-manager lack branch-bound handles or ContextManager.derive'; + +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; + }; +} + +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}`); +} + +/** + * A fork presents as the resident, so a per-participant mock cannot tell + * them apart — and `MockMembrane` hands its whole queue to whichever + * stream starts first. This one keeps two queues and routes by the one + * thing that distinguishes a reader's request: its framing message. Each + * stream takes one turn's worth of its queue: the responses up to and + * including the first that calls no tool. + */ +class RoutedMembrane { + calls: NormalizedRequest[] = []; + lastStream: MockYieldingStream | null = null; + private queues = { resident: [] as NormalizedResponse[], reader: [] as NormalizedResponse[] }; + + push(who: 'resident' | 'reader', ...responses: NormalizedResponse[]): void { + this.queues[who].push(...responses); + } + + streamYielding(request: NormalizedRequest): MockYieldingStream { + this.calls.push(request); + const who = JSON.stringify(request.messages).includes('[Tune-out reader') ? 'reader' : 'resident'; + const queue = this.queues[who]; + const turnEnd = queue.findIndex((response) => response.toolCalls.length === 0); + const stream = new MockYieldingStream(queue.splice(0, turnEnd === -1 ? queue.length : turnEnd + 1)); + this.lastStream = stream; + return stream; + } + + asMembrane(): import('@animalabs/membrane').Membrane { + return this as unknown as import('@animalabs/membrane').Membrane; + } +} + +type Msg = { participant: string; content: Array<{ type: string; text?: string }>; metadata?: Record }; +const textOf = (m: Msg | { content: Array<{ type: string; text?: string }> }): string => + m.content.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('\n'); +const stripCache = (value: unknown): string => + JSON.stringify(value, (key, inner) => (key === 'cacheBreakpoint' || key === 'cache_control' ? undefined : inner)); + +describe('tune-out with reader forks', { skip }, () => { + let tempDir: string; + let membrane: RoutedMembrane; + let framework: AgentFramework; + let commandPath: string; + const traces: TraceEvent[] = []; + + before(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'tune-out-forks-')); + commandPath = join(tempDir, 'commands.txt'); + writeFileSync(commandPath, ''); + membrane = new RoutedMembrane(); + 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, + reader: 'forks', + model: 'test-model', + voice: 'You are reading for scout. Report in second person, briefly.', + // How a fork is prompted is the host's: here, the default plus a marker. + framing: (ctx) => [ + ...defaultReaderFraming(ctx), + { type: 'text', text: `[custom framing: ${ctx.fork} reading for ${ctx.resident}, ${ctx.backlog.length} held]` }, + ], + }, + mcplServers: [{ + id: 'disc', + command: process.execPath, + args: [FIXTURE], + env: { STATUS_PATH: join(tempDir, 'status.jsonl'), COMMAND_PATH: commandPath }, + }], + modules: [], + }); + framework.onTrace((event) => traces.push(event)); + 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 }); + }); + + const emit = (kind: 'ambient' | 'addressed', id: string, text: string): void => { + appendFileSync(commandPath, `${kind} ${id} ${text}\n`); + }; + const scoutMessages = () => framework.getAgent('scout')!.getContextManager().getAllMessages() as unknown as Msg[]; + const forkTraces = () => traces.filter((t) => t.type === 'tune-out:reader-fork') as Array< + TraceEvent & { agentName: string; trigger: string; held: number } + >; + + it('runs one subconscious cycle as forks: divert → fork → report as mail → auto-cancel', async () => { + const i = internals(framework); + const coordinator = i.tuneOutCoordinator!; + assert.ok(coordinator, 'coordinator exists with reader forks + mcpl, and no persistent subconscious'); + assert.equal(framework.getAgent('Subconscious'), null, 'no persistent reader agent'); + assert.deepEqual(framework.listAgents().map((r) => r.name), ['scout']); + + // ---- the resident's own request, for the prefix comparison ---------- + membrane.push('resident', createMockResponse([{ type: 'text', text: 'hi' }])); + emit('addressed', 'm0', 'scout, are you around?'); + await waitFor(() => membrane.calls.length >= 1, 'scout wakes for an addressed message'); + const scoutRequest: NormalizedRequest = membrane.calls[0]!; + assert.ok( + scoutRequest.tools!.some((t) => t.name === 'deliver_summary'), + 'the reader\'s tools are in the resident\'s block so a fork can share the prefix', + ); + await framework.runUntilIdle(); + + // ---- enter + ambient traffic ----------------------------------------- + const entered = coordinator.enter('disc', CHANNEL, { cadenceSeconds: 3600, backlogCap: 2, maxWakes: 1 }, 'agent-tool'); + assert.ok(entered.ok); + emit('ambient', 'a1', 'release chatter one'); + emit('ambient', 'a2', 'release chatter two'); + emit('ambient', 'a3', 'release chatter three'); + await waitFor(() => scoutMessages().filter((m) => (m.metadata as { tuneOut?: unknown })?.tuneOut).length >= 3, 'three stamped messages'); + assert.equal(membrane.calls.length, 1, 'ambient diverted traffic wakes nobody, and derives no fork'); + assert.equal(forkTraces().length, 0); + + // ---- addressed message: one reader fork ------------------------------ + membrane.push( + 'reader', + createMockResponse([ + { type: 'tool_use', id: 'call-report', name: 'deliver_summary', input: { text: 'Three release-chatter lines and one question for you; nothing urgent.', channelId: CHANNEL } }, + ], 'tool_use'), + createMockResponse([{ type: 'text', text: 'reported' }]), + ); + membrane.push('resident', createMockResponse([{ type: 'text', text: 'thanks, reader' }])); + emit('addressed', 'm1', 'hey scout, quick question'); + await waitFor(() => forkTraces().length >= 1, 'a reader fork is derived (coalesced wake)', 20_000); + const first = forkTraces()[0]!; + assert.equal(first.trigger, 'wake'); + assert.equal(first.held, 4, 'a1–a3 and m1: everything held so far, handed to this fork'); + assert.match(first.agentName, /^reader\/scout\//); + + await waitFor(() => framework.getAgentRecord(first.agentName)?.ended?.reason === 'completed', 'the fork ends when its turn ends', 20_000); + const record = framework.getAgentRecord(first.agentName)!; + assert.equal(record.kind, 'subconscious-fork'); + assert.equal(record.relationships.spawnedBy, 'scout'); + assert.equal(record.onParentEnd, 'end', 'attention tenancy: it would end with scout'); + assert.deepEqual(record.relationships.resultTo, { to: 'scout', as: 'message' }); + assert.equal(record.inherit?.mode, 'shared'); + assert.equal(record.inherit?.refusals, true, 'with scout\'s refusals'); + assert.equal(record.presentAs, 'scout'); + + // The fork's framing: the notice, the voice block, and the held traffic — media-capable, author-attributed. + const forkContext = await framework.inspectAgentContext(first.agentName); + const framing = (forkContext.getAllMessages() as unknown as Msg[]).find((m) => m.metadata?.kind === 'tune-out-reader-framing')!; + assert.ok(framing, 'framing is an ordinary stored message'); + const framingText = textOf(framing); + assert.match(framingText, /\[Tune-out wake: disc:guild:noisy — 1 addressed message\]/); + assert.match(framingText, /You are reading for scout/); + assert.match(framingText, /antra: release chatter one/); + assert.match(framingText, /hey scout, quick question/); + assert.match(framingText, /\[custom framing: reader\/scout\/\w+\/1 reading for scout, 4 held\]/, 'the host\'s framing hook ran'); + assert.deepEqual((framing.metadata as { heldMessageIds: string[] }).heldMessageIds.length, 4); + // The held originals stay out of the resident's compiled view. + const compiled = await framework.getAgent('scout')!.getContextManager().compile(); + assert.ok(!compiled.messages.some((m) => textOf(m).includes('release chatter'))); + + // The fork's request shares the resident's prefix: tools, system, and scout's turns as its own. + const forkRequest = membrane.calls.find((c) => JSON.stringify(c.messages).includes('[Tune-out reader'))!; + assert.ok(forkRequest, 'the fork made a request'); + assert.equal(JSON.stringify(forkRequest.tools), JSON.stringify(scoutRequest.tools), 'identical tool block'); + assert.equal(forkRequest.system, scoutRequest.system, 'identical system prompt: the voice block is framing'); + assert.equal(forkRequest.assistantParticipant, 'scout'); + const prefix = scoutRequest.messages.length; + assert.equal( + stripCache(forkRequest.messages.slice(0, prefix - 1)), + stripCache(scoutRequest.messages.slice(0, prefix - 1)), + 'scout\'s request is a prefix of the fork\'s', + ); + + // The report is attributed mail: the fork's words under the fork's name and incarnation. + await waitFor(() => scoutMessages().some((m) => m.metadata?.kind === 'agent-result'), 'the report reaches scout'); + const report = scoutMessages().find((m) => m.metadata?.kind === 'agent-result')!; + assert.equal(report.participant, first.agentName); + assert.match(textOf(report), /nothing urgent/); + assert.deepEqual((report.metadata!.dendrite as { from: unknown }).from, { agent: first.agentName, incarnation: 1 }); + assert.equal(scoutMessages().some((m) => textOf(m).includes('[Tune-out wake:')), false, 'notices never leak into the resident\'s window'); + assert.equal(scoutMessages().some((m) => textOf(m) === 'reported'), false, 'a fork that reported through the tool does not report its trailing prose again'); + + // The agent ended; the epoch did not. + assert.equal(framework.listPolicyEpochs().length, 1); + assert.equal(framework.listAgents().some((r) => r.kind === 'subconscious-fork'), false, 'no reader persists between invocations'); + await framework.runUntilIdle(); // scout's wake for the mail + + // ---- a second wake exceeds maxWakes=1: auto-cancel ------------------- + membrane.push( + 'resident', + createMockResponse([{ type: 'text', text: 'ack' }]), + createMockResponse([{ type: 'text', text: 'ack the prose report' }]), + ); + membrane.push('reader', createMockResponse([{ type: 'text', text: 'final look' }])); + 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(() => scoutMessages().some((m) => textOf(m).includes(' forkTraces().some((t) => t.trigger === 'cancel'), 'one last fork for the cancel', 20_000); + const last = forkTraces().find((t) => t.trigger === 'cancel')!; + assert.equal(last.held, 1, 'only m2: the rest was handed to the first fork'); + await waitFor(() => framework.getAgentRecord(last.agentName)?.ended?.reason === 'completed', 'the cancel fork ends', 20_000); + const lastFraming = (await framework.inspectAgentContext(last.agentName)).getAllMessages() as unknown as Msg[]; + assert.match(textOf(lastFraming.find((m) => m.metadata?.kind === 'tune-out-reader-framing')!), /\[Tune-out cancelled: disc:guild:noisy — wake budget exhausted/); + + // A reader that answers in prose has still reported (first live run, + // finding A): the report is the fork's whole job, so its final prose + // reaches scout as its report, under its own name — not lost to + // proseRouting=disabled. + await waitFor(() => scoutMessages().some((m) => m.participant === last.agentName && textOf(m).includes('final look')), 'the prose report reaches scout as mail'); + const proseReport = scoutMessages().find((m) => m.participant === last.agentName && textOf(m).includes('final look'))!; + assert.equal(proseReport.metadata?.kind, 'agent-result'); + assert.ok(traces.some((t) => t.type === 'tune-out:reader-prose-delivered' && (t as { agentName: string }).agentName === last.agentName), 'traced as a prose report'); + assert.equal(framework.listPolicyEpochs().length, 0); + assert.deepEqual( + framework.listAgents({ includeEnded: true }).filter((r) => r.kind === 'subconscious-fork').map((r) => r.ended?.reason), + ['completed', 'completed'], + ); + await framework.runUntilIdle(); + + // ---- the resident cannot use the reader's tools ---------------------- + membrane.push( + 'resident', + createMockResponse([ + { type: 'tool_use', id: 'call-nope', name: 'deliver_summary', input: { text: 'me?', channelId: CHANNEL } }, + ], 'tool_use'), + createMockResponse([{ type: 'text', text: 'ok' }]), + ); + emit('addressed', 'm3', 'scout one more'); + await waitFor(() => (membrane.lastStream?.receivedToolResults.length ?? 0) >= 1, 'scout\'s call is answered', 20_000); + assert.match(JSON.stringify(membrane.lastStream!.receivedToolResults[0]), /available to the resident's reader, not to scout/); + await framework.runUntilIdle(); + }); +}); + +describe('tune-out reader forks: configuration', () => { + it('refuses an unstated model: whose weights read the prefix is never defaulted', async () => { + const dir = mkdtempSync(join(tmpdir(), 'tune-out-forks-cfg-')); + try { + await assert.rejects( + AgentFramework.create({ + storePath: join(dir, 'test.chronicle'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'x' }], + subconscious: { enabled: true, reader: 'forks', voice: 'read' } as never, + modules: [], + }), + /`model` must say whose weights run it — "test-model" for a copy of the resident, or another model/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('allows another model when stated: every configuration stays reachable', async () => { + const dir = mkdtempSync(join(tmpdir(), 'tune-out-forks-cfg-')); + try { + const framework = await AgentFramework.create({ + storePath: join(dir, 'test.chronicle'), + membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'x' }], + subconscious: { enabled: true, reader: 'forks', model: 'other-model', voice: 'read' }, + modules: [], + }); + await framework.stop(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('refuses forks for a folding resident without a strategy factory', async () => { + const dir = mkdtempSync(join(tmpdir(), 'tune-out-forks-cfg-')); + try { + await assert.rejects( + AgentFramework.create({ + storePath: join(dir, 'test.chronicle'), + membrane: new MockMembrane().asMembrane(), + agents: [{ + name: 'scout', model: 'test-model', systemPrompt: 'x', + strategy: new AutobiographicalStrategy({ compressionModel: 'mock' }) as never, + }], + subconscious: { enabled: true, reader: 'forks', model: 'test-model', voice: 'read' }, + modules: [], + }), + /needs `subconscious.strategyFactory`/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/tune-out-reader-host-toolsets.test.ts b/test/tune-out-reader-host-toolsets.test.ts new file mode 100644 index 000000000..45f051d50 --- /dev/null +++ b/test/tune-out-reader-host-toolsets.test.ts @@ -0,0 +1,100 @@ +/** + * A reader fork with channel speech off calls only its own tools: every + * other call is refused at dispatch, whether the tool is in the resident's + * tool list or not (the host's built-in toolset, a module's tool placed + * there, a module utility called by its own name) and whether it existed + * when the fork was derived or arrived after. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { rig, skip } from './helpers/reader-fork-rig.js'; +import { createMockResponse } from './helpers/mock-membrane.js'; +import type { Module, ModuleContext, ToolCall, ToolResult, ToolDefinition } from '../src/types/index.js'; + +/** A module with a tool it places in the built-in toolset and a utility. */ +class StubModule implements Module { + readonly name = 'stub'; + calls: ToolCall[] = []; + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { + return [{ name: 'adminish', description: 'An administrative tool.', inputSchema: { type: 'object' } }]; + } + getUtilities(): ToolDefinition[] { + return [{ name: 'rare_op', description: 'A rarely-used operation.', inputSchema: { type: 'object' } }]; + } + toolPlacement(name: string): 'header' | 'builtin' | undefined { + return name === 'adminish' ? 'builtin' : undefined; + } + async handleToolCall(call: ToolCall): Promise { + this.calls.push(call); + return { success: true, data: { handled: call.name } }; + } + async onProcess(): Promise { return {}; } +} + +/** A module added once the fork exists. */ +class LateModule implements Module { + readonly name = 'late'; + calls: ToolCall[] = []; + startedAt: number | undefined; + calledAt: number | undefined; + async start(_ctx: ModuleContext): Promise { this.startedAt = Date.now(); } + async stop(): Promise {} + getTools(): ToolDefinition[] { + return [{ name: 'op', description: 'A tool added after the fork was derived.', inputSchema: { type: 'object' } }]; + } + async handleToolCall(call: ToolCall): Promise { + this.calls.push(call); + this.calledAt ??= Date.now(); + return { success: true, data: { handled: call.name } }; + } + async onProcess(): Promise { return {}; } +} + +const use = (id: string, name: string) => ({ type: 'tool_use', id, name, input: {} }); + +describe('reader forks and tools out of the tool list', { skip }, () => { + const stub = new StubModule(); + const late = new LateModule(); + const r = rig('host-toolsets', false, { modules: [stub] }); + + it('refuses every call outside its own tools, a tool added after derivation included', async () => { + const before = await r.tuneOut(); + let lateAdded: Promise | undefined; + const refusedAt = new Map(); + r.state.framework!.onTrace((event) => { + if (event.type === 'tune-out:reader-fork' && !lateAdded) lateAdded = r.state.framework!.addModule(late); + if (event.type === 'tool:failed' && (event as { module: string }).module === 'dendrite') { + refusedAt.set((event as { tool: string }).tool, Date.now()); + } + }); + r.state.membrane.push('reader', + createMockResponse([ + use('c1', 'agent_settings'), + use('c2', 'stub--adminish'), + use('c3', 'stub--rare_op'), + ] as never, 'tool_use'), + createMockResponse([use('c4', 'late--op')] as never, 'tool_use'), + createMockResponse([{ type: 'text', text: 'nothing urgent' }])); + r.state.membrane.push('resident', createMockResponse([{ type: 'text', text: 'thanks' }])); + r.emit('addressed', 'm1', 'hey scout, quick question'); + await r.waitFor(() => r.forkTraces().length >= 1, 'a reader fork is derived'); + const fork = r.forkTraces()[0]!.agentName; + await r.waitFor(() => r.state.framework!.getAgentRecord(fork)?.ended !== undefined, 'the fork ends'); + await lateAdded; + + assert.ok(late.startedAt !== undefined, 'premise: the late module started'); + const lateCalled = late.calledAt ?? refusedAt.get('late--op'); + assert.ok(lateCalled !== undefined && late.startedAt! <= lateCalled, 'premise: the late module was there before its tool was called'); + assert.deepEqual( + ['agent_settings', 'stub--adminish', 'stub--rare_op', 'late--op'].filter((t) => !refusedAt.has(t)), + [], + 'each call outside the fork\'s own tools is refused at dispatch', + ); + assert.deepEqual(stub.calls.map((c) => c.name), [], 'the built-in toolset\'s module tool and the utility never ran'); + assert.deepEqual(late.calls.map((c) => c.name), [], 'the tool added after derivation never ran'); + assert.equal(r.published().length - before, 0, 'nothing of the fork\'s reached the channel'); + await r.state.framework!.runUntilIdle(); + }); +}); diff --git a/test/tune-out-reader-queued.test.ts b/test/tune-out-reader-queued.test.ts new file mode 100644 index 000000000..d47404cd8 --- /dev/null +++ b/test/tune-out-reader-queued.test.ts @@ -0,0 +1,643 @@ +/** + * Reader forks and the messages still queued behind the resident's turn. + * + * While the resident's turn runs, what arrives for it waits in the deferred + * queue rather than in its store. That is when a reader is most useful (the + * resident is busy), so a reader fork is handed those messages along with + * the stored ones: on a wake, on a cadence look and on a cancel. Each held + * message still goes to exactly one reader: a message handed while queued + * isn't handed again once it lands in the store (its row carries the + * deferred write's id as `metadata.deferredWriteId`). A fork that never read + * what it was handed gives it back (`readerFailed`). A reader's own cancel + * is followed by one more fork only for what arrived after the reader was + * handed its own, and a cadence period with nothing new derives no fork. + * + * The coordinator runs here against a fake registry and fake framework + * hooks, so no Dendrite build of chronicle or context-manager is needed. + * test/tune-out-reader-fork-handoff.test.ts has the framework's side. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import type { ContentBlock } from '@animalabs/membrane'; +import type { StoredMessage } from '@animalabs/context-manager'; +import { TuneOutCoordinator, type ReaderInvocation, type TuneOutFrameworkHooks } from '../src/tune-out/coordinator.js'; +import { defaultReaderFraming } from '../src/tune-out/tools.js'; +import type { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; + +const SERVER = 'disc'; +const NOISY = 'disc:guild:noisy'; +const OTHER = 'disc:guild:other'; +const EPOCH = 'e-noisy-0001'; + +type Queued = { participant: string; content: ContentBlock[]; metadata: Record; writeId?: string }; +interface Where { epochId?: string; channelId?: string; serverId?: string } + +const text = (t: string): ContentBlock => ({ type: 'text', text: t } as ContentBlock); +const textOf = (content: ContentBlock[]): string => + content.filter((b) => b.type === 'text').map((b) => (b as { text: string }).text).join('\n'); +const meta = (author: string, where: Where = {}) => ({ + author: { name: author }, + tuneOut: { epochId: where.epochId ?? EPOCH }, + channelId: where.channelId ?? NOISY, + serverId: where.serverId ?? SERVER, +}); + +function world(opts: { subconscious?: boolean } = {}) { + const stored: StoredMessage[] = []; + /** What the persistent subconscious is given, with `subconscious: true`. */ + const looks: Array<{ text: string; kind: unknown }> = []; + const deferred: Queued[] = []; + const invocations: ReaderInvocation[] = []; + const coordinatorState = new Map(); + /** Durable records whose read throws, by the last part of their id, such as `last-looks`. */ + const unreadable = new Set(); + const active = new Map; wakeCount: number }>(); + let nextSeq = 1; + + const registry = { + nameShown: (ref: { channelId: string }) => ref.channelId, + // main's #852: a channel named in a sentence is quoted as tune_out takes it back. + quotedShown: (ref: { channelId: string }) => JSON.stringify(ref.channelId), + listChannelsRaw: () => [NOISY, OTHER, 'disc:guild:work'].map((id) => ({ serverId: SERVER, descriptor: { id } })), + getTuneOutState: (serverId: string, channelId: string) => active.get(`${serverId}\0${channelId}`) ?? null, + cancelTuneOut: (serverId: string, channelId: string) => { + const key = `${serverId}\0${channelId}`; + const ended = active.get(key) ?? null; + active.delete(key); + return ended; + }, + readCoordinatorState: (id: string) => { + if (unreadable.has(id.split('/').pop()!)) throw new Error(`the store couldn't read ${id}`); + return coordinatorState.get(id) ?? null; + }, + writeCoordinatorState: (id: string, value: unknown) => { coordinatorState.set(id, value); }, + } as unknown as ChannelRegistry; + const servers = { getServer: () => undefined } as unknown as McplServerRegistry; + const hooks = { + addMessage: (_participant: string, content: ContentBlock[], metadata: Record | undefined, agent?: string) => { + if (agent === 'Subconscious') looks.push({ text: textOf(content), kind: metadata?.kind }); + }, + requestInference: () => {}, + subconsciousName: () => (opts.subconscious ? 'Subconscious' : null), + invokeReader: (invocation: ReaderInvocation) => { invocations.push(invocation); }, + deliverFromReader: () => {}, + primaryName: () => 'scout', + getStoredMessages: () => stored, + getDeferredMessages: () => deferred, + // The store's sequence: its newest row's, as chronicle's currentSequence. + currentSequence: () => nextSeq - 1, + setSubconsciousAnchor: () => {}, + isForkBound: () => false, + isPrivilegedAuthor: () => false, + allowChannelSpeech: () => false, + emitTrace: () => {}, + } as unknown as TuneOutFrameworkHooks; + type Internals = { + invokeSubconscious(serverId: string, channelId: string, trigger: 'cadence' | 'wake', count: number): void; + startCadence(serverId: string, channelId: string, cadenceSeconds: number): void; + readerHandedWrites: Map>; + }; + let coordinator = new TuneOutCoordinator(registry, servers, hooks); + let internals = coordinator as unknown as Internals; + + return { + get coordinator() { return coordinator; }, + get internals() { return internals; }, + invocations, + looks, + unreadable, + /** A durable record as the store holds it, by the last part of its id. */ + stored: (name: string): unknown => [...coordinatorState].find(([id]) => id.endsWith(`/${name}`))?.[1], + /** A host restart: a new coordinator over the same store and durable state, its timers armed again. */ + restart(): void { + coordinator.stop(); + coordinator = new TuneOutCoordinator(registry, servers, hooks); + internals = coordinator as unknown as Internals; + coordinator.resumeActiveEpochs(); + }, + /** Hold a channel under an epoch, as a per-channel tune-out does. */ + tuneOut(backlogCap = 200, where: Where = {}) { + active.set(`${where.serverId ?? SERVER}\0${where.channelId ?? NOISY}`, { + params: { epochId: where.epochId ?? EPOCH, cadenceSeconds: 30, backlogCap, maxWakes: 5, startedAtSequence: 0 }, + wakeCount: 0, + }); + }, + /** A held message stored directly (the resident idle). */ + store(author: string, body: string, where: Where = {}): StoredMessage { + const message = { + id: `m-${nextSeq}`, + sequence: nextSeq++, + participant: 'user', + content: [text(body)], + metadata: meta(author, where), + timestamp: new Date(), + } as unknown as StoredMessage; + stored.push(message); + return message; + }, + /** A held message queued behind the resident's running turn (no write id: a host that gives none). */ + queue(author: string, body: string, writeId: string | undefined, where: Where = {}): void { + deferred.push({ participant: 'user', content: [text(body)], metadata: meta(author, where), ...(writeId ? { writeId } : {}) }); + }, + /** The resident's turn ends: the queued messages land (only those named, if any are), stamped with their write ids. */ + land(...writeIds: string[]): void { + const lands = (q: Queued): boolean => writeIds.length === 0 || (q.writeId !== undefined && writeIds.includes(q.writeId)); + const landing = deferred.filter(lands); + deferred.splice(0, deferred.length, ...deferred.filter((q) => !lands(q))); + for (const q of landing) { + stored.push({ + id: `m-${nextSeq}`, + sequence: nextSeq++, + participant: q.participant, + content: q.content, + metadata: { ...q.metadata, ...(q.writeId ? { deferredWriteId: q.writeId } : {}) }, + timestamp: new Date(), + } as unknown as StoredMessage); + } + }, + wake: (serverId = SERVER, channelId = NOISY) => internals.invokeSubconscious(serverId, channelId, 'wake', 1), + startCadence: (seconds: number) => internals.startCadence(SERVER, NOISY, seconds), + }; +} + +const bodies = (invocation: ReaderInvocation) => ({ + stored: invocation.backlog.map((m) => textOf(m.content)), + queued: (invocation.queued ?? []).map((m) => textOf(m.content)), +}); + +describe('reader forks and messages queued behind the resident turn', () => { + it("a wake while the resident's turn runs hands the reader the queued messages, the one that woke it included", () => { + const w = world(); + w.tuneOut(); + w.queue('ann', 'AMB-1 chatter', 'w1'); + w.queue('bo', 'AMB-2 more chatter', 'w2'); + w.queue('cy', 'ADDR-3 hey scout, your build is stuck', 'w3'); + w.wake(); + assert.equal(w.invocations.length, 1, 'one fork'); + assert.deepEqual(bodies(w.invocations[0]!), { + stored: [], + queued: ['AMB-1 chatter', 'AMB-2 more chatter', 'ADDR-3 hey scout, your build is stuck'], + }, 'all three, oldest first, though none is stored yet'); + w.coordinator.stop(); + }); + + it('a message handed while queued is not handed again once it lands; what is new still is', () => { + const w = world(); + w.tuneOut(); + w.store('ann', 'S-1 stored before the turn'); + w.queue('bo', 'Q-2 queued', 'w2'); + w.wake(); + assert.deepEqual(bodies(w.invocations[0]!), { stored: ['S-1 stored before the turn'], queued: ['Q-2 queued'] }); + + w.land(); + w.store('cy', 'S-3 stored after the turn'); + w.queue('dee', 'Q-4 queued in the next turn', 'w4'); + w.wake(); + assert.equal(w.invocations.length, 2); + assert.deepEqual(bodies(w.invocations[1]!), { stored: ['S-3 stored after the turn'], queued: ['Q-4 queued in the next turn'] }, + 'Q-2, now stored under its write id, and S-1 are not handed again'); + + w.land(); + w.wake(); + assert.deepEqual(bodies(w.invocations[2]!), { stored: [], queued: [] }, 'and Q-4 is not handed again once it lands'); + w.coordinator.stop(); + }); + + it('a handed write id is let go once its row has landed behind the cursor', () => { + const w = world(); + w.tuneOut(); + w.queue('ann', 'Q-1', 'w1'); + w.wake(); + const handed = () => w.internals.readerHandedWrites.get(`${EPOCH}\0${SERVER}\0${NOISY}`); + assert.deepEqual([...(handed() ?? [])], ['w1'], 'kept while it could still be handed by sequence'); + w.land(); + w.store('bo', 'S-2 after it'); + w.wake(); + assert.deepEqual(bodies(w.invocations[1]!), { stored: ['S-2 after it'], queued: [] }); + assert.equal(handed(), undefined, 'Q-1 landed behind the cursor, so its id is dropped'); + w.wake(); + assert.deepEqual(bodies(w.invocations[2]!), { stored: [], queued: [] }, 'and Q-1 is still not handed again'); + w.coordinator.stop(); + }); + + it('a queued message the host gives no write id for is left for the reader that comes once it is stored', () => { + const w = world(); + w.tuneOut(); + w.queue('ann', 'Q-1 without an id', undefined); + w.wake(); + assert.deepEqual(bodies(w.invocations[0]!), { stored: [], queued: [] }, 'not handed while queued'); + w.land(); + w.wake(); + assert.deepEqual(bodies(w.invocations[1]!), { stored: ['Q-1 without an id'], queued: [] }, 'handed once stored'); + w.coordinator.stop(); + }); + + it('a cadence period with nothing new derives no fork, and one with something new does', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const w = world(); + w.tuneOut(); + w.startCadence(30); + const m1 = w.store('ann', 'AMB-1 once'); + w.coordinator.recordHeld(SERVER, NOISY, m1.id, [], 'ann', true); + t.mock.timers.tick(30_000); + assert.equal(w.invocations.length, 1, 'the first period reads AMB-1'); + t.mock.timers.tick(30_000); + t.mock.timers.tick(30_000); + assert.equal(w.invocations.length, 1, 'two quiet periods derive no fork'); + w.queue('bo', 'AMB-2 while the turn runs', 'w2'); + w.coordinator.recordHeld(SERVER, NOISY, 'x2', [], 'bo', true); + t.mock.timers.tick(30_000); + assert.equal(w.invocations.length, 2, 'a period with a new message does'); + assert.deepEqual(bodies(w.invocations[1]!), { stored: [], queued: ['AMB-2 while the turn runs'] }); + w.coordinator.stop(); + }); + + it("a failed fork's messages go to the next reader: stored, still queued, and landed behind the cursor", () => { + const w = world(); + w.tuneOut(); + w.store('ann', 'S-1 stored'); + w.queue('bo', 'Q-2 queued, lands later', 'w2'); + w.queue('cy', 'Q-3 queued, still queued', 'w3'); + w.wake(); + const failed = w.invocations[0]!; + assert.equal(w.coordinator.readerFailed(failed), 3, 'all three go back'); + // Q-2 lands, and a newer message is stored after it; Q-3 stays queued. + w.land('w2'); + w.store('dee', 'S-4 new'); + w.wake(); + assert.deepEqual(bodies(w.invocations[1]!), { + stored: ['S-1 stored', 'Q-2 queued, lands later', 'S-4 new'], + queued: ['Q-3 queued, still queued'], + }, 'the returned three again, with the new one'); + w.wake(); + assert.deepEqual(bodies(w.invocations[2]!), { stored: [], queued: [] }, 'and once handed again, not a third time'); + w.coordinator.stop(); + }); + + it('a fork still running when its epoch ends, which then fails, has what it was handed go to one late fork', () => { + const w = world(); + w.tuneOut(); + w.queue('ann', 'Q-1', 'w1'); + w.store('bo', 'S-2 stored'); + w.wake(); + const failed = w.invocations[0]!; + assert.deepEqual(bodies(failed), { stored: ['S-2 stored'], queued: ['Q-1'] }); + assert.deepEqual(w.coordinator.cancel(SERVER, NOISY, 'agent-tool', 'resident cancelled'), { ok: true }); + const atCancel = w.invocations.length; + assert.deepEqual(w.invocations.slice(1).map(bodies), [{ stored: [], queued: [] }], "premise: the cancel's last fork is handed neither"); + w.land(); + assert.equal(w.coordinator.readerFailed(failed), 2, 'both go to a late fork'); + const late = w.invocations.slice(atCancel); + assert.equal(late.length, 1, 'one late fork'); + assert.deepEqual(bodies(late[0]!), { stored: ['S-2 stored', 'Q-1'], queued: [] }, 'Q-1, landed since, handed as stored'); + assert.equal(late[0]!.epochId, EPOCH); + assert.ok(late[0]!.notice.includes('These 2 messages were handed to a reader that failed after the tune-out ended'), late[0]!.notice); + w.coordinator.stop(); + }); + + it("the cancel's fork is handed the messages still queued at the cancel", () => { + const w = world(); + w.tuneOut(1); + w.queue('ann', 'HELD-1 first', 'w1'); + w.queue('bo', 'HELD-2 second', 'w2'); + assert.deepEqual(w.coordinator.cancel(SERVER, NOISY, 'agent-tool', 'resident cancelled'), { ok: true }); + assert.equal(w.invocations.length, 1, "the cancel's fork"); + const fork = w.invocations[0]!; + assert.equal(fork.trigger, 'cancel'); + assert.deepEqual(bodies(fork), { stored: [], queued: ['HELD-1 first', 'HELD-2 second'] }, + 'both, though the dump shows only the newer'); + assert.ok(fork.notice.includes("1 of 2 diverted messages, the oldest, weren't delivered raw"), 'its notice counts what the dump left out'); + assert.ok(!fork.notice.includes('may not be in what you see'), 'and no longer says the fork may not see them'); + w.coordinator.stop(); + }); + + it("a reader's own cancel derives no fork when the reader was handed everything", () => { + const w = world(); + w.tuneOut(1); + w.queue('ann', 'AMB-1', 'w1'); + w.queue('bo', 'ADDR-2 hey scout', 'w2'); + w.wake(); + assert.deepEqual(w.coordinator.cancel(SERVER, NOISY, 'subconscious', 'subconscious judgment', undefined, 'reader/scout/x/1'), { ok: true }); + assert.equal(w.invocations.length, 1, 'only the reader that cancelled'); + w.coordinator.stop(); + }); + + it("a reader's own cancel is followed by one fork for what arrived after the reader was handed its own", () => { + const w = world(); + w.tuneOut(1); + w.queue('ann', 'ADDR-1 hey scout', 'w1'); + w.wake(); + w.queue('bo', 'AMB-2 arrived while the reader read', 'w2'); + w.store('cy', 'AMB-3 stored meanwhile'); + assert.deepEqual(w.coordinator.cancel(SERVER, NOISY, 'subconscious', 'subconscious judgment', undefined, 'reader/scout/x/1'), { ok: true }); + assert.equal(w.invocations.length, 2, 'one more fork'); + const late = w.invocations[1]!; + assert.equal(late.trigger, 'cancel'); + assert.deepEqual(bodies(late), { stored: ['AMB-3 stored meanwhile'], queued: ['AMB-2 arrived while the reader read'] }, + 'handed only what the cancelling reader was not'); + assert.ok(late.notice.includes('These 2 messages arrived after the reader that cancelled was handed its own.'), late.notice); + w.coordinator.stop(); + }); + + it("a reader's own release of the all-except hold derives a fork for each channel with messages no reader was handed", () => { + const w = world(); + assert.ok(w.coordinator.enterAllExcept({ serverId: SERVER, channelId: 'disc:guild:work' }, { durationSeconds: 600 }, 'agent-tool').ok); + const epochId = w.coordinator.getAllExcept()!.params.epochId; + w.queue('ann', 'N-1 in noisy', 'w1', { epochId, channelId: NOISY }); + w.wake(); // the reader for noisy is handed N-1 + assert.deepEqual(bodies(w.invocations[0]!), { stored: [], queued: ['N-1 in noisy'] }); + w.queue('bo', 'O-1 in other', 'w2', { epochId, channelId: OTHER }); + assert.deepEqual(w.coordinator.cancelAllExcept('subconscious', 'subconscious judgment', undefined, 'reader/scout/x/1'), { ok: true }); + assert.equal(w.invocations.length, 2, 'one more fork: for other, whose message no reader had'); + assert.equal(w.invocations[1]!.channelId, OTHER); + assert.deepEqual(bodies(w.invocations[1]!), { stored: [], queued: ['O-1 in other'] }); + w.coordinator.stop(); + }); + + it("the resident's release of the all-except hold hands each channel's reader what is still queued", () => { + const w = world(); + assert.ok(w.coordinator.enterAllExcept({ serverId: SERVER, channelId: 'disc:guild:work' }, { durationSeconds: 600 }, 'agent-tool').ok); + const epochId = w.coordinator.getAllExcept()!.params.epochId; + w.store('ann', 'N-1 stored in noisy', { epochId, channelId: NOISY }); + w.queue('bo', 'N-2 queued in noisy', 'w2', { epochId, channelId: NOISY }); + w.queue('cy', 'O-1 queued in other', 'w3', { epochId, channelId: OTHER }); + assert.deepEqual(w.coordinator.cancelAllExcept('agent-tool', 'resident released'), { ok: true }); + const byChannel = Object.fromEntries(w.invocations.map((inv) => [inv.channelId, bodies(inv)])); + assert.deepEqual(byChannel, { + [NOISY]: { stored: ['N-1 stored in noisy'], queued: ['N-2 queued in noisy'] }, + [OTHER]: { stored: [], queued: ['O-1 queued in other'] }, + }); + w.coordinator.stop(); + }); + + it('two servers sharing a channel id keep their readers apart, on a wake and on the release', () => { + const w = world(); + assert.ok(w.coordinator.enterAllExcept({ serverId: SERVER, channelId: 'disc:guild:work' }, { durationSeconds: 600 }, 'agent-tool').ok); + const epochId = w.coordinator.getAllExcept()!.params.epochId; + w.queue('ann', 'A-1 on disc', 'w1', { epochId, channelId: 'general', serverId: 'disc' }); + w.queue('bo', 'B-1 on slack', 'w2', { epochId, channelId: 'general', serverId: 'slack' }); + w.wake('disc', 'general'); + assert.deepEqual(bodies(w.invocations[0]!), { stored: [], queued: ['A-1 on disc'] }, "disc's reader is handed disc's message only"); + assert.deepEqual(w.coordinator.cancelAllExcept('agent-tool', 'resident released'), { ok: true }); + assert.equal(w.invocations.length, 2, "one more fork: for slack's general, whose message no reader had"); + assert.equal(w.invocations[1]!.serverId, 'slack'); + assert.deepEqual(bodies(w.invocations[1]!), { stored: [], queued: ['B-1 on slack'] }); + w.coordinator.stop(); + }); +}); + +describe("a channel held in turn by the all-except hold and by its own tune-out", () => { + // A per-channel tune-out takes a channel the scope holds; its messages are + // its own epoch's. What readers were handed is each epoch's, so neither + // epoch's reading moves or clears the other's. + const scoped = (w: ReturnType): string => { + assert.ok(w.coordinator.enterAllExcept({ serverId: SERVER, channelId: 'disc:guild:work' }, { durationSeconds: 600 }, 'agent-tool').ok); + return w.coordinator.getAllExcept()!.params.epochId; + }; + + it("the channel's own tune-out ending leaves what the scope's readers were handed as it was", () => { + const w = world(); + const scope = scoped(w); + w.store('ann', 'S-1 under the scope', { epochId: scope }); + w.wake(); + assert.deepEqual(bodies(w.invocations[0]!), { stored: ['S-1 under the scope'], queued: [] }); + w.tuneOut(); + w.store('bo', 'P-2 under its own tune-out'); + w.wake(); + assert.deepEqual(bodies(w.invocations[1]!), { stored: ['P-2 under its own tune-out'], queued: [] }); + assert.deepEqual(w.coordinator.cancel(SERVER, NOISY, 'agent-tool', 'resident cancelled'), { ok: true }); + w.store('cy', 'S-3 under the scope again', { epochId: scope }); + w.wake(); + const next = w.invocations.at(-1)!; + assert.equal(next.epochId, scope, 'the scope holds the channel again'); + assert.deepEqual(bodies(next), { stored: ['S-3 under the scope again'], queued: [] }, 'S-1 was read under the scope, so it is not handed again'); + w.coordinator.stop(); + }); + + it("the scope's release hands its reader the scope's unread messages, whatever the channel's own tune-out's readers were handed", () => { + const w = world(); + const scope = scoped(w); + w.store('ann', 'S-1 under the scope, unread', { epochId: scope }); + w.tuneOut(); + w.store('bo', 'P-2 under its own tune-out'); + w.wake(); + assert.deepEqual(bodies(w.invocations[0]!), { stored: ['P-2 under its own tune-out'], queued: [] }); + assert.deepEqual(w.coordinator.cancelAllExcept('agent-tool', 'resident released'), { ok: true }); + const last = w.invocations.slice(1).filter((inv) => inv.epochId === scope); + assert.deepEqual(last.map(bodies), [{ stored: ['S-1 under the scope, unread'], queued: [] }], 'one last fork, handed S-1'); + w.store('cy', 'P-3 still under its own tune-out'); + w.wake(); + assert.deepEqual(bodies(w.invocations.at(-1)!), { stored: ['P-3 still under its own tune-out'], queued: [] }, + "and the channel's own tune-out's readers go on from where they were"); + w.coordinator.stop(); + }); + + it("a scope's fork that fails once the channel's own tune-out holds the channel gives its messages back to the scope", () => { + const w = world(); + const scope = scoped(w); + w.store('ann', 'S-1 under the scope', { epochId: scope }); + w.wake(); // the scope's fork for noisy is handed S-1, and is still running... + const running = w.invocations[0]!; + assert.deepEqual(bodies(running), { stored: ['S-1 under the scope'], queued: [] }); + w.tuneOut(); // ...when the resident tunes noisy out on its own + assert.equal(w.coordinator.readerFailed(running), 1, 'its one message goes back to the scope'); + assert.deepEqual(w.coordinator.cancelAllExcept('agent-tool', 'resident released'), { ok: true }); + const last = w.invocations.slice(1).filter((inv) => inv.epochId === scope); + assert.deepEqual(last.map(bodies), [{ stored: ['S-1 under the scope'], queued: [] }], "the scope's last fork is handed S-1"); + w.coordinator.stop(); + }); + + it("a late fork's own failure derives no other", () => { + const w = world(); + w.tuneOut(); + w.store('ann', 'P-1 under its own tune-out'); + w.wake(); + const running = w.invocations[0]!; + assert.deepEqual(w.coordinator.cancel(SERVER, NOISY, 'agent-tool', 'resident cancelled'), { ok: true }); + assert.equal(w.coordinator.readerFailed(running), 1, 'premise: a late fork is handed P-1'); + const late = w.invocations.at(-1)!; + assert.deepEqual(bodies(late), { stored: ['P-1 under its own tune-out'], queued: [] }); + const forks = w.invocations.length; + assert.equal(w.coordinator.readerFailed(late), 0, 'the late fork fails too: nothing more'); + assert.equal(w.invocations.length, forks, 'and no other fork is derived'); + w.coordinator.stop(); + }); +}); + +describe('a cadence look for held messages no reader has looked at, though nothing new arrives', () => { + const held = (w: ReturnType, author: string, body: string, where: Where = {}): void => { + const m = w.store(author, body, where); + w.coordinator.recordHeld(where.serverId ?? SERVER, where.channelId ?? NOISY, m.id, [], author, true); + }; + + it('after a restart, a channel held across it gets its cadence look', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const w = world(); + w.tuneOut(); + held(w, 'ann', 'H-1 held before the restart'); + held(w, 'bo', 'H-2 held before the restart'); + w.restart(); + t.mock.timers.tick(30_000); + assert.equal(w.invocations.length, 1, 'the first period after the restart reads them'); + assert.deepEqual(bodies(w.invocations[0]!), { stored: ['H-1 held before the restart', 'H-2 held before the restart'], queued: [] }); + t.mock.timers.tick(30_000); + assert.equal(w.invocations.length, 1, 'and a quiet period after it derives none'); + w.coordinator.stop(); + }); + + it('after a restart, each channel the all-except hold holds gets its cadence look', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const w = world(); + assert.ok(w.coordinator.enterAllExcept({ serverId: SERVER, channelId: 'disc:guild:work' }, { durationSeconds: 600, cadenceSeconds: 30 }, 'agent-tool').ok); + const epochId = w.coordinator.getAllExcept()!.params.epochId; + held(w, 'ann', 'N-1 in noisy', { epochId, channelId: NOISY }); + held(w, 'bo', 'O-1 in other', { epochId, channelId: OTHER }); + w.restart(); + t.mock.timers.tick(30_000); + const byChannel = Object.fromEntries(w.invocations.map((inv) => [inv.channelId, bodies(inv)])); + assert.deepEqual(byChannel, { + [NOISY]: { stored: ['N-1 in noisy'], queued: [] }, + [OTHER]: { stored: ['O-1 in other'], queued: [] }, + }); + w.coordinator.stop(); + }); + + it("a failed fork's messages get the next cadence look", (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const w = world(); + w.tuneOut(); + w.startCadence(30); + held(w, 'ann', 'F-1 read by a fork that failed'); + t.mock.timers.tick(30_000); + assert.equal(w.invocations.length, 1); + assert.equal(w.coordinator.readerFailed(w.invocations[0]!), 1); + t.mock.timers.tick(30_000); + assert.equal(w.invocations.length, 2, 'the next period reads F-1 again'); + assert.deepEqual(bodies(w.invocations[1]!), { stored: ['F-1 read by a fork that failed'], queued: [] }); + w.coordinator.stop(); + }); + + it("the channel's own tune-out ending gives the all-except hold's unread messages there their cadence look", (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const w = world(); + assert.ok(w.coordinator.enterAllExcept({ serverId: SERVER, channelId: 'disc:guild:work' }, { durationSeconds: 600, cadenceSeconds: 30 }, 'agent-tool').ok); + const scope = w.coordinator.getAllExcept()!.params.epochId; + held(w, 'ann', 'S-1 under the scope', { epochId: scope }); + w.tuneOut(); + assert.deepEqual(w.coordinator.cancel(SERVER, NOISY, 'agent-tool', 'resident cancelled'), { ok: true }); + const before = w.invocations.length; + t.mock.timers.tick(30_000); + const looks = w.invocations.slice(before); + assert.deepEqual(looks.map((inv) => [inv.epochId, bodies(inv)]), [[scope, { stored: ['S-1 under the scope'], queued: [] }]], + "the scope's next period reads S-1"); + w.coordinator.stop(); + }); + + it("after a restart, the persistent subconscious's cadence look counts only what was held since its last look", (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const w = world({ subconscious: true }); + w.tuneOut(); + w.startCadence(30); + held(w, 'ann', 'S-1'); + held(w, 'bo', 'S-2'); + t.mock.timers.tick(30_000); + assert.equal(w.looks.length, 1); + assert.match(w.looks[0]!.text, /— 2 diverted messages since your last look/); + held(w, 'cy', 'S-3 after its look'); + w.restart(); + t.mock.timers.tick(30_000); + assert.equal(w.looks.length, 2, 'the first period after the restart has a look'); + assert.match(w.looks[1]!.text, /— 1 diverted message since your last look/, 'counting S-3 alone'); + t.mock.timers.tick(30_000); + assert.equal(w.looks.length, 2, 'and a quiet period after it has none'); + w.coordinator.stop(); + }); + + it('after a restart, a held message still queued behind the resident gets its cadence look', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const w = world(); + w.tuneOut(); + w.queue('ann', 'Q-1 queued across the restart', 'w1'); + w.restart(); + t.mock.timers.tick(30_000); + assert.equal(w.invocations.length, 1, 'the first period after the restart reads it'); + assert.deepEqual(bodies(w.invocations[0]!), { stored: [], queued: ['Q-1 queued across the restart'] }); + w.coordinator.stop(); + }); + + it("the channel's own tune-out ending gives the all-except hold's queued message there its cadence look", (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const w = world(); + assert.ok(w.coordinator.enterAllExcept({ serverId: SERVER, channelId: 'disc:guild:work' }, { durationSeconds: 600, cadenceSeconds: 30 }, 'agent-tool').ok); + const scope = w.coordinator.getAllExcept()!.params.epochId; + w.queue('ann', 'Q-1 queued under the scope', 'w1', { epochId: scope }); + w.coordinator.recordHeld(SERVER, NOISY, 'q1', [], 'ann', true); + w.tuneOut(); + assert.deepEqual(w.coordinator.cancel(SERVER, NOISY, 'agent-tool', 'resident cancelled'), { ok: true }); + const before = w.invocations.length; + t.mock.timers.tick(30_000); + const looks = w.invocations.slice(before); + assert.deepEqual(looks.map((inv) => [inv.epochId, bodies(inv)]), [[scope, { stored: [], queued: ['Q-1 queued under the scope'] }]], + "the scope's next period reads Q-1"); + w.coordinator.stop(); + }); + + it("a look while the stored last looks can't be read leaves the other channels' stored looks as they were", (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const w = world({ subconscious: true }); + w.tuneOut(); + w.tuneOut(200, { channelId: OTHER, epochId: 'e-other' }); + held(w, 'ann', 'N-1'); + w.internals.invokeSubconscious(SERVER, NOISY, 'cadence', 1); + const before = structuredClone(w.stored('last-looks')); + assert.deepEqual(Object.keys(before as object), [EPOCH], "premise: noisy's look is stored"); + w.unreadable.add('last-looks'); + w.restart(); + held(w, 'bo', 'O-1', { channelId: OTHER, epochId: 'e-other' }); + w.internals.invokeSubconscious(SERVER, OTHER, 'cadence', 1); + assert.deepEqual(w.stored('last-looks'), before, "noisy's stored look is as it was"); + w.unreadable.delete('last-looks'); + w.internals.invokeSubconscious(SERVER, OTHER, 'cadence', 1); + assert.deepEqual(Object.keys(w.stored('last-looks') as object).sort(), [EPOCH, 'e-other'].sort(), + 'once the record reads again, the next look is stored beside it'); + w.coordinator.stop(); + }); +}); + +describe('the default reader framing', () => { + const ctx = { + resident: 'scout', + fork: 'reader/scout/x/1', + voice: 'Read for scout.', + channelId: NOISY, + epochId: EPOCH, + trigger: 'wake' as const, + notice: '[Tune-out wake: disc:guild:noisy — 1 addressed message]', + dispositions: null, + }; + const row = (id: string, sequence: number, author: string, body: string) => + ({ id, sequence, participant: 'user', content: [text(body)], metadata: meta(author), timestamp: new Date() }) as unknown as StoredMessage; + const stored = row('m-1', 1, 'ann', 'stored line'); + const queued = row('held:w2', 2, 'bo', 'queued line'); + + it('shows the queued messages after the stored ones, counted with them, under a note that they are still queued', () => { + const out = textOf(defaultReaderFraming({ ...ctx, backlog: [stored, queued], queuedCount: 1 })); + assert.ok(out.includes('since your last look: 2 messages'), out); + const at = (s: string) => out.indexOf(s); + assert.ok(at('stored line') >= 0 && at('queued line') > at('stored line'), 'stored first, then queued'); + const note = "[The last one arrived while scout's turn was running, so it is still queued, not yet stored.]"; + assert.ok(at(note) > at('stored line') && at(note) < at('queued line'), out); + }); + + it('with only queued messages, the note names them all', () => { + const out = textOf(defaultReaderFraming({ ...ctx, backlog: [queued], queuedCount: 1 })); + assert.ok(out.includes("[It arrived while scout's turn was running, so it is still queued, not yet stored.]"), out); + }); + + it('with no queued messages, the framing is as it was', () => { + const out = textOf(defaultReaderFraming({ ...ctx, backlog: [stored] })); + assert.ok(!out.includes('still queued'), out); + assert.ok(out.includes('since your last look: 1 message'), out); + }); +}); diff --git a/test/tune-out-reader-report.test.ts b/test/tune-out-reader-report.test.ts new file mode 100644 index 000000000..f3e62f127 --- /dev/null +++ b/test/tune-out-reader-report.test.ts @@ -0,0 +1,53 @@ +/** + * A reader fork's `deliver_summary` counts as its report only once the mail + * is held. If the delivery throws, the fork gets an error result and is not + * marked as having reported, so its final prose is still delivered as its + * report when it ends (#546, probe R14). Before, it was marked first, and a + * failed mail write lost both the summary and the prose. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { TuneOutCoordinator, type TuneOutFrameworkHooks } from '../src/tune-out/coordinator.js'; +import type { ChannelRegistry } from '../src/mcpl/channel-registry.js'; +import type { McplServerRegistry } from '../src/mcpl/server-registry.js'; + +const CHANNEL = 'disc:guild:noisy'; +const READER = 'reader/scout/e1/1'; + +function coordinatorWith(deliver: () => void) { + const registry = { + nameShown: (ref: { channelId: string }) => ref.channelId, + ownChannelFor: () => null, + sharedNameRefusalFor: () => undefined, + channelsNamed: (id: string) => [{ serverId: 'disc', channelId: id }], + readCoordinatorState: () => null, + } as unknown as ChannelRegistry; + const hooks = { + subconsciousName: () => null, + invokeReader: () => {}, + deliverFromReader: deliver, + primaryName: () => 'scout', + emitTrace: () => {}, + } as unknown as TuneOutFrameworkHooks; + return new TuneOutCoordinator(registry, {} as McplServerRegistry, hooks); +} + +const summary = { text: 'two people asked about the build', channelId: CHANNEL }; + +describe("a reader fork's deliver_summary", () => { + it('marks the fork as having reported once the mail is held', async () => { + const delivered: unknown[] = []; + const c = coordinatorWith(() => { delivered.push(1); }); + const result = await c.handleSubconsciousTool('deliver_summary', summary, [], READER); + assert.equal(result.success, true); + assert.equal(delivered.length, 1); + assert.equal(c.readerReported(READER), true, 'so its trailing prose is not a second report'); + }); + + it('does not mark it when the delivery throws, so its prose is still its report', async () => { + const c = coordinatorWith(() => { throw new Error('durable mail write failed'); }); + await assert.rejects(c.handleSubconsciousTool('deliver_summary', summary, [], READER), /durable mail write failed/, + 'the fork gets the failure as its tool result'); + assert.equal(c.readerReported(READER), false, 'not reported: the framework delivers its final prose instead'); + }); +}); diff --git a/test/tune-out-reader-speech.test.ts b/test/tune-out-reader-speech.test.ts new file mode 100644 index 000000000..fb2a7766b --- /dev/null +++ b/test/tune-out-reader-speech.test.ts @@ -0,0 +1,30 @@ +/** + * What a reader fork can do with channel speech on: speak_in_channel is + * its, and goes out through the resident's publish path — the channel sees + * the post under the resident's account, in the fork's words; the + * resident's own channel_publish is still refused at dispatch. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { CHANNEL, rig, skip, publishCall, speakCall } from './helpers/reader-fork-rig.js'; +import { createMockResponse } from './helpers/mock-membrane.js'; + +describe('reader forks with channel speech on', { skip }, () => { + const r = rig('speech-on', true); + + it("speak_in_channel is the fork's and goes out through the resident's publish path; channel_publish is still refused", async () => { + const before = await r.tuneOut(); + r.state.membrane.push('reader', publishCall('c1'), speakCall('c2'), createMockResponse([{ type: 'text', text: 'said my piece' }])); + // The fork's report wakes scout the moment the fork ends, so scout's reply is queued now, not after. + r.state.membrane.push('resident', createMockResponse([{ type: 'text', text: 'thanks' }])); + r.emit('addressed', 'm1', 'hey scout, quick question'); + await r.waitFor(() => r.forkTraces().length >= 1, 'a reader fork is derived'); + const fork = r.forkTraces()[0]!.agentName; + await r.waitFor(() => r.published().length > before, 'the post reaches the channel'); + assert.deepEqual(r.refusals().map((t) => t.tool), ['channel_publish']); + assert.equal(r.published().length - before, 1); + assert.equal(r.published().at(-1)!.channelId, CHANNEL); + await r.waitFor(() => r.state.framework!.getAgentRecord(fork)?.ended !== undefined, 'the fork ends'); + await r.state.framework!.runUntilIdle(); + }); +}); diff --git a/test/tune-out-reader-tools.test.ts b/test/tune-out-reader-tools.test.ts new file mode 100644 index 000000000..5c8a50acb --- /dev/null +++ b/test/tune-out-reader-tools.test.ts @@ -0,0 +1,39 @@ +/** + * What a reader fork can do with channel speech off (docs/dendrite.md, + * "What a reader fork can do"): its own three tools; every other tool the + * resident has — speak_in_channel among them — stays in the block it is + * shown, the shared prefix, and is refused at dispatch, so the fork can + * post nowhere (connectome-host#334, scout's condition). + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { CHANNEL, rig, skip, publishCall, speakCall, toolNames } from './helpers/reader-fork-rig.js'; +import { createMockResponse } from './helpers/mock-membrane.js'; + +describe('reader forks with channel speech off', { skip }, () => { + const r = rig('speech-off', false); + + it("the resident's tools stay in the fork's block and are refused at dispatch; nothing is published", async () => { + const before = await r.tuneOut(); + // The fork tries the resident's publish tool, then speak_in_channel, then reports. + r.state.membrane.push('reader', publishCall('c1'), speakCall('c2'), createMockResponse([{ type: 'text', text: 'nothing urgent' }])); + // The fork's report wakes scout the moment the fork ends, so scout's reply is queued now, not after. + r.state.membrane.push('resident', createMockResponse([{ type: 'text', text: 'thanks' }])); + r.emit('addressed', 'm1', 'hey scout, quick question'); + await r.waitFor(() => r.forkTraces().length >= 1, 'a reader fork is derived'); + const fork = r.forkTraces()[0]!.agentName; + await r.waitFor(() => r.refusals().length >= 2, 'both calls are refused at dispatch'); + assert.deepEqual( + r.refusals().map((t) => [t.tool, t.error]), + [['channel_publish', `channel_publish is not available to ${fork}`], ['speak_in_channel', `speak_in_channel is not available to ${fork}`]], + ); + await r.waitFor(() => r.state.framework!.getAgentRecord(fork)?.ended !== undefined, 'the fork ends'); + const readerRequest = r.state.membrane.calls.find((c) => JSON.stringify(c.messages).includes('[Tune-out reader'))!; + assert.ok(toolNames(readerRequest).includes('channel_publish'), 'still advertised: the block is the resident\'s, for the shared prefix'); + assert.equal(r.published().length - before, 0, 'nothing of the fork\'s reached the channel'); + // Its report still goes to scout. + await r.waitFor(() => r.state.framework!.getAgent('scout')!.getContextManager().getAllMessages().some((m) => (m.metadata as { kind?: string } | undefined)?.kind === 'agent-result'), 'the report reaches scout'); + await r.state.framework!.runUntilIdle(); + }); +}); + diff --git a/test/tune-out-timers.test.ts b/test/tune-out-timers.test.ts index e7feda179..c44dd58c5 100644 --- a/test/tune-out-timers.test.ts +++ b/test/tune-out-timers.test.ts @@ -69,11 +69,15 @@ function harness( let cancelCalls = 0; /** The reason of each inference requested, the subconscious's turns among them. */ const inferences: string[] = []; + /** The coordinator's own durable state: the all-except hold lives here. */ + const coordinatorState = new Map(); const registry = { // Each channel's id is unique here, so it's shown by its id. nameShown: (channel: { channelId: string }) => channel.channelId, quotedShown: (channel: { channelId: string }) => JSON.stringify(channel.channelId), listChannelsRaw: () => (state ? [{ serverId: 'disc', descriptor: { id: '#a' } }] : []), + readCoordinatorState: (id: string) => coordinatorState.get(id) ?? null, + writeCoordinatorState: (id: string, value: unknown) => { if (value === null) coordinatorState.delete(id); else coordinatorState.set(id, value); }, getTuneOutState: () => state, enterTuneOut: (_s: string, _c: string, params: TuneOutParams) => { entered.push(params); @@ -116,6 +120,8 @@ function harness( t.mock.timers.reset(); }); const cadenceTurns = () => inferences.filter((r) => r.startsWith('tune-out cadence')); + /** The resident's turns for a hold that ended, with the reason. */ + const releases = () => inferences.filter((r) => r.startsWith('tune-out cancelled (')); const wakeTurns = () => inferences.filter((r) => r.startsWith('tune-out wake')); const noTimerPastOne = () => assert.ok( delays.every((ms) => ms <= ONE_TIMER_MS), @@ -134,7 +140,7 @@ function harness( /** The arguments of each line written to stderr. */ const stderrLines = () => stderr.mock.calls.map((c) => c.arguments as unknown[]); return { - coordinator, entered, cancels, cadenceTurns, wakeTurns, delays, noTimerPastOne, endInRegistry, active, + coordinator, entered, cancels, cadenceTurns, releases, wakeTurns, delays, noTimerPastOne, endInRegistry, active, failNext, timerFailures, ackFailures, notices, stderrLines, cancelCalls: () => cancelCalls, }; } @@ -333,6 +339,67 @@ it('an option below its floor is raised to it, as before', (t) => { ); }); +// The all-except hold (Dendrite) arms its own end time and cadence, and reads +// its options by the same rule as a channel's tune-out. + +it('an all-except hold past one timer ends at its end time and not before', (t) => { + const h = harness(t); + assert.equal(h.coordinator.enterAllExcept({ serverId: 'disc', channelId: '#a' }, { durationSeconds: 30 * 86_400 }, 'agent-tool').ok, true); + t.mock.timers.tick(ONE_TIMER_MS); + assert.ok(h.coordinator.getAllExcept(), "still held after one timer's worth"); + t.mock.timers.tick(30 * DAY_MS - ONE_TIMER_MS - 1); + assert.ok(h.coordinator.getAllExcept(), 'still held 1 ms before its end'); + assert.deepEqual(h.releases(), [], 'the resident has not been woken'); + t.mock.timers.tick(1); + assert.equal(h.coordinator.getAllExcept(), null, 'released at its end time'); + assert.deepEqual(h.releases(), ['tune-out cancelled (duration elapsed)'], 'one wake, at the end time'); + h.noTimerPastOne(); +}); + +it('an all-except cadence past one timer gives the subconscious a turn each period for a held channel with traffic', (t) => { + const h = harness(t); + assert.equal( + h.coordinator.enterAllExcept({ serverId: 'disc', channelId: '#a' }, { durationSeconds: 90 * 86_400, cadenceSeconds: 30 * 86_400 }, 'agent-tool').ok, + true, + ); + assert.ok(h.coordinator.onIncoming('disc', '#b', 'm1', undefined), 'a message in another channel is held'); + t.mock.timers.tick(ONE_TIMER_MS); + t.mock.timers.tick(30 * DAY_MS - ONE_TIMER_MS - 1); + assert.deepEqual(h.cadenceTurns(), [], 'no turn before the cadence'); + t.mock.timers.tick(1); + assert.deepEqual(h.cadenceTurns(), ['tune-out cadence: #b'], 'one turn at the cadence'); + h.coordinator.onIncoming('disc', '#b', 'm2', undefined); + t.mock.timers.tick(ONE_TIMER_MS); + t.mock.timers.tick(30 * DAY_MS - ONE_TIMER_MS); + assert.equal(h.cadenceTurns().length, 2, 'a second turn a period later'); + h.noTimerPastOne(); +}); + +it('enterAllExcept refuses an option that gives no finite number, and a duration left out, before anything changes', (t) => { + const h = harness(t); + const except = { serverId: 'disc', channelId: '#a' }; + const error = (r: { ok: boolean; error?: string }) => (assert.equal(r.ok, false), r.error); + assert.equal( + error(h.coordinator.enterAllExcept(except, { durationSeconds: 'abc' as unknown as number }, 'agent-tool')), + 'Nothing was tuned out: durationSeconds must be a finite number of seconds, and it was the string "abc".', + ); + assert.equal( + error(h.coordinator.enterAllExcept(except, { durationSeconds: 3600, cadenceSeconds: Number.NaN }, 'agent-tool')), + 'Nothing was tuned out: cadenceSeconds must be a finite number of seconds, and it was NaN. Leave it out for the default, 1800.', + ); + assert.equal( + error(h.coordinator.enterAllExcept(except, { durationSeconds: Number.MAX_VALUE }, 'agent-tool')), + `Nothing was tuned out: durationSeconds ${Number.MAX_VALUE} is longer than an end time can be kept.`, + ); + assert.match(error(h.coordinator.enterAllExcept(except, {}, 'agent-tool'))!, /needs `durationSeconds`/); + assert.match(error(h.coordinator.enterAllExcept(except, { durationSeconds: null as unknown as number }, 'agent-tool'))!, /needs `durationSeconds`/); + assert.equal(h.coordinator.getAllExcept(), null, 'nothing was held'); + assert.deepEqual(h.delays, [], 'no timer was armed'); + const read = h.coordinator.enterAllExcept(except, { durationSeconds: '3600' as unknown as number }, 'agent-tool'); + assert.equal(read.ok, true, 'a numeric string is read as its number'); + assert.equal((read as { params: { expiresAtMs: number } }).params.expiresAtMs, NOW + 3_600_000); +}); + it('armAt arms a deadline that has passed for the next turn of the loop, with no negative-delay warning', async () => { // Real timers: Node 23 and later warn about a negative delay. const warnings: string[] = []; @@ -369,6 +436,49 @@ it("a cadence turn that throws stays in its timer, is reported, and the next per assert.match(h.notices.at(-1) ?? '', /— 1 diverted message since your last look\]$/); }); +it('an all-except release that throws at its end time stays in its timer, is reported, and is tried again', (t) => { + const h = harness(t); + assert.equal(h.coordinator.enterAllExcept({ serverId: 'disc', channelId: '#a' }, { durationSeconds: 120 }, 'agent-tool').ok, true); + h.failNext('addMessage'); + assert.doesNotThrow(() => t.mock.timers.tick(120_000), "the release's error stays in the timer"); + assert.deepEqual(h.timerFailures(), [failure('expiry')]); + assert.ok(h.coordinator.getAllExcept(), 'the hold is still in force, its release owed'); + t.mock.timers.tick(60_000); + assert.equal(h.coordinator.getAllExcept(), null, 'released on the retry'); + assert.deepEqual(h.releases(), ['tune-out cancelled (duration elapsed)']); +}); + +it('an all-except cadence turn that throws stays in its timer and the next period comes', (t) => { + const h = harness(t); + assert.equal(h.coordinator.enterAllExcept({ serverId: 'disc', channelId: '#a' }, { durationSeconds: 3600, cadenceSeconds: 60 }, 'agent-tool').ok, true); + assert.ok(h.coordinator.onIncoming('disc', '#b', 'm1', undefined), 'a message in another channel is held'); + h.failNext('addMessage'); + assert.doesNotThrow(() => t.mock.timers.tick(60_000), "the turn's error stays in the timer"); + assert.deepEqual(h.timerFailures(), [failure('cadence')]); + t.mock.timers.tick(60_000); + assert.deepEqual(h.cadenceTurns(), ['tune-out cadence: #b'], "the next period's turn comes"); +}); + +it("a channel's tune-out outlives its reader: once the reader stops, its end time still releases it", (t) => { + const h = harness(t); + assert.equal(h.coordinator.enter('disc', '#a', { durationSeconds: 120, cadenceSeconds: 60 }, 'agent-tool').ok, true); + h.coordinator.onIncoming('disc', '#a', 'm1', undefined); + h.coordinator.stopReader(); + t.mock.timers.tick(60_000); + assert.deepEqual(h.cadenceTurns(), [], 'no reader turns after the reader stopped'); + t.mock.timers.tick(60_000); + assert.equal(h.active(), false, 'ended at its end time'); +}); + +it('an all-except hold outlives its reader: once the reader stops, its end time still releases it', (t) => { + const h = harness(t); + assert.equal(h.coordinator.enterAllExcept({ serverId: 'disc', channelId: '#a' }, { durationSeconds: 120 }, 'agent-tool').ok, true); + h.coordinator.stopReader(); + t.mock.timers.tick(120_000); + assert.equal(h.coordinator.getAllExcept(), null, 'released at its end time'); + assert.deepEqual(h.releases(), ['tune-out cancelled (duration elapsed)']); +}); + it('a timer failure is written to stderr with the error', (t) => { const h = harness(t); assert.equal(h.coordinator.enter('disc', '#a', { cadenceSeconds: 60 }, 'agent-tool').ok, true); diff --git a/test/turn-continuation.test.ts b/test/turn-continuation.test.ts index 5c4c790c5..865ab4ae2 100644 --- a/test/turn-continuation.test.ts +++ b/test/turn-continuation.test.ts @@ -1675,13 +1675,13 @@ describe("a conversation agent's disposal while its turn's continuation is held" framework.onTrace((e) => { traces.push(e); }); const internal = framework as unknown as Internals & { spawnConversationAgent(channel: { serverId: string; channelId: string }, decision: { agentName: string; generation: number }): Promise; - conversationAgentHomes: Map; }; try { framework.start(); // The channel's fork, spawned as a routing spawns it, takes a turn whose tool round restarts it. await internal.spawnConversationAgent({ serverId: 'zz-server', channelId: 'zz-chat' }, { agentName: fork, generation: 1 }); - assert.ok(internal.conversationAgentHomes.has(fork), 'a conversation agent'); + // On this branch a fork's home is the registry's (homeChannel), not a framework map. + assert.ok(framework.getAgentRecord(fork)?.kind === 'conversation-fork', 'a conversation agent'); framework.getAgent(fork)!.getContextManager().addMessage('User', [{ type: 'text', text: 'fetch it' }]); internal.pendingRequests.push({ agentName: fork, reason: 'conversation', source: 'test', timestamp: Date.now() }); await waitFor("the fork's continuation to be held, its frame gone", () => diff --git a/test/workspace-lost-contents.test.ts b/test/workspace-lost-contents.test.ts index c1175a1ad..5f15c4fc3 100644 --- a/test/workspace-lost-contents.test.ts +++ b/test/workspace-lost-contents.test.ts @@ -15,6 +15,54 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'); const sha = (bytes: string | Buffer): string => createHash('sha256').update(bytes).digest('hex'); +/** + * Whether storing a blob's bytes again replaces a file for them the store + * can't read: one torn as Env.damage tears it, cut short by six bytes while + * the store is closed, so no cache of an open store answers for it. Both + * chronicles these tests run against are legitimate, and they differ here. + * Released @animalabs/chronicle 0.4.0 takes any file already at the hash's + * path as stored, so the bad file stays: a read answered from disk's matching + * copy tells 'workspace-contents-unstored', and each read does so again. + * Chronicle main's storeBlob, since the #60 series (stage each blob and rename + * it into place), reads a file already there back and rewrites it where it + * can't be read or holds other bytes, so storing disk's copy takes: the read + * tells 'workspace-contents-restored' once, and the store answers the reads + * after it. As the workspace module does, the probe judges by reading the + * bytes back, not by what storeBlob returns. + */ +function probeRewritesTornBlob(): boolean { + const root = mkdtempSync(join(tmpdir(), 'af-ws-lost-probe-')); + const storePath = join(root, 'probe.chronicle'); + const bytes = Buffer.from('a blob the probe tears\n'); + try { + let store = JsStore.openOrCreate({ path: storePath }); + const hash = store.storeBlob(bytes, 'application/octet-stream'); + store.close(); + const file = join(storePath, 'blobs', hash.slice(0, 2), hash); + writeFileSync(file, readFileSync(file).subarray(0, -6)); + store = JsStore.openOrCreate({ path: storePath }); + try { + assert.throws(() => store.getBlob(hash), 'the probe tears its blob as Env.damage does'); + try { + store.storeBlob(bytes, 'application/octet-stream'); + } catch { + // A store that refuses them hasn't taken them, as the module reads it. + } + try { + return store.getBlob(hash)?.equals(bytes) ?? false; + } catch { + return false; + } + } finally { + store.close(); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const rewritesTornBlob = probeRewritesTornBlob(); + type Alert = { kind: string; agent: string; message: string; data?: Record }; class Env { @@ -682,10 +730,21 @@ describe("contents whose blob file the store can't read", () => { const found = await ok(m, 'grep', { pattern: 'needle' }); assert.deepEqual(found.results.map((r: { file: string }) => r.file), ['work/a.txt', 'work/b.txt']); assert.equal(await textOf(m, 'work/a.txt'), 'needle a\n'); - assert.deepEqual(env.kinds(), ['workspace-contents-unstored', 'workspace-contents-unstored']); assert.equal(typeof env.alerts[0]!.data!.storeError, 'string'); - assert.match(env.alerts[0]!.message, /unreadable in the workspace's store .*reads are answered from it, but storing it again didn't take: the store can't read its file for them/); assert.equal(env.alerts[0]!.data!.blobHash, a); + if (rewritesTornBlob) { + // grep's read stored disk's copy in place of the bad file, so the read + // after it is answered from the store, and nothing more is told. + assert.deepEqual(env.kinds(), ['workspace-contents-restored']); + assert.match(env.alerts[0]!.message, /^Blob [0-9a-f]+: the saved contents of work\/a\.txt were unreadable in the workspace's store \(.+\), and were stored again from disk's copy, whose bytes have their hash\.$/); + assert.equal(env.storedText(a), 'needle a\n'); + } else { + // The store kept the bad file, so grep and the read are each answered + // from disk, and each tells it. + assert.deepEqual(env.kinds(), ['workspace-contents-unstored', 'workspace-contents-unstored']); + assert.match(env.alerts[0]!.message, /unreadable in the workspace's store .*reads are answered from it, but storing it again didn't take: the store can't read its file for them/); + assert.throws(() => env.store.getBlob(a)); + } }); test("with other contents on disk, read says so, and a sync takes disk's copy in their place", async (t) => { @@ -735,7 +794,20 @@ describe("contents whose blob file the store can't read", () => { assert.equal(await textOf(m, 'work/a.txt'), 'the same NEEDLE\n'); assert.equal(await textOf(m, 'work/b.txt'), 'the same NEEDLE\n'); } - assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), [['workspace-contents-unstored', `work/${restoring}`]], `${tool}, ${unrestorable} first`); + if (rewritesTornBlob) { + // The restoring file's copy was stored in place of the bad file, so + // the store answers either file from now on, telling nothing more, + // even the one whose disk copy holds other contents. + assert.equal(env.storedText(hash), 'the same needle\n'); + if (tool === 'grep') { + assert.equal(await textOf(m, 'work/a.txt'), 'the same needle\n'); + assert.equal(await textOf(m, 'work/b.txt'), 'the same needle\n'); + } + } else { + assert.throws(() => env.store.getBlob(hash)); + } + const told = rewritesTornBlob ? 'workspace-contents-restored' : 'workspace-contents-unstored'; + assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), [[told, `work/${restoring}`]], `${tool}, ${unrestorable} first`); } } }); @@ -760,7 +832,19 @@ describe("contents whose blob file the store can't read", () => { }); assert.equal(await textOf(m, 'work/b.txt'), 'the same NEEDLE\n'); } - assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), [['workspace-contents-unstored', 'work/a.txt']], tool); + if (rewritesTornBlob) { + // The first file's copy was stored in place of the bad file, so the + // store answers both from now on, telling nothing more. + assert.equal(env.storedText(hash), 'the same needle\n'); + if (tool === 'grep') { + assert.equal(await textOf(m, 'work/a.txt'), 'the same needle\n'); + assert.equal(await textOf(m, 'work/b.txt'), 'the same needle\n'); + } + } else { + assert.throws(() => env.store.getBlob(hash)); + } + const told = rewritesTornBlob ? 'workspace-contents-restored' : 'workspace-contents-unstored'; + assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), [[told, 'work/a.txt']], tool); } }); @@ -775,7 +859,17 @@ describe("contents whose blob file the store can't read", () => { await ok(m, 'edit_many', { edits: [{ path: 'work/b.txt', oldString: 'beta', newString: 'BETA' }] }); await ok(m, 'materialize', { path: 'work' }); assert.deepEqual([env.readDisk('a.txt'), env.readDisk('b.txt')], ['ALPHA\n', 'BETA\n']); - assert.deepEqual(env.kinds(), ['workspace-contents-unstored', 'workspace-contents-unstored']); + if (rewritesTornBlob) { + // Each plan's read also stored disk's copy in place of its bad file. + assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), + [['workspace-contents-restored', 'work/a.txt'], ['workspace-contents-restored', 'work/b.txt']]); + assert.deepEqual([env.storedText(a), env.storedText(b)], ['alpha\n', 'beta\n']); + } else { + assert.deepEqual(env.alerts.map(({ kind, data }) => [kind, data?.path]), + [['workspace-contents-unstored', 'work/a.txt'], ['workspace-contents-unstored', 'work/b.txt']]); + assert.throws(() => env.store.getBlob(a)); + assert.throws(() => env.store.getBlob(b)); + } }); test('whoever runs the host is told that removing the unreadable blob file lets a read store the contents again, and it does', async (t) => { @@ -785,11 +879,23 @@ describe("contents whose blob file the store can't read", () => { m = await env.damage(m, hash); assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); - assert.match(env.alerts[0]!.message, /Chronicle keeps a blob file it can't read in place, so where the file is damaged, removing it lets the next read store them\.$/); - rmSync(join(env.storePath, 'blobs', hash.slice(0, 2), hash)); - assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); - assert.equal(env.storedText(hash), 'alpha\n'); - assert.deepEqual(env.kinds(), ['workspace-contents-unstored', 'workspace-contents-restored']); + if (rewritesTornBlob) { + // This chronicle replaces the bad file when the read stores disk's + // copy, so there is nothing to remove: the read stores the contents + // again itself, the notice offers no removal, and the next read is + // answered from the store, telling nothing more. + assert.deepEqual(env.kinds(), ['workspace-contents-restored']); + assert.doesNotMatch(env.alerts[0]!.message, /removing it/); + assert.equal(env.storedText(hash), 'alpha\n'); + assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); + assert.deepEqual(env.kinds(), ['workspace-contents-restored']); + } else { + assert.match(env.alerts[0]!.message, /Chronicle keeps a blob file it can't read in place, so where the file is damaged, removing it lets the next read store them\.$/); + rmSync(join(env.storePath, 'blobs', hash.slice(0, 2), hash)); + assert.equal(await textOf(m, 'work/a.txt'), 'alpha\n'); + assert.equal(env.storedText(hash), 'alpha\n'); + assert.deepEqual(env.kinds(), ['workspace-contents-unstored', 'workspace-contents-restored']); + } }); test('materialize lists a draft whose blob it can\'t read as skipped, saying why, and writes the rest', async (t) => {