Skip to content

feat(framework): keep the rows that open a turn raw while it runs - #867

Merged
nissa-seru merged 6 commits into
anima-research:mainfrom
nissa-seru:paloma/f569-turn-opening-pin
Oct 29, 2026
Merged

nissa-seru merged 6 commits into
anima-research:mainfrom
nissa-seru:paloma/f569-turn-opening-pin

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

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 pinRange and the name agent-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 sets suppressProse, 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.ts and src/agent.ts from 612f6fd3bd, the main commit 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 from f14e7fbc99, the main commit it started from before it first merged main, 25 of the 35 didn't. context-manager's README says pinRange keeps 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:

  • an agent uses the kv-unified strategy, with a 20,000-token context budget, a 6,000-token raw tail and maxStreamTokens at 8,000;
  • it runs a turn of 40 tool rounds, and the module waits 1 ms at each tool result.

With f14e7fbc99's src/framework.ts and src/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. With 612f6fd3bd'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), pinTurnInputs reads the agent's consumed watermark. When the watermark's branch is the current branch (:12399), turnInputs scans back from the newest stored row. Its scan:

    • skips rows the agent doesn't see;
    • ends at the first row whose sequence is at or below the watermark's, or that the agent sees under its own name (:1533–1535);
    • ends the range it returns at the newest row the agent sees, and starts it at the oldest row it passed that holds more than tool results.

    That range is pinned with pinRange under the name agent-framework:turn-inputs.

  • Removing it. The pin is removed when the turn settles, when its start or continuation is dropped (endDroppedTurn), and at stop(). 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). compileUnpinningOnRefusal wraps the context-manager compile that compileWithInjections and compileContext run. When the compile throws an OverBudgetError and listPins() 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.pinTurnInputs and Agent.unpinTurnInputs are new and marked @internal. Agent.pinTurnInputs returns undefined before pinning when the strategy has no pinRange function (:1216).

  • README.md gains 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

  • The cost. The pinned rows' tokens count against the budget in each compile of the turn. In the fallback tests, a compile with the pin held refuses as over budget, and the turn answers once the pin is removed.
  • The fallback logs a line beginning [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.
  • Listing pins. While the turn holds the pin, listPins() on the agent's context manager lists one pin named agent-framework:turn-inputs. Tests check it while a turn runs, and that none is left once the turn ends.
  • Under a strategy without pins, PassthroughStrategy in its test, a turn runs and logs no [turn-inputs] line.
  • Residents sharing a store. In a test with two residents in one store, each resident's opening gives it one pin of the name, and one resident's opening and turn end leave the other's pin in place. The test calls the framework's two internal steps directly, in a fixed order, rather than running whole turns.
  • A fork of the agent. In a test where the agent's turn is blocked with its pin held, a fork made from it with deriveAgent runs 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

86fcad2c9d merges main at 612f6fd3bd with no conflicts, and its diff against 612f6fd3bd adds and removes the same lines as caa0b8b3cc's against 0f11975e48. a7475a986c then 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:

  • The build (npm run build) gives no TypeScript errors.
  • test/turn-inputs-pin.test.ts: 16 of 16 pass.
  • The long-turn test, six copies at once, three times: 18 of 18 pass.
  • With pinTurnInputs also calling the parent's unpinTurnInputs() 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.
  • With 612f6fd3bd's src/framework.ts and src/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 main at 612f6fd3bd, whose tree is this head's. Its Build & Test job, on ubuntu-latest with Node 22.12.0, runs npm test after 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-exit passes 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:

  • The suite (npm test after tsc --noEmit and a build): 8,783 tests, 8,721 pass, 0 fail, 62 skipped.
  • test/turn-inputs-pin.test.ts: 15 of 15 pass.
  • With f14e7fbc99's src/framework.ts and src/agent.ts: 14 of the 15 fail. The test of a strategy without pins passes.
  • With the fallback removed, so that a refusal is thrown on: 4 fail. They are the two fallback tests, the test of a second refusal, and the test of two refusals at once.
  • The long-turn test, six copies at once, three times: 18 of 18 pass. With 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 of 5f26d006b9, 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. With f14e7fbc99's two files, 14 of the 15 fail, and with the fallback removed, the same four as above fail.

f42272669f merges main at 0f11975e48 with no conflicts, and its diff against 0f11975e48 adds and removes the same lines as 5f26d006b9's against f14e7fbc99. caa0b8b3cc changes only test/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, on caa0b8b3cc'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:

  • The build (npm run build) gives no TypeScript errors in either run.
  • test/turn-inputs-pin.test.ts: 16 of 16 pass in each run.
  • With pinTurnInputs also calling the parent's unpinTurnInputs() 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.
  • The long-turn test, six copies at once, three times (second run): 18 of 18 pass.
  • With 0f11975e48's src/framework.ts and src/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:

    • a turn whose request was stored before the host restarted;
    • the first turn after a branch switch;
    • rows stored before the agent's newest reply that no request carried, such as a wake for a row older than that reply;
    • rows a request carried whose turn failed before replying;
    • the start's own notices, its afterUser injections, and a co-resident's row stored during the start's hooks;
    • a resume after 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 in README.md, and main at 612f6fd3bd merges with fix(framework)!: decline a maxStreamTokens restart that won't fit #842 without conflicts. No test runs the two together. At cd581ebe11, fix(framework)!: decline a maxStreamTokens restart that won't fit #842's restart decision calls agent.previewCompile() (src/framework.ts:12329), which calls context-manager's previewContext (src/agent.ts:2073). This change's fallback wraps the two calls to compile (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, named agent-framework:turn-inputs, right after deriveAgent, and none after the fork's run. The parent listed pin-0 at both points.


  • Changelog fragment added: 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 test caa0b8b3cc adds, both merges of main, and a7475a986c. Internal reviews read earlier rounds of it. Daniel-2612 wrote this description.

Paloma-2644 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.
Paloma-2644 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.
@nissa-seru
nissa-seru merged commit 8145085 into anima-research:main Oct 29, 2026
12 checks passed
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