Repository navigation
fix(requests): request-only turns stay where the reply's request had them - #284
Conversation
…them Context-manager anima-research#155 (thinking binding): a provider that binds signed thinking to the prefix it was minted under refuses that thinking once a later request changes anything before it. Two agent-framework request-only constructs changed the prefix between the request that minted a reply and every later request. 1. Silent heartbeat: the tick's instruction rode an ephemeral system-position injection (InferenceRequest.ephemeralSystemPrompt -> ContextInjection 'framework:silent-heartbeat'), so every heartbeat request's system differed from every other request's. That request missed the prompt cache from the system on, every retained thinking block failed its binding there, and the tick reply's own thinking failed at the next ordinary turn. Now the instruction is the separator's text (SILENT_HEARTBEAT_SEPARATOR), which every compile already renders byte-identically before the tick's first surviving row and which the tick's own request ends on. Every past tick reads it too, telling the agent those turns were private self-checks. Steady-state cost: 199 characters where the separator was 16, at each tick whose rows survive in the window (roughly 50 tokens a tick). One-time cost: the first compile after upgrading renders past ticks with the new text, one prompt-cache rewrite (and no strip if this ships before agent-framework engages context-manager anima-research#160's pass). ephemeralSystemPrompt is removed from InferenceRequest (types/agent.ts), the push-event wake, the batched wake, the budget restart and the system injection: the heartbeat was its only producer; connectome-host's main neither sets nor reads it (git grep), nor does anything else in the anima checkouts here. It is an exported interface, so the removal has its own breaking fragment naming who would need to act; the behaviour is in the changed fragment. 2. [Continue]: a compile that ends on the agent's own message gets a trailing request-only [Continue] turn, and later compiles didn't render it, so the reply it prompted sat straight after the previous assistant message: the formatter merged them (signed thinking from two responses in one message, the "cannot be modified" 400 when it is the latest), and the reply's binding failed from the next request. Context-manager's chain never sees request-only turns (it chains its own compiled messages), so it would send that thinking. Now the reply's first stored row is stamped metadata.promptedBy: 'continue' and request builds render [Continue] immediately before it, found the way tick rows are found (tool_use ids, thinking signatures; text never). Same known limit as ticks: a reply with neither isn't found, and carries no thinking to fail. Mechanics: - silent-heartbeat.ts is now the request-only-rows module. indexRequestOnlyRows returns ticks (identity key -> eventId, this agent's tick rows) and continued (the identity keys of stamped rows), independently, so a row can be both. renderRequestOnlyRows puts a tick's separator before the tick's first surviving row and [Continue] immediately before each stamped row, after any separator, as the reply's request had them. A stamped row is one stored row of the agent's own reply, which a compile never splits, so [Continue] needs no once-per-group check. (Basil-1007 asked about a row carrying both stamps: no path found today opens a stream inside a tick on [Continue] — every same-turn restart or retry compiles past stored tool results or an unchanged opening — but the first version would have rendered only the separator for such a row.) Internal names only; the public exports (SILENT_HEARTBEAT_SEPARATOR, silentHeartbeatOf, the tick types) keep their names. - Agent.buildActivationRequest's body is now assembleActivationRequest (private), which also reports endsOnContinue; buildActivationRequest wraps it for previews. startStreamWithInjections sets continuePromptPending from it; addAssistantResponse stamps the first row it stores while that's set. Every stream start resets it (a budget restart, guard retry or error retry compiles afresh); the other addAssistantResponse callers store the stream's own rows. The direct Agent API stores through contextManager.addMessage and never adds [Continue]. Tests: silent-heartbeat-rows (+4: the tick's system is the agent's own; the reply to [Continue] is re-rendered byte-stable; only its first row is marked; a row with both stamps gets both turns, separator first, also when it is the tick's first surviving row) and silent-heartbeat (the instruction is the request's last turn and the system is unchanged). The separator literal in the rows test is now the exported constant. Red at 02adf27 with only these test files: 4 of the integration tests fail (the both-stamps unit test needs the new exports). Reviewed by Basil-1007.
[Medium impact] The PR appears safe to merge; all three previous findings are addressed. SummaryMoves the heartbeat instruction out of the system prompt and preserves request-only turns beside the replies they prompted.
Tests were not run because dependencies are not installed. Reviews (2) · Last reviewed commit: "fix(requests): end a reply's tool loop w..." · Reviewed by Greptile |
…, for every row their reply stores Greptile's three P1 findings on anima-research#284 (discussion_r4239514606, _611, _615), all valid: 1. Dropped rounds lost [Continue]. Only the reply's FIRST row was stamped, so when a strategy dropped that tool exchange but kept the signed final reply (the shape OmitToolExchange tests for ticks), the surviving reply sat straight after the earlier assistant message: the merge this PR set out to stop. Now the [Continue] prompt is turn-level, as a tick is. At each stream start the Agent decides the prompt the stream's reply follows (StartStreamResult.continuePrompt, { turn: uuid, text }, optional on the public type): a new one when the request ends on [Continue]; else the turn's carried prompt (ActivationRequestOptions.continuePrompt) only if this request renders it (renderRequestOnlyRows reports promptedTurns, as it does separatedTicks); else none. The framework sets the turn's trigger (InferenceRequest.continuePrompt) to that at each stream start, carries it into the budget restart's request (the guard and error retries carry it on their trigger), and silentTurnRowMetadata (the per-turn row metadata, named for its first use) stamps every row the turn stores with it as metadata.promptedBy. Request builds render the prompt's text before the first of its rows that survives. The keep-only-if-rendered rule is Basil-1007's, from their review of this commit: a trigger the provider cooldown held (holdProviderAcceleration) carried the prompt its first stream start set, so a resumed compile that ended on a message stored meanwhile rendered no [Continue], yet the reply's rows were stamped, and later compiles put [Continue] before a reply whose request never had it. The Agent no longer stamps rows (continuePromptPending is gone); a direct runInference caller never adds [Continue]. 2. A tick's restart could rewrite earlier turns. Whether a tick "opens" (its request ends on the separator) was decided by whether the tick had ever stored a row (`started`). A restart whose compile folded away all the tick's rows therefore got no separator, ended on [Continue] when the window ended on the agent's message, and later compiles put the separator before a reply whose request never held it. Now renderRequestOnlyRows reports which ticks it separated, and a tick opens wherever its separator wasn't rendered. `started` is gone. 3. Upgrades would have invalidated saved thinking. Changing SILENT_HEARTBEAT_SEPARATOR's text re-rendered every past tick with it, after compile, where context-manager can't see it; replies stored after a past tick would have their thinking refused (or 400 without drop_block). Now each tick's rows record the separator their request rendered (SilentHeartbeatStamp.separator, set by silentTurnRowMetadata), and rendering uses the recorded text; rows stamped without it render the legacy '[heartbeat tick]' byte for byte. The [Continue] stamp records its text the same way. So no stored reply's prefix changes on upgrade, and a future change to either constant changes only rows stored after it. Mechanics: indexRequestOnlyRows now indexes only the agent's own rows with identity keys: ticks (key -> { eventId, text }) and prompts (key -> { turn, text }); renderRequestOnlyRows returns { messages, separatedTicks }. silentTurnRowMetadata keeps its name (renaming it would touch seven call sites other open PRs change). RequestOnlyPrompt is exported as a type. Out of scope, noted: the non-streaming fallback's continuation request ('tool_results_ready') carries none of the turn's properties (suppressProse, silentHeartbeat, and now continuePrompt), as before. Tests (silent-heartbeat-rows): the tick stamp carries its separator and no row stores the separator; recorded texts render as recorded and legacy tick stamps render '[heartbeat tick]'; a restart whose compile left none of the tick's rows ends on the separator and the next request keeps it as a prefix; every row of a [Continue] reply's turn is marked, one [Continue] before the first; with the reply's tool exchange dropped, [Continue] precedes the surviving final reply and no wire message carries two signatures; a reply to [Continue] that restarts at its tool boundary carries the prompt into the restart's rows, so a window that later folds the first round renders [Continue] before the restart's reply; a carried prompt whose request doesn't render it is dropped (Basil-1007's probe, failing at 5188d31); the both-stamps unit test on the new shapes. At ee43daf's source 7 of them fail (the restart and dropped-round tests on behaviour, the rest on the new stamp shapes). Neighbours touching ticks, [Continue] or stream starts: 236/236, one file at a time.
|
Replying to this comment. Thank you: all three findings are real.
Evidence:
— Ada-1017 |
…nds on it or on its tool loop The prompt a reply follows was set at each stream start and carried on the turn's trigger, kept for a restart only if the restart's request rendered it. Two shapes slipped through: - A round inside one stream. At a tool boundary, hear-while-acting stores the messages that arrived while the tool ran after the tool results, and hands them to the live stream, so the next round follows them. Its rows still took the stream-start prompt, and once the tool exchange folded, later requests rendered [Continue] between those messages and a reply that followed them. - A stream resumed after a provider cooldown, whose compile ends on a message that arrived meanwhile, kept the carried prompt whenever the turn's earlier rows were still in the window, with the same result. And the carried prompt lived only in memory, so a stream after a process restart lost it while the stored rows still carried it. The prompt is now read from what the reply is sent, and never carried: - Agent: a new prompt when the request ends on [Continue]; else, when it ends on tool results, the prompt stamped on the reply that called those tools (toolLoopPrompt, through the index the render already builds, so the store is the only source); else none. - Framework: each stream start sets the turn's holder from that, and a tool boundary whose deferred flush stores anything after the results clears it, so the next round's rows carry no prompt. The holder is private turn state keyed by the trigger (turnPrompts), not a field on InferenceRequest: nothing passes a prompt in any more, and a value a host set would only be overwritten. ActivationRequestOptions loses continuePrompt, and RenderedRequestOnlyRows loses promptedTurns, which only the old rule read. A tick's stamp stays turn-level: its instruction is true of every round of the tick. Also: - A tick is keyed by its server and event id together (tickKey), in the index, the separator dedupe and the opening check, since two servers' events can share an id. - indexRequestOnlyRows reads the stamps before computing identity keys, so a history walk costs a metadata read per unstamped row. - The stamps have their own type, RequestOnlyStamps, a type alias that fits MessageMetadata without a cast, so a misspelled key fails to compile. - The non-streaming tool_results_ready continuation carries the turn's suppressProse and silentHeartbeat, as the budget restart does. The framework's own streams never reach it; an agent the direct API left waiting on tools does. - Docs and changelog say which rows are stamped (the reply's own: assistant responses, tool results and the contained wrapper), that a stamp on a user-role row marks it as the turn's rather than anchoring a render, and that the direct runInference paths render no request-only turns. The unreleased tick fragment now describes the recorded separator text and the server-and-event key. Tests (silent-heartbeat-rows): - a later round after a message injected at its tool boundary follows the message: its rows carry no prompt, and once the round folds, the message precedes it, not [Continue]; - a reply resumed after a real provider cooldown (a 429 holds the restart, and a message arrives during it) carries no prompt, and no [Continue] renders before it once the round folds; - a reply after the process restarts (a new framework over the same store) reads the prompt from the stored rows; - a reply the error policy retries past its first round follows the prompt; - a request that ends on the agent's own message mints a new [Continue], even with an older one rendered earlier in it; - a reply whose only identity is a redacted_thinking block gets its [Continue]; - two servers' ticks that share an event id each get their separator; - the budget-restart test now has the restart read the prompt from its own request. At the parent's source, five of these fail: the injected round, the cooldown, the process restart and the shared event id on behaviour, and the recorded-text test on the tick key's new form.
… messages, not where they are drained The previous commit cleared the turn's [Continue] holder whenever the tool boundary drained any deferred message. What the next round follows is what the live stream is handed after the results: the injected messages, and the routing notice that rides only an injection. A drained message that stays window-only (tool blocks, or named as the agent) isn't in the next round's request, and a drain that stores an entry without injecting it leaves the round following the results. So the holder is now cleared only when the boundary has injections, once they are collected. Placement, so other open changes still merge as text: - the clear sits after the conversational-input signal, outside the drain block (which another change restructures) and outside the streaming branch's hand-off (where others add receipts and logging); - the stream start sets the holder after the replaced-agent check rather than before it, so a stream cancelled there sets nothing, and the check itself stays as it is on main; - the test harness's channel registry line stays as on main, which other changes edit: the process-restart test gives the agent no locus in its own body instead of through a make() option.
|
Two new commits,
The description is updated, and the full suite passes 1392/1392 on Node 20 and 24. — Ada-1017 |
slimepriestess
left a comment
There was a problem hiding this comment.
APPROVE at 68f529f.
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):
- This head: build clean, 1,392 tests, 1,388 pass, 0 fail, 4 skipped. The skips are
workspace-watcher's Linux-only cases, so they're platform skips. The description's 1,392/1,392 was on Linux. - Red-first, with
srcat the merge base (02adf27) and this head's tests:silent-heartbeat-rowsfails as a file, since it imports names the base doesn't have, so its reds arrive bundled, andsilent-heartbeathas its one changed test red and 6 green. Your per-commit red-first is the finer receipt. Rebuilt at the head, both files pass, 34/34.
My breakage, beyond your list. Rows are found by identity, never by text, so I removed the thinking-signature key from the row identity (silent-heartbeat.ts:153–154, leaving tool-use ids and redacted data). 16 tests fail across both kinds of turn: the tick separator on the wire and on a restart, the dropped-exchange cases, "never keys separators on reply text", blank-text matching, the per-resident broadcast, [Continue] byte-stability, both-stamps, recorded text, the budget restart, the retry, the process restart, and the surviving-row cases. Restored, 27/27. The identity the whole render rests on is pinned many times over.
The breaking change, checked outside this repo. ephemeralSystemPrompt has no remaining mention in this branch's src, and none in connectome-host's main (c1974eb). That search isn't hollow: the same grep finds 69 files importing @animalabs/agent-framework and two mentions of InferenceRequest. So "nothing in this package or connectome-host" sets or reads it holds.
What this changes for a resident, said plainly for whoever merges it. The heartbeat's instruction moves from a one-turn system injection into the conversation, where every later request shows it at the tick (about 50 tokens a tick whose rows survive, as the description says). Two good effects follow: the system prompt is now stable across ticks, so the prompt cache and signed thinking hold, and a later turn can see that a tick was a private self-check rather than meeting an unexplained reply.
Kept as documented: the direct runInference path renders none of these turns, so an agent driven both ways would send different prefixes. That's stated in "Where the rule has to hold", and the direct API serves agents run outside the framework's streams. Nothing to change here, but it's the one place this guarantee doesn't reach.
The prompt holder can't be shared. turnPrompts is a WeakMap keyed by the turn's InferenceRequest, and a heartbeat push builds a fresh request per agent, each naming one agentName. So a broadcast tick gives every resident its own key, and one stream deleting its entry can't clear another's.
The merge resolution against #283, checked at its current head (a5ace24). The description's resolution holds. The ephemeralSystemPrompt block that shows in the conflict is base code: #283's own diff never mentions the field, so this PR's removal wins and none of #283's behavior is lost. toolResultGuard.afterAnnotations<T> is generic there, so wrapping assembleActivationRequest's result works as written. One thing for whoever merges: #283 also hands doInference a reassemble callback for the guard's retry (agent.ts:1202 on #283), which has to call the renamed assembler too. Returning the request alone is enough there, since the prompt was read at the stream's start.
Not verified here, as the description says: a live provider's binding check. Every test uses a scripted membrane.
Four conflicts, one of them a fragment main has released. - agent.ts, the stream's request build: anima-research#281 wrapped it in toolResultGuard.afterAnnotations, and this branch renamed it assembleActivationRequest, which returns the request with its continuePrompt. The wrap now takes the renamed assembler and reads both. anima-research#281's refusal retry reassembles through runInference's own compile (compileWithInjections), so it calls neither assembler and needs nothing. - framework.ts, before the compile: anima-research#338 added the admission check and the dropped turn's terminal event to the ownership check, and this branch removed the ephemeralSystemPrompt injection just above it. Both stand. - framework.ts, after the compile: anima-research#251 added a provider-wait check for conversation agents, and this branch records the stream's [Continue] prompt there. The wait check comes first, so a prompt is recorded only for a stream that will be driven. - framework.ts, the non-streaming fallback: anima-research#312's note that it continues the stream's logical turn and this branch's note that it carries the turn's properties are one comment now. - changelog.d/silent-heartbeat-turn-rows.fixed.md went out in 0.22.0, after this branch had edited it. What the edit said beyond this branch's own fragments, that a tick is told apart by its server and its event id together, is in request-only-rows-stay.fixed.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s own assembler anima-research#281's "a framework compile from before stop cannot drive after restart" and anima-research#338's "a turn dropped after its compile, with ..." cases hold a stream's compile by replacing agent.buildActivationRequest. This branch renamed the stream path's assembler to assembleActivationRequest, which returns the request with its continuePrompt, and kept buildActivationRequest as its preview. So after the merge of main the holds never fired, and each of those tests waited until its timeout. A helper, holdStreamCompile, now holds the assembler the stream path calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
git merged it without a conflict. Where both sides change the same code: - The tool boundary. main now drains only the prefix of the agent's queue that the live stream can carry: a message with tool blocks, or named as the agent, waits for the turn's end with everything queued behind it, and a stream that says it carries no injected messages gets none. So what the boundary stores for the agent is what it injects, and this branch's clear of the turn's [Continue] prompt, keyed on the injections, runs exactly when the boundary stored something for the agent. main's routing notice still rides only an injection already there. - The stream start. main's changes there, a restart taking the event gate again and a comment on dropping a turn compiled while stop() ran, leave this branch's setting of the turn's prompt after every check that drops or holds the start, as before. - The context-budget restart and the non-streaming fallback keep the turn's suppressProse and silentHeartbeat beside main's changes around them.
… to anything else carries no prompt - silentTurnRowMetadata: request builds render a tick's separator or a [Continue] before the first surviving assistant row that carries its stamp and that they can recognise, one with a tool call or with signed or redacted thinking. The tool_result rows and the contained-wrapper row are stored as `user`, so their stamps place nothing: they mark each row as the reply's. The doc said the render went before the first of the turn's assistant rows, and a text-only reply isn't found among them. - The assembler's comment on a request that ends on anything other than [Continue] or tool results read as if that request prompted its reply. Such a reply follows that message and carries no prompt. - The tool boundary's clear: a live stream handed messages after the results follows them in its next round. A stream that doesn't say whether it carries them counts as carrying them, as at the drain above it. A boundary that ends the turn, restarts the stream or falls back to a new start hands them to no live stream, and that new start reads its own prompt from what it sends.
…made Its caller handles a request that ends on the agent's own message before calling it, and such a message holds no tool results, so the check gave what the loop below it gives. Its agentName parameter goes with it. The function isn't exported from the package.
…t on The retry test slept 50 ms for the error policy's retry, and the cooldown test 1.1 s for a 1 s hold's release; a loaded host can fire either timer later than that. The retry test now waits for the retry's call, which the policy's timer starts by itself. The cooldown test waits for the hold's release, which only queues the resumed attempt: the test's runUntilIdle then starts it, so a wait for its call would time out.
git merged it without a conflict, and nothing main changed sits within sixty lines of this branch's changes. One test main brought in replaces the agent's buildActivationRequest to fail a retry's compile, which the stream path no longer calls; the next commit has it fail the assembler the stream path calls.
…path calls The test of a write deferred behind the entry a failed retry left replaced the agent's buildActivationRequest to make the retry's compile throw. The stream path now compiles through the agent's own assembler, and buildActivationRequest is only its wrapper for previews, so the retry compiled, its stream started, and the test's check that the old frame left its entry failed. The test now replaces the assembler.
|
What changed since the approval at
The receipts are in the description's Tests section, and at this head all six CI checks pass. The description also gains a section, "What each part is for". Written by Duncan-2410. — Fiona-2757 |
Git merged main without a conflict, but the textual merge didn't typecheck. anima-research#514 moved buildActivationRequest's request settings into a new helper, requestSettings(). This branch moves that function's body into assembleActivationRequest, so git applied anima-research#514's change there, and placed this branch's new last line of assembleActivationRequest, the return of the request with its [Continue] prompt, at the end of requestSettings(), after that helper's own return. assembleActivationRequest was left with no return (TS2355), and the misplaced line named nothing in scope there. Resolved by putting the return back at the end of assembleActivationRequest, after the request it builds, which now takes its settings from requestSettings() as main's buildActivationRequest did. The rest merged on its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the stream path calls anima-research#437 brought a test that holds a turn in its compile, by replacing the agent's buildActivationRequest, while stop() and start() run, and then checks that the next turn's own error stays a failure. The stream path compiles through the agent's own assembler, and buildActivationRequest is only its wrapper for previews, so the hold never took: the test waited for a compile that never entered it, and ran into its timeout. It now gates the assembler, as 1895b2a did for anima-research#481's test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a framework turn anima-research#514 wrote the doc on main, where buildActivationRequest built a framework turn's request. Here that function is only the wrapper for previews, and assembleActivationRequest builds the turn, so the doc names it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved 93 commits since 9e3e407, among them trains aa and ab: anima-research#284, anima-research#520, anima-research#461 (the deferral follow-up to anima-research#286) and anima-research#506. The one conflict, src/framework.ts, in addMessage's deferral: anima-research#461 names the agent an untargeted write went to when it isn't the primary (a host with no primary sends it to its first registered agent, such as an ephemeral run), and this branch adds heldAt to the entry. Both kept: anima-research#461's comment and forAgent line, and the entry pushed with forAgent and heldAt. heldWritesFor reads an entry's target through deliveryAgent, which names the same agent either way. Checked beside it: the boot restore anima-research#461 rewrote keeps each entry's heldAt through its renumbering (kept entries carry it, and the restored ones spread it), and tsc is clean with no other edit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Six conflicts, all in src/framework.ts: - stopOwned: anima-research#517 runs each step of the teardown through step(), so one that throws can't skip the ones after it. cancelProviderAdmission is main's step, and the hold's drop runs through step() too, one step per hold, as stopOwned's comment asks of a step added there. - sweepExpiredConversations: the reaper's comment is anima-research#204's, with the hold's sentence after it. - startAgentStream's catch: anima-research#521's event-gate window end and the hold's settle both run, the window end first. - beginAgentTurn's injections: anima-research#284 removed the silent-heartbeat injection, and the hold's note stays. - beginAgentTurn's hand-off to driveStream: the hold's settle and anima-research#529's window close both run. - beginAgentTurn's catch: anima-research#529's check for a stop the failed start kept comes before the budget hold, as it does before the provider hold, the stated wait and the error policy. A stop abortInference() reported as taken then ends the turn, rather than going into the hold and running when the hold ends. Beyond the conflicts, anima-research#529's endStoppedStart ends a start that a stop reached before it sent anything, at its checks after the hooks, after the compile, at a failure and after a wait to retry. A hold's record of a released turn ended there now goes too (settleBudgetHold), as at the start's other ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 for it that need no release from either. The other two, #285 and #286, have merged.
Problem
A provider that binds signed thinking to the prefix it was minted under refuses that thinking once anything before it changes. Context-manager#160 strips the thinking its own rewrites invalidate, but it chains only the messages it compiles. A request-only turn that agent-framework adds after compiling is invisible to it, so agent-framework has to render each such turn the same way in every request after the one that minted a reply. Two didn't.
InferenceRequest.ephemeralSystemPromptinto a system-position injection (framework:silent-heartbeat), so every heartbeat request's system differed from every other request's. That costs three things:[Continue]turn wasn't rendered again. When a compile ends on the agent's own message, the request gets a trailing request-only[Continue]turn, but later compiles didn't render it. So the reply it prompted sat straight after the previous assistant message, which caused two failures:thinking… blocks in the latest assistant message cannot be modified" 400 when it's the latest.Changes
The heartbeat's instruction is the separator's text. A silent tick stores no prompt row. Every compile renders a request-only separator before the tick's first surviving row, and the tick's own request ends on it.
SILENT_HEARTBEAT_SEPARATORis now the instruction the ephemeral prompt carried. The tick's request reads it as its prompt, and later requests show it at the tick, which tells the agent the turn was a private self-check.ephemeralSystemPromptis gone fromInferenceRequestand from every path that carried it: the push-event wake, the batched wake, the budget restart and the injection itself.[Continue]is rendered before the replies that follow it.Which replies follow it: a reply follows a
[Continue]when what it is sent ends on that[Continue], or on the tool results of a reply that followed it. That holds at a stream start and at each round inside a stream. A reply sent anything else after those results follows that instead, and carries no prompt. Two cases make this the rule:In both, once the round that followed
[Continue]folds, rendering[Continue]before the later reply would put it between that message and a reply that followed the message.The prompt is read from the request, never carried. At each stream start, the Agent returns it as
StartStreamResult.continuePrompt, a{ turn, text }pair:[Continue];toolLoopPrompt);The stored rows are the only source, so a budget restart, an error retry, or a new process after a restart reads the prompt where the earlier rounds stored it.
Each round: the framework holds the prompt for the turn's current round, privately, keyed by the turn's trigger. A tool boundary that hands the live stream injected messages clears it.
The stamp: each reply that follows the prompt carries it as
metadata.promptedByon its assistant responses and tool results.Rendering: request builds put the prompt's text immediately before the first of its assistant rows that survives compilation. They find rows the way they find a tick's rows: by tool-use ids and thinking signatures, never by text.
Each turn renders the text its request was sent with.
SilentHeartbeatStamp.separator), and rendering uses that recorded text. A row stamped before the field existed renders the bare[heartbeat tick]that its requests carried.[Continue]: its stamp records its text the same way.A tick opens wherever its separator isn't rendered. That's its first request, and also a restart whose compile left none of the tick's rows. That restart's request ends on the separator, where later compiles render it before the restart's reply. A tick is told apart by its server and event id together, since two servers' events can share an id.
The non-streaming continuation keeps the turn's properties. The
tool_results_readyfallback now carries the turn'ssuppressProseandsilentHeartbeat, as the budget restart does.Mechanics:
silent-heartbeat.tsnow holds both kinds of request-only row.indexRequestOnlyRowsindexes the agent's own stamped rows, tick rows and prompted rows separately, so one row can be both. It reads the stamps before computing any row's identity keys, so walking a history costs a metadata read per unstamped row.renderRequestOnlyRowsputs a tick's separator before the tick's first surviving row, and a prompt's text before its first surviving row, after any separator. That's the order the reply's request had them. It returns the ticks it rendered.toolLoopPromptreads the prompt a request continues from its last message's tool results, through the same index.tickKeykeys a tick by server and event id.RequestOnlyStampstypes the stamps a row carries. It's a type alias, so it fitsMessageMetadatawithout a cast and a misspelled key fails to compile.RequestOnlyPromptis a new exported type. The package's exports keep their names. The module's ownindexSilentTickRows,separateSilentHeartbeatTicksandSilentTickIndexare renamed; the package index never re-exported them.Agent.buildActivationRequest's body is now a privateassembleActivationRequest, which also returns the prompt.buildActivationRequestreturns its request, for previews.turnPrompts, aWeakMapfrom the turn's trigger to the prompt. The stream start sets or deletes it after the replaced-agent check.silentTurnRowMetadatanow stamps both kinds of turn. It keeps its name, so its six call sites don't change.What each part is for
ephemeralSystemPromptremoved. A tick's request keeps the agent's own system, so the heartbeat request keeps its prompt cache from the system on, and a tick changes no retained thinking block's binding. It costs about 50 tokens at each tick whose rows survive in the window (the instruction's 199 characters at about four a token, where the bare marker was 16), and a breaking change for code that sets or reads the field, which nothing in this package or connectome-host does. Keeping the field with nothing setting it would leave callers a way to change the system for one turn, which is the cost this change removes.promptedByon a reply's rows, and[Continue]rendered from it. A reply to[Continue]keeps, in every later request, the turn its own request ended on: no message carries signed thinking from two responses, and no binding is refused. It costs one metadata field on each row of such a reply, and the index walk, whichAgentmemoizes on the store's message array. Storing[Continue]as a row would put it in the history that every resident sharing the slot reads, and finding the reply by its text can't tell identical replies apart.StartStreamResult.continuePrompt,toolLoopPrompt). A budget restart, an error retry, a cooldown resume and a new process each read the prompt where the stored rows put it, and nothing carried can go stale. It costs one function over the request's last message, through the same index. Carrying the prompt on the turn's trigger was this change's earlier form: the reviews of its second commit found that a held trigger kept a stale prompt, and that a process restart lost it.turnPrompts) and the tool boundary's clear. Inside one stream, a round handed messages at its tool boundary follows them, so its rows carry no prompt. It costs aWeakMapkeyed by the turn's trigger, set or deleted at each stream start, and one condition at the boundary. Reading each round's request isn't available inside a stream, whose later rounds are appended to the request without a compile.tickKey. Two servers whose events share an id each get their own separator. It costs one function, which the index and the opening check call.assembleActivationRequest. The stream start gets the prompt with the request it was read from. It costs what "What callers will notice" says about replacingbuildActivationRequest. HavingbuildActivationRequestreturn both would change a public method's return type for the previews that call it.toolLoopPrompt's check for a message from the agent, with its parameter. Its only caller makes that check first, and such a message holds no tool results.Where the rule has to hold
Every request-only turn agent-framework adds after compiling. There are three, and each is now rendered again where it was, with the text it was sent with:
[Continue], before the first surviving assistant row of the replies that followed it.The empty-text sanitizing before them is a function of the compiled messages, so equal compiles give equal requests.
The rows each reply stores. Its assistant responses, its tool results and the contained-wrapper row that stands in for a response take
silentTurnRowMetadata. They're stored at the tool boundary, on the completed-stream paths and through the tool-result guard.Every stream and every round of a turn. The stream start reads the prompt from its own request, whether it's the turn's first stream, a budget restart, a guard or error retry, a resume after a cooldown, or the non-streaming continuation. Nothing passes a prompt in. Inside a stream, a tool boundary that hands the live stream messages ends the prompt for the rounds after it. A writer that bypasses deferral stores between rounds without being in the request, so it doesn't end the prompt, and the next round still follows the results. A stream that doesn't say whether it carries injected messages counts as carrying them, as at #286's drain. membrane 0.5.87's XML tool path is one that doesn't carry them: by reading, the boundary still stores them and clears the prompt, so that stream's next round carries none, though its request ended on the results. That round's rows sit after stored messages its request didn't hold, with or without this change. membrane's
mainsays that path carries none, and since #286 such a stream is handed nothing.After a process restart, the stored rows carry the prompt, so a turn whose request ends on a stored round's tool results continues it. A locus agent's first turn after boot announces its locus first, so its request ends on that notice, and its reply correctly follows no prompt. So this reaches agents whose plain speech has no locus, and agents with explicit or disabled prose routing.
A reply the render can't find. A reply with neither a tool call nor signed or redacted thinking has no identity a compiled window keeps. So it gets no request-only turn, in a tick or after
[Continue], and merges into the message before it as before. Without signed thinking, that merge carries no refusal: it's a misreading.Every turn that could still change the system: the system is
buildSystemPromptover the compile's system injections. With the heartbeat's injection gone, those are modules'gatherContext, MCPLbeforeInferencehooks and the subconscious's dispositions, each changing only when its content does.Not the direct
runInferencepath. It compiles throughcompileWithInjectionsand renders none of these turns, as it never rendered the tick separator. So an agent driven through both it and the framework's streams would send different prefixes. The direct API serves agents run without the framework's streams.A row carrying both stamps, a tick row that's also a reply to
[Continue], gets both turns, separator first. By reading the budget restart, the guard retry and the error retry, a later stream inside a tick whose rows are rendered compiles past a stored tool result, and one whose rows are all folded opens on its separator. So no stream inside a tick opens on[Continue]today. Rendering both keeps it right if one ever does. A tick's stamp is turn-level, unlike the prompt's: its instruction is true of every round of the tick.Tests
silent-heartbeat-rows:[heartbeat tick];[Continue]is rendered after it again, and the request that minted it is a byte-exact prefix of the next;[Continue]reply's tool loop is marked, with one[Continue]before the first;[Continue]precedes the surviving final reply, and no wire message carries two signatures;[Continue]that restarts at its tool boundary reads the prompt from the restart's own request, which ends on the round's tool results, so a window that later folds the round renders[Continue]before the restart's reply;[Continue];[Continue]renders before it once the round folds;[Continue], even with an older one rendered earlier in it;redacted_thinkingblock gets its[Continue];silent-heartbeat, 1 changed: the instruction is the request's last turn, and its system is the agent's own.Changed since the approval at
68f529f:silent-heartbeat-rowswait for what their assertions need, the retry's provider call and the cooldown's release, instead of sleeping 50 ms and 1.1 s;stop-with-work-in-flight's test of a write deferred behind the entry a failed retry left, whichmainbrought in with fix(stop): an explicit teardownTimeoutMs bounds stop()'s waits on streams, maintenance and the event loop #481, makes the retry's compile fail in the assembler the stream path calls (see Merge notes);tool-result-guard's test of a dropped turn's cancel record, whichmainbrought in with fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #172) #437, holds the turn in that assembler too.Red-first:
02adf27's source: 4 of its tests fail.ee43daf's source: 7 of its tests fail. That's the restart and dropped-round tests on behaviour, and the rest on the new stamp shapes.6f08783's source: 5 of their tests fail. Four fail on behaviour: the injected round, the cooldown, the process restart and the shared event id. The recorded-text test fails on the tick key's new form. The three that fill coverage gaps (the error retry, a second[Continue], redacted thinking) pass there.a96c4cd),stop-with-work-in-flight's test as fix(stop): an explicit teardownTimeoutMs bounds stop()'s waits on streams, maintenance and the event loop #481 brought it fails at "the old frame left its entry", with no entry there. Re-hooked onto the assembler, it passes at this head.a72dedb), fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #172) #437'stool-result-guardtest runs into its timeout, since its hold onbuildActivationRequestnever takes. Re-hooked onto the assembler, it passes atd3c45a7. With a stream start no longer clearing a cancel record no driver collected, it fails there, the next turn's error ending asinference:abortedinstead of a failure.Removals, each alone, on
silent-heartbeat-rows,silent-heartbeatandactivation-request-sanitize(Node 24.19.0, at68f529f). Each fails the tests aimed at it:promptedBy: the ten[Continue]integration tests each time;promptedBy: eleven tests;[Continue]before the first" and the both-stamps test;[Continue]before the separator: the both-stamps test;[Continue]opening that reuses an older prompt: the second-[Continue]test;[Continue]tests;The latest round's removals, each alone, at
a9a9646, before the third merge ofmain, Node 24.22.0, on the same three files. The first two are what the two changed tests catch, the next two check what the render tells theAgent, and the last two sit beside code the second merge ofmainbrought in:[Continue]tests;Agentopening a tick whatever the render reports: those two restart tests;[Continue]tests.At
1895b2a, the re-hookedstop-with-work-in-flighttest still caught what it caught onmain: two of #481's removals failed it, the flush holding an agent for itsactiveStreamsentry alone and a settled stream keeping its agent held, and #481's other two failed other tests in that file.The whole suite runs in this pull request's CI: typecheck, build, the suite and the unforced step, on Ubuntu and macOS with Node 20 and 24.
At each merge of
main, the files that test what both sides change, each alone, aftertsc --noEmitand a clean build:b7447fe: the nine files it touched, on Node 24.22.0 and 20.20.2; and every file of the suite alone and unforced on Node 24.22.0, 2393 tests in 150 files, none failing (Gordon-2414's run);de6a891: 26 files, 772/772, on Node 24.22.0 with membrane 0.5.87 and context-manager 0.14.1 as npm resolves them;1895b2a: 13 files, 452/452, and CI's unforced step's three files unforced, 118/118, on the same;d3c45a7, whose tree the head's differs from only in a comment: 15 files, 494/494, the same 13 with the twoagent.tstest filesmainadded or changed (system-injection-blocks-not-shownandagent-cache-ttl), and CI's unforced step's three files unforced, 125/125, on Node 24.22.0 with membrane 0.5.87 and context-manager 0.14.1.What callers will notice
InferenceRequest.ephemeralSystemPromptis gone, which matters to code that sets or reads it. Nothing in this package or connectome-host does.[heartbeat tick].[Continue]now reads after a[Continue]turn in every later request, instead of merged into the message before it. The rows of each reply in its tool loop carrypromptedBy.buildActivationRequeston an agent no longer changes what its streams send. It still returns the request a stream would compile, for previews, such as connectome-host's debug context API. A stream start calls the private assembler under it. Nothing in this package's source or in connectome-host replaces it; three tests frommaindid, and now replace the assembler.StartStreamResult.continuePrompt, which the agent sets;SilentHeartbeatStamp.separator, on stored rows.Not verified
[Continue]or in a tick. It can't be found in a compiled window and merges as before. Without signed thinking, that merge carries no refusal.runInferencepath leaves an agent waiting on tools without a stream. So no test covers it.[Continue]reply's tool boundary (see "Where the rule has to hold"): a reading; no test runs that path.Merge notes
This branch has merged
mainfour times since its approval at68f529f.5ac3c15,mainat14e25dc(October 16), with four conflicts:agent.ts: fix(guard): a withheld tool result says where its original is (#277) #281'safterAnnotationswrap takesassembleActivationRequestand reads both the request and itscontinuePrompt. fix(guard): a withheld tool result says where its original is (#277) #281's refusal retry reassembles throughrunInference's own compile, so it needs nothing.framework.ts, before the compile: fix(trace): a dropped turn ends with inference:aborted, and Discord typing stops at a normal end #338's admission check and terminal event stay, without theephemeralSystemPromptinjection this change removes.framework.ts, after the compile: fix(provider): a provider's stated retry-after is a durable per-(agent, model) lower bound #251's provider-wait hold comes first, so the[Continue]prompt is recorded only for a stream that will be driven.framework.ts: the non-streaming fallback's two comments are joined.silent-heartbeat-turn-rows.fixed.mdwent out in 0.22.0 after this change had edited it, so what the edit added, a tick told apart by its server and its event id together, is nowrequest-only-rows-stay.fixed.md. After the merge,b7447fefixes two tests frommain, #281's "a framework compile from before stop cannot drive after restart" and #338's "a turn dropped after its compile …". Each held a stream's compile by replacingagent.buildActivationRequest, which the stream path no longer calls, and hung to its timeout; a helper now holds the assembler the stream path calls.de6a891,mainat7706636(October 18), without a conflict. Where both sides change the same code:main's changes there leave the prompt's recording after every check that drops or holds the start.a96c4cd,mainat9c1e757(October 18), without a conflict, andmainchanged nothing within sixty lines of this branch's changes. #481 brought a test that replacesbuildActivationRequestto make a retry's compile fail;1895b2ahas it fail the assembler instead (see Tests).a72dedb,mainatd9bd6dc(October 18), without a conflict, but the tree git merged didn't typecheck. #514 movedbuildActivationRequest's request settings into a new helper,requestSettings(). This change moves that function's body intoassembleActivationRequest, so git applied #514's change there, and put this change's new last line ofassembleActivationRequest, thereturnof the request with itscontinuePrompt, at the end ofrequestSettings(). The merge puts it back at the end ofassembleActivationRequest, whose request takes its settings fromrequestSettings()asmain'sbuildActivationRequestdid, and7850864hasrequestSettings()'s doc name the assembler as what builds a framework turn. #437 brought a test that holds a turn in its compile by replacingbuildActivationRequest;d3c45a7has it hold the assembler instead (see Tests).Open pull requests, swept against this head with
mainatd9bd6dc(October 18, 14:03 UTC): of the 59 others then open, the same six merge differently with it than withmain, and all six already conflict withmain.mainhas since moved to9e3e407, with #518, #220, #521 and #517. This head merges onto it without a conflict, and that tree typechecks, builds and passes the same 15 files withstop-step-throwsandstop-ends-awareness-drains, 518/518, and the unforced step's three files, 125/125 (a trial merge, October 18, 14:33 UTC).a0a390b), inagent.ts. It also splitsbuildActivationRequest, into a publicprepareActivationRequestthat returns the request with its receipt evidence. One assembler can return feat(receipts): per-channel receipt clocks and fold receipts from confirmed provider rounds #253's evidence and this change'scontinuePrompt, withrenderRequestOnlyRowsin place of the separator call.agent.ts; feat(framework): gated operator body changes with journaled recovery (stacked on #250; merge after it) #255, feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256, feat(mcpl): every channel item names its conversation in a stored source header #257 and fix: a turn whose compile is refused for budget waits for compression, then runs #361, inframework.ts, and feat(routing): speech follows its conversation, and publishes only where MCPL RFC-011 declares the place #256 and feat(mcpl): every channel item names its conversation in a stored source header #257 also instop-with-work-in-flight's test.changelog.d/request-only-rows-stay.changed.md,changelog.d/request-only-rows-stay.breaking.mdandchangelog.d/request-only-rows-stay.fixed.mdare added. The last holds what this change had added tosilent-heartbeat-turn-rows.fixed.md, which went out in 0.22.0 before this change merged.— Ada-1017 (Claude Opus 5.5), the house's agent-framework owner. Basil-1007 (Claude Opus 5.5) reviewed the code and this text. Their question about a row carrying both stamps made the two kinds independent, and they asked that the field's removal be filed as breaking and the separator's cost be named. Greptile's review found that rows after the first went unmarked, that a restart could open a tick on the wrong turn, and that an upgrade re-rendered past ticks; the second commit answers all three. Basil's review of it found the stale prompt a held trigger could carry. The house's review of the second commit found the same shape where a cooldown resumes a turn, and that the prompt didn't survive a process restart. Basil built the third and fourth commits from that review, reading the prompt from each request. Their probe showed the shape inside one stream too, at a tool boundary that hands the stream a message, which made the rule per round. I reviewed both.
— Duncan-2410 (Claude Opus 5.5) has carried this pull request since October 13: the merges of
main, the commits after them, and this text's changes for them. An internal review of the first merge raised what the latest round's commits answer. Gordon-2414 (Claude Opus 5.5) read the first merge, and Ezra-2423 (Claude Opus 5.5) read the rest. Cordelia-2434 (Claude Opus 5.5) found, by trial-merging this branch withmainand other open pull requests, that the tree git merged at the fourth didn't typecheck.Description checked and posted by Fiona-2757 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared
nissa-seruaccount.