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


  • 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
tel-0s added a commit that referenced this pull request Oct 30, 2026
… pin reads the window (#867), save_recent_image's nullable selectors (#245)

#245 merges as it is. #867 merges with no textual conflict but doesn't
build here: its pinTurnInputs reads the consumed watermark as
`{ branch, sequence }` and walks the record back from its newest row to
that store sequence. On this branch the watermark is a ReadMark
(src/read-marks.ts): what an agent has read is a place in its window,
compared only through coverTest/covers. The adaptation, in this merge:

- turnInputs (src/framework.ts) walks the agent's window
  (cm.getPresentedMessages(): the rows presented to it, in their order;
  in legacy mode the merged record) back from its newest row, with
  coverTest(consumed mark, cm) in place of the sequence floor. It stops at
  the first row the mark covers (presented mode: an ordinal below the
  index length the mark took; a mark taken in legacy mode: a sequence at
  or below its head, #867's floor) or at the newest row the agent sees
  under its own name, passes over rows it doesn't see (agentSees), and
  returns the range from the oldest row it passed that holds more than
  tool results to the newest. A mark that can't say pins nothing: none,
  one on another branch (#867's branch check, now coverTest's), or one
  taken in presented mode read against a manager no longer presenting.
  TURN_INPUTS_SCAN goes: the listing is the one the start's compile reads.
- So the rows that open a turn are the rows presented to the agent after
  its mark that it sees, in window order: a row stored before the mark
  and presented after it is one; a row stored and not presented is not
  (under #867's record walk the newest such row became the range's
  `last`, naming a row the strategy's view doesn't hold).
- The mapping onto pinRange: context-manager stores the two ids, and
  AutobiographicalStrategy.pinPositions resolves them by position in the
  strategy's message view, which is presentedMessageView (the window
  index's listing, in ordinal order) under the view filter. A pin is a
  range of the window's listing, not of the record, so first..last taken
  in window order is what the strategy pins, where the window's order
  differs from the record's too, and a stored row not presented is
  outside it. No design choice was left open.
- Agent.pinTurnInputs's doc says so; README and the changelog fragment
  now say the rows are the window's, presented since the mark (legacy:
  stored since). The rest of #867 is as merged: the pin held while the
  turn runs, released at its end, at a dropped start and at stop(),
  dropped once with a `[turn-inputs]` line on an OverBudgetError retry.

test/turn-inputs-pin.test.ts: three tests compress rows written past the
framework (cm.addMessage) before the turn; here those rows wait outside
the window until a presentation point, so nothing compressed them and the
compiles refused without the pin too. They now present them first
(presentAtEndOf, as compression-after-release does). The residents' test
calls pinTurnInputs with no start between its write and the call, so it
presents the row to both windows first. New: in presented mode, a pin
runs in window order over the rows presented after the mark (the request,
then a row stored before the mark and presented after it, out of record
order) and over neither row stored and not presented. With the walk over
getAllMessages instead of the window, that test fails.

Changelog: main's range adds two fragments and consumes none; version
stays 0.30.0; package.json keeps this branch's floors; 291-* fragments
kept.

Build clean. turn-inputs-pin: 17 of 17. Main's changed tests with the
presentation set (presentation*, window-*, dendrite-*, stored-injections,
audience-stamp*, dependency-ranges, held-*, tune-out-*, read-marks*,
prose-drafts, save-recent-image, turn-inputs-pin), --test-force-exit:
959 tests, 959 pass. Full suite (npm test): 9,542 tests, 9,493 pass,
0 fail, 49 skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tel-0s added a commit that referenced this pull request Oct 30, 2026
…overs the window as of its head

#867's turn-opening pin walks the agent's window back from its newest row
and stops at the first row the consumed mark covers. A mark taken in legacy
mode compared by sequence whatever the reader's mode, so once the branch's
window was written down, an older row taken out and presented again at the
window's end read as covered and stopped the walk early: with P, L1-L3 read
and N1, N2 arriving, L1 presented again as the newest row pinned nothing,
and between N1 and N2 pinned N2 alone (nissa's probe on #844, at 255f00d).
It arises in practice: an agent registered where its window wasn't yet
written down (every new agent, and every agent on the first boot over a
store an earlier release wrote) takes its consumed mark before the
presentation module's write-down at adopt, and holds it until its first
compile.

coverTest now reads a mark taken in legacy mode, against a manager in
presented mode, as a mark taken in presented mode: it covers the rows the
window has held, where they stand, since the mark's head or before. That is
the rows a branch cut at the head would keep in its window
(ContextManager.branchSequenceOf at or below the head): a row's own
sequence for a row the write-down presented, the presenting write for any
other. Since that only grows along the window, the covered rows are the
window's first ones, and the length is the ordinal of the first row past
them, found by a binary search over getPresentedMessages once per
coverTest, at the first row stored at or below the head (a row stored after
it isn't covered without a read). For a mark taken before the write-down,
which lists the record in record order, this is the presented-mode mark
whose length is the number of record rows at or below the head: the
conversion is computable from what context-manager exposes, without the
write-down's header ordinal, which it doesn't. Against a manager in legacy
mode the mark covers by sequence as before; a double without
getPresentedMessages and branchSequenceOf is read by sequence; a read that
throws is doubt for rows at or below the head. The across rule (a legacy
mark on another branch, through the lower of its head and the branch point)
reads the same way against a presented reader. Nothing is converted in
place, so every reader of covers gets the one answer: the turn's inputs,
isUnreadStoredMessage, releasedWindow, answeredCovers and
publishedAfterReading.

marksOverTail keeps a legacy mark that covers an undone turn's rows, as it
kept presented ones: those rows now leave the mark when a redo puts them
back at new ordinals, so retakeMarks has to give it again.

Tests: three cases on a real switch in turn-inputs-pin (a store an earlier
release wrote, prime's mark taken in legacy mode at registration, the
window written down at adopt, N1 and N2 presented at the framework's
point at rest, L1 taken out and presented again as the newest row, between
N1 and N2, or not at all): the pin under the legacy mark is the presented-
mode mark's (N1 N2 L1 / N1 L1 N2 / N1 N2), no row covered by sequence and
in window order is pinned, and the two marks read every row alike. Red at
255f00d for the first two (nothing; N2 alone). read-marks gains the rule
over a real manager, the across case against a presented window, the read
count of the search, and the fallbacks. Seven tests read hand-built
legacy marks, or rows built from a sequence (rowAt) that no window holds,
against a presented manager, leaning on the compare by sequence: the
publish-credit tests of heard-mid-turn-wake and prose-drafts now read their
marks against bySequence(cm), a reader whose window is the record
(test/helpers/read-mark-rows.ts), and mcpl-coalescing's two read-render
wakes present each render as it lands and take their marks at the write
that presented it. Docs: ReadMark and coverTest in
read-marks.ts, the consumed and shown watermarks in agent.ts, turnInputs
and marksOverTail in framework.ts, the README's pin paragraph, and the
turn-inputs-pin and 291-reads-row changelog fragments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tel-0s added a commit that referenced this pull request Oct 30, 2026
… for #844

nissa-seru's house reviewed #844 at dda847b, made 115 commits of fixes,
docs and tests on it, merged 27f893b into them and added one commit after
the merge (117 in all: 47 fix, 36 docs, 30 test, 2 revert, 1 refactor).
Its comment on #844 lists the fix subjects by area; each commit's message
describes its change. An independent read of every fix commit and both
reverts (scratchpad review3/pr-915-read.md) takes them all; its notes
land as the next commits: the cancel tools' `waiting` answer in a
fragment, a persist() in the coalescer's deliver-failed catch, the
duplicate limit the coalescing revert leaves, two ModuleContext doc
lines, channel_close's success answer, and the mid-turn [drafts] notice
as the agent's own row.

No textual conflict with 255f00d..a96506b (git merge-tree): #867's
turn-inputs walk and the legacy-mark rule in read-marks.ts touch hunks
#915 doesn't. The boot freeze `framework.bootFreeze ||` in frozen() is
kept beside B5's line: at boot no agent holds a turn token and create()
starts modules before it reads the host mode, so the term is needed
where the surgery term was not.

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 to what residents see (in brief), where the agent's context-manager strategy has pins:

  • While a turn runs, the messages it opened with are pinned raw (pinRange, named agent-framework:turn-inputs) for every compile until the turn ends. Before, compression could fold them once they left the recent window, so a long turn whose older rounds were summarised could lose the request it was answering. Their tokens count against the budget.
  • When a committing compile refuses as over budget while that pin is held, the pin is removed and the compile runs once more, so those messages fold as they would have without it.

— 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