Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
174 commits
Select commit Hold shift + click to select a range
b28009b
feat(dendrite): agent registry, lifecycle and attributed results
tel-0s Oct 6, 2026
1fbd886
feat(dendrite): derive, resume and inspect agents; role assignment at…
tel-0s Oct 6, 2026
1801a2d
test(dendrite): run the dispatch-deny test under copy inheritance
tel-0s Oct 6, 2026
e9f9101
docs(dendrite): record store-level fork costs
tel-0s Oct 6, 2026
bde810a
feat(dendrite): name the model at creation; deliver means entered the…
tel-0s Oct 6, 2026
f97196f
docs(dendrite): list the off-branch sequence scan as a remaining Chro…
tel-0s Oct 6, 2026
b35f1c4
feat(dendrite): inherit the parent's refusal ledger on request
tel-0s Oct 6, 2026
10376fe
docs(dendrite): state that an inherited refusal ledger is consultable…
tel-0s Oct 7, 2026
d928e2b
feat(dendrite): the tune-out reader as a succession of forks
tel-0s Oct 9, 2026
7f88c74
feat(dendrite): a reader fork runs on the resident's weights
tel-0s Oct 9, 2026
4e14f21
feat(dendrite): forks mode states whose weights; `voice` replaces `sy…
tel-0s Oct 9, 2026
9f8d957
feat(dendrite): a fork sees the call that made it; reader framing is …
tel-0s Oct 9, 2026
1bdaa18
feat(dendrite): the derived-you note names kind and lifetime
tel-0s Oct 10, 2026
6acb89c
test(dendrite): live acceptance of the rendering contract on the prov…
tel-0s Oct 11, 2026
cfc2b51
feat(dendrite): the admission stage; `speech.locus` defaults to the §…
tel-0s Oct 12, 2026
0a9d104
docs(dendrite): live acceptance leads with counts; reuse follows the …
tel-0s Oct 12, 2026
e3fbc64
feat(dendrite): the all-except scope — focus folded into tune-out
tel-0s Oct 12, 2026
a484a81
feat(dendrite): the excepted channel hears the all-except scope open …
tel-0s Oct 12, 2026
94dfea7
fix(dendrite): the first live run's findings — prose reports, address…
tel-0s Oct 13, 2026
d1f8a21
Merge main (0.22.1) into feat/dendrite: the turn-routing rework meets…
tel-0s Oct 14, 2026
85b9f88
refactor(dendrite): main's two hold checks read the admission stage's…
tel-0s Oct 14, 2026
bf5de4f
feat(dendrite): a derivation tells the agent it was made from (#270)
tel-0s Oct 15, 2026
695ab6d
feat(dendrite): derivation notices collapse to a count past a thresho…
tel-0s Oct 15, 2026
f3c94de
fix(mcpl): a channel message is stored as the model reads it, under i…
tel-0s Oct 16, 2026
8d92a3a
test: the deliverTo store site is pinned; the reader-forks e2e skips …
tel-0s Oct 17, 2026
02cf278
Merge main (3af13bf, 0.22.2) into feat/dendrite: the deferred-write r…
tel-0s Oct 17, 2026
220f4cf
Merge main (aae86b7f, 0.22.3) into feat/dendrite: held prose drafts, …
tel-0s Oct 18, 2026
49eae9d
Merge main (9c6964c9) into feat/dendrite: an ephemeral run takes no f…
tel-0s Oct 19, 2026
93424f6
Merge main (2f05c28e, 0.23.1) into feat/dendrite: marker tags, one us…
tel-0s Oct 19, 2026
e645dbc
Reader forks run under the resident's yes: the consent record, its dr…
tel-0s Oct 19, 2026
23ca551
The consent gate and the derivation notices become policies with defa…
tel-0s Oct 19, 2026
0ff40f8
The consent model leaves the framework; two hooks and a guarantee tak…
tel-0s Oct 19, 2026
b20be3b
Remove twelve Node diagnostic reports committed by mistake; ignore them
tel-0s Oct 22, 2026
be26b1a
diagnostics-signal test: keep Node from writing a report on the signa…
tel-0s Oct 22, 2026
c649eb8
Merge main (e4d1e468) into feat/dendrite: sixth catch-up
tel-0s Oct 23, 2026
6ba5f16
The registry's durable state keeps main's rules: nissa's #546 finding…
tel-0s Oct 23, 2026
61491b3
The registry's writes: a requested change is put back on failure, a r…
tel-0s Oct 23, 2026
11a868e
Two test notes from the merge's second read: the fake's reader filter…
tel-0s Oct 23, 2026
83b66ca
The registry's end: a stop is a requested change, a run's end or a re…
tel-0s Oct 23, 2026
a40c660
Merge main (a4a74b48) into feat/dendrite: #710's server-qualified cha…
Oct 24, 2026
cba4d05
feat(dendrite): a fork's home is a channel with its server
Oct 24, 2026
219bda5
Merge main (8ec1de5a) into feat/dendrite: 0.26.2's changelog and #720
Oct 24, 2026
7b11e5c
fix(dendrite): reader forks are handed what is queued behind the resi…
antra-tess Oct 24, 2026
f7a8577
fix(dendrite): queued messages in the framing backlog, a failed fork'…
antra-tess Oct 24, 2026
848b689
fix(dendrite): a reader fork has reported only once its summary's mai…
antra-tess Oct 24, 2026
9a04881
Merge main (ccccc31c) into feat/dendrite: seventh catch-up, on #790 a…
tel-0s Oct 24, 2026
d7b0976
Merge main (371923e9) into feat/dendrite: eighth catch-up
tel-0s Oct 25, 2026
abec840
Merge main (65f219b4, 0.27.0) into feat/dendrite: ninth catch-up
tel-0s Oct 27, 2026
0141141
fix(dendrite): the derived manager takes the run's membrane; an ended…
tel-0s Oct 27, 2026
07d34d4
Merge main (f14e7fbc, 0.28.0) into feat/dendrite: eleventh catch-up
tel-0s Oct 28, 2026
bc7b069
A shared fork sees its own stored injections; four tests for the elev…
tel-0s Oct 28, 2026
da0e7a3
fix(dendrite): the fork's view goes to derive, not onto the manager a…
tel-0s Oct 28, 2026
6e70480
Merge main (62a126c784, 0.29.0) into feat/dendrite
Oct 28, 2026
69847b6
test(dendrite): a derived agent's host toolsets, and a reader fork's …
Oct 28, 2026
22305b5
fix(dendrite): a derived agent is shown its parent's host presentation
Oct 28, 2026
b62188f
fix(dendrite): a reader fork may call only its own tools and allowed …
Oct 28, 2026
b4d85d1
fix(dendrite): a task deadline past the longest timer waits it out
Oct 28, 2026
63b4873
fix(dendrite): create()'s trace replay keeps the start of the stream
Oct 28, 2026
e04ca0f
fix(tune-out): the all-except hold's record is read, written and rele…
Oct 28, 2026
c425efe
fix(tune-out): the all-except hold's timers keep a failure in the tim…
Oct 28, 2026
5a409e5
fix(tune-out): a hold outlives its reader's end, and still ends at it…
Oct 28, 2026
b692e35
fix(dendrite): a tool refused at dispatch is refused on every path th…
Oct 28, 2026
6f15a61
fix(tune-out): an all-except hold's backlog cap counts whole messages
Oct 28, 2026
223820f
fix(tune-out): a held deferred batch is stored under its hold when it…
Oct 28, 2026
b4ccda1
fix(dendrite): a reader fork may call its own tools whatever its resi…
Oct 28, 2026
ae2b1b4
fix(tune-out): cancel_tuneout naming the all-except scope ends the scope
Oct 28, 2026
146e283
fix(tune-out): reader hand-offs are kept per epoch, not per channel
Oct 28, 2026
d34149f
fix(tune-out): held messages no reader has looked at get their cadenc…
Oct 28, 2026
abda7b6
docs(dendrite): bring the docs and fragments to what the code does
Oct 28, 2026
c75cc0a
docs(dendrite): the reader-fork example's model, and a reused name's …
Oct 28, 2026
26ce22c
fix(tune-out): cancel_tuneout naming the scope "channel" ends only a …
Oct 28, 2026
c7b93d6
fix(dendrite): what judges a call's effect uses the rule dispatch app…
Oct 28, 2026
6ee4a3c
fix(tune-out): a failed reader fork's messages go back to its epoch w…
Oct 28, 2026
b2e2e18
test(dendrite): each registry and lifecycle test can fail on what it …
Oct 28, 2026
a72c9a0
test(routing): three routing tests can fail on what they name
Oct 28, 2026
e4a1926
test(tune-out): the truncation note names the resident's history tool…
Oct 28, 2026
9af1307
fix(gate): a batched push event's hold is looked up by its registered…
Oct 28, 2026
b0ba6af
fix(tune-out): a held line names its author once, and a blank name as…
Oct 28, 2026
a6489b5
fix(tune-out): all-except release and check keep two servers' channel…
Oct 28, 2026
8cddd86
fix(routing): an addressed-elsewhere notice whose store fails is stil…
Oct 28, 2026
77fe8ed
fix(routing): an empty channelId on channel_publish is refused as empty
Oct 28, 2026
e324026
fix(routing): the routing announcement's baseline survives a restart
Oct 28, 2026
761b879
fix(tune-out): focus and tune_out read their options as the coordinat…
Oct 28, 2026
6577de4
fix(routing): plain speech and channel_publish resolve in one order
Oct 28, 2026
4842fc8
fix(channels): channel_close ends the all-except hold whatever the ch…
Oct 28, 2026
f58189e
docs(channels): channel_publish's description says where an omitted c…
Oct 28, 2026
aa6b659
fix(dendrite): a host's call of a reader's own tool runs it, as the m…
Oct 28, 2026
3e271ee
fix(dendrite): a push is gated as its own server's push, whatever its…
Oct 28, 2026
1eeb3bf
fix(dendrite): a server can't supply the stamp that releases held mail
Oct 28, 2026
29dc68b
fix(dendrite): a restored batch whose wake fails is logged, not left …
Oct 28, 2026
a1b3707
fix(dendrite): a call refused at dispatch says it was refused
Oct 28, 2026
66fcbf4
fix(dendrite): stopping an agent whose stream's cancel throws still r…
Oct 28, 2026
0ee6787
fix(dendrite): a sleep ends with the agent that set it
Oct 28, 2026
468c3d2
fix(dendrite): a conversation fork's end is recorded with its own reason
Oct 28, 2026
15ce57f
fix(dendrite): a derivation that fails after its child is made closes…
Oct 28, 2026
7de5349
fix(dendrite): a creation whose notice to its source fails still runs
Oct 28, 2026
f1d89c8
fix(dendrite): resumeAgent: one resume at a time, the host asked, the…
Oct 28, 2026
907e733
fix(dendrite): an inherited voice's request-only rows are rendered as…
Oct 28, 2026
bb3f15f
fix(dendrite): a copy derivation refuses what only a shared one inherits
Oct 28, 2026
6b7d2c2
fix(dendrite): mail from an agent the recipient counts as its own voi…
Oct 28, 2026
67d9bee
docs(dendrite): mail stored as <sender> (mail), and what a copy refuses
Oct 28, 2026
c08a467
docs(dendrite): what roles decide, and the two kind values the framew…
Oct 28, 2026
c7d958e
fix(dendrite): a registry file that reads as null, or a registry with…
Oct 28, 2026
9a975dc
fix(dendrite): a change that was put back leaves no written copy of t…
Oct 28, 2026
c70427f
fix(dendrite): an agent whose end was written before the snapshot let…
Oct 28, 2026
1f8ea23
feat(dendrite): agentRegistryPath and agentRegistryEndedPath, for an …
Oct 28, 2026
e0e0f72
fix(dendrite): interrupted jobs stay in the registry until their reci…
Oct 28, 2026
47c6167
fix(dendrite): an agent that ended past the registry's memory is foun…
Oct 28, 2026
bcb3e4f
fix(dendrite): a finished orphan's held result is told of, and repare…
Oct 28, 2026
6e6b95b
fix(dendrite): a worker's context opens in the namespace its spec names
Oct 28, 2026
624a9bc
fix(dendrite): a job cancelled with its stream in flight rejects with…
Oct 28, 2026
033bc8e
test(dendrite): the task-deadline test awaits the outcome, not the gate
Oct 28, 2026
ef8ef5e
test(dendrite): keep reader tool permissions independent of resident …
Oct 28, 2026
3c58bfd
docs(dendrite): what beforeDerive is asked about, now that a resume a…
Oct 28, 2026
e0f0843
docs(dendrite): channel_publish with no channelId, and when a turn wi…
Oct 28, 2026
5e70233
fix(dendrite): the ended log's index notices a log that changed under it
Oct 28, 2026
d076667
fix(dendrite): a worker can't open a namespace a live agent's context…
Oct 28, 2026
ca391f9
fix(dendrite): an orphan that ends with its result held tells its fir…
Oct 28, 2026
93e7069
test(dendrite): the put-back's guards and the registry's own counter …
Oct 28, 2026
516d829
fix(dendrite): the missing-recovery-paths warning agrees with what it…
Oct 28, 2026
0f0d5b7
fix(dendrite): the put-back rewrites the recovery files only
Oct 28, 2026
be400b0
docs(dendrite): derivations emit their trace; whether it's kept is th…
Oct 28, 2026
d8fc741
fix(dendrite): a namespace belongs to the agent that opened it
Oct 28, 2026
548ce3e
test(tune-out): fakes standing for a store with no hold say so
Oct 28, 2026
eb6d7bb
test(dendrite): the puppet test's fake agent answers the dispatch deny
Oct 28, 2026
23fc94d
test(dendrite): the puppet test's fake framework has an empty registry
Oct 28, 2026
b162b8f
test(prose): the latest-inbound fallback for a channel-less wake is p…
Oct 28, 2026
92f9fc5
fix(tune-out): a held channel's tally counts its queued messages too
Oct 28, 2026
411cc47
fix(routing): focus says where the calling turn's speech goes
Oct 28, 2026
6f9df9b
fix(tune-out): a reader fork that fails after its tune-out ended gets…
Oct 28, 2026
e3404e6
fix(dendrite): an ended log that can't be read fails the lookup
Oct 28, 2026
07a5b47
test(tune-out): fake registries answer the reads a cancel now makes
Oct 28, 2026
8051748
fix(dendrite): a resumed job registers past the incarnation it resumes
Oct 28, 2026
121dbb4
fix(dendrite): a resume's hold on its name is released only by the ca…
Oct 28, 2026
1ce9bd9
fix(dendrite): an ended agent's trace names its spawner
Oct 28, 2026
0262d1a
fix(dendrite): only bounded work is dropped at boot as already ended
Oct 28, 2026
7c74fb8
fix(dendrite): a restart notice isn't repeated for a job an earlier o…
Oct 28, 2026
2565f5d
docs(dendrite): reparenting moves held mail, results and messages alike
Oct 28, 2026
ba0ad79
fix(tune-out): an all-except release is delivered once however often …
Oct 28, 2026
e62380b
fix(tune-out): a failed read of the stored last looks writes nothing …
Oct 28, 2026
50b4ae4
test(tune-out): the held-batch test checks its hold woke nobody after…
Oct 28, 2026
05f4e3f
fix(tune-out): a held batch that opens while another renders is store…
Oct 28, 2026
cce0bda
docs(dendrite): text that says more than the code, from the set's review
Oct 28, 2026
a4d1111
fix(dendrite): a worker whose spawner ended before it ran registers a…
Oct 28, 2026
2469738
fix(dendrite): mail held for a name reaches it when it registers in-p…
Oct 28, 2026
a912a77
test(tune-out): a late fork derives no other through the framework's …
Oct 28, 2026
206343b
fix(dendrite): settling interrupted jobs writes every end, and keeps …
Oct 28, 2026
6a802aa
docs(dendrite): an inherited dispatch deny still refuses a reader's o…
Oct 28, 2026
5fba9ff
docs(framework): a name the framework made is refused once unregister…
Oct 28, 2026
b88d794
test(tune-out): the late-fork test waits for the late fork's failure,…
Oct 28, 2026
647436e
docs(changelog): where a channel-less turn's prose and publish go is …
Oct 28, 2026
3a01a35
docs(dendrite): register() says it admits a spawner that has ended
Oct 28, 2026
2d78312
docs(changelog): what a reader fork is shown, and may call, under a r…
Oct 28, 2026
6a50092
test(dendrite): the reader-dispatch case claims only what it checks
Oct 28, 2026
81fee14
test(dendrite): the reader-fork tests judge dispatch by what it did
Oct 28, 2026
3645fdd
fix(routing): a publish outside a turn takes none of the last turn's …
Oct 28, 2026
7ff7b71
fix(dendrite): mail delivered at registration goes through the boot's…
Oct 28, 2026
ba072ec
fix(dendrite): a restart notice names each job by its creation time too
Oct 28, 2026
891c610
fix(dendrite): inspection finds the newest of two pending incarnations
Oct 28, 2026
03390f8
test(routing): a host's call with an open turn's id meets its publish…
Oct 28, 2026
4da33ed
docs(changelog): the publish sentences name their exceptions
Oct 28, 2026
a7b2be8
fix(tune-out): a reader fork cancels only the hold it was derived to …
Oct 28, 2026
520881e
docs(dendrite): register()'s documentation is one block again
Oct 28, 2026
e7d94a8
fix(tune-out): a reader fork speaks only into a channel its own hold …
Oct 28, 2026
867d22f
docs(framework): a name the framework made is refused while it isn't …
Oct 28, 2026
f82d153
fix(routing): the focus answer names a heartbeat's turn as the exception
Oct 28, 2026
4bfb702
test(tune-out): keep reader channel actions within their epoch
Oct 28, 2026
6a380da
fix(routing): the focus answer's exception covers every turn with no …
Oct 28, 2026
9d35f86
docs(tune-out): a reader fork's epoch check is made when its call is …
Oct 28, 2026
2fbff64
docs(dendrite): a restricted resident's reader forks can still report…
Oct 28, 2026
4bcef75
test(tune-out): the reader-fork e2e test's mock hands each stream one…
Oct 28, 2026
b56fd13
test(context): the shared-inheritance compression case skips where de…
Oct 29, 2026
73e7f55
docs(dendrite): three AgentRegistry methods the framework calls are m…
Oct 29, 2026
7c9c2c6
build(deps): depend on context-manager ^0.24.0 and chronicle ^0.5.0
Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions bench/dendrite/cache-acceptance.mjs
Original file line number Diff line number Diff line change
@@ -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})`);
}
13 changes: 13 additions & 0 deletions changelog.d/author-named.fixed.md
Original file line number Diff line number Diff line change
@@ -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: <text>` 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`.
19 changes: 19 additions & 0 deletions changelog.d/dendrite-admission.added.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions changelog.d/dendrite-admission.changed.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions changelog.d/dendrite-admission.fixed.md
Original file line number Diff line number Diff line change
@@ -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).
21 changes: 21 additions & 0 deletions changelog.d/dendrite-all-except.added.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions changelog.d/dendrite-cache-acceptance.added.md
Original file line number Diff line number Diff line change
@@ -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).
13 changes: 13 additions & 0 deletions changelog.d/dendrite-derivation-notice.added.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions changelog.d/dendrite-derive.added.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions changelog.d/dendrite-derive.changed.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading