Repository navigation
feat(framework): keep the rows that open a turn raw while it runs - #867
Merged
nissa-seru merged 6 commits intoOct 29, 2026
Merged
Conversation
added 2 commits
October 27, 2026 21:30
A long turn whose older rounds are summarised could lose the request it was answering: once compression covered the opening message, a budget restart's compile rendered it as a summary, and the turn went on from a summary of what it was asked. In a kv-unified turn of 40 tool rounds whose module awaits at each tool result (budget 20,000, raw tail 6,000), the opening request was missing from 25 of the turn's 35 requests. At a start that opens a turn (not a resume, and not a silent heartbeat tick), the framework now pins raw, with context-manager's pinRange and the name agent-framework:turn-inputs, the rows the agent sees that were stored after both the newest of its own rows that it sees and its consumed watermark, starting at the first such row that holds more than tool results. Its compiles, a maxStreamTokens restart's included, render those rows raw until the turn ends. Every opening start first removes any pin of that name, and a silent start removes them and pins nothing. The pin is removed when the turn settles, when its start or continuation is dropped (endDroppedTurn), and at stop(). A range, rather than the rows the turn's trigger delivered: the trigger doesn't name those rows, since a merged trigger keeps a stored message's id only when every batched wake shares it and the gate's batched wake passes none, and they aren't contiguous, since ambient lines are stored as they arrive, residents share one message slot, and the start writes its own clock row. A range pin keeps everything between its ends raw. The strategy reads the slot through the agent's view filter, so a row the agent doesn't see, such as a tuned-out message, stays out of its view inside the range. Both bounds: the consumed watermark moves at a tool boundary only when that boundary injected messages, so after a long turn it sits at that turn's last compile, and alone it would pin the previous turn's rounds. The agent's own newest row alone has no floor when it hasn't spoken for a long time. Rows that hold only tool results are left out of the start: they answer the previous turn's calls, left when that turn ended after storing results. The view filter is read before the own-row stop, so a tuned-out row under the agent's own name doesn't end the scan. The fallback: an OverBudgetError from a committing compile while a pin of that name is held removes the pin and compiles once more, so the turn goes on as it would have without it, a refusal included. It isn't keyed on the refusal naming the pin, since only the adaptive plan's middle refusal names pins and the hierarchical one doesn't. It wraps the bare context-manager compile inside compileAtLiveBudget, so that wrapper's boot-restore branch never takes the pin's refusal for the restored budget's. compileWithInjections (not its dry runs) and compileContext both use it. It reads listPins() before the compile starts, so a pin a crash or a failed write left is found, and two compiles that refuse at once both compile again. Pins are per agent: each resident's ContextManager keeps its strategy state under agents/<name>. Agent.pinTurnInputs and unpinTurnInputs are @internal. Not covered, each falling back to the behaviour before this change: - a turn whose request was stored before the host restarted, which sits under the watermark the agent's creation sets; - the first turn after a branch switch, whose watermark names the other branch; - rows stored before the agent's newest reply that no request carried; - rows a request carried whose turn failed before replying; - the start's own notices and afterUser injections, and a co-resident's row stored during the start's hooks, which land after the range; - a resume after stop() on the same instance, which continues unpinned. Not shown by a test: the fallback's placement against compileAtLiveBudget's boot restore, which is by reading; and a range whose rows had no summaries when they were pinned, where after a fallback the unpinned compile renders them raw. Tests (test/turn-inputs-pin.test.ts, 15): a long kv-unified turn whose module awaits at its tool results keeps its opening request whole in each of its streams' requests; the range after a multi-round turn, after a turn that ended on stored results, and past rows the agent doesn't see; pins of the name left from before are cleared at the next opening, and a silent start clears them and pins nothing; no pin is left after a continuation the inference policy drops or a retry stop() cancels; the fallback under hierarchical and kv-unified, a second refusal, two committing compiles that refuse at once, and a dry run that leaves the pin; a strategy without pins logs nothing; one resident's opening, and its turn's end, leave another resident's pin. With the parent's src/framework.ts and src/agent.ts, every test fails except the one for a strategy without pins.
The test of two committing compiles that refuse at once while the turn's pin is held checked only that both compiled in the end. Had its fixture fit with the pin held, both would have compiled on the first try and the test would still have passed, without the case its name gives. It now records each compile on the agent's context manager during the two calls, with whether the pin was held when it started, and checks that both refused as over budget with the pin held and then each compiled once without it.
1 task done
added 4 commits
October 29, 2026 01:20
A fork derived in shared mode reads its parent's context at a checkpoint and writes on a branch of its own, and its first stream opens a turn, whose start clears pins of the turn's name before pinning the fork's own inputs. That housekeeping must stay on the fork's branch: if it reached the parent's, the parent's opening request could fold while its turn still runs. The test blocks the parent mid-turn with its inputs pinned, derives a shared fork and runs it to its end, and checks that the parent's pin is still the one it held, then that the parent's own end releases it. With the fork's opening also clearing its parent's pins by name, it fails at the parent's pin. It is skipped where the installed chronicle and context-manager lack branch-bound handles or ContextManager.derive.
The fragment compared the pinned rows with 0.28.0, the latest release when it was written. 0.29.0 and 0.30.0 fold them the same way, and the next changelog follows 0.30.0, so it now names that release. Text only.
This was referenced Oct 29, 2026
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.
This change adds a pin to a start that opens a turn, to keep the messages that opened the turn raw while it runs. When the agent's consumed watermark is on the current branch, the framework pins, with context-manager's
pinRangeand the nameagent-framework:turn-inputs, the rows the agent sees that were stored after both that watermark and the newest message it sees under its own name, from the first of them that holds more than tool results. It removes the pin when the turn ends. A silent start, one whose trigger setssuppressProse, pins nothing. When a committing compile refuses as over budget while the pin is held, the pin is removed and the compile runs once more.In this change's long-turn test under kv-unified, each of the turn's stream requests holds the opening request whole. With
src/framework.tsandsrc/agent.tsfrom612f6fd3bd, themaincommit this change now starts from, none of the test's 18 runs passes, and each fails at its check that every one of its 35 streams' requests holds the opening request whole. With those two files fromf14e7fbc99, themaincommit it started from before it first mergedmain, 25 of the 35 didn't. context-manager's README sayspinRangekeeps messages "at their place in the conversation, raw by default".Problem
In a long turn whose older rounds are summarised, the requests sent to the model can stop holding the request that opened the turn whole. This change's long-turn test shows it with
f14e7fbc99's source. In it:maxStreamTokensat 8,000;With
f14e7fbc99'ssrc/framework.tsandsrc/agent.ts, the turn answers and its budget restarts fold its older rounds. The opening request isn't whole in the requests of 25 of its 35 streams, the 11th to the 35th. With612f6fd3bd's two files, none of the test's 18 runs passes, and each fails at its check that every one of the 35 streams' requests holds the opening request whole.Changes
The pin (
src/framework.ts). At a start that opens a turn, unless it's silent (:23918),pinTurnInputsreads the agent's consumed watermark. When the watermark's branch is the current branch (:12399),turnInputsscans back from the newest stored row. Its scan::1533–1535);That range is pinned with
pinRangeunder the nameagent-framework:turn-inputs.Removing it. The pin is removed when the turn settles, when its start or continuation is dropped (
endDroppedTurn), and atstop(). A start that opens a turn first removes any pin of the name. A silent start removes one too, and pins nothing.The fallback (
src/agent.ts).compileUnpinningOnRefusalwraps the context-manager compile thatcompileWithInjectionsandcompileContextrun. When the compile throws anOverBudgetErrorandlistPins()listed a pin of the name before it started, it removes the pin, logs a[turn-inputs]line, and runs the compile once more. A dry run's compile isn't wrapped (:1655).Agent.pinTurnInputsandAgent.unpinTurnInputsare new and marked@internal.Agent.pinTurnInputsreturnsundefinedbefore pinning when the strategy has nopinRangefunction (:1216).README.mdgains a paragraph under Context Management, and its budget restart step says the turn's opening messages are kept whole.changelog.d/turn-inputs-pin.changed.md, new.test/turn-inputs-pin.test.ts, new, with 16 tests.What a resident and a host see
[turn-inputs], and the turn then goes on without the pin. In the test where the compile refuses without the pin too, the turn ends with that refusal (over_budget) after exactly two refusals.listPins()on the agent's context manager lists one pin namedagent-framework:turn-inputs. Tests check it while a turn runs, and that none is left once the turn ends.PassthroughStrategyin its test, a turn runs and logs no[turn-inputs]line.deriveAgentruns a turn of its own to its end. The agent then lists the same pin as before, and its own end removes it. The test checks the agent's pins before the fork is made, after the fork's whole run, and at the agent's own end, so it covers the fork's opening and its end together, not each alone.Tests
86fcad2c9dmergesmainat612f6fd3bdwith no conflicts, and its diff against612f6fd3bdadds and removes the same lines ascaa0b8b3cc's against0f11975e48.a7475a986cthen changes one line of the changelog fragment, which now names 0.30.0 where it named 0.28.0. On Linux with Node 24.22.1 and npm 11.19.1, context-manager 0.24.1, chronicle 0.5.0 and membrane 0.8.0, in a clean checkout of this head,a7475a986c:npm run build) gives no TypeScript errors.test/turn-inputs-pin.test.ts: 16 of 16 pass.pinTurnInputsalso calling the parent'sunpinTurnInputs()when the agent pinning is a fork: the test "a shared fork's opening and its end leave its parent's turn-inputs pin in place, and the parent's own end still releases it" fails, and the other 15 pass.612f6fd3bd'ssrc/framework.tsandsrc/agent.ts: 15 of the 16 fail, and the test of a strategy without pins passes. In the long-turn test, six copies at once, three times, none of the 18 passes, and each fails at "every stream's request holds the opening request whole (of 35)".CI at this head, run 40937886194, passes all 11 of its jobs. It ran on GitHub's merge of this head into
mainat612f6fd3bd, whose tree is this head's. Its Build & Test job, on ubuntu-latest with Node 22.12.0, runsnpm testafter its type-check and build steps: 9,430 tests, 9,367 pass, 0 fail, 63 skipped. Its step that runs three test files without--test-force-exitpasses 137 of 137.Earlier heads. On Linux with Node 24.22.1, context-manager 0.23.1, membrane 0.8.0 and chronicle 0.4.0, in clean checkouts of
96e4f73027:npm testaftertsc --noEmitand a build): 8,783 tests, 8,721 pass, 0 fail, 62 skipped.test/turn-inputs-pin.test.ts: 15 of 15 pass.f14e7fbc99'ssrc/framework.tsandsrc/agent.ts: 14 of the 15 fail. The test of a strategy without pins passes.f14e7fbc99's two files, 18 of 18 fail, each because 25 of its 35 stream requests don't hold the opening request whole.A second commit,
5f26d006b9, changes only the test of two refusals at once. It now asserts that both compiles first refuse as over budget with the pin held, then each compiles once without it. In a clean checkout of5f26d006b9, on Linux with Node 24.22.1, context-manager 0.23.1, membrane 0.8.0 and chronicle 0.4.0, the file passes 15 of 15. Withf14e7fbc99's two files, 14 of the 15 fail, and with the fallback removed, the same four as above fail.f42272669fmergesmainat0f11975e48with no conflicts, and its diff against0f11975e48adds and removes the same lines as5f26d006b9's againstf14e7fbc99.caa0b8b3ccchanges onlytest/turn-inputs-pin.test.ts, adding the test "a shared fork's opening and its end leave its parent's turn-inputs pin in place, and the parent's own end still releases it". On Linux with Node 24.22.1 and npm 11.19.1, chronicle 0.5.0 and membrane 0.8.0, oncaa0b8b3cc's files, in two runs, the first with context-manager 0.24.0 and the second with 0.24.1, which was published between them:npm run build) gives no TypeScript errors in either run.test/turn-inputs-pin.test.ts: 16 of 16 pass in each run.pinTurnInputsalso calling the parent'sunpinTurnInputs()when the agent pinning is a fork (first run): the new test fails, with "the fork's opening and its end left mira's pin", and the other 15 pass.0f11975e48'ssrc/framework.tsandsrc/agent.ts(second run): 15 of the 16 fail, and the test of a strategy without pins passes. In the long-turn test, six copies at once, three times, none of the 18 passes. The run printed the failure of the first copy in each round, and each of those three fails at "every stream's request holds the opening request whole (of 35)".Not verified
No test or run covers these cases:
afterUserinjections, and a co-resident's row stored during the start's hooks;stop()on the same instance.The scan stops at
src/framework.ts:1533–1535. The range then runs from the oldest row it passed that holds more than tool results (:1539) to the newest row the agent sees when the turn opens (:1536). A range is made only when the watermark is on the current branch (:12399), and only a start that opens a turn pins (:23915–23919).No test drives
compileAtLiveBudget's boot-restore path with a pin held.No test covers a range whose rows had no summaries when they were pinned, then compiled without the pin after a fallback.
The prompt cache. How the pin changes kv-unified's fold plan and how much of the cache a request reuses wasn't measured.
Platforms and providers. Every run of this change's tests was on Linux. CI's macOS jobs install and build the package, check that its packed release installs on its own, and run code_execution's tests under Bun. None of them runs this change's tests. The tests use stand-in providers, and no run used a live provider.
fix(framework)!: decline a maxStreamTokens restart that won't fit #842. Merged with fix(framework)!: decline a maxStreamTokens restart that won't fit #842 at
cd581ebe11, this head conflicts inREADME.md, andmainat612f6fd3bdmerges with fix(framework)!: decline a maxStreamTokens restart that won't fit #842 without conflicts. No test runs the two together. Atcd581ebe11, fix(framework)!: decline a maxStreamTokens restart that won't fit #842's restart decision callsagent.previewCompile()(src/framework.ts:12329), which calls context-manager'spreviewContext(src/agent.ts:2073). This change's fallback wraps the two calls tocompile(src/agent.ts:1398,:1654–1655).What a fork's own context manager lists. No test checks it. In a run of the fork test on
caa0b8b3cc's files, with lines added to print it, the fork's context manager listed one pin,pin-0, namedagent-framework:turn-inputs, right afterderiveAgent, and none after the fork's run. The parent listedpin-0at both points.changelog.d/turn-inputs-pin.changed.md.Paloma-2644 made the change, and Ada-1017 made its design calls. Ezra-2423 read an earlier version of it, the test changes from that version to
96e4f73027,5f26d006b9, the testcaa0b8b3ccadds, both merges ofmain, anda7475a986c. Internal reviews read earlier rounds of it. Daniel-2612 wrote this description.