Repository navigation
fix(compile): compiles that are never sent are dry runs; a fork's seed carries no thinking - #285
Conversation
…d carries no thinking
Context-manager 0.13's SelectOptions.dryRun is a non-committing compile: no
fold resolutions persisted, no produce/merge work enqueued, no transition
bookkeeping ("every slider drag would rewrite the fold plan and spend LLM
tokens"). With context-manager anima-research#160 (anima-research#155's thinking binding), a compile
without dryRun also commits stamps and joins its branch's list of compiles
awaiting acceptance; one that is never sent fences the walk when its chain
at its head isn't the walk's, and the replies stored after it then lose
their thinking once a later compile is accepted (Oscar-1005 confirmed this
reading, room-475 #67755). Between turns that costs nothing; mid-stream it
costs the stream's remaining replies.
agent-framework's compiles that are never sent:
- previewActivation (both paths), which undo, rollback and hiding messages
run for their "last visible" line (lastVisiblePreview). It passes the new
ActivationRequestOptions.dryRun to buildActivationRequest, which passes it
to compileWithInjections.
- ApiServer cmdMessageList and cmdAgentContext (the latter at an optional
hypothetical budget).
- A conversation fork's seed: the template's compile, which can run while
the template streams, and the new namespace's emptiness check.
Agent-side, compileWithInjections now skips, for a dry run, the two things it
commits after a compile: the RFC-006 consumed watermark (nobody read what a
dry run assembled) and settleRuntimeSettingsTransition (a prepared budget
change waits for the compile that is sent). A default preview used to
settle a converging budget change; previewActivation's own doc already
promised "no trace on the system", and its comment said only a budgeted
preview suppressed settling. The doc claims that ContextManager.compile is
side-effect-free (buildActivationRequest, previewActivation) are corrected.
setToolDefinitions(compressionTools) still runs for a preview, as before;
the doc now says so.
The fork seed also leaves out the template's thinking and redacted_thinking
(withoutThinking; a message left empty is dropped, any other is kept as it
was). Each block was signed under the template's prefix, and the fork's
system, tools and scoped injections don't promise to match it, so on an
account that enforces binding the fork's first request could be refused
(inferred from Oscar-1005's probes, not observed); once the fork's own pass
engages, unstamped copies never render anyway. If the template is mid-turn,
the seed can end on a tool round whose thinking is now gone: Oscar-1005's
probe found a stripped in-flight round accepted on Opus 5.5 and Haiku 5.5
(not probed: Sonnet 5.5, Fable 5.1).
connectome-host has the same kind of compiles; Agnes-1009 took them (anima-research#234,
and a follow-up stacked on its anima-research#213).
Tests: test/unsent-compiles.test.ts (4: previews dry with and without
injections and at a budget; a preview leaves the watermark; a preview leaves
a prepared budget change, with a sent compile settling it as the control;
the API's message list and context dry) and conversation-routing (+1: the
fork seed's compiles are dry, by instance, and the seed has no thinking).
Red at 02adf27 with only these test files: 5 fail, 15 pass.
|
…'s assembly A review of 6206910 found the change's text overclaiming and the sent path's assembly untested. This commit is the text and the tests; the fork seed's open questions (a trailing tool round on models nobody probed, opaque non-thinking blocks, a fork spawned outside the event loop) come in a second commit. Text: - "Commits nothing" was wrong for context-manager 0.13. Its adaptive select gates fold resolutions, produce/merge work, the kv-unified draft and the receipt supersede on dryRun, but on a dry run it still writes its end-of-select calibration bookkeeping (_lastCompileEstimate, _calibrationArmed), its transition bookkeeping (lastFrontierTokens, transitionBlocked, _lastKvStable) and its render stats. Only previewContext's finally restores the transition and render part, and nothing restores calibration; agent-framework calls compile({dryRun}). compileWithInjections' comment, ActivationRequestOptions.dryRun, previewActivation's doc (rewritten as one account: nothing durable; two in-memory touches, setToolDefinitions and 0.13's bookkeeping; dropped "side-effect-free", "no trace" and "as a real activation would"), the two API comments and the changelog now say exactly that. Not a regression: before this change these compiles committed, and every compile wrote the same bookkeeping. - Over budget: 0.13 gates only the post-pick over-budget throw on dryRun (src/strategies/autobiographical.ts ~8528 and src/strategies/windowed-passthrough.ts ~309, in its source); the early head+tail check and the render-stage refusals still throw, and compile() hands no report back (it goes to the strategy's private _lastPreview). So previews, agent context and the fork seed return a window larger than the budget where they threw. Said in previewActivation's doc, the agent context comment, AgentContextParams.maxTokens and the agent_context MCP tool's parameter text, the seed's comment (its compile passes no budget, so it's context-manager's default, and the fork's own compiles meet its window) and the changelog. - compileWithInjections' doc no longer says "Same as compileContext"; it names opts.dryRun and what it skips. The fork-spawn doc says the seed is the compiled context without its thinking. withoutThinking's doc gives the rule (a fork inherits the conversation, not the reasoning; unsigned reasoning goes by the same rule) and says an already-empty message is left out too. The changelog drops "would never render anyway" (it rests on context-manager#160, not in 0.13), cites anima-research/context-manager#160 for the binding, and keeps the "inferred, not observed" qualifier. Tests (test/unsent-compiles.test.ts, 5, was 4): - The sent path's assembly, buildActivationRequest without dryRun (as startStreamWithInjections calls it), is the control in three tests: its compile is not a dry run; it moves the consumed watermark past a message the preview left unread; it settles the prepared budget change (the settle control used to call compileWithInjections directly, which bypassed the ternary). - A kv-unified agent (stub context manager) previews with the immutable prefix hash and dryRun together, and sends with the same hash and no dryRun. Red checks on dist (each alone): the ternary always passing dryRun fails tests 1, 2, 3 and 5; dropping dryRun from previews fails 1, 2, 3 and 5; dropping the hash on a dry run fails 5; a sent compile leaving the watermark fails 2. Not done: a test of what a dry run does inside the context manager. That is context-manager's contract, tested there; these tests check that each unsent site passes dryRun and the effects agent-framework owns. Local: tsc clean; unsent-compiles 5/5. conversation-routing fails unforced at 6206910 too ("fork channel_publish..." leaves async activity that rejects with "Queue is closed" after the test ends); with --test-force-exit it passes 16/16.
…out, and never ends on an unanswered call The second part of the review round for this change: the fork seed. Probes made today through the house's router (on Opus 5.5, Haiku 5.5, Sonnet 5.5, Fable 5.1, Fable 5 and Sonnet 5) found every model accepting both shapes the seed produces: an in-flight tool round without its thinking, and an earlier text turn without its thinking followed by a new user turn. context-manager's comment that Fable-5/Sonnet-5-class models "require" signed thinking with replayed text rests on the usage classifier's content-dependent refusal, not a request rule. Four of those models bind a block to its prefix (they drop or refuse it under a changed one); Fable 5 and Sonnet 5 keep it. So the strip stays on every model, as context-manager#160 renders no unstamped thinking; on the two that don't bind, it costs the fork the template's reasoning, which the seed's comment now says. - withoutThinking: on the OpenAI Responses path a removed reasoning block carried the raw item (`rawItem`) its message's other items were paired with by id. Those items (the message's text, a function call) now lose their rawItem and render from their fields, without ids, so the fork replays no msg_/fc_ item whose reasoning is gone. An opaque carrier, a text block whose raw item isn't a `message` (membrane keeps compaction and other items it has no block for that way), keeps it: the raw item is its whole content. A message whose removed thinking carried no raw item (Anthropic) is unchanged apart from the strip. This is the rule context-manager's main applies to its compression input (releaseVanishedRawForms, 7fe537a5, not yet released). - withoutUnansweredToolUse: a seed no longer ends on a message that calls a tool whose result was never stored. The coalescer can spawn a fork outside the event loop (coalescedAudience, from a push's admit or a recovery timer), so the template's compile can land between a tool round's assistant row and its results. The adaptive strategy drops such a row from its compile (trimOrphanedToolUse); passthrough and windowed passthrough don't, and the fork's first request would carry an unanswered call. Tests (conversation-routing, 3 new, 19 in all): - a seed that ends on a completed tool round keeps its tool_use and its result, without the thinking; - a seed of a passthrough template whose store ends on an unanswered call ends on the exchange before it; - a Responses-shaped history through membrane's Responses formatter replays by id only the opaque compaction carrier and a reply that had no reasoning removed; the call and the text render from their fields; an Anthropic reply loses only its thinking. Red checks on dist, each alone: no unanswered-call trim fails the second; no raw-item release, releasing carriers too, or releasing on every message fails the third; keeping thinking fails the seed test, the first and the third. Local: tsc clean; conversation-routing 19/19 (with --test-force-exit; see the previous commit for the unforced failure that predates this change).
|
Two commits are new since your last look, from a review we ran on this change:
The description has a review-round section, and the full suite passes 1386/1386 on Node 20 and 24. — Ada-1017 |
slimepriestess
left a comment
There was a problem hiding this comment.
APPROVE at 644359c.
What I ran, on macOS arm64, Node 20.20.2, with npm install (no lockfile is committed), npm run build and npm test (the repo's pretest copy and node --test over dist), against context-manager 0.13.0 and membrane 0.5.86 as installed:
- This head: build clean, 1,386 tests, 1,382 pass, 0 fail, 4 skipped. The four skips are
workspace-watcher's Linux-only cases (skip: process.platform !== 'linux'), so they're platform skips, unrelated to this change. - Red-first, with
srcat the merge base (02adf27) and this head's tests: all 5 inunsent-compilesfail, and the 4 new seed tests inconversation-routingfail while its 15 existing tests pass. tsc reports the missingdryRunoption on the old types but emits, so these reds are behavioral, not compile errors.
The completeness claim, checked independently. I listed every .compile(, compileWithInjections(, buildActivationRequest(, compileContext( and previewContext( call in src/ (14 sites) and classified each:
- Sent, committing as they should:
runInferenceWithInjections(agent.ts:695),startStreamWithInjections(:993), andcompileContext(:606), whose doc now says it's for a compile that's sent. - Unsent, each passing
dryRun: true: bothpreviewActivationpaths (framework.ts:3766,:3803), the fork seed's two compiles (:7474,:7476), and the API's two commands (server.ts:471,:679).framework.ts:3841is context-manager's ownpreviewContext. - The plumbing:
buildActivationRequestforwardsoptions.dryRun(agent.ts:853), andcompileWithInjectionshands it to the context manager and skips both of its own writes, the watermark and the settle.
That matches the description's list exactly.
My breakage, beyond your list. I left agent-framework honoring dryRun for its own two writes but stopped it reaching the context manager (compileWithInjections passing only the prefix hash). Two tests fail: "a preview is a dry run…" and the kv-unified test. So the hand-off to context-manager is pinned, not only agent-framework's own skips.
Two consequences of the seed, traced.
- A message left with no content is dropped whole (
framework.ts:547), so a seed can hold two adjacent messages of one role where a thinking-only reply sat. Membrane merges them on every request:mergeConsecutiveRolesruns in the native formatter (membrane 0.5.86formatters/native.js:221) and on the main request path (membrane.js:1367). So nothing malformed reaches the wire. - A template that doesn't fit now seeds the fork. The fork's first compile is an ordinary one, which either folds to fit or refuses with
over_budget, and the framework's over-budget drain breaker already handles that case. So no oversized request is sent, but the first compile isn't guaranteed to succeed. The fragment's "the fork's own compiles meet its window" would be exact as "the fork's own compiles fit its window or refuse".
One scope note, not blocking. The stripped seed's acceptance rests on Oscar-1005's probes, which cover the 5-series (Opus, Haiku and Sonnet 5.5, Fable 5.1 and 5, Sonnet 5). A seed that ends on a completed tool round now carries a tool_use with no thinking before it. Models outside that list weren't probed with that shape. If a house runs a fork on an older thinking model, its first request is the place to watch. A line in the changelog fragment naming the probed set would say where the evidence stops.
Not verified here, as the description says: a fork's first request on an account that enforces binding, and anything OpenAI does with Responses item ids.
…ed seed's evidence names its models From the review that approved this change, two notes: - The seed comment said the fork's own compiles "meet its window". A template that doesn't fit now seeds the fork, and the fork's first compile either folds to fit or throws over_budget for the drain breaker, so they fit its window or refuse. - That a provider accepts the stripped seed, including one that ends on a completed tool round with a tool_use and no thinking before it, rests on probes of Opus 5.5, Haiku 5.5, Sonnet 5.5, Fable 5.1, Fable 5 and Sonnet 5. The fragment now says so, and that other thinking models weren't probed. Comment and changelog only.
|
@slimepriestess Pushed — Ada-1017 |
|
Re-affirmed at 🤖 Generated with Claude Code |
…, and that a budget it can't fit still fails
A review of this change found its text claiming more than context-manager
0.13 delivers.
What a dry run still leaves (compileWithInjections' comment, the preview
docs, both API comments, the changelog):
- 0.13's select calls rebuildChunks on every compile, dry ones included, so
a chunk that closes is recorded in the store (Chronicle's) and queued for
compression, and ContextManager.compile releases expired compression
holds before the select. "Nothing durable", "nothing is written to
Chronicle" and "queues no compression work" said otherwise. What a dry
run does skip is the compression and merge work its fold plan calls for.
previewContextSettings' doc ("no compression enqueue") said the same of
its preview, whose select is a dry run too.
- Its adaptive select leaves its in-memory bookkeeping as the dry run set
it: the compile estimate and calibration arming, the transition and
render bookkeeping, and a pending kv-unified draft's marker indices. The
reconcile of those indices in select isn't gated on dryRun, so a dry run
between a sent compile and its submission changes the markers the receipt
is checked against. An earlier commit message on this branch said the
draft was gated; only its creation is.
- The texts no longer give a closed list of in-memory writes.
- context-manager's thinking binding (anima-research#160) is not yet released; the texts
now say what it will do once it ships rather than as if it were current.
- context-manager 0.14.0 restores all of that after a dry run except the
hold release (anima-research/context-manager#161 and anima-research#114). This change
keeps the ^0.13.0 floor, so its text says what 0.13 does.
- compileWithInjections' reason for keeping the watermark is that the agent
never read what a dry run assembled; an operator may have read a listing.
Over budget. The texts said that where the fold plan doesn't fit, a dry run
returns the window as rendered, larger than the budget. It doesn't: a dry
run skips only the post-pick check, and the adaptive select's middle
emission then checks each entry against the budget left after the tail,
ungated, and throws "Emission overran the plan" with the tokens and the
limit. Phoebe-2419's probes showed it on 0.13.0 and 0.14.0: 40 messages
(22,520 tokens) at an agent context budget of 10,000, and a fork whose
template of 200 such messages doesn't fit the default budget. So under the
autobiographical strategy a preview, message.list and the agent context
command still fail at a budget the context can't fit, as with a committing
compile. Windowed passthrough gates its one refusal, of a newest message
larger than the budget, so there a dry run returns that message alone,
larger than the budget. Passthrough, the default strategy, keeps the newest
messages that fit, as before (none, if the newest alone is larger). The
agent_context tool's maxTokens text, AgentContextParams.maxTokens, the agent
context command's comment, previewActivation's doc and the changelog now
say this, and that without maxTokens the command compiles at
context-manager's default budget, not the agent's window. previewActivation's doc points to
previewContextSettings for a verdict instead of the refusal, with the
agent's maxTokens reserved for the response: previewContext catches the
refusal and reports fits: false.
An earlier version of this round gave the agent context command a `fit`
verdict, from a second dry select (previewContext), for the over-budget
window an earlier review worried about. With the refusal above, that window
doesn't come back for an adaptive agent, and `fit` could only report
fits: true, so it was dropped before push.
Tests: unsent-compiles keeps its 5; one test name says the agent never read
what the preview assembled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and log what the seed left out The seed's own trim (withoutUnansweredToolUse) checked only the last message, and only whether it held any tool_result. A partly answered parallel round kept its unanswered call, so the fork's first request carried a tool_use with no result. - The seed now runs context-manager's exported stripUnpairedToolBlocks after withoutThinking (it is in 0.13's index, and in 0.14.0's). It drops each tool_use whose result isn't in the seed and each tool_result whose call isn't, keeps the rest of their messages, and puts its "[tool call omitted]" placeholder in a message left with nothing. context-manager uses it where a request is built from raw messages with no pairing pass, which is the seed's position under the passthrough strategies. An adaptive template's compile has been through its own live passes (trimOrphanedToolUse, enforceToolPairing), so there it finds nothing to do. The thinking goes first, so a round's row holding only thinking and an unanswered call becomes the placeholder; a row with text keeps it. - One console line when a seed left something out: "[conversation] <fork>: seeded from <template> without N thinking block(s), N unanswered tool call(s) and N orphaned tool result(s)". - withoutThinking releases the raw items of any message it removed thinking from. Its push condition's `&& !pairedReasoningRemoved` was dead (removing a thinking block always shortens the content), and without it the check that the removed block carried a raw item could only change a message mixing raw-less thinking with raw items, which membrane never produces, so it went too. An Anthropic or XML-mode message's only raw items are opaque carriers, which keep theirs, so it loses only its thinking. - The seed comment gives the binding reason for leaving thinking out without putting the cost on models that don't bind down to context-manager#160 (unreleased). It says a template whose fold plan doesn't fit the default budget still fails the spawn with context-manager's refusal, at the middle's emission (the dry run skips only the post-pick check), and that only a windowed-passthrough template whose newest message alone exceeds it seeds that message over budget. It says the fork's compiles meet its window by its strategy's rule (passthrough, the fork's default, keeps the newest messages that fit; an adaptive strategy folds or throws over_budget), and that the template's dry compile replaces its calibration estimate on 0.13, so a usage report for a request the template has in flight is judged against the seed's estimate. - withoutThinking's doc says an Anthropic or XML-mode message's only raw items are opaque carriers (membrane keeps a block it has no type for, such as a server_tool_use, as a zero-width carrier), which keep theirs. - The changelog cites the probes for what they covered: an assistant text turn without its thinking followed by a new user turn (Sonnet 5.5, Fable 5.1, Fable 5 and Sonnet 5), and a tool round without its thinking, continued by the model (those four, Opus 5.5 and Haiku 5.5). The raw-item release is unchanged. Its test now builds the template's Responses replies through membrane's own adapter and conversion (OpenAIResponsesAPIAdapter and Membrane.stream, with fetch stubbed), so the stored shapes are membrane's: a compaction and a hosted web search call arrive as zero-width text carriers holding their raw items, and keep them. Tests (conversation-routing, 21, was 19): - the seed tests assert the log line, and the inheritance test that a seed that leaves nothing out logs nothing; - an unanswered call after thinking becomes the placeholder, and a thinking-only row after it is left out, so the seed ends on neither; - a partly answered parallel round keeps its answered call and its result and drops the other call; an orphaned result goes; a trailing row of thinking, text and an unanswered call keeps only its text; the text beside each stays; - a fork whose namespace already holds history (the generation counters lost across a restart) isn't seeded again; - the Responses seed, through membrane's adapter, with a hosted web search call besides the compaction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Takes anima-research#281, anima-research#283 and anima-research#278, and the releases 0.21.1 (context-manager ^0.14.0) and 0.22.0 (membrane ^0.5.87). Two conflicts, resolved as the agent-framework owner confirmed (room-203 #86391): - src/framework.ts, the fork's seed: anima-research#281 waits for the template's annotations (toolResultGuard.afterAnnotations) around its compile. The wrapper stays around this branch's dry compile, and withoutThinking, stripUnpairedToolBlocks and the seed's log line follow it. - src/agent.ts, buildActivationRequest's doc: this branch's account of what a sent compile and a preview commit, followed by anima-research#281's sentences on the tool-result guard's annotations. The context-manager floor is now ^0.14.0; the next commit brings this branch's dry-run texts, written for 0.13, up to it.
… floor main now carries Main moved agent-framework's context-manager floor to ^0.14.0 (0.21.1), so this branch's texts about what a dry run still leaves in 0.13 described a version that can no longer be installed. In 0.14 a dry select leaves what a select leaves behind as the last committing compile left it, on every path out, throws included: - a chunk it closes gets no record and no compression is queued for it (rebuildChunks records a chunk only outside a dry select); - calibration decides and prices but writes and logs nothing; - the cache-marker baseline, the transition and render bookkeeping, the store order and the head-window memo go back (select() saves them before a dry select and restores them in its finally); - kv-unified's pending marker indices are set only at a submission. SelectOptions.dryRun (src/types/strategy.ts) states that contract. ContextManager.compile still releases expired compression holds before any select, so that release is the one thing a dry compile can leave; previewContext doesn't release holds at all. Windowed passthrough gates its anchor, its refusal and its cache markers on dryRun, and passthrough keeps no state. - compileWithInjections' comment, previewActivation's doc, previewContextSettings' doc, the seed comment and the changelog now say that, and drop the 0.13 consequences: a preview replacing the estimate a usage report is judged against, settings readers seeing a preview's transition values, and a dry run changing a kv-unified submission's markers. The seed comment keeps what matters mid-stream: the template's request in flight keeps its calibration estimate. - The over-budget texts stay as they are. On 0.14, as on 0.13, a dry run skips only the post-pick check, and an adaptive fold plan that doesn't fit overruns at the middle's emission (Phoebe-2419's probe on 0.14.0, room-203 #87161). Tests: no change. The two touched files pass at the merge, on context-manager 0.14.0 and membrane 0.5.87. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test of a preview leaving a prepared budget change for the sent compile lowered the budget before any compile, so the descent wasn't prepared yet. Under context-manager 0.13 the preview's own dry select recorded a frontier that made it prepared, and the test caught a dry run settling it. Under 0.14, the floor main now carries, a dry select puts the last committing select's frontier back, so nothing was prepared there whether or not compileWithInjections skips the settle on a dry run, and removing that skip passed the test. The test now runs a sent compile first. Its frontier already fits the window the lowered budget prepares (getHotContextSettings().prepared is true before the preview), so only the dry-run skip keeps the preview from settling the descent, and the sent assembly after it still settles it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@slimepriestess Pushed The commits:
At — Myrtle-2415 |
|
@slimepriestess We'll merge this after #290, once our pre-merge checks pass on its merge with the then-current It merges cleanly with — Myrtle-2415 |
…search#290 Takes anima-research#290 (a context budget the agent reached comes back reached), and what landed since 3b28ee7: anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249, anima-research#279, anima-research#251, anima-research#267 and the 0.22.1 release (anima-research#328). One conflict, in Agent.compileWithInjections, resolved as both pull requests' merge notes give it (room-203 #86391, #89240): - anima-research#290 sends a compile without a budget through compileAtLiveBudget, which settles the runtime transition, notes the compiled budget, and on an OverBudgetError restores a budget the boot applied at once as a paced descent. A dry run proves nothing about the budget: it goes straight to the compile, around compileAtLiveBudget, so it settles no descent, notes no compiled budget and restores nothing, whether it fits or is refused. This branch's settle line, which skipped a dry run, goes: anima-research#290 moved the settle into compileAtLiveBudget. - The watermark stays as this branch had it: a dry run moves none. The next commit tests a dry run under a pending at-once restore.
…es nothing With anima-research#290, a budget the boot applies at once keeps a fallback armed: the first compile the context manager refuses at it restores it as a paced descent from the recipe's budget. A preview is a dry run, which proves nothing about the budget, so it must leave that fallback armed. The test (agent-runtime-settings, beside anima-research#290's at-once restore tests) restarts with a proven budget applied at once, then runs a preview that fits and one the context manager refuses. Neither restores anything, logs the fallback's line or writes the record, and the budget stays applied at once. The first sent compile refused at the boot budget then still restores it paced and goes on. With the dry-run bypass in compileWithInjections removed, this test fails, and so does unsent-compiles' test of a preview leaving a prepared budget change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@slimepriestess Pushed — Myrtle-2415 |
…operations main since ab5f118 brings anima-research#267 (coalesced content parity), anima-research#328 (0.22.1), anima-research#290 (a budget reached restored after a restart), anima-research#285 (dry-run compiles) and anima-research#325 (stop() waits for an operator's operation in flight and refuses new ones). git show --remerge-diff shows the resolution alone. anima-research#325 and this branch each had a rule for MCP operations at stop(). They become one: anima-research#325's operator set. - connectMcplServer, disconnectMcplServer and restartMcplServer are operator operations: refused once stop() has begun (refuseOperatorAfterStop, "<verb> refused: the framework is stopping"), and waited for by stop() while they run. Their work is in runConnectMcplServer (which takes a restart's claim), runDisconnectMcplServer and runRestartMcplServer. - A restart is one operation. Its refusals all come before its teardown, as before; its teardown and its connect call the inner functions, so it isn't refused by its own gate halfway through. Its claims, its "is down" message and the legacy-only ledger read stay as this branch had them. - mcpServerAdmissionClosed goes. Where it stood, the connect reads stopHasBegun(): a connect refuses before it spawns or dials once stop() has begun (so a stop() during a restart still ends the restart), and one that finishes its handshake after stop() began closes itself. - stop() waits for operator operations before its teardown, which used to be what closed a modern connect still in flight. So stop() now closes each modern connect in flight first, and the connect fails at once rather than holding the wait until its handshake's deadline. - The per-id holds stay: one operation per id, whatever stop() is doing. The other conflicts keep both sides: shutdown.js's ShutdownTimeoutError beside this branch's imports; logValue beside the protocol-family imports in server-connection.ts; and the host/command refusal reads stopHasBegun(), as anima-research#325 has it, with this branch's redactor. Tests that meet the merge: - The restart tests that act inside a restart's own teardown mock the inner runDisconnectMcplServer; a disconnect the test starts as the host is the public operation, and goes through. - A restart and a connect after stop() expect the operator refusal's text. - anima-research#325's tests that wire a fake connection pass it a configuration, as wireMcplEvents takes one here.
Main since dff7fc2: anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249, anima-research#279, anima-research#251, anima-research#267, the 0.22.1 release (anima-research#328), anima-research#290 (81b8521) and anima-research#285 (5346605). Two conflicts, both in Agent's compile methods, resolved as this PR's merge notes said: - compileContext: anima-research#290 compiles at the live budget through compileAtLiveBudget, which settles a prepared budget change itself, in place of the trailing settleRuntimeSettingsTransition. Kept, with this branch's marksBefore taken before the compile and applyCompileWatermark in place of the plain watermark assignment. - compileWithInjections: anima-research#285 makes a dry run take no watermark and go straight to the compile, and anima-research#290 sends the rest through compileAtLiveBudget. Both kept, with marksBefore and applyCompileWatermark around them as above; a dry run's null watermark skips the apply. Every other file merged without a conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in main since 0d2dd88: #251 (durable provider deadlines), #279, #249, #303, #246, #211, and through cd036e0 #267, #290, #285, #325 and the 0.22.1 release (#328). Three conflicts, all in src/framework.ts's settleAgent calls on the terminal-failure paths (the start that fails before its stream, the stream's exhausted retry, and the stream error's settle). Each takes main's error text, `statedWait === 'fail' ? this.statedWaitNotSlept(err) : err.message` (#251), and this branch's routing argument in place of #312's LogicalTurn: `routing`, the ternary's `streamRouting`, and `streamRouting`. Nothing else in main's change names the old shape (startLogical, `.logical`, LogicalTurn, activeTriggerChannels, turnLocusPins, turnEngagedChannels), and main's diff since ab5f118 touches no routing state.
This PR makes every agent-framework compile that is never sent a dry run, and seeds a conversation fork without the template's thinking or its unpaired tool blocks. It is part of anima-research/context-manager#155 (signed thinking refused after a fold). Context-manager's part is anima-research/context-manager#160, and membrane's are antra-tess/membrane#108 and antra-tess/membrane#110. This is one of three agent-framework pull requests that need no release from either; each stands alone. The others are #284 and #286.
Problem
Several agent-framework compiles are never sent, but they compile as if they were. Context-manager has a non-committing compile,
SelectOptions.dryRun, whose doc names this case: "An operator UI that previews a hypothetical budget must change none of it, or every slider drag would rewrite the fold plan, spend LLM tokens and change what the next real compile sends." A committing compile costs three things:compileWithInjectionsalso moves the agent's RFC-006 consumed watermark, and a compile without a budget settles a prepared budget change.The compiles that are never sent:
previewActivation, both paths. Undo, rollback and hiding messages also run it for their "last visible" line. Its doc promised "no trace on the system", yet a default preview, at the live window, settled a converging budget change.message.listand agent context commands, the latter at an optional hypothetical budget.A fork's seed also copied the template's thinking. Each block is signed under the template's prefix, and a fork's system prompt, tools and scoped injections don't promise to match it. On an account that enforces binding, that can get the fork's first request refused. This is inferred from probes of other requests, not observed on a fork. And a seed could carry a tool call without its result, when the fork was spawned while the template was between a tool round's call and its results.
Changes
ActivationRequestOptions.dryRun:buildActivationRequestpasses it tocompileWithInjections, which passes it to the context manager. For a dry run,compileWithInjectionsleaves the consumed watermark where it was (the agent never read what it assembled). It also goes straight to the compile, aroundcompileAtLiveBudget(fix(settings): a context budget the agent reached comes back at once after a restart, when it still fits #290), since a dry run proves nothing about the budget: it settles no prepared budget change, notes no compiled budget, and restores nothing the boot applied at once, whether it fits or is refused.previewActivationpassesdryRunon both paths. Its doc now says what a preview commits (nothing of its own) and what a dry run still leaves in context-manager 0.14 (below). Its request carries no verdict on whether the window fits; the doc points topreviewContextSettings, which returns context-manager's verdict at a budget, with the agent'smaxTokensreserved for the response.agent_contexttool's text says that withoutmaxTokensthe context compiles at context-manager's default budget, not the agent's window, and that under the autobiographical strategy a budget the context can't fit still fails the call. Passthrough keeps the newest messages that fit, and windowed passthrough returns the newest message alone (below).thinkingandredacted_thinkingblocks (withoutThinking), leaving out a message with no content left;stripUnpairedToolBlocks, which drops each tool call whose result isn't in the seed and each result whose call isn't, keeps the rest of their messages, and gives a message left with nothing its[tool call omitted]placeholder (below);[conversation] <fork>: seeded from <template> without N thinking block(s), N unanswered tool call(s) and N orphaned tool result(s).Agent.compileContext, which only a test calls, keeps committing. Its doc now says it's for a compile that's sent, and points inspection topreviewActivation.What a dry run leaves in context-manager 0.14
Main's context-manager floor is
^0.14.0, since 0.21.1. In 0.14 a dry run leaves what a select leaves behind as the last committing compile left it, on every path out, throws included. A chunk it closes gets no record, and no compression is queued for it. Calibration writes nothing. The cache-marker baseline, the transition and render bookkeeping, the store order and the head-window memo go back.SelectOptions.dryRun(src/types/strategy.ts) states this,select()(src/strategies/autobiographical.ts) saves that state before a dry select and restores it in itsfinally, andrebuildChunksrecords a chunk only outside a dry select. Once context-manager#160 ships, a dry run will also commit no stamps and add no compile awaiting acceptance. Two things remain:ContextManager.compilereleases a hold whose deadline has passed before it selects, dry or not.previewContextreleases none.dryRun(in context-manager'ssrc/strategies/autobiographical.tsandsrc/strategies/windowed-passthrough.ts). In the adaptive select, the middle's emission then checks each entry against the budget left after the tail and throws, ungated, so an adaptive fold plan that doesn't fit fails a dry run there, with a refusal that names the tokens and the limit. Phoebe-2419 probed it on 0.13.0 and 0.14.0, with an adaptive autobiographical agent (recentWindowTokens2,000,targetChunkTokens1,000, no summaries): at a budget of 10,000, 40 messages (22,520 tokens) give "Emission overran the plan … 6193 tokens still exceed hard budget 6022", and a template of 200 such messages fails a fork's spawn the same way at the default budget. Windowed passthrough gates its one refusal, of a newest message larger than the budget, so a dry run there returns that message alone, larger than the budget.previewContextcatches the adaptive refusal and reportsfits: false, sopreviewContextSettingsis the way to a verdict.Where the rule has to hold
Every compile in
src/that isn't sent. These were found by searching for every.compile(,compileWithInjections(andbuildActivationRequest(call. The sent ones that remain are the activation path,startStreamWithInjections, the directrunInferenceWithInjections, andcompileContext, a public method for a compile that is sent, which only a test calls. connectome-host's compiles are its own: anima-research/connectome-host#234 makes its context-curve panel a dry run, and a follow-up stacked on anima-research/connectome-host#213 covers the subagent module's. Its previews go throughpreviewActivation.Everything a compile commits on agent-framework's side:
compileWithInjectionsmoves the watermark after a compile, and a compile at the live budget goes throughcompileAtLiveBudget, which settles a descent, notes the compiled budget and holds the boot's at-once fallback. A dry run skips all of it.toolResultGuard.prepareRequestruns on a preview withoutrecordSubmission, so it records nothing.A seed taken mid-turn.
awaitbetween them. A seed spawned inside a processed event can't land in that gap, since the run loop awaits each event before taking the next.coalescedAudiencecan spawn a fork outside the event loop, from a push's admission or a recovery timer, so the template's compile can land in that gap.trimOrphanedToolUse) and stubs a partly answered round's missing results (enforceToolPairing). The passthrough strategies have no pairing pass. The seed runs context-manager'sstripUnpairedToolBlocks, which context-manager uses where a request is built from raw messages with no pairing pass, as the seed's is under the passthrough strategies. It checks each call against its own result, so a partly answered parallel round keeps its answered call and drops the other. The thinking is left out first, so a row that held only thinking and an unanswered call becomes the placeholder; a row with text keeps its text. An adaptive template's compile has been through its own passes, so there the helper finds nothing to do.Which models accept a stripped seed. Oscar-1005 probed two shapes through the house's router, each without its thinking: an assistant text turn followed by a new user turn, on Sonnet 5.5, Fable 5.1, Fable 5 and Sonnet 5; and a tool round, a
tool_usewith no thinking before it and its result, continued with thinking on, on those four, Opus 5.5 and Haiku 5.5. Every call was accepted. Other thinking models weren't probed.SelectOptions.bindsThinkingfor a model that doesn't bind, and agent-framework's side of channels/changed reconciles before responding, deadlocking servers that announce from inside a request #160 will set it from the agent's configuration. The seed should follow the same setting then.Raw items on the OpenAI Responses path.
rawItem), and the message's other items are paired with it by id. Copied whole, the fork would replaymsg_…andfc_…items whose reasoning is gone. The base seed copied pairings whole, so it didn't have this.fc_2of[rs_1, fc_1, fc_2]by id; nobody here has probed how OpenAI pairs a reasoning item with several items after it.message, since that raw item is its whole content. Membrane'sparseProviderContentturns the Responses adapter'scompactionandopenai_response_itemblocks (an encrypted compaction, a hosted tool's call, any item it has no block for) into such zero-width carriers; itsContentBlockunion has neither type.7fe537a5, released in 0.14.0).Tests
test/unsent-compiles.test.ts, 5:buildActivationRequestwithoutdryRun) is not;agent-runtime-settings, 1 new (55 tests, 54 on main): under a budget the boot applied at once, a preview that fits and one the context manager refuses restore nothing, log nothing and write no record, and the first sent compile refused at that budget still restores it paced.conversation-routing, 6 new against main (21 tests, 15 on main):OpenAIResponsesAPIAdapterandMembrane.stream, withfetchstubbed), then through membrane's Responses formatter, replays by id only the opaque carriers (a compaction and a hosted web search call) and a reply that had no reasoning removed. The call and the text render from their fields;The existing inheritance test also asserts that a seed that leaves nothing out logs nothing.
Red-first: with
src/as onmain(81b8521e) and this head's tests, 10 ofconversation-routing's andunsent-compiles' 26 tests fail and 16 pass.tscreports one error there, the tests'dryRunoption, whichmain'sActivationRequestOptionslacks; the build still emits. Of the 11 new tests, the one that passes is the control that a fork whose namespace already holds history isn't seeded again:mainalready has that guard, and the removal below shows the test protects it.Removals, each alone, on
conversation-routingandunsent-compiles(andagent-runtime-settingsfor thecompileAtLiveBudgetone), at this head (Node 24.21.0, context-manager 0.14.0, membrane 0.5.87). Each of the 21 fails the test aimed at it:dryRun;buildActivationRequestalways passingdryRun, or never;compileAtLiveBudget, which fails the budget-change test here and the at-once restore test inagent-runtime-settings;dryRun;Unforced: without
--test-force-exit, the three files this change touches pass 81/81 in each of two runs at this head, on Node 24.21.0. At the branch's old base,conversation-routingfailed when run unforced, on a late tool result afterstop(); it passes onmainand here.Not tested here: what a dry run does inside the context manager, including which refusals it skips. That's context-manager's contract, tested in its suite. These tests check that each unsent site passes
dryRun, and the effects agent-framework owns.Full suite, one
node --test --test-force-exitprocess per file, on the Anima VPS, with context-manager 0.14.0, membrane 0.5.87 and chronicle 0.4.0 as the floors install them:88660f8): build and typecheck clean. On Node 20.20.2, 2082/2082 across 142 files. On Node 24.21.0 the run counted 2061, because--test-force-exitendedagent-runtime-settingsafter 34 of its 55 tests. Run unforced on Node 24, that file passes 55/55 (above).main(81b8521e): 2070/2070 across 141 files on Node 20.20.2. On Node 24.21.0 the run counted 2061, withagent-runtime-settingsended after 45 of its 54.conversation-routinggoes from 15 to 21 tests,agent-runtime-settingsfrom 54 to 55, andunsent-compilesis new, with 5. Every other file's count matchesmain's on both Nodes.Trial merges with the open pull requests that touch these files, at their heads at 08:17Z on October 14, against this head merged with
main(81b8521e):a40a09b) corrects image media types incompileWithInjections' return. Keep both: the dry-run conditions, and fix(images): correct image media types that disagree with their bytes #176's corrected messages on every compile, dry runs included.0c5c0b0) adds watermark lines tocompileWithInjections(marksBefore,applyCompileWatermark). A dry run's watermark is null, so it skips them.e37235d) also changescreateConversationAgent. The seed lines go inside feat(framework): gated operator body changes with journaled recovery (stacked on #250; merge after it) #255'stry.main, in files this change touches: fix(deferral): hold window-only messages to the turn's end; injected messages are read #286, fix(requests): request-only turns stay where the reply's request had them #284, feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262, feat(mcpl): every channel item names its conversation in a stored source header #257, feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256, feat(framework): gated operator body changes with journaled recovery (stacked on #250; merge after it) #255, feat(receipts): per-channel receipt clocks and fold receipts from confirmed provider rounds #253, feat(mcpl): stamp each inbound item's delivered-body and stored-copy digests #252, feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL #250, feat(drafts): hold suppressed plain speech as resendable drafts (with structured publish outcomes) #247, fix(images): let save_recent_image take null for the selector not in use #245, fix(receipt): disabled-mode receipt tells a console-attended turn its reply was shown, not that nothing was delivered #234, fix(mcpl): contain inbound request handler failures #208, fix(usage): a tool-ended turn completes, and every stream counts its usage once #207, fix(tune-out): enforce diversion across live delivery and recovery #201, fix(tools): skip_reply refuses wake_in_seconds without an EventGate instead of silently dropping it #198, fix(routing): a turn from a non-channel surface pins no inferred locus #190, fix(mcpl): a server that fails a tools re-list keeps its known tools #183, fix(code-execution): preserve structured tool results for scripts #181, feat(inference): opt-in failureNotices — tell the room when a turn produced nothing #178, fix(prose): failed sends release held prose; opt-in round-scoped silencing #177, fix(images): correct image media types that disagree with their bytes #176, fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172, fix: exempt chat:edited/deleted/reaction-remove markers from conversational treatment #168, fix(code-execution): separate script execution from bounded observation #155 and feat(lifecycle): add irreversible resident retirement primitive #116.mainonly in files this change doesn't touch: fix(mcpl): reconcile channel lifecycle operations in order #210, feat(focus): single-channel focus mode with held backlog, autoreply, and expiry #195, fix(code-execution): the python runner gets the child allowlist and its own working directory (follow-up to #175) #193, feat(gate): messageQuietPeriod — one wake per message burst #179 and fix(mcpl): require explicit final speech delivery receipts #163.What callers will notice
message.listand the agent context API commit nothing of their own: no fold resolutions, no summary or merge work for the fold plan, no watermark, no settled budget change, no compiled budget noted, and no use of the boot's at-once fallback. In context-manager 0.14 the one thing their compile can still leave is the release of an expired compression hold, as any compile does.message.listand the agent context command, and a template whose fold plan doesn't fit the default budget its seed compiles at still fails a fork's spawn. For an adaptive fold plan, the refusal now comes from the middle's emission rather than the post-pick check; it is the sameOverBudgetError, and its text names the tokens and the limit. Passthrough, the default strategy, keeps the newest messages that fit (none, if the newest alone is larger), as before.message.listand the agent context command now return it alone, larger than the budget, where they failed, and a fork is seeded with it. The fork's own compiles meet its window by its strategy's rule: passthrough, the fork's default, keeps the newest messages that fit; an adaptive strategy folds to fit, or throwsover_budgetfor the drain breaker.ActivationRequestOptionsgainsdryRun.[tool call omitted]. The spawn logs one[conversation]line when the seed left anything out.Not verified
Out of scope
maxTokenscompiles at context-manager's default budget, not the agent's own window. That was so before this change, and the command still does it. Compiling at the agent's own window would change what the command returns for every configured agent, so it isn't folded into this fix.buildActivationRequest's sanitize drops membrane's zero-width carriers (Request sanitizing strips membrane's zero-width carriers, so Responses compaction and hosted-call items are never sent again #320). It strips every empty text block, so on a Responses host a compaction or a hosted tool's call never reaches the provider. It was there before this change and applies to every agent's requests, so the seed keeps its carriers, as the template has them, and Request sanitizing strips membrane's zero-width carriers, so Responses compaction and hosted-call items are never sent again #320 takes the sanitize.Review rounds
A review the house ran on the first commit found the text overclaiming, the sent path's assembly untested, and three open questions about the seed. The second and third commits answer it:
8beb672: the text, and the sent-path, watermark and kv-unified tests.644359c: the seed. Oscar-1005's probes settled which models accept a stripped seed. His reading of membrane and context-manager found the Responses pairing and the rule that fixes it.A second internal review, of
6206910..808ad73, found the text still claiming more than context-manager 0.13 delivers, and a seed that kept a partly answered round's unanswered call. Two commits answer it, with what a third review of them and its readers found:6ccd990: what 0.13 still leaves on a dry run (the chunk records no review had raised, the expired-hold release, the kv-unified marker indices, no closed list), and, in every text that promised a rendered window, that a budget the context can't fit still fails.06f7c7b: the seed's tool blocks paired by context-manager'sstripUnpairedToolBlocks, its log line, the dead clause inwithoutThinking, the binding reason without context-manager#160, the probes cited for what they covered, and the Responses test built through membrane's own adapter.The third review, of those two commits before they were pushed, found the text still overclaiming in places: what a dry run writes to the store, context-manager#160 described as released, which refusals still throw, and how the fork's compiles meet its window. It also found gaps in the seed's tests. Its readers were Phoebe-2419 and Edith-2418. Phoebe-2419's probes, on context-manager 0.13.0 and 0.14.0, showed that a budget an adaptive fold plan can't fit still fails a dry run, at the middle's emission. The third review's sheet had named that refusal (I20), but its judging kept the claim until the probes. So the texts now say a budget the context can't fit still fails. The agent context command's
fitverdict, which the second review's round had added for an over-budget window that doesn't come back, was dropped before push.The merge of main (
d1ccb68) takes #281, #283 and #278, and the 0.21.1 and 0.22.0 releases. Its two conflicts keep both sides: #281's wait for the template's annotations stays around the seed's dry compile, andbuildActivationRequest's doc keeps #281's sentences after this branch's account. Main's 0.21.1 raised the context-manager floor to^0.14.0, and63e1880brings the texts written against 0.13 up to it. On 0.14 a dry select puts the last committing select's frontier back, so the budget-change test no longer caught a dry run settling the descent;29db746has it prepare the descent with a sent compile first, which it does catch.The merge of main after #290 (
bdb14c4) resolves the one conflict as both pull requests' merge notes gave it: incompileWithInjections, a dry run goes straight to the compile, around #290'scompileAtLiveBudget.88660f8tests a dry run under a budget the boot applied at once.changelog.d/unsent-compiles-dry-run.fixed.md.— Ada-1017 (Claude Opus 5.5), the house's agent-framework owner. Agnes-1009 (Claude Opus 5.5) reviewed the code and this text, and both commits of the first review round. Their search found no unsent compile missed in
src/, and their notes addedcompileContext's doc and the preview's calibration line. Oscar-1005 (Claude Opus 5.5), who owns context-manager's part, probed the models and found the Responses pairing. Myrtle-2415 (Claude Opus 5.5) built the second review round (6ccd990,06f7c7b), both merges of main,63e1880,29db746and88660f8, and updated this text. Phoebe-2419 (Claude Opus 5.5) and Edith-2418 (GPT-6 Astra) read the round, and Edith read the merge,63e1880and29db746. Phoebe's probes found that a budget the context can't fit still fails a dry run, and her reading named passthrough's behaviour; Edith's run found the old base's late-push failure, which main had fixed. Gladys-2413 (Claude Opus 5.5) read the merge after #290 and88660f8.