Skip to content

feat(agent): thinkingBinding.binds tells the context manager what each request carried - #785

Merged
nissa-seru merged 31 commits into
anima-research:mainfrom
nissa-seru:feat/thinking-binding-binds
Oct 25, 2026
Merged

nissa-seru merged 31 commits into
anima-research:mainfrom
nissa-seru:feat/thinking-binding-binds

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

This adds thinkingBinding.binds to 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 for maxStreamTokens alone where physicalWindowTokens is set, and, with dropBlock too, 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:

  • that the agent's model binds, and under which participant the model's own replies are stored;
  • what the request carries ahead of its messages: the system text, the tools, the model, the thinking config and output limit, and a leading user turn, since a change there changes what every block is bound to;
  • what each stream sent after its compiled messages, round by round, and which stored reply answered which request. Past what it's told, it keeps no reply's thinking.

On main, agent-framework tells it none of these. Two more places matter once it does:

  • The budget restart. At a tool boundary, a stream whose input has passed maxStreamTokens is 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.
  • Telling a loss from a routine drop. feat(agent): thinkingBinding.dropBlock asks the provider to drop thinking blocks that a fold invalidated #768 traces each call's dropped blocks. A reply stored before a branch's record began was sent under no record, so the provider may drop its thinking with nothing missed. After the record begins, a drop the record can't account for means something ahead of the block changed that the record didn't see. The trace can't tell the two apart.

Changes

  • The setting: AgentConfig.thinkingBinding.binds, a boolean, off by default (src/types/agent.ts:263). assertAgentSwitches refuses one that isn't true or false, as for dropBlock (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 as participant, 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; and request, the thinking config, maxTokens and providerParams (where a host puts its own thinking config and tool_choice), and prefillUserMessage, 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. previewContextSettings passes the binding to the context manager's previewContext (src/framework.ts:8446).

  • What each stream notes (noteRoundSent, noteStreamEnd and noteStreamRows, src/framework.ts:25727-25778, with the comparison in src/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 as carriedBlock, 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's roundContent, the results against those submitted (by id, content and error flag), and an injected message by participant and content.

      • An exactly empty text block is in neither request, since the formatter leaves it out and the API refuses one.
      • A whitespace text block, which the formatter sends (behind the participant's name in a multiuser conversation), and an empty text block carrying a provider-native item (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.
      • A block's own cache marker is left out on both sides, since the provider's binding doesn't count it, nor does the context manager's record.
      • is_error: false and an absent is_error compare 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);
      • the max-tokens flush (:20512);
      • a tool boundary that doesn't continue the stream: a result that ends the turn, a budget or window restart, or a stopping framework (: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. runInference stores its response (src/agent.ts:2163) after a compile that was told the binding, so with binds set, 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 no roundContent (membrane's XML or fallback path), its compile reported no provenance, or a note throws or writes nothing (the context manager's false: 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 binds and physicalWindowTokens are both set (src/framework.ts:12795). The agent reads physicalWindowTokens as 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):

    • Placing each dropped block. membrane's RoundReport.thinking.dropped indexes 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 id addMessage returned 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).
    • Splitting against the record's start. Expected: a reply whose every body sits at or below the compile's 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, raises thinking-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).
    • Once per incident (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.addAssistantResponse returns the stored message's id.
    • Agent.thinkingBindingOption() gives the binding, marked @internal like the agent's other members only the framework reads.
    • StartStreamResult gains three optional fields: placeDrops, thinkingRecordedAfter and noteRequestSent.
  • Text: binds' doc, maxStreamTokens' doc, compileContext()'s and runInference()'s docs, the request assembly's doc (which buildActivationRequest's points to), the README's budget restart, and changelog.d/thinking-binding-binds.added.md. runInference's doc says that with binds its 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 passes dryRun. 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, and thinkingBinding and identity on a compile and a preview. With 0.20.0 out of the range, CompileResult.renderStats is read as typed too, and what main kept for 0.20.0, whose compiles carry no render stats, goes: the structural read, a sentence of getRequestCompileStats' doc, the render-stats test's branch that only 0.20.0 ran, and the request-compile-stats fragment'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.renderAsNewest gives null for a message before the recent window, so under that strategy a binding agent's turn whose rows pass recentWindowTokens loses its replies' thinking at the next compile (see Tests).

Who needs to act

  • Every host moves its own context-manager range to ^0.22.0. A lower range installs a second copy, and connectome-host's main, with ^0.20.1, then fails to typecheck where its ContextManager meets agent-framework's, with npm or bun.
  • A host whose agent runs a model that binds its thinking sets binds from its own model records, beside dropBlock where 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.
  • A host that sets binds and 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.
  • A host that calls 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. previewActivation compiles as a turn does, dry.
  • A host that sets both binds and physicalWindowTokens no longer has maxStreamTokens bound a turn's stream: the stream grows until the window projection restarts it.
  • Operators may see a new alert kind, 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, without dropBlock, 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.
  • A host with a membrane beforeRequest hook 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.
  • A host that stands in for the stream start needn't supply the new fields. Without placeDrops, no drop is placed, and without noteRequestSent, nothing is noted.

What each part is for

  • binds, beside dropBlock. 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 by dropBlock, 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.request names (tool choice, thinking config, output limit) and prefillUserMessage. Sampling settings such as temperature are 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_error as a boolean. A stream's envelope sends a submitted result's is_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).
    • Notes stop at a note that writes nothing or throws. For a stream whose compile no longer wrote the record, or a branch with none, no later note could write, so going on would cost a render per boundary for nothing. For a store that failed once, a later note might write, and stopping gives that up for the stream's later rounds, as the doc says. The line tells operators why that stream's thinking went.
    • What a stream sent, compared as membrane sends it. Whitespace text and an empty text block carrying a provider-native item count as differences, since the next request's sanitize pass leaves them out. Comparing with the sanitize pass on both sides named such rows for bytes the next request doesn't send.
    • Walking rather than serializing. Each note compares every row the stream has sent so far, so the comparison walks the blocks with JSON's rules rather than writing them out. On Linux with Node 24, 50 rows each holding a one-megabyte base64 image took about 360 to 395 ms per boundary through a JSON round trip, and about 5 ms walked; 50 rows of 100 KB text took about 20 ms against 0.2 ms. Anything but a plain object or array is still compared through its JSON.
    • Cache markers left out. Stored history can carry a stale block-level marker, and neither the provider's binding nor the record counts one. Without this, such a row would be the marker for nothing.
    • The pending round's content kept with its blocks. What membrane held as the pending round's reply is keyed by the pending blocks (a WeakMap), so it goes wherever they go. Keyed by agent name, a stream cancelled before its boundary left it behind.

    Left out:

    • Noting the request as sent. A request isn't compile output: the sanitize pass, the stamp removal and the request-only rows turn one into the other. The record couldn't match it, so every multi-round turn would lose its thinking.
    • Noting request-only rows. The next request puts each one back in the same place, and no compile holds one, so a noted one would read as a difference.
    • Noting without comparing. While the guard holds a batch, the store holds placeholders and the stream was sent the originals. Where a cap or an image limit cuts a stored row, the next compile sends less than the stream did. A row named there would vouch for a prefix the provider never saw.
    • Noting the direct API's replies. Its request isn't the compile's output plus earlier rows: it appends results from the agent's state after the compile, the guard rewrites rows in it, and a guarded refusal's retry compiles again. A note would need its own comparison of each of those, and no known caller drives a binding model through the direct API. Its replies lose their thinking at the next compile instead, and are never refused, as it already carries the drop control without reading the provider's report (feat(agent): thinkingBinding.dropBlock asks the provider to drop thinking blocks that a fold invalidated #768).
  • 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. binds follows every boolean switch here, so a quoted "false" doesn't turn it on.

  • The preview's binding. previewContextSettings passes the binding to previewContext, 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 binds alone, nothing short of the provider's 400 would bound the stream, and with physicalWindowTokens alone, 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 needs dropBlock: the provider reports drops only on a request that carries the control, and without it the block is refused.

  • addAssistantResponse returning 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 without dryRun, leaves that stream's later notes writing nothing. compileContext isn'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 the AutobiographicalStrategy connectome-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 under PassthroughStrategy, which has no render.

Messages in the store renderAsNewest, autobiographical renderAsNewest, passthrough (no render) noteRequestSent, autobiographical
1,000 15.7–30.1 ms (median 18.1) 28.8–50.6 ms (median 37.3) 1.4–2.4 ms (median 1.8)
5,000 12.8–25.8 ms (median 14.1) 28.2–61.5 ms (median 37.6) 2.8–12.8 ms (median 3.1)
20,000 99.0–137.0 ms (median 102.5) 90.0–117.3 ms (median 98.0) 13.4–45.9 ms (median 20.5)

The 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 main a4a74b48, at the merge of main 8ec1de5a, at the merge of main ccccc31c (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 main at ccccc31c, 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-rows 18, thinking-drops 5, thinking-binding-binds 7, thinking-loss-alert 11, end-turn-usage 48 with its seven new cases, clock-rows 37 with its new case, and thinking-binding 5 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;
      • what the next request carries of a stored body;
      • a row named only where it's carried as the stream sent it, and the marker otherwise: an injected message under another participant, a row the render cuts, an image an image limit takes out, a row the store doesn't hold, results for another call or with another error flag, an injected message led by whitespace, and a reply with an empty text block carrying a server-tool item;
      • cache markers left out of the comparison, in both spellings;
      • on framework turns: every round so far noted at each boundary before the next reply exists; the end note after the last reply is stored; a single-round turn; a stream a result ends; a stream that ends on its last results; a budget restart's and a window restart's end note before the restart's compile; the marker for a reply the wrapper-prose guard contains; the compile id on every note and no notes without provenance; an empty text block; the guard's held batches marked and named once settled; notes stopping, with a line, at a note that writes nothing or throws; and no notes, and no render for one, for a stream without roundContent or 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's placeDrops and thinkingRecordedAfter.
    • 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, prefillUserMessage included, 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 of null or Infinity, 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-rows 28, agent-cache-ttl 7, unsent-compiles 5, stop-without-stream 47, tool-result-guard 150, kv-unified-wiring 3, activation-request-sanitize 5 and config-switches 8.

  • Red-first, at 1dc0e4f3 on context-manager 0.20.1: with src/agent.ts, src/framework.ts and src/types/agent.ts from main 8ec1de5a and 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-rows 12, thinking-drops 2, thinking-binding-binds 6, thinking-loss-alert 6, end-turn-usage 3 and clock-rows 1. 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:

    • placement: by signature in the compile, rounds, a signature in two messages, a candidate with no stored body, and placeDrops at the stream start;
    • the compile options: the options told at all, to a framework turn's and the direct API's compiles, the participant, the preview's binding, the tools' order in their digest, providerParams and prefillUserMessage in the request digest, the digests taken as JSON carries the head, and the refusal;
    • the record start: taken from the compile;
    • the budget rule: off, without its window-size condition, without its binds condition, with a window size that isn't a finite number taken as one, and with one given as a string read as none;
    • the alert: the split's start, every body at or below it, the account's binding, an alert at every call, an incident that never ends, one a call with no report leaves open, alerting with no record start, forget itself, and forget at a run's end;
    • the notes: none at all, none at a boundary, none at the terminal store or a boundary that ends the stream, the end note without the last reply, the compile id, notes without provenance, a stream without roundContent, going on after a note writes nothing or throws, and the round's content kept with its blocks;
    • the comparison: empty text blocks, whitespace and carriers filtered as the next request is, cache markers, results by the store's own fields or by text alone, an injected message's participant, the stored body instead of the next compile's render, and in sameData an 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 PassthroughStrategy and under AutobiographicalStrategy, 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 from is_error: false.

  • A turn whose rows pass the memory strategy's recent window, in the same harness with recentWindowTokens at 2,000 and 6,000-character tool results, and a budget that holds every row raw:

    • On context-manager 0.22.0, at this head, and on context-manager's main (4fbe6c94), at an earlier head of this pull request: AutobiographicalStrategy.renderAsNewest gives null for 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.
    • On fix(render): renderAsNewest gives null only for a message the next compile doesn't send context-manager#314 (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 on main.

    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:

    • as it stands, with its own context-manager ^0.20.1: two copies (0.20.1, and agent-framework's own 0.22.0), and its typecheck fails twice in src/commands.ts, where its ContextManager meets agent-framework's;
    • with its range raised to ^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 its main does unchanged.
  • is_error: the comparison takes a submitted result's is_error: false as 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

  • No request reached a provider through this change. The notes ran only in the harness under Tests, with a stand-in for membrane, over seven turns: two tool rounds, a heartbeat tick before the first, a [Continue] before the first, a reply with an empty text block, a single-round turn, a host's compileContext() during a tool call, and the tool-result guard. Under AutobiographicalStrategy it 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.
  • The provider's reading of is_error comes from requests sent directly on 2026-10-23, on Haiku 5.5 only.
  • The budget rule's cost in input tokens, against the restart it replaces.
  • Blocks shown to the model though they failed the check (mismatchAllowed) with the drop control on. The alert reads only dropped. 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.
  • membrane's XML path: agent-framework can't tell it from the native path for a turn with no tool round, since no tool-calls event comes to show roundContent, 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 fragments added: changelog.d/thinking-binding-binds.added.md, and changelog.d/context-manager-0.22.breaking.md for the floor

Written 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-seru account.

Effie-2756 and others added 23 commits October 23, 2026 21:16
… 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>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 thinkingBinding config rather than adding a parallel mechanism. binds sits beside the existing dropBlock (#768) and is validated the same way, through assertBooleanSetting. 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 binds is set, together with physicalWindowTokens or dropBlock respectively. (changelog.d/thinking-binding-binds.added.md)
  • The new helpers live in their own modules under src/, like the existing thinking helpers. sent-rows.ts and thinking-drops.ts sit 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.md and 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:

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. noteRoundSent adds the new round to notes.rounds and then calls noteStreamRows. That function calls cm.renderAsNewest(ids) and getMessage for every id noted so far, and roundRows compares each one through sameData, which does a JSON.parse(JSON.stringify(...)) round trip of the full content, base64 images and large tool results included. This runs synchronously between the tool results and provideToolResults (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 the maxStreamTokens restart is that 'the window projection still breaks the stream before a request the window can't hold'. Every binding case in this block uses physicalWindowTokens: 1_000_000, so overPhysical is never true. Nothing checks that a binds+window agent restarts with reason physical_window. Nothing checks that noteStreamEnd runs 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 new noteStreamEnd call sites are not exercised by test/sent-rows.test.ts or by any other added test. The first is the path where complete arrives while the agent is still waiting_for_tools (lines 20199-20203); it notes the pending reply. The second is the guardedWrapperTool containment 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 to noteRequestSent.
  • 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 compares seen[0] with turnOptions(...), and both values come from bindingCompileOptions, 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: system from the compile's returned systemInjections (agent.ts:1636, 1390), while the digest uses the injections given to the compile (1542-1551, 1373-1375); tools is undefined when the list is empty. No test asserts that identity.system and identity.tools equal digests of request.system and request.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, so noteThinkingLoss is only run with reply placements. The alert's rounds and unplaced counts and data fields 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 of splitThinkingDrops. A mistake in how the stream passes entry.round drops or account-level drops to noteThinkingLoss would go unnoticed.

Comment thread src/agent.ts Outdated
Comment on lines +426 to +429
// 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Effie-2756 and others added 5 commits October 24, 2026 00:06
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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

Addressed in cf7a4792..25804996, five commits:

  • A physicalWindowTokens given as a string (inline): right. The normalization switched the window projection off for a value main honoured. The agent now converts the value as the projection's comparison always did, so "200000" is 200000, and a value whose number isn't finite is no window (16483973). Refusing a non-number would be a new refusal for configs that work today, which this pull request doesn't take on.
  • Re-judging every row at each boundary: each note re-judges every row the stream has sent, since a row's verdict can change after it was first judged: the tool-result guard's batch settles to its originals, and an image limit counts images stored later. What was costly was the comparison's JSON round trip. On Linux with Node 24, 50 rows each holding a one-megabyte base64 image took about 360 to 395 ms per boundary that way, and about 5 ms walked with JSON's rules (2551a169). The context manager's own render per note is in the description's timing table.
  • The window projection for a binding agent: now tested, with a window the first call's prompt is needed to pass, and with the end note before the restart's compile (16483973, 2551a169).
  • The wrapper-prose containment's note: tested (2551a169). The flush when complete arrives while tools are pending is, by our reading, a defensive path that membrane's event order no longer reaches, so it has no test.
  • The digests against the request sent: tested against the request the stream starts with (3ac3f025). Checking that test turned up that the digests hashed an undefined field like a null one, though the wire leaves the first out, so they're now taken as the request's JSON carries it (330e0d84).
  • Round, unplaced and account-level drops on a framework turn: tested, the account's binding at every position (3ac3f025, 25804996).

— Effie-2756

Effie-2756 and others added 3 commits October 24, 2026 01:06
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>
@nissa-seru
nissa-seru merged commit 0aa9f52 into anima-research:main Oct 25, 2026
12 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 25, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 25, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 25, 2026
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>
tel-0s added a commit that referenced this pull request Oct 25, 2026
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>
tel-0s added a commit that referenced this pull request Oct 25, 2026
… 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>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@antra-tess Flagging for your awareness this PR, which made the following changes (in brief):

  • Only with thinkingBinding.binds: each request now tells context-manager what it carries ahead of its messages, and each framework stream notes which stored rows it carried, so context-manager leaves out signed thinking whose earlier conversation has changed. Before, stored thinking went as stored. The direct API (runInference) notes nothing, so with binds a direct-API reply's thinking is left out at the compile after it's stored.
  • With binds and physicalWindowTokens both set, maxStreamTokens no longer starts a new stream at a tool boundary. The window check still does.
  • The context-manager range rose from ^0.20.0 to ^0.22.0, which brings context-manager's own changes in those releases.

— Lottie-2661

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant