Repository navigation
fix(pairing): send a tool result that answers a call in its own message - #365
Merged
nissa-seru merged 8 commits intoOct 29, 2026
Merged
nissa-seru merged 8 commits into
nissa-seru merged 8 commits into
Conversation
added 8 commits
October 29, 2026 06:58
… result enforceToolPairing's Rule A keeps a tool_result only when the entry just before it holds its call. So in a message whose own content holds a call and then its result (a claude.ai export's tool cycle, or a user message carrying one), the result was dropped as answering nothing, and the call was then stubbed, by the tail or at the next entry. compile() runs splitMixedToolMessages for sessions imported in that shape, and membrane's normalizer re-roles a user message's calls, but both run after the repair, so the message lost its results in every compile of the autobiographical family: on both selects, and in KnowledgeStrategy, which doesn't override the repair. Passthrough never ran the repair and kept them. The repair now judges such an entry in parts: each run of tool_result blocks that follows a tool_use in the same content is a part, and so is what follows it (cycleParts). Results before any call stay in the first part and are judged against the entry before, as they were. Rule A, Rule B and the tail run over the parts unchanged (repairToolPairing), and each entry's parts fold back into it in order, with any stub entry Rule B placed between two of its parts folded in as a run of results. So a result answers the calls just before it in its own message, a call its own results don't answer is stubbed beside them, and the entry stays one entry, which the compile's split then cuts as before (attribution judges an entry by whether it covers its stored message), and compile provenance now reports such a message carried whole where it reported it missing content. A compile in which no entry holds such a cycle takes the old path as it was. In one that does, the look-ahead for a displaced result counts parts, so it reaches one entry less for each extra part between. Two places decided pairing the old way, and change with it: - answeredToolCalls, which toolResultMaxLastN, the across-windows closure and calibration epoch 4's predicate read, let a message's calls wait only from the next message on, "pairing repair drops a result in its call's own message". They now wait from right after the call, as the store's callsInOrder already counts a cycle. A message without such a cycle counts as before; the closure already skips a pair within one message. toolPruning's and toolResultMaxLastN's docs are corrected. - The calibration's estimate is the render stats' total, taken before the repair, so a raw message holding a cycle was priced with its results while the request carried stubs, and such a store learned a multiplier that covered results it didn't send. CALIBRATION_PRICING_EPOCH is 6, and its reprice predicate holds where a store holds a message with a tool result after a tool call in its own content: there an older multiplier is discarded at load and restarts provisional, which the first sample the band accepts replaces outright (reportRealInputTokens). Elsewhere a multiplier stamped 5 is kept and re-stamped. Tests (tool-cycle-in-one-message, on hierarchical, kv-unified with the host's policy, and flat-profile): a cycle in an assistant message and in a user message, mid-conversation and last, is sent with its own result, membrane 0.8.0's normalizeToolPairs synthesizes and textifies nothing, and provenance reports the message carried whole; a call its own results don't answer is stubbed beside the result that is; a message whose results answer none of its calls has each call answered; under toolResultMaxLastN 0 the cycle's result gets the marker; calls and results in messages of their own send as stored. calibration-band: an epoch-5 multiplier is discarded in a store holding such a message, and kept and re-stamped in one without. Two tests pinned the old count, and are turned round in tool-pruning-price: "leaves a result in its own call's message out of the count, as pairing repair drops it" now counts it (under a limit of 1 the earlier call's result gets the marker and the cycle's is sent), and epoch 4's predicate's "a result in its call's own message" case now asserts that epoch 6's predicate holds for that store: there epoch 6 discards an older multiplier whatever epoch 4 answers, so epoch 4's answer decides nothing.
Two corrections to the cut that lets a message holding a tool call and its result send that result: where it cuts, and how far the repair's look-ahead reaches once it has cut. The cut. cycleParts cut at any run of results after any call, without reading ids, so a message whose result answers the previous message's call ([call B, result A]) or answers nothing ([call 1, result 9]) was cut too. The second then folded the repair's "[tool call omitted]" text into a message the old repair sent without it, and epoch 6 repriced stores holding only those shapes. A run of results now starts a part only when one of its results answers a call earlier in the same content. That is the notion the store's callsInOrder and toolResultMaxLastN's count (answeredToolCalls) already use for a result answered in its own message, so the count, the cut and epoch 6's predicate agree: every result the count calls answered in its own message reaches the request, either in the part right after its call's part or moved there by the look-ahead, which runs at that part before the repair's orphan rule reaches the result's own part. A cut never splits a run of results or a run of other blocks, so a raw replay form, which blocks of one such run share, stays within one part (said in cycleParts' doc). The look-ahead. relocateOrDropMissingResults searched 6 entries of the array it was given, which in a compile with a cut message is the parts array, so a message's extra parts could push a displaced result out of reach: the call was stubbed and the orphan rule dropped the real result, where main moved it to its call. The window now counts units, a message's parts being one unit and every other entry, a stub entry the repair inserted included, its own. With no cut each entry is its own unit, and the window is main's. The fold now gathers each message's parts in order and assigns its content once, so a message can't be emitted twice. Tests: the new file runs on KnowledgeStrategy too, the "last" user case is last (asserted), results are asserted whole, and it adds a message holding two cycles, a result answering its call past another result of its message, an orphan result folding in no repair text, and the look-ahead through a subclass exposing enforceToolPairing. The epoch-6 reset asserts the restarted multiplier is provisional.
The fold that gathers a cut message's parts back into its one entry pushed the message at its first part and every entry without a message of its own where it stood, so an entry the repair spliced between two parts of one message, such as a stub for a call whose result is nowhere, went out after the whole message. In [call A, call B, result B, text, call C, result A, text, call D, result D] with no result for C, the look-ahead moves result A up and empties its part, the repair stubs C before that part, and C's stub was sent after D's result, so the compile's own output left C unanswered after its call, and the request was well formed only once membrane's normalizer had reshaped it. The fold now folds an entry that sits between two parts of one message into that message at its place, by each message's last part. Tests: a message holding a call whose result is nowhere between two cycles of its own, on each select, with the compile sending its stub in the message right after the call's (the membrane check, which counts a stood-in or textified result, can't see it); KnowledgeStrategy on the kv-unified and flat-profile selects too; the unanswered-call case for a user message; the two-cycle case asserts order and no repair rather than the split's shape; the look-ahead reaches a result seven entries after its call and stubs one eight entries after; the epoch-6 case keeps a multiplier in a store whose results answer no call of their own message.
… keeps The header of test/tool-pairing-invariant.test.ts said every tool_use is answered only in the entry after it, and every tool_result answers only the entry before it. Since pairing repair judges a message holding a tool cycle of its own in parts and folds them back, a call can be answered later in its own entry's content, and a result can answer a call before it there.
…adds nothing Two corrections from reading the repair over random entry lists against main. A call now waits in cycleParts until a result answers it, so a second result for a call its message already answered starts no part of its own. Such a part moved where pairing repair answered the message's next call: in [call A, result A, call B, text, result A] followed by [result B], Rule B for B fired at the further result's part instead of at the next message, which answers B as main sends it, so the look-ahead moved that message's result into the cut message and left it as "[tool result moved during context repair]". The cut, the count's earliest-waiting-call rule and epoch 6's predicate now agree for unique ids on which result answers a call in its own message. A part the repair empties now adds nothing to its message: Rule A and the look-ahead leave it with no content, where a whole entry they empty keeps its placeholder. A cut message always keeps a call, so its folded content is never empty. Before, a further result dropped by Rule A, or a result moved up to its call, left "[tool call omitted]" or the moved-result text inside the message. The fragment's split clause now says compile() runs the split on every message, and an unanswered call is answered as pairing repair answers any call: by its result moved up where the look-ahead finds one, else by a stub. Tests, on each select: the further-result case with the next message's own result kept there; a further result leaving no placeholder; the past-another case asserting no moved-result text.
…e pairing checker Tests and text only. The further-result case adds a message where Rule A empties a part: in [call A, call B, result A, text, result B, result B] the look-ahead moves one result B up beside A's, and Rule A drops the other, leaving its part empty; nothing of it reaches the message. The next-message case asserts that message's own content, not only its result's id. The pairing invariant test's assertPaired accepts a call answered later in its own entry and a result answering a call earlier in its own, as its header states; its cases hold no such entry. Text: relocateOrDropMissingResults' doc says a part of a cut message the move empties is left empty; the fragment's count sentence says a result answers the earliest call with its id that no result has answered yet; two doc paragraphs rewrapped.
…plit sends assertPaired accepted a call answered anywhere later in its own entry, so it passed one entry [use A, use B, result A, text, result B], which the compile's split sends as Claude [use A, use B] | user [result A] | Claude [text] | user [result B], B's result two messages after its call. It now holds what the split makes adjacent: a call is answered in the run of results right after its run of calls, or, where its run ends its entry, in the next entry; a result answers a call in the run just before its own, or, where its run starts its entry, in the previous entry. The header says so. Its cases pass at this tree and with main's autobiographical.ts.
The header claimed the repair's output keeps the run rule the compile's split sends as adjacent messages. It doesn't, at main or with this change: pairing repair pairs by entry, and leaves a result that stands after other blocks of its entry for membrane's normalizer to place. The header now says that, and says that assertPaired holds these cases' repaired entries to the stricter run rule. Comment only.
1 task done
tel-0s
added a commit
that referenced
this pull request
Oct 30, 2026
… raw window's messages left out, a tool cycle in one message sent with its result, compression that waits on tools kept queued, the canonical map linear, test files run at once No textual conflicts. Read for presented mode, PR by PR. Nothing in this range needed changing. - #360 summaries across windows (shipped in 0.24.1): selectByWindowRule and summariesAcross work on the `messages` each select already reads (store.getAll() of the strategy view, the window once a host presents). Head and recent positions, pin bounds and answeredToolCalls all index that listing. Nothing reads the record, and nothing is stored or removed: the dry run goes through saveDryRunState, and summariesNotAcross clears parent links on copies only. Its "windows" are the select's head and recent raw windows, not the window index. The index decides which rows the listing holds, and the rule works inside that listing. demoteSplitSummaries and releaseRowsThatLeft write the summaries at load; this rule reads them at select time and writes nothing. A leaf the window doesn't hold has no position and is skipped. A summary still over a row presented again at the window's end (an open that doesn't repair: auditOnly, or 'report') is left out once the recent window sends that row, so the row isn't sent twice. No change. - 0.24.1 release: version taken (package.json, lockfile; chronicle ^0.6.0 kept). The release consumed summaries-across-windows.fixed.md and summaries-across-fallbacks.added.md, which were on main only, so no fragment of this branch goes. Main's unreleased fragments (#362, #365, #367) and the 291-* fragments stay. - #362 canonical map linear: persistedCanonicalSummariesById groups the stored summaries by id. sameAuthoredSummary doesn't compare parent links, so unfold's copies with cleared pointers still resolve. No change. - #363 test slim: tests and the runner script only. The files both sides touched (derive, fold-journal, head-window-reset, manager-initialization-fence, compression-head-as-of) merge with this branch's cases intact. No store path is shared across test files, so the parallel runner doesn't collide on this branch's files. dist/test/autobiographical.test.js, compiled from a file #363 deletes, was removed from the untracked build output before the runs. No change. - #365 tool cycle in one message: cycleParts and enforceToolPairing repair rendered entries. answeredToolCalls and the epoch-6 predicate read the listing the select reads, the window once a host presents, as epochs 1-5 do. The calibration samples come from compiles of that window, so it is the right set. Nothing is stored. No change. - #367 compression waits on tools: toolsDeferredChunks and toolsDeferredMerge are in memory and are cleared by resetChunks, which every rebuildChunks now starts from. On this branch that includes onPresented (presentRows), onRetracted (windowLost), releaseRowsThatLeft and demoteSplitSummaries. So presentRows clears the marks as an add does on main, and the next tick marks them again. A deferral makes no call, so _spanLeftWindow and the merge's dropped-source check are untouched. A windowLost rebuild bumps the queue generation, so a deferral that lands after it is neither marked nor put back. presentRows, retractRows and the removal paths read no readiness. Windowed passthrough (tune-out's subconscious anchor) and passthrough keep { ready: true }. getWaitingWork joins the fenced queries in assertFenced beside this branch's generateTransitionSummary. No change. Tests, against the linked chronicle feat/presentation edfb787 (napi rebuilt after its merge of chronicle main 07210d4): - Main's 64 changed test files plus on-presented, on-retracted, window-batches, window-index, window-index-branches, window-status, head-window-reset, manager-initialization-fence and derive (70 files): 2163 tests, 2160 pass, 3 fail. - Full suite (dist/test/*.test.js, dist/test/adaptive/*.test.js, --test-force-exit): 3591 tests, 3585 pass, 6 fail. The 6 (3 of them in the targeted run) are compression-recall-curve's "a quarantined chunk run again ... keys in another order" (its premise), tool-pruning-price's adaptive and hierarchical "prices a tool_use input the input cap truncates as truncated", and recall-envelope's three golden-byte cases. Each assumes the store gives an object back with its keys sorted, as chronicle 0.5.0 does. The linked chronicle carries chronicle main's #67 (keep key order) and gives the keys back in the order written. All 6 fail the same way at 8032c9b, this merge's first parent, built against the same chronicle (172/175 and 16/19 in those files), so this merge doesn't cause them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 task done
Contributor
Author
|
@antra-tess Flagging for your awareness this PR, which made the following changes (in brief), in
— Lottie-2661 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
AutobiographicalStrategyandKnowledgeStrategynow send a tool result that answers a tool call earlier in its own stored message. At3383f8dfdthey sent a stub for the call in its place, and the result reached the request nowhere. Pairing repair (enforceToolPairing) judged such a message whole, so it took the result for one that answers no call before it. It now cuts such a message into parts, repairs the parts as before, and folds them back into the one message. A compile holding no such message is repaired as before. Callers see three more changes.toolResultMaxLastNnow counts such a result, compile provenance reports such a message complete, and a store holding one discards its calibration multiplier (pricing epoch 6) and sends different requests after the upgrade. At this head the four changed test files andnpm testpass; with3383f8dfd'ssrc, 76 ofnpm test's 3,573 tests fail, all in those four files.Problem
A probe at
3383f8dfd(a test file added to a clean checkout, not part of this change) storedClaude [text, tool_use look-1, tool_result look-1, text], mid-conversation or last, and compiled it at 200,000 tokens:AutobiographicalStrategysentClaude [text, tool_use look-1, text], thenuser [stub for look-1]. The stub answers the call, the real result is nowhere in the request, and the text after the result goes before the stub.PassthroughStrategysent it with its result, ascompile()'s split (src/normalize-tool-messages.ts) leaves it:Claude [text, tool_use] | user [tool_result] | Claude [text].Usermessage went out the same way on the three configurations.complete: false,missing: ['content'].3383f8dfdonKnowledgeStrategy's three configurations (under Tests).Who stores this shape, as context-manager's own doc says it (
src/normalize-tool-messages.ts:7–9,:30–33): "claude.ai exports bundle the entire tool cycle into a single assistant message", and connectome-host's importer "already splits at ingest time", socompile()'s split "handles legacy sessions imported before that fix landed".compile()'s split came in05d4c8af7on May 16, "runtime tool_result splitting for bundled cycles", and pairing repair's validator in10160ac5don July 7.Changes
cycleParts(src/strategies/autobiographical.ts:2086–2107) makes a part of each run oftool_resultblocks that answers atool_useearlier in the same content that no result there has answered yet, and of what follows it.enforceToolPairing(:19577–19616) repairs the parts with the old body, nowrepairToolPairing(:19618), and folds each message's parts back into its one entry in order. What the repair placed between two parts, a stub or a result moved up, is folded into the message at that place (:19607–19611). When no entry is cut, it callsrepairToolPairingon the entries as they are (:19588–19591).:19637,:19755). A whole entry the repair empties keeps3383f8dfd's placeholder.relocateOrDropMissingResults,:19732–19739), so a cut moves no result out of its reach.toolResultMaxLastNcounts such a result as answering its call.answeredToolCalls(:692) lets a call wait from right after it. The option's doc (src/types/strategy.ts:2189–2191) said "A result answers the earliest call with its id, in an earlier message, that no result has answered yet", and now says "A result answers the earliest call with its id before it, in its own message or an earlier one, that no result has answered yet". Such a result answered no call before, so it was never replaced, and an older result of the same tool can now get its marker.complete: truewhere nothing else alters it.:14934), whose predicate holds for a store holding a messagecyclePartscuts (:15025). An epoch-5 multiplier is discarded at load in such a store and restarts provisional, and is kept in a store whose results answer no call of their own message (test/calibration-band.test.ts:357).enforceToolPairing's,toolPruning's andansweredToolCalls' comments, and the fragment.git merge-treeat 12:57 UTC on October 29: fix(shards): a sharded body keeps each block in place and goes out as given, and window edges keep a tool call with its results #310 gains four conflicted regions insrc/strategies/autobiographical.tswith this change, where it merges with3383f8dfdwithout a conflict. fix(reset): send store order while a reset head fills, and keep a reset head's messages whole #315, chore(deprecate): ContextInjection and compile(injections) #140, RenderStats.nesting; the kv-overlap warning stops claiming a double render #130 and feat: folding-strategy migration utility (kv-stable ⇄ kv-unified) #94 conflict with this change in the same files, with as many regions, as they do with3383f8dfd, and feat(presentation): a window per manager, which the strategy view reads (#291 step 1) #350 has none.test/tool-cycle-in-one-message.test.tsis new, with 14 cases on each of six selects (AutobiographicalStrategyandKnowledgeStrategy, each hierarchical, kv-unified and flat-profile) and two look-ahead cases. Each of the 84 cases on a select runs membrane 0.8.0'snormalizeToolPairson the compiled messages and asserts it repaired nothing. The two look-ahead cases call the repair on a list of entries and assert on the entries.test/calibration-band.test.tsadds the epoch-6 case.test/tool-pruning-price.test.tsturns round the two tests that pinned the old count.test/tool-pairing-invariant.test.ts's checker holds the repaired entries to the run rule the compile's split sends.What this leaves
3383f8dfdsent a result. This is3383f8dfd's rule for an entry the repair empties, applied where this change keeps or moves a real result. It happens in two shapes. In one, a later message repeats a result for a call the cut message answers itself: this change keeps the copy in the message and drops the later one, so the later message goes out as "[tool call omitted]". In the other, as in[call t0, call t1, result t1]then[result t0], the look-ahead movest0's result up beside its call, and the emptied next message goes out as "[tool result moved during context repair]". Over 3,000 random entry lists, 197 send more such placeholders than at3383f8dfdand none fewer, 61 of the 197 also send more real results, and no list sends fewer real results or more stubs.3383f8dfd, and leaves a result standing after other blocks of its entry for membrane's normalizer to place. A cut message carries that into its parts. In one holding a result for the previous entry's call after its own calls, as[use t1, use t2, result t0, text, result t1]after[use t0, text], the compile's split sendsresult t0's message between the calls and their results, and membrane hoists them.3383f8dfdsends that message with stubs fort1andt2after it, also hoisted. This happened in 27 of the 3,000 lists.3383f8dfd, not only send its results. In the counter harness under Tests, at4d0e3bfe0, one generated store's resident configuration selected differently in 56 of 132 compiles, and with the harness's kv-unified acceptance calls skipped, in none. In 40 compiles, all in one kv-unified part of that harness at budget 3, the selection sends raw a region3383f8dfdsends as summaries, and 1 to 4 more messages go out as "[tool call omitted]": results of a sharded message whose calls the adaptive render sends as text only. fix(shards): a sharded body keeps each block in place and goes out as given, and window edges keep a tool call with its results #310, still open, makes a sharded body keep each block in place.Tests
On Linux x86_64 (Debian 13, 12 CPUs) with Node v24.22.1 and npm 11.19.1,
npm cifrom the lockfile installs @animalabs/chronicle 0.5.0, @animalabs/membrane 0.8.0, @types/node 22.19.15 and typescript 7.0.2.At this head,
390a9f8bd, in a clean checkout:tscexits 0, the four changed test files pass 174 of 174, andnpm test's two globs (dist/test/*.test.js,dist/test/adaptive/*.test.js) pass 3,573 of 3,573.Red first: with
3383f8dfd'ssrc/strategies/autobiographical.tsandsrc/types/strategy.tsand this head's tests, 76 tests fail and the other 3,497 pass. They are the new file's 12 cycle cases on each select and its look-ahead case, the epoch-6 case, andtool-pruning-price's two turned-round tests.The probe under Problem, at this head: on all four configurations, the cut message's result is sent with no stub and provenance
complete: true, the hierarchical request is byte-identical to passthrough's, and the separately stored call and result send the same bytes as at3383f8dfd.Each piece removed, at this head on the four changed files:
unitOf, through which the look-ahead and the emptied-part rule see a cut message as one entry3,000 random entry lists through
enforceToolPairing, at this head and at3383f8dfd: no placeholder inside a message and no empty entry at either. Here 6,485 real results and 5,995 stubs, against 5,075 and 7,405, with more real results in 1,140 lists and fewer in none. Membrane 0.8.0's normalizer over the same lists, atb11bd90f0, whosesrcis this head's, and at3383f8dfd, synthesizes, textifies and hoists nothing here that it doesn't at3383f8dfd, and hoists 970 times against 1,624. In 95 lists it defers one block where it hoisted at3383f8dfd, each in a user message holding its own cycle.A counter harness (not part of this change) ran 579,000 compiles over kv-unified, flat-profile and hierarchical configurations at
3383f8dfdand at4d0e3bfe0, and again ata966e15f3, whosesrcdiffers from this head's only in comments, and whose compiles were byte-identical to4d0e3bfe0's, compile for compile.3383f8dfdand 2,336 here, of 5,264 checks.Not verified
tool_useids: no new case stores two calls with one id.changelog.d/tool-cycle-in-one-message.fixed.md.Desmond-2579 built the change and ran the probes, the lists and the harness. Greta-2425 wrote the 3,000-list generator, counted membrane's events over the lists, and read each commit the internal review's runs didn't cover. An internal review ran four times. Beth-2389 wrote the counter harness. Oscar-1005 made the design calls. Lottie-2661 made the runs at
390a9f8bdand wrote this description.