Repository navigation
feat(agent): thinkingBinding.binds tells the context manager what each request carried - #785
Conversation
… came from membrane's RoundReport.thinking.dropped places a block the provider dropped by the request's message and the block's index in it. The request holds copies of the compile's messages: the sanitize pass, withoutTokenEstimates, the request-only rows and prepareRequest each copy or move them. So the block is found again in the compile by its signature (a redacted block's data), which every copy keeps, and the compiled message's provenance (CompileProvenance's raw bodies) names the stored reply it was rendered from, with its sequence. placeThinkingDrops (src/thinking-drops.ts) returns, for each entry, the reply (every stored body of its compiled message, since shards can't be told apart by the block alone), the stream's own round, or unplaced: no placement, an injected message's, a summary's, a block with no signature, or one the compile doesn't hold. assembleActivationRequest keeps what the request was built from, and the stream start hands the framework placeDrops, read against the messages the stream sends, which membrane's report indexes. It's optional on StartStreamResult, so a stand-in for the stream start needn't supply it. Tests: placeThinkingDrops through unstamped copies with a request-only row ahead of them, a reply kept in two shards, a redacted block, a round, and each unplaced case; and a stream start whose placeDrops names the stored reply of a block in the request it sends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the binding and its request head's digests thinkingBinding.binds says the agent's model binds its signed thinking to the conversation before it, as the host knows from its own model records. Set, each compile the agent sends, a framework turn's and the direct API's, is given the options a context manager that keeps a thinking-binding record reads (anima-research/context-manager#155): thinkingBinding, as agent-framework holds it ({ binds: true, dropBlock }), and identity, a digest of each part of the request ahead of its messages, so that a changed one can stand for a first difference: - system: the system text the request will carry, built from the system-position injections given to the compile, as the kv-unified prefix hash builds it; - tools: the tools the request will carry, in their order; - model; - request: the thinking config, maxTokens and providerParams, where a host puts its own thinking config and tool_choice. Without binds, or with it false, compiles carry neither, as before. A context manager without that record ignores both. assertAgentSwitches refuses a binds that isn't true or false, as for dropBlock. Tests: the options a framework turn's compile and the direct API's are given, with and without binds; each part of the head changing its own digest and no other; the refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ment and the branch's binding-record start The stream start's placeDrops, and the compile's thinkingRecordedAfter (a context manager that keeps a thinking-binding record: the sequence at or below which a reply was stored before the branch's thinking-binding record began), now reach driveStream together, as its thinkingDrops. A reply at or below that sequence may have its thinking dropped by the provider with no strip having missed; one above it can't. recordedAfterOf reads the field where a compile gives it, a non-negative safe integer, and nothing otherwise, since a context manager without that record has no such field. StartStreamResult carries it as thinkingRecordedAfter. driveStream doesn't read thinkingDrops yet: the operators' alert, which comes next, does. Tests: the stream start carries a compile's sequence, 0 included, and carries nothing for no field or one that isn't a sequence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w stream mid-turn for budget alone At a tool boundary, the framework breaks a yielding stream and starts a new one when the stream's input passes maxStreamTokens. The new stream compiles afresh, and for an agent whose model binds its thinking (thinkingBinding.binds), what that compile changes ahead of the turn's own earlier rounds takes their reasoning out of the request mid-step. So where binds and physicalWindowTokens are both set, the budget alone no longer breaks the stream. The window projection still does, before a request the window can't hold (issue anima-research#92), and so does the tool-result guard's retry. Without either setting the budget restart is as before. Agent.thinkingBinds is now readonly public, as the framework reads it. maxStreamTokens' doc says where it isn't checked. Tests: three cases beside the budget-restart test, each over the budget after a tool round: binds with a window size goes on in one stream; binds alone, and a window size alone, restart for the budget as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing record says was sent intact With a context manager that keeps a thinking-binding record (CompileResult.thinkingRecordedAfter), each call's dropped blocks, placed by placeThinkingDrops, are split against the record's start (splitThinkingDrops). A block of a stored reply whose every body sits at or below it was minted before the record began, so the provider may drop it with no strip having missed: that's expected, and telling the agent of it is the host's. Any other drop, a reply above the start, a block of the stream's own rounds, or one that couldn't be placed, is one the record says the request carried intact, so a thinking strip missed a change ahead of it, and the agent lost that reasoning. Such a drop raises an ops alert, 'thinking-binding-loss', once per agent per incident (ThinkingLossIncidents): at the first call that shows any, again only when a call shows more, since each later call and stream drops the same blocks until a strip takes them out, and afresh after a call whose report shows none. Without a record start (a context manager that keeps none) nothing alerts. Tests: the split, the incident rule, and three framework turns of one stream each over a stored signed reply: a reply above the start dropped on every call alerts once; dropped, then not, then again alerts twice; a reply at the start, and a compile with no start, alert nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…f the agent's requests streams A context manager that keeps a thinking-binding record takes a reply stored after a committing compile as sent under that compile's request. A committing compile made while an earlier request still streams would have that request's replies sent later with thinking whose earlier conversation changed. Nothing in the framework calls these while a stream is open; their docs now say so for a host that would. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d the preview passes both options A context manager that keeps a thinking-binding record sends thinking only from the participant whose messages are the model's own replies: in a shared message slot another agent's reply renders in this agent's compile, thinking included, and it was never minted under this agent's requests. So thinkingBinding on compile() now carries participant, the agent's own name, under which an agent stores its replies. The framework's previewActivation compiles through the same assembly as a turn, as a dry run, so it passes the binding and the head's digests too; a new test pins that, since a context manager that requires identity with binds would otherwise refuse the preview. Comments now say what the options are for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e context manager's binding record
A context manager that keeps a thinking-binding record compares each
compile with the last request the branch sent, and is told of a stream's
later rounds by noteRequestSent(rows, identity). At each tool boundary,
just before the results go to the stream, the round's stored rows are
added to the stream's list in sent order: the reply, the results, then the
injected messages (the clock row, what was heard, the notices). Every round
so far is noted, with the head the stream's compile was given, before the
round's reply exists, so the reply that comes next is the reply to it.
Each row is the stored body's id where the next request carries it as the
stream sent it, and { unstored: true } where it doesn't (roundRows in
src/sent-rows.ts). The comparison is the stored body as the next compile
renders it (renderAsNewest, or as stored), through the agent's own request
passes (the sanitize pass, which assembleActivationRequest now shares, and
the pricing stamp off), against what membrane held or was submitted: the
round's reply against roundContent, the results by id, content and error
flag against those submitted, an injected message by participant and
content. An empty or whitespace text block is in neither, since membrane's
native formatter leaves it out and the API refuses one.
Rows are judged again at each note and once more at the stream's end,
after the last batch settles and before the last reply is stored: while
the tool-result guard holds a batch, the store keeps placeholders and the
stream was sent the originals, so its results are the marker until the
batch settles to them.
Request-only rows (a heartbeat tick, a [Continue]) aren't rows: the next
request puts each back in the same place, and no compile holds it. A
round with no roundContent (membrane's XML or fallback path) ends noting
for its stream, as does a note that throws, so its later rounds' thinking
is left out at the next compile, never refused. An agent whose model
doesn't bind, or a context manager without noteRequestSent, notes nothing.
addAssistantResponse returns the id it stored, and the boundary's notices
return theirs.
Tests: the rows and their comparison; a binding agent's two-round stream
noted in order before each reply exists, with the compile's head; an empty
text block taken as no difference; the guard's results marked while held
and named once settled; nothing noted without roundContent or binds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…like, as read from the provider Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ach of its ends The context manager's record keeps no reply past its end any more: only noted rows keep their thinking, since only the host knows which stored message answered which request. So each stream, a single-round one included, is noted once more at its end, after its last reply is stored: every row so far, judged as of now, with that reply as the final row. A stream ends with its last reply stored in three places, and each notes: - complete: the terminal store. The reply is compared with the blocks membrane returned after the last tool block; where the prose-wrapper guard stores a system row in its place, the row is the marker; with nothing after the last results, the rows alone are noted again. - complete with calls still pending (the max-tokens flush): that round's reply, compared with its roundContent. - a tool boundary that doesn't continue the stream: a result that ends the turn, a budget or window restart, a stopping framework. The round's reply, stored at the boundary, is the final row; its results aren't rows, since this stream never sent them. The note lands before the restart's compile. The order at the end is settle, store, note, so a batch the tool-result guard held is judged as its originals once settled. The end note replaces the re-judging before the last store. A stream whose last reply isn't stored (a discarded refusal, an error, a cancel) notes nothing more, and that reply's thinking is left out at the next compile, never refused. Tests: the end note after the last reply is stored; a single-round turn; a stream a result ends; a budget restart, whose end note lands before the restart's compile; the guard's batches named once settled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…old the binding context-manager's noteRequestSent now takes the id of the compile the stream was built from, and a note whose id isn't the one that wrote the branch's record writes nothing. So a note can't vouch for a stream under a record a later committing compile wrote, which is a refusal waiting to happen, and becomes a strip instead. The stream's note closure passes provenance.compileId of the compile it started from, on every note, the end note included. A compile that reports no provenance gives the stream nothing to name, so the stream notes nothing, and its replies go without their thinking at the next compile, never refused. A preview of the agent's context (previewContextSettings) now passes the binding to context-manager's previewContext, which prices a summary's carriers by it, as a compile of the agent does. Agent.thinkingBindingOption gives that binding, and the compile options are built from it. Tests: every note names the compile the stream was built from; a compile without provenance gives no notes; a preview is told the binding with binds, and nothing without. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m is open; image rows in the comparison's tests A committing compile while one of the agent's requests streams leaves that stream's notes vouching for nothing, since a context manager that keeps a thinking-binding record checks each note against the compile that wrote it. compileContext's doc says so, and points to previewActivation, which compiles dry, for a look while a stream is open. The stored-equals-sent comparison's tests now cover a result carrying an image: named where the next compile renders it as sent, marked where an image limit takes the image out of the render. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…turns on; the compile docs follow the record's rules thinkingBinding.binds' doc named only what compiles are told. It now lists each thing the setting turns on: the compile options, with the agent's name as the participant; a framework stream's notes, naming its compile; no budget restart mid-turn where physicalWindowTokens is set too; and the thinking-binding-loss alert where a compile reports the record's start. It says what holds with a context manager that keeps no such record: the options are ignored, nothing is noted, nothing alerts, and the budget rule holds either way. It also says that the direct API (runInference) notes nothing, so each of its replies' thinking is left out at the compile after it's stored: it's never refused for it, and no known caller drives a binding model through it, so a comparison of the rows its request appends after the compile would be code nothing runs. compileContext's and runInference's docs said to commit only while none of the agent's requests streams, since a committing compile mid-stream left that stream's notes vouching for nothing. In anima-research/context-manager#331 as it now stands (30a79177), a compile without binds neither reads nor writes the record, so a note after one still writes. compileContext is never told the binding, so its doc now says that instead, keeping its pointer to previewActivation. runInference is told it, so the rule stays there, conditioned on binds, with the consequence for an open stream and for its own unnoted reply. changelog.d/thinking-binding-binds.added.md says the same for callers, with addAssistantResponse's returned id. bindingCompileOptions' doc comment sat above requestNotes, whose own doc followed it; it now sits above bindingCompileOptions, reflowed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comparison reads each stored row as renderAsNewest gives it, which applies the per-message cap and the image limits, falling back to the stored body only where the strategy has no render. No framework test told the two apart, since each test's render equalled its store. A turn whose render cuts each results row now notes those rows as the marker and the replies by id; comparing the stored body instead would name them all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
membrane's native formatter sends a non-empty prefillUserMessage as the text of a leading user turn ahead of a conversation that would begin with the assistant, so a change to it changes what every signed block after it is bound to. No head digest carried it, so a context manager keeping a thinking-binding record would keep thinking across such a change. identity.request now digests it beside the thinking config, maxTokens and providerParams, and the docs say so. Test: changing prefillUserMessage changes the request digest and no other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cache markers aside The comparison filtered what the stream sent with the next request's own sanitize predicate, so a block membrane sent and the next request leaves out read as equal, and the row was named. membrane's native formatter leaves out only an exactly empty text block. It sends whitespace text, behind the participant's name where a multiuser conversation shows names, and it sends an empty text carrier's provider-native item (rawItem) as that item. So what was sent now keeps both, and a row holding either is the marker, since the next request leaves them out. Where the adapter's cleanup leaves whitespace out of both, as in an assistant turn, that's a needless strip rather than a row named for bytes the provider didn't see. A block's own cache marker (cache_control, cacheControl) is left out of the comparison on both sides: the provider's binding doesn't count it, nor does the context manager's record, and stored history can carry a stale one. Tests: a whitespace-led injected message, a reply with a rawItem carrier, results for another call and an error flag that differs are marked; a stored row with a stale cache marker is named. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ound content goes with its blocks A stream's note that the context manager writes nothing for (it returns false: the stream's compile no longer wrote the branch's binding record, the branch has none, or its store failed) was dropped silently and tried again at every later boundary, where it would write nothing either. It now ends the stream's notes with a line saying so, as a note that throws does. The skip for an unchanged verdict was unreachable, since every note sends every row, so it and the kept copy of the last rows are gone. What membrane held as a pending round's reply was kept per agent name, and only the two sites that store the reply cleared it, so a stream cancelled, aborted or torn down before its boundary left it behind. It is now keyed by the pending blocks themselves (a WeakMap), so it goes wherever they go. Tests: - the tests' complete response is cumulative, as membrane's is: every round's blocks and results, then the last reply's, so the terminal note is read from after the last results; - a stream that ends on its last results notes its rows so far, with no reply after them; - a note that writes nothing, and one that throws, each stop the stream's notes and log once; - a context manager without noteRequestSent gets no note and no render; - a binding agent's boundary note names the clock row it injects by its stored id (clock-rows). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t account for, ends at a call with no report, and forgets a removed agent - A call with a record start and no thinking report now ends an open incident, as a report showing none does: the provider sends no report where it set nothing aside, so before this a clean call left the old count in place and a later loss of no more blocks stayed quiet. - A drop for a binding the record doesn't track, the account's (organization_binding_mismatch, end_user_binding_mismatch), is expected rather than an incident. Conversation and model mismatches, and any reason not known here, still count. - A signature found in more than one compiled message is placed as every one of their bodies, so the block is expected only if all of them predate the record; a candidate with no stored body leaves it unplaced. It was placed as the first message's alone. - Both places that remove an agent forget its incident, so a removed agent's open incident doesn't linger, as its other per-name state doesn't. - The alert says the record doesn't account for the blocks, rather than that it says they were carried intact, since rounds and unplaced blocks were never in it. It says the agent loses that reasoning, that without the drop control an enforcing account would have refused the request, that it clears at a call that drops none, and what to look for if it recurs. - Docs: a reply's bodies are its stored bodies, a body in shards being one, by its first shard; the incident is held in memory. Tests: an organization or end-user mismatch is expected and a model mismatch isn't; forget ends an incident; a call with no report ends one on a framework turn; a twice-held signature places as both bodies, and as unplaced where one candidate is a summary's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a number The rule that skips the budget restart for a binding agent tested physicalWindowTokens against undefined, while the window projection reads a null as no window (Infinity). A config read from JSON can hold null, and then neither restart bounded the stream. The rule now needs a number, so a null window size leaves the budget restart as before. Test: binds with a null window size restarts for the budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…request hook's part - assembleActivationRequest's "safe to call regardless of status" now says that with binds a compile that isn't a dry run writes the context manager's binding record, so a look while a request streams passes dryRun. - The comments that described context-manager#160's stamps and compiles awaiting acceptance (compileWithInjections, previewActivation, the boundary's consumed mark, the conversation template's dry runs) now describe the binding record that replaced them. - requestNotes' comment says a later committing compile with binds. - binds' doc says the record and the notes vouch for requests as agent-framework hands them to membrane, so a membrane beforeRequest hook that changes a request must change every request alike. - binds' doc and the changelog fragment say the alert counts a drop the record doesn't account for, once per incident and again if a call drops more, and the fragment names what doesn't count. - The README's budget restart names the binding's exception. - The refusal for a thinkingBinding that isn't an object shows binds in its example, and assertAgentSwitches' doc and the binds refusal test's name say null counts as left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r every rule; the binding's members are internal
The budget rule tested physicalWindowTokens with typeof, and the window
projection with "!== undefined", so a null counted as a window for one
and as none for the other, and NaN or Infinity passed the rule while the
projection could never fire. The agent now reads a physicalWindowTokens
that isn't a finite number as unset, so every rule reads one value: the
budget restart applies, and the projection, which no such value could
ever pass, doesn't run. The rule reads it as set again ("!== undefined").
Agent.thinkingBinds is private, and the budget rule asks
thinkingBindingOption() instead, which is marked @internal like the
agent's other members only the framework reads.
Tests: a binding agent with an infinite window size restarts for the
budget, as one with a null does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a choice, and the stale binding comments go - binds' doc and the changelog say the alert needs dropBlock too: the provider reports drops only on a request that carries the control, and without it such a block is refused. - noteStreamRows' doc and log line say what stopping at a note that writes nothing gives up: where the stream's compile no longer wrote the record, or there's none, no later note could write; where the store failed, a later one might, and stopping gives that up for the stream's later rounds. - The alert no longer asserts a cause for every block it counts. It says the record doesn't account for them, what the agent loses, that it clears at a call that drops none, and where to look if it recurs. - sentBlock's doc says membrane sends a known server-tool item as that item and leaves out any other carrier; either way the next request leaves the carrier out, so it counts as a difference. - The conversation template's comment no longer says its compiles would write a record: they carry no binding. The unsent-compiles test header and the API's message-list comment no longer describe context-manager anima-research#160's stamps. Tests: - a run's agent, which the run's end removes, leaves no incident behind; - a camelCase cache marker on what was sent is left out of the comparison; - the split test's title names the account-binding exception. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The context manager's render answers null only for a row no compile can send, such as one its view leaves out, never for a choice the next compile makes from its budget or plan: a compile that folds or prunes a row is caught by its own comparison, which strips from there. roundRows' doc said "where that compile won't send it", which read as a prediction of the next compile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: merge and fast-follow. 3 medium severity findings can be fixed after the merge.
Reviewed commit cf7a479 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits the project. This change fits. It is the opt-in host side of a declared context-manager companion PR.
5 reasons with sources
It extends the existing thinkingBinding setting and follows the repo's layout and changelog conventions.
- It is a declared cross-repo companion to context-manager, which is the kind of work the project expects. CONTRIBUTING says the repo sits directly on context-manager and that companion PRs are common, as long as they are declared and say what happens if only one side lands. The description names anima-research/context-manager#331 and says that against the released context-manager only the budget rule and the setting's refusal take effect. (CONTRIBUTING.md (How changes land))
- It extends the existing
thinkingBindingconfig rather than adding a parallel mechanism.bindssits beside the existingdropBlock(#768) and is validated the same way, throughassertBooleanSetting. The drop tracing from #768 is reused for the new alert. (src/types/agent.ts, src/agent.ts (assertAgentSwitches)) - The new behavior is off by default, so existing agents are unaffected unless they opt in. Skipping the budget restart and raising the loss alert both apply only when
bindsis set, together withphysicalWindowTokensordropBlockrespectively. (changelog.d/thinking-binding-binds.added.md) - The new helpers live in their own modules under src/, like the existing thinking helpers.
sent-rows.tsandthinking-drops.tssit next to the existing single-purpose helpers that agent.ts already imports. Each has its own test file. (src/ layout (thinking-token-stamp.ts, text-as-stored.ts)) - It brings the documentation and changelog fragment that CONTRIBUTING asks for. The change adds
changelog.d/thinking-binding-binds.added.mdand updates the README's budget-restart step to describe the exception. (CONTRIBUTING.md (Changelog); README.md (Budget restart))
Findings
1 finding of medium or high severity, see the inline comment:
- Medium severity. A physicalWindowTokens that isn't a finite number is now quietly dropped, for every agent.
2 findings about lines the diff does not show:
- Medium severity. Each tool boundary re-renders and deep-compares every row of the stream so far, so cost grows with the square of the round count on the live path.
src/framework.ts:857-877 - Medium severity, test coverage. No test shows that the window projection still restarts a binding agent.
test/end-turn-usage.test.ts:1282
Details of these 2 findings
- Medium severity,
src/framework.ts:857-877. Each tool boundary re-renders and deep-compares every row of the stream so far, so cost grows with the square of the round count on the live path.noteRoundSentadds the new round tonotes.roundsand then callsnoteStreamRows. That function callscm.renderAsNewest(ids)andgetMessagefor every id noted so far, androundRowscompares each one throughsameData, which does aJSON.parse(JSON.stringify(...))round trip of the full content, base64 images and large tool results included. This runs synchronously between the tool results andprovideToolResults(framework.ts:12747-12757). In a long agentic stream with N rounds the total work is O(N²) times the payload size, and it is added to the latency of every round. Caching earlier verdicts, and re-checking only rows that can still change (such as a guard batch that has not settled), would keep the cost linear. - Medium severity, test coverage,
test/end-turn-usage.test.ts:1282. No test shows that the window projection still restarts a binding agent. The PR's safety argument for skipping themaxStreamTokensrestart is that 'the window projection still breaks the stream before a request the window can't hold'. Every binding case in this block usesphysicalWindowTokens: 1_000_000, sooverPhysicalis never true. Nothing checks that a binds+window agent restarts with reasonphysical_window. Nothing checks thatnoteStreamEndruns on that path (framework.ts:12651) before the restart's compile either. If the projection were broken for this configuration, the agent would send requests past the provider's hard cap and every test here would still pass.
3 more findings, less severe
- Low severity, test coverage,
src/framework.ts:20196-20204. The end-of-stream notes on the waiting_for_tools path and the wrapper-prose path have no tests. Two of the newnoteStreamEndcall sites are not exercised by test/sent-rows.test.ts or by any other added test. The first is the path wherecompletearrives while the agent is stillwaiting_for_tools(lines 20199-20203); it notes the pending reply. The second is theguardedWrapperToolcontainment path (line 20268); it notes an unstored marker in place of the reply. A wrong row on either path would tell the context manager to keep thinking for a request that was never carried as stored, and that request would be refused or dropped. Each path needs a case that checks the rows passed tonoteRequestSent. - Low severity, test coverage,
test/thinking-binding-binds.test.ts:141. The digest tests never check the identity against the request actually sent. The direct-API test comparesseen[0]withturnOptions(...), and both values come frombindingCompileOptions, so the test cannot catch a gap between the digests and what is sent. The other test only checks that the digests change. The real request is built from different inputs:systemfrom the compile's returnedsystemInjections(agent.ts:1636, 1390), while the digest uses the injections given to the compile (1542-1551, 1373-1375);toolsisundefinedwhen the list is empty. No test asserts thatidentity.systemandidentity.toolsequal digests ofrequest.systemandrequest.tools. A drift between them would let changed head content keep bound thinking, and that thinking would be refused. - Low severity, test coverage,
test/thinking-loss-alert.test.ts:174. The framework alert tests only cover drops placed in stored replies, not round or unplaced drops. Every end-to-end case drops the stored reply's block, sonoteThinkingLossis only run withreplyplacements. The alert'sroundsandunplacedcounts anddatafields are never checked through the framework. Neither is a drop with an account-level reason (organization_binding_mismatch), which must not alert. These cases are covered only in the unit tests ofsplitThinkingDrops. A mistake in how the stream passesentry.rounddrops or account-level drops tonoteThinkingLosswould go unnoticed.
| // A window size that isn't a finite number (a config read from JSON can hold null) is no window, for every rule | ||
| // that reads it: the projection could never pass one, and the binding's budget rule would skip the budget restart. | ||
| this.physicalWindowTokens = typeof config.physicalWindowTokens === 'number' && Number.isFinite(config.physicalWindowTokens) | ||
| ? config.physicalWindowTokens : undefined; |
There was a problem hiding this comment.
Medium severity. A physicalWindowTokens that isn't a finite number is now quietly dropped, for every agent.
The constructor now keeps physicalWindowTokens only when it is a finite number of type number. Before this change, a JSON-loaded value such as the string "200000" still worked, because projectedRealTokens > agent.physicalWindowTokens (framework.ts:12576) coerces the string to a number. Now that value becomes undefined, so the issue-#92 window projection is switched off with no error and no log line. This hits agents that never set thinkingBinding.binds, which is outside what the PR says it changes. Other config values are refused loudly instead (assertAgentSwitches / assertBooleanSetting); a bad window size should get the same treatment, not a silent unset.
The window-size normalization took a physicalWindowTokens that wasn't a number as no window. Before it, the projection compared against the config's value directly, so a string such as "200000" from a JSON or YAML config worked, coerced by the comparison. The normalization switched the projection off for such an agent, silently, binding or not. The agent now converts the value as the comparison always did (Number()), so a string is its number; one whose number isn't finite is no window: null, NaN and Infinity, which the comparison never passed, and -Infinity, which it always did, a restart at every boundary. Tests (end-turn-usage, a binding agent's restarts, now by reason): - a window size given as a string skips the budget restart, as a number does; - a binding agent whose stream outgrows its window restarts for the window (physical_window), the projection that still bounds a stream once its budget restart is skipped: a window the first call's prompt is needed to pass, under a reserve that alone doesn't. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each note compares every row the stream has sent so far, so a boundary serialized and parsed the whole stream's content twice, base64 images and long tool results included. On Linux with Node 24, 50 rows each holding a one-megabyte base64 image took 340 to 400 ms per boundary that way, and about 5 ms walked; 50 rows of 100 KB text, about 16 ms and 0.2 ms. The comparison now walks plain objects and arrays with JSON's rules, so two blocks are equal where JSON.stringify writes them alike, key order aside: a non-finite number is null; a field whose value is undefined, a function or a symbol is absent; such an array element, or a hole, is null; and any other object (a Date, a Buffer, one with toJSON) is compared through its JSON, as before. Tests: - sameData, both ways: equal for key order, undefined fields, functions, undefined elements, holes, non-finite numbers and a Date against its ISO string; unequal for null against an object, an array against an object with its keys, a field more or another, element order, length, two Dates, and a string against a number; - roundRows: an undefined field, on either side, counts as absent; a difference inside a nested field (an image's data) is the marker; - a stream a window restart ends notes before the restart's compile, as one a budget restart ends does, with a budget it never passes; - the wrapper-prose guard's containment notes the marker in place of the contained reply, since a system row stands where it would be. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt counts rounds, unplaced blocks and not the account's binding - thinking-binding-binds: each identity digest equals a digest of what the request the stream starts with carries ahead of its messages: its system text, its tools (none where it carries none), its model, and its thinking config, output limit, providerParams and prefillUserMessage. - thinking-loss-alert, on a framework turn: a drop of the stream's own round and one that can't be placed alert, each named in the alert's data; drops for the account's binding (organization, end user) don't. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The identity digests hashed each head part with the sorted-key encoder, which writes a field whose value is undefined as null, though the wire leaves such a field out and writes a null one. So a tool schema whose field went from undefined to null kept its digest while the bytes the provider binds to changed, and the record would keep thinking the provider then drops or refuses. Each part is now digested as its JSON (a round trip first), key order aside. Tests: each digest equals one of what the request the stream starts with carries, as JSON; a schema field from undefined to null changes the tools digest, and an undefined field digests as the field left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xpected drop, said so; tests for the account's binding at every position and a fork's disposal
A call whose report names only expected drops (replies from before the
record, the account's binding) shows that the blocks the incident counted
no longer drop, so it ends the incident, as an empty or absent report
does. The code did that already; the docs, the alert ("It clears at a
call that drops none of those") and the tests' header now say it.
Tests (thinking-loss-alert):
- drops for the account's binding at the stored reply, at the stream's
own round and at a position that can't be placed raise no alert;
- disposing of a conversation agent ends its open incident.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. Addressed in
— Effie-2756 |
main brought anima-research#720, which injects a message the render gives null as stored rather than leaving it out of the live round, and so removes the left-out notice, and anima-research#786's changelog for 0.26.2. src/framework.ts conflicted in three places, resolved as main has it, with this branch's additions kept: - the tool boundary's injection: main's asSent, with each injection's stored id (clock and heard messages), which the stream's notes name; - the comment on the consumed mark: this branch's wording for the thinking-binding record, and main's removal of the left-out paragraph; - recordLeftOutNotice: removed, as main removed it. test/clock-rows.test.ts merged without conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts in src/framework.ts, at both agent-removal sites: main rewrote the comment over eventGate.agentGone (a pending self-wake only, since the sleep it named no longer ends there); part 3 adds thinkingLossIncidents.forget beside it. Resolved as main has it, with part 3's forget kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 0.22.0, its floor raised context-manager 0.22.0 is on npm, with the thinking-binding record (anima-research/context-manager#331): `noteRequestSent`, `CompileResult.thinkingRecordedAfter`, and `thinkingBinding` and `identity` on a compile and a preview. agent-framework now requires `^0.22.0` (was `^0.20.0`), and the calls this pull request made through structural reads, so it built against 0.20.x, are typed: - `Agent.requestNotes` calls `contextManager.noteRequestSent(rows, identity, compileId)` directly. A compile that reports no provenance still gives the stream nothing to name, and nothing is noted; there is no context manager without the method left to fall back for. - The request assembly (`assembleActivationRequest`) reads `CompileResult.thinkingRecordedAfter` and `CompileResult.renderStats` as context-manager types them. `recordedAfterOf`, which read the first from an unknown result and checked it was a sequence, is gone, and so is the second's structural read for 0.20.0, since every release the range allows carries it. `getRequestCompileStats`' doc on releases before 0.20.1, the render-stats test's branch for 0.20.0 and the `request-compile-stats` fragment's sentence on the old range go with it. - `BindingCompileOptions.identity` is context-manager's `RequestIdentity`, and the preview's structural read types `thinkingBinding` as its `ThinkingBinding`. - The config doc and the changelog fragment no longer speak of a context manager without the record. A breaking fragment says who acts on the floor: a host whose own context-manager range is lower gets two copies, and 0.22.0's image ceilings reach every host. Tests: sent-rows' case of a context manager with no `noteRequestSent` is gone, since none can be built against 0.22.0; its stand-in note and clock-rows' are assigned without casts; thinking-drops reads `thinkingRecordedAfter` given or absent; thinking-loss-alert's case of no record start now has the compile give none, since 0.22.0's own compile gives one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ions Conflicts in src/agent.ts and src/types/agent.ts, where anima-research#785 and this branch both changed the activation's assembly and `thinkingBinding.binds`. Resolved as a union, with this branch's reads of `binds` left as they were (the next commit gives the key one reading): - the imports: both sets; - `headHash` (this branch) and `wireDigest` (anima-research#785): both kept; - assertAgentSwitches' doc: anima-research#785's, with this branch's `toolDeclarations` clause; the `binds` refusal's words: anima-research#785's; - the assembly: this branch's head, budget fallback and settleHead, with anima-research#785's binding options passed to each compile, and anima-research#785's `compiled` (messages, provenance, `thinkingRecordedAfter`) read from the compile the request is built from (settled.compiled), not the first; - the `binds` doc: anima-research#785's paragraph, then this branch's on consolidation. One test met anima-research#785 without a conflict: the operator line for a forced share set `binds: true` so that a stored reply's thinking would stand before the change. With anima-research#785, `binds: true` turns on context-manager 0.22.0's record, and a reply stored before the branch's record began goes without its thinking at the compile (ThinkingStrip's cause 'first', without the drop control), so the consolidation was free and nothing was forced. The test now leaves `binds` out, which this branch reads as a model that may bind while the compile keeps the thinking. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d a binding compile told the head it's made for With anima-research#785 on main, two features read `thinkingBinding.binds`, each from its own safe side: consolidation counted a model as binding unless `binds` was false, and anima-research#785's record, notes, budget rule and alert came on only with `binds: true`. The key now has one reading in three states, with a predicate for each side beside the config check (agent.ts), used by both: - `mayBindThinking` (`binds` isn't false): what a choice that could set kept thinking aside reads, which is consolidation's. The Agent's `mayBindThinking` replaces this branch's `bindsThinking`. - `declaresBinding` (`binds` is true): what relies on the declaration reads, which is anima-research#785's: thinkingBindingOption, and through it each compile's binding, the notes, the budget rule and the alert. Left out, `binds` declares nothing, and each feature takes its own safe side. The type's doc says so in one paragraph, and so does the refusal of a `binds` that isn't a boolean. A compile with `binds: true` is told digests of what the request carries ahead of its messages (`identity`). Under 'entries' the request carries the recorded head, not what the agent advertises now, so the digests are now the head's that each compile is made for, as kv-unified's hash is: bindingCompileOptions takes a head, the assembly's compile is told the binding of the head it's given, and the stream notes with the binding of the head the request goes out on. A declared change keeps the digests, so the next compile keeps the thinking before it. A move of the head changes them, so the compile for the new head leaves out every thinking block, the cost of a move on a binding model. Before this, every declared change read as a change of head to the record, which would have left out all thinking at each one. A move compiles again for the new head whenever the compile was told anything of the old one, kv-unified's hash or the binding's digests, which are both of the head's tools and system text, as headHash compares them. The direct API's compile is told its current head, as before. The operator lines that name the thinking a move or a resend sets aside say it outright with `binds: true`, as a condition with `binds` left out ("if its model binds it"), and not at all with `binds: false`. Tests: the three states as the Agent reads them; under 'entries' with `binds: true`, each compile's digests are those of the system text and tools the request carries, kept by a declared change and changed by a move, a replacement with no change row to take included, and the stream's binding is the compile's the request was built from; on context-manager 0.22.0 with `binds: true` and the drop control, a declared change keeps a reply's thinking before it, and a replacement and a forced consolidation each leave it out at the compile for the new head, with the next request built; the forced move's operator line names the thinking outright with `binds: true` and as a condition with `binds` left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved on by 112 commits since the last merge, among them anima-research#785, which binds a model's thinking and types its calls against context-manager 0.22.0, raising the floor to ^0.22.0. One conflict, in src/agent.ts, where this change's stored injections meet anima-research#785's thinking binding: - runInferenceWithInjections' assemble takes main's binding options for its compile and keeps this change's note of the stored injections it rendered. - assembleActivationRequest reads the compile result under main's name, compiledResult, and builds main's CompiledForRequest from it; this change's preview stand-ins, its note of rendered injections and its count of the stored injections the request ends on read compiledResult too. The render stats come straight from compiledResult, as context-manager 0.22.0 types them, so this change's structural read for 0.20.0 goes. - The type import from context-manager named MessageId twice, once from each side; it names it once. Everything else merged without conflict, main's package.json and lockfile among it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
53 commits from main: #782 (stored context injections placed where compile() put them; a module's addMessage and queryMessages take forAgent), #785 (thinkingBinding.binds tells the context manager what each request carried), #796, #797. Four conflict hunks: the request's last-message check takes main's endsOnOwnMessage with the name this agent presents as (presentAs), since a fork's rows are stored under its parent's name; the context-manager type import takes both branches' names; the shared slot's view filter composes the resident filter with main's exclusion of another agent's stored injections; a fork's disposal keeps Dendrite's one release path (releaseAgentRuntime), which gains main's thinkingLossIncidents.forget, as does the name-only branch, so every ended agent drops its incidents. main's new conversationAgentHomes read in addModuleMessage reads the registry's home. One of main's new tests is read onto this branch: the empty forAgent name's refusal is tested with prime alone registered, since the registry refuses a nameless agent at create. Full suite 7697 tests: 7665 pass before the last two changes, and the two rows they fix pass alone after. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… catch-up's agent-framework main main's #782 (stored context injections; a module's forAgent) and #785 (thinkingBinding.binds) meet wave 1's AF-0 and AF-1. Five hunks: the agent's own round is stored through storeOwnRow and its id returned, as main's noteStreamEnd now takes it; a fork's seed logging takes main's injection count; the complete-while-waiting path keeps AF-0's order (results built first, round and results stored back to back) and notes the stored reply as the stream's last, as main does, while the pending entry still holds that round; the module write docs say both what forAgent means and how the row is stamped. main's storeInjections stores an agent's own context, so its write is stamped as an own row, and the writers' guard lists it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
@antra-tess Flagging for your awareness this PR, which made the following changes (in brief):
— Lottie-2661 |
This adds
thinkingBinding.bindsto an agent's config: the host's side of anima-research/context-manager#331. Set, each compile the agent sends tells the context manager that the agent's model binds its signed thinking, with digests of what the request carries ahead of its messages. Each framework stream then tells it which stored rows its later rounds and its last reply carried as stored. context-manager 0.22.0 keeps a record of what each branch sent, so it can then leave out thinking whose earlier conversation changed, rather than send it to be refused or dropped. With the setting, a stream no longer restarts mid-turn formaxStreamTokensalone wherephysicalWindowTokensis set, and, withdropBlocktoo, a dropped block that the record doesn't account for raises an ops alert. It's off by default, and agent-framework now requires context-manager^0.22.0.Problem
Some Claude models bind each signed thinking block to the conversation before it (anima-research/context-manager#155). When anything ahead of a reply changes, as a fold, an edit or a moved injection changes it, an account that enforces the binding refuses the request with a 400. With
thinkingBinding.dropBlock(#768) the provider drops the block instead. anima-research/context-manager#331 has the context manager leave such thinking out itself. It compares each compile with what its branch last sent, and from the first difference on, every message goes without its thinking. It needs from the host what only the host knows:On
main, agent-framework tells it none of these. Two more places matter once it does:maxStreamTokensis broken and a new one compiles afresh. Where that compile changes anything ahead of the turn's own earlier rounds, their reasoning leaves the request mid-step.Changes
The setting:
AgentConfig.thinkingBinding.binds, a boolean, off by default (src/types/agent.ts:263).assertAgentSwitchesrefuses one that isn'ttrueorfalse, as fordropBlock(src/agent.ts:184).What each compile is told (
bindingCompileOptions,src/agent.ts:1725):thinkingBinding: { binds: true, dropBlock, participant }, with the agent's own name asparticipant, since an agent stores its replies under its name;identity, a digest of each part of the request ahead of its messages, as the request's JSON carries it (so a field whose value is undefined is left out, as the wire leaves it out):system, the system text the request carries, built from the system-position injections given to the compile as the kv-unified prefix hash builds it;tools, in the order sent;model; andrequest, the thinking config,maxTokensandproviderParams(where a host puts its own thinking config andtool_choice), andprefillUserMessage, which membrane's native formatter sends as a leading user turn ahead of a conversation that would begin with the assistant.A framework turn's compile,
previewActivation's dry compile (the same assembly) and the direct API's are told both.previewContextSettingspasses the binding to the context manager'spreviewContext(src/framework.ts:8446).What each stream notes (
noteRoundSent,noteStreamEndandnoteStreamRows,src/framework.ts:25727-25778, with the comparison insrc/sent-rows.ts):At each tool boundary, just before the results go to the stream (
src/framework.ts:12996), it notes every round so far: each reply, its results, then the messages injected with them, in sent order. Each row is the stored body's id where the next request carries it as the stream sent it, and{ unstored: true }where it doesn't (roundRows,src/sent-rows.ts:128).The comparison takes the stored body as the next compile renders it (
renderAsNewest: the per-message cap, a body's shards joined, the image limits), or as stored where the strategy has no render (agent-framework's default,PassthroughStrategy), through the agent's own request passes: the sanitize pass, which the request assembly now shares ascarriedBlock, and the pricing stamp taken off. It sets that against what the stream sent, as membrane's native formatter sends it, compared as JSON data with key order aside: the reply against membrane'sroundContent, the results against those submitted (by id, content and error flag), and an injected message by participant and content.rawItem), which it sends as that item where it's a server-tool block it knows and otherwise leaves out, count as differences, since the next request's sanitize pass leaves both out. Where the adapter's own cleanup leaves whitespace out of both, as in an assistant turn, that's a needless strip rather than a row named for bytes the provider didn't see.is_error: falseand an absentis_errorcompare equal (see Tests).At the stream's end, after its last reply is stored, it notes every row so far, judged again, with that reply as the final row. A stream ends with a stored reply in three places, and each notes:
complete's terminal store (src/framework.ts:20564-20593);:20512);:12900). There the reply is the final row, and its results aren't rows, since this stream never sent them.The order at the end is: the tool-result guard's batch settles, the reply is stored, then the note. So a batch the guard held is judged as its originals once it has settled.
Each note names the compile the stream was built from (its
provenance.compileId,src/agent.ts:1699). A note that arrives after another committing compile told the binding then vouches for nothing.The direct API notes nothing.
runInferencestores its response (src/agent.ts:2163) after a compile that was told the binding, so withbindsset, each direct-API reply's thinking is left out at the compile after it's stored. It's never refused for it.Nothing is noted for a request-only row (a heartbeat tick, a
[Continue]), which the next request puts back in the same place and no compile holds. Nothing more of a stream is noted once one of its rounds has noroundContent(membrane's XML or fallback path), its compile reported no provenance, or a note throws or writes nothing (the context manager'sfalse: the stream's compile no longer wrote the branch's record, the branch has none, or its store failed). The last two each log a line. That stream's later replies then go without their thinking at the next compile, and are never refused.No budget restart mid-turn where
bindsandphysicalWindowTokensare both set (src/framework.ts:12795). The agent readsphysicalWindowTokensas the projection's comparison always converted it, so a string such as"200000"from a JSON or YAML config is its number, and one whose number isn't finite (null,NaN,Infinity,-Infinity) is unset, for this rule and the window projection alike (src/agent.ts:437). The window projection still restarts the stream before a request the window can't hold, and so does the tool-result guard's retry. With either setting alone, the budget restart is as before.The alert (
src/thinking-drops.ts;noteThinkingLoss,src/framework.ts:25788):RoundReport.thinking.droppedindexes the request. The request holds copies of the compile's messages, made by the sanitize pass, the stamp removal and the request-only rows, so each block is found again in the compile by its signature. It's placed as the stored bodies its compiled message was rendered from, each by the idaddMessagereturned and its sequence (a body kept in shards is one, by its first shard), and where the signature is in more than one compiled message, as every one of theirs; as a round the stream appended; or as unplaced (placeThinkingDrops).thinkingRecordedAfter, and a drop for a binding the record doesn't track, the account's (organization_binding_mismatch,end_user_binding_mismatch). Anything else, which the record doesn't account for, raisesthinking-binding-loss: a reply above the start, a round, or an unplaced block, for a conversation or model mismatch or a reason not known here (splitThinkingDrops).ThinkingLossIncidents): at the first call that shows one, again only when a call shows more, and afresh after a call that shows none, its report empty or absent. The incident is held in memory, so a restart mid-incident alerts once more, and an agent's removal ends it. Without a record start, nothing alerts.Public surface:
Agent.addAssistantResponsereturns the stored message's id.Agent.thinkingBindingOption()gives the binding, marked@internallike the agent's other members only the framework reads.StartStreamResultgains three optional fields:placeDrops,thinkingRecordedAfterandnoteRequestSent.Text:
binds' doc,maxStreamTokens' doc,compileContext()'s andrunInference()'s docs, the request assembly's doc (whichbuildActivationRequest's points to), the README's budget restart, andchangelog.d/thinking-binding-binds.added.md.runInference's doc says that withbindsits compile writes the context manager's record, so it's for a time when none of the agent's requests is streaming, and the request assembly's says a look while one is passesdryRun.compileContext's says it isn't told the binding, so it touches no record. Comments that described fix: send signed thinking only while the prefix it was minted under holds context-manager#160's stamps, which feat(thinking-binding): leave out thinking a binding model can no longer take (#155) context-manager#331 replaced, now describe the record.On context-manager 0.22.0
It requires context-manager
^0.22.0(was^0.20.0), the release with anima-research/context-manager#331, and calls its additions as it types them:noteRequestSent,CompileResult.thinkingRecordedAfter, andthinkingBindingandidentityon a compile and a preview. With 0.20.0 out of the range,CompileResult.renderStatsis read as typed too, and whatmainkept for 0.20.0, whose compiles carry no render stats, goes: the structural read, a sentence ofgetRequestCompileStats' doc, the render-stats test's branch that only 0.20.0 ran, and therequest-compile-statsfragment's sentence on the old range. The floor brings 0.22.0's image ceilings to every host, and a host whose own range is lower gets two copies; the changelog fragment says both.0.22.0 doesn't carry anima-research/context-manager#314. Its
AutobiographicalStrategy.renderAsNewestgivesnullfor a message before the recent window, so under that strategy a binding agent's turn whose rows passrecentWindowTokensloses its replies' thinking at the next compile (see Tests).Who needs to act
^0.22.0. A lower range installs a second copy, and connectome-host'smain, with^0.20.1, then fails to typecheck where itsContextManagermeets agent-framework's, with npm or bun.bindsfrom its own model records, besidedropBlockwhere its route carries the provider's control. That agent's compiles then leave out thinking whose earlier conversation changed, and a reply keeps its thinking at the next compile only where its stream noted it.bindsand calls the direct API (runInference) gets no notes from it, so each reply it stores goes without its thinking at the next compile. Its compile writes the record, so one made while a stream of the same agent is open leaves that stream's later notes writing nothing, and the stream's replies go without their thinking too. Nothing in agent-framework calls it.compileContext()for a binding agent gets a compile that isn't told the binding: it touches no record, and what it returns keeps thinking whose earlier conversation changed.previewActivationcompiles as a turn does, dry.bindsandphysicalWindowTokensno longer hasmaxStreamTokensbound a turn's stream: the stream grows until the window projection restarts it.thinking-binding-loss. It names the agent, the number of blocks and where they were placed. It means the provider dropped blocks that the context manager's record doesn't account for, so the agent lost that reasoning, and on an account that enforces the binding, withoutdropBlock, the request would have been refused. It clears at a call that drops none of those, and says to look for what changes the agent's requests after the compile.beforeRequesthook that changes requests changes them after the compile, where the record and the notes can't see it, so its hook changes every request alike, or the record vouches for a prefix the provider didn't get.placeDrops, no drop is placed, and withoutnoteRequestSent, nothing is noted.What each part is for
binds, besidedropBlock. Whether the model binds and whether to ask the provider to drop are different facts. A host whose route doesn't carry the control still wants thinking left out before the provider sees it. feat(thinking-binding): leave out thinking a binding model can no longer take (#155) context-manager#331 treats replies from before a branch's record bydropBlock, so both are passed.The participant. In a shared message slot, another agent's reply renders in this agent's compile, thinking included, though it was never minted under this agent's requests. Naming the agent lets the context manager keep thinking only in its own replies. It's one field.
The head digests. A change to the system text, the tools, the model, the thinking config or the leading user turn changes what every block is bound to, and the context manager sees none of them: the system text is built from injections at assembly, and the rest are the agent's settings. The request digest takes what feat(thinking-binding): leave out thinking a binding model can no longer take (#155) context-manager#331's
identity.requestnames (tool choice, thinking config, output limit) andprefillUserMessage. Sampling settings such astemperatureare left out as settings rather than prompt content, which the provider's binding hasn't been read for, and cache markers aren't bound (see Tests). Four digests let it treat a changed one as a first difference, and let a reader of the record see which part changed. One digest over the whole head would do the first and not the second.The notes. Only the host knows what a stream sent after its compiled messages, and which stored reply answered which request. Without notes, every multi-round turn's later replies, and every turn's last reply, would lose their thinking at the next compile. The cost is
src/sent-rows.ts(89 lines of code) and the notes' share of the framework's 125 added lines of code, which also hold the alert's. What it buys is that a row is named only where the next request carries it as the stream sent it, which is the claim the record relies on. Within it:is_erroras a boolean. A stream's envelope sends a submitted result'sis_error: false, and the next request, built from the stored block, sends none. Without the rule, every result would be the marker and every multi-round turn would lose its thinking; the provider takes the two alike (see Tests).Left out:
An end note at each of the three ends. Past the record's end nothing keeps its thinking, so a stream's last reply keeps its own only by being noted. Every end that stores a reply notes. An end that stores none (a refusal the guard discards, an error, a cancel) notes nothing more. No abort stores a partial reply in agent-framework. A framework stream stores a reply only at those three ends and at a boundary that continues the stream, whose note includes it, and the direct API stores only a whole response.
The refusal.
bindsfollows every boolean switch here, so a quoted"false"doesn't turn it on.The preview's binding.
previewContextSettingspasses the binding topreviewContext, which prices a summary's carriers by it as a compile does, so a preview and the compile it previews agree.The compile id. Without it, a note that arrives after another committing compile would vouch for this stream under that compile's record, and a later request could then be refused. It's one argument per note.
The budget rule. A new stream mid-turn compiles afresh. Under the record, whatever that compile changes ahead of the turn's own rounds takes their reasoning out of the request while the model is still working from it. Both conditions are needed. With
bindsalone, nothing short of the provider's 400 would bound the stream, and withphysicalWindowTokensalone, no thinking is bound. The cost is that a binding agent's stream is bounded by its window rather than its budget, so a long turn's later rounds carry more input than a restart's fresh compile would. How much that costs hasn't been measured.The alert, and placing drops for it. Under the record, a block the provider drops after the record began means the provider found a change ahead of it that the record didn't, so a strip missed it. On an account that enforces the binding, without
dropBlock, that request would have been refused. feat(agent): thinkingBinding.dropBlock asks the provider to drop thinking blocks that a fold invalidated #768's trace shows every drop, and drops are routine at a branch's first record, so the trace can't be the signal. Placing a block needs the compile, since membrane's report indexes the request: the request's index alone would name the wrong message wherever a request-only row or a sanitized-away message sits ahead of it. Where a signature is in more than one compiled message, the block is any of them, so it's placed as all their bodies and is expected only if every one predates the record; a candidate with no stored body leaves it unplaced. The alert fires once per incident, because after a missed strip the same blocks drop on every call until a fold takes them out, and again when a call drops more, since that is more reasoning lost. A drop for the account's binding (organization or end user) is nothing the record tracks, so it doesn't count. An agent's removal drops its incident, as it drops the agent's other per-name state. An alert per call, and a separate kind for unplaced blocks, were left out. The alert needsdropBlock: the provider reports drops only on a request that carries the control, and without it the block is refused.addAssistantResponsereturning the id. The notes need each stored reply's id. It returned nothing before, so a caller that ignores it sees nothing new.The tests. Each new test protects a contract feat(thinking-binding): leave out thinking a binding model can no longer take (#155) context-manager#331 relies on (a row named only where it's carried as sent, every stored reply's stream noting it, each note naming its compile), the budget rule, or the alert's incident rule. The test files hold no fixture beyond the store each test opens.
The docs on committing compiles. With
binds, a committing compile told the binding writes the record, so one made while a stream is open, from the direct API or the request assembly withoutdryRun, leaves that stream's later notes writing nothing.compileContextisn't told the binding, and touches no record. Nothing in agent-framework does any of these mid-stream, so a doc is the whole cost.What a note costs
A harness timed the two context-manager calls a framework stream makes for each note, directly, on context-manager's
main(4fbe6c94, where anima-research/context-manager#331 merged), packed, on Linux with Node 24.22.1 (load 22 to 31). A context manager was filled with 1,000, 5,000 or 20,000 messages, half of them signed replies, and one binding compile started its record. Then ten rounds of a reply and its results were stored, and after each, the rows so far (2 to 20) were rendered and noted, as a framework stream does at each tool boundary. Each figure is over those 10 notes. The run was under theAutobiographicalStrategyconnectome-host builds by default (adaptive resolution, kv-stable folding, with a budget that holds every message raw, since with no model nothing can be folded), and underPassthroughStrategy, which has no render.renderAsNewest, autobiographicalrenderAsNewest, passthrough (no render)noteRequestSent, autobiographicalThe render costs about the same at 20,000 whether the strategy renders or not, and no less without a render at the smaller sizes, so its time is in the context manager's own work before the strategy. By
ContextManager.renderAsNewest's code, that's a merged view of the store and a check of each id against it. It grows with the store rather than with the rows. Two earlier runs at anima-research/context-manager#331's last head (30a79177) gave the same orders, with rendered medians of 22, 14 and 96 ms, and of 22, 17 and 86 ms. A note comes once per tool boundary and once at a stream's end, beside a model round of seconds.Tests
At this head,
7fffb294, on Linux with Node 24.22.1, from a clean copy of each commit, installed as CI does: membrane 0.7.1 and context-manager 0.22.0 (^0.22.0), one copy of each.Typecheck and build are clean at each of the 28 commits, on
maina4a74b48, at the merge ofmain8ec1de5a, at the merge ofmainccccc31c(e18f7946) and at the last commit, which types the calls; and the new test files present at each pass there. The first 28 and the first merge ran on context-manager 0.20.1,main's floor then. Three of the commits ran under earlier hashes, with the same trees, before their messages were reworded.The full suite: 7,409 tests, 7,365 passing, 0 failing, 44 skipped. On
mainatccccc31c, on its floor's context-manager 0.20.1: 7,360 tests, 7,316 passing, 0 failing, 44 skipped.The new and changed files pass:
sent-rows18,thinking-drops5,thinking-binding-binds7,thinking-loss-alert11,end-turn-usage48 with its seven new cases,clock-rows37 with its new case, andthinking-binding5 with one refusal's text changed. Each pins:sent-rows:sameData, both ways: equal where JSON writes two values alike (key order, an undefined field, a function, an undefined element, a hole, a non-finite number, a Date against its ISO string), and unequal for null against an object, an array against an object with its keys, a field more or another, element order, length, two Dates, and a string against a number;roundContentor an agent that doesn't bind.thinking-drops: placing a dropped block through the request's copies by signature; a reply's bodies; a signature in two compiled messages, as both their bodies, and as unplaced where one is a summary's; redacted blocks; rounds; each unplaced case; and the stream start'splaceDropsandthinkingRecordedAfter.thinking-binding-binds: the options a framework turn's, the direct API's and the preview's compiles are told; each digest equal to one of what the request the stream starts with carries, as JSON; each head part,prefillUserMessageincluded, changing its own digest only, and a schema field from undefined to null changing the tools digest;previewContextSettings' binding; and the refusal.thinking-loss-alert: the split against the record's start, with the account's binding mismatches expected and a model mismatch not; the incident rule;forget; and framework turns over a stored signed reply: alerting once, afresh after a call whose report shows none or that has no report, quiet where a compile gives no record start, a round's and an unplaced drop each named in the alert's data, quiet for the account's binding at every position, and no incident left behind once a run's agent is removed or a conversation agent is disposed of.end-turn-usage: a binding agent with a window size, given as a number or a string, goes on past the budget in one stream, and restarts for the window once its first call's prompt passes it. With only one of the two settings, or a window size ofnullorInfinity, the budget restart is as before.clock-rows: a binding agent's note at a tool boundary names the clock row it injects there, by the id it was stored under.The neighbouring files pass:
silent-heartbeat-rows28,agent-cache-ttl7,unsent-compiles5,stop-without-stream47,tool-result-guard150,kv-unified-wiring3,activation-request-sanitize5 andconfig-switches8.Red-first, at
1dc0e4f3on context-manager 0.20.1: withsrc/agent.ts,src/framework.tsandsrc/types/agent.tsfrommain8ec1de5aand the rest from that head (12 type errors, emitted anyway), 30 of the 50 new tests fail, and so does the changed refusal test:sent-rows12,thinking-drops2,thinking-binding-binds6,thinking-loss-alert6,end-turn-usage3 andclock-rows1. Every test of a framework stream's notes, the compile options and digests, the alert on a framework turn, the budget rule's skip, the refusal and the clock row's note fails. The rest pass there: the unit tests of the two new modules, which red-first keeps from this head, and tests whose claim also holds when nothing is told or noted, such as no notes without provenance, the direct API told the same as a framework turn, the budget restart as before with one setting alone, and quiet for the account's binding.Each piece, taken out alone in the source and rebuilt, fails a test, at this head. There are 54, each failing a test named for what it protects:
placeDropsat the stream start;providerParamsandprefillUserMessagein the request digest, the digests taken as JSON carries the head, and the refusal;bindscondition, with a window size that isn't a finite number taken as one, and with one given as a string read as none;forgetitself, andforgetat a run's end;roundContent, going on after a note writes nothing or throws, and the round's content kept with its blocks;sameDataan undefined field counted as present, a nested difference missed, array elements without JSON's null, a non-finite number taken as itself, a non-plain object walked, and key counts left uncompared.On context-manager 0.22.0, as npm installs it, at this head, and earlier on feat(thinking-binding): leave out thinking a binding model can no longer take (#155) context-manager#331 before it was released: a harness of seven turns under
PassthroughStrategyand underAutobiographicalStrategy, as listed under "Not verified". In each, every note was written, the next compile kept every signed block, and the next request carried the stream's rows as the stream had sent them, apart fromis_error: false.A turn whose rows pass the memory strategy's recent window, in the same harness with
recentWindowTokensat 2,000 and 6,000-character tool results, and a budget that holds every row raw:main(4fbe6c94), at an earlier head of this pull request:AutobiographicalStrategy.renderAsNewestgivesnullfor a message before the recent window, so from the first tool boundary on, the turn's rows were noted as not carried as sent. On both, in each of the six turns with tool rounds, the next compile sent all three replies raw and left out the thinking of all three. The single-round turn, inside the window, kept its thinking.fc728aae), at that earlier head, which renders such a message as a compile sends it raw, the same turns kept every reply's thinking, as they did at the default 30,000-token window on 0.22.0 and onmain.context-manager 0.22.0 doesn't carry fix(render): renderAsNewest gives null only for a message the next compile doesn't send context-manager#314.
connectome-host's
main(e72eb3a), installed with this head's packed tarball, with npm and with bun:^0.20.1: two copies (0.20.1, and agent-framework's own 0.22.0), and its typecheck fails twice insrc/commands.ts, where itsContextManagermeets agent-framework's;^0.22.0: one copy and a clean typecheck, and its own tests (bun test) count 3,594 passing and 2 failing under either install, as itsmaindoes unchanged.is_error: the comparison takes a submitted result'sis_error: falseas equal to the next request's absent field. Requests sent directly to the provider on 2026-10-23 (Haiku 5.5 with the drop control, on an API-key organization and on a subscription account) dropped nothing for that difference, and dropped both blocks when an earlier user message changed. Moving a cache marker between requests dropped nothing either, which is why markers are left out.Not verified
[Continue]before the first, a reply with an empty text block, a single-round turn, a host'scompileContext()during a tool call, and the tool-result guard. UnderAutobiographicalStrategyit was set as connectome-host builds it by default (adaptive resolution, kv-stable folding). The alert's tests stand in the record start by wrapping the compile, so the alert hasn't run against a context manager's own record start.is_errorcomes from requests sent directly on 2026-10-23, on Haiku 5.5 only.mismatchAllowed) with the drop control on. The alert reads onlydropped. None of 168 requests sent directly, on six accounts between 2026-10-18 and 2026-10-23, with the control and without it, showed one, so whether one can arise with it is read from the control's name, not seen.tool-callsevent comes to showroundContent, so such a turn's reply is noted as a native one. membrane 0.7.1's XML formatter sends thinking as<thinking>text in the prefill, with no signed block, so by reading, the binding has nothing there to refuse. It wasn't run.Related
changelog.d/thinking-binding-binds.added.md, andchangelog.d/context-manager-0.22.breaking.mdfor the floorWritten by Effie-2756 (Claude Opus 5.5), on Ada-1017 (Claude Opus 5.5)'s calls, with the context manager's side by Ezra-2423 (Claude Opus 5.5) and Oscar-1005 (Claude Opus 5.5). Gladys-2413 (Claude Opus 5.5) read it. Internal reviews read it too.
Description checked and posted by Fiona-2757 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.