Skip to content

fix(routing): each turn keeps its own routing state, so a tool-ended turn's queued prose follows that turn - #312

Merged
nissa-seru merged 22 commits into
anima-research:mainfrom
nissa-seru:fix/fresh-turn-awaits-queued-prose
Oct 13, 2026
Merged

nissa-seru merged 22 commits into
anima-research:mainfrom
nissa-seru:fix/fresh-turn-awaits-queued-prose

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

TL;DR: in explicit mode, a turn that a tool ends, such as with skip_reply, no longer leaves the prose it queued to run under the next turn's routing state. Fixes #305.

  • Routing: each turn has its own routing state, and a queued delivery keeps the routing state of the turn that queued it.
  • The logical turn: a turn's ! request and receipt tallies stay with it across a framework retry or a re-inference after unstreamed tool results.
  • Order: the agent's prose deliveries go out in the order they were written, across turns.
  • Continuations and receipts: a tool-ended turn's ! continuation now starts, and a receipt waits for the deliveries it reports.

The problem

At each round's tool-calls event the framework queues the round's prose and doesn't wait for it, and explicit deliveries read the sticky destination when they post. A turn that a tool ends settles at once, and in explicit mode, which doesn't hold the turn for its queued prose, the next turn can start right away. That start cleared the per-agent state the queued deliveries read and wrote. So:

  • a queued unprefixed segment that followed the turn's >> destination found none, and bounced into the next turn;
  • a late delivery's ! continuation started after the next turn, not its own.

In hybrid and locus mode the ended turn already kept the next one waiting: its teardown awaits the queued deliveries to write its receipt before it releases the turn. So the hybrid misroute the issue describes doesn't occur end to end. Two related gaps remained, and this PR closes them too:

  • in hybrid mode, a ! asked for before a tool ended the turn started only after the next turn;
  • in hybrid and locus mode, a turn whose last response was empty wrote its receipt before its queued deliveries had posted, so they were never receipted, or were counted in the next turn's receipt.

The change

  • Per-turn state. TurnRouting holds one turn's routing state and receipt tallies: the sticky destination, the destination a resumed stream starts at, hybrid's hold, and its logical turn's ! continuation and the destinations and suppressed segments its receipt reports. A fresh turn, or a silent wake, starts a new object instead of clearing the old one; a stream that resumes the turn (a budget restart, a tool-result-guard retry) keeps it. Each stream takes its turn's object when it starts, and every delivery, suppression, receipt and continuation check uses that object. Explicit and hybrid delivery both require it.
  • Order across turns. Every stream queues its deliveries behind the agent's latest link, a fresh turn's included, so the agent's prose deliveries go out in the order they were written. A turn's text-only reply can therefore wait for an earlier turn's posts, up to their request timeouts, one after another.
  • A tool-ended turn's ! (explicit and hybrid mode, which parse it). A continuation an earlier round asked for now starts when a tool ends the turn, once that turn's deliveries have run: right after the receipt in hybrid mode, and at the end of the turn's chain in explicit mode, where a turn that another event started meanwhile runs first. A check that runs while the framework is stopping or stopped, or after the agent has been disposed, starts nothing, and logs that it dropped the continuation. On main, hybrid started it only after the next turn, and explicit dropped it, or, when its delivery ran late, started it after the next turn.
  • A completion waits for its deliveries, in every mode. A normal completion awaits the turn's queued deliveries before its receipt (hybrid and locus mode write one) and its continuation check, whatever its final text. Before, only a completion with final text waited. So a stream whose last response was empty, such as a budget restart's, wrote its receipt before the earlier stream's deliveries had posted, and those were never receipted, and a ! they carried was missed.
  • A logical turn stays open until it settles. An agent's logical turn opens at a fresh start and stays open until the turn settles: it completes, a tool ends it, or it fails or is abandoned (settleAgent). Every fresh start that routes prose while it's open continues it: a framework retry, the start that consumes unstreamed tool results (whichever wake the scheduler starts it with), the later compile a provider-capacity hold defers a turn to. A silent start neither continues nor opens one.
    • Each such start begins with a fresh sticky destination and hold (see "Not covered"), but shares the logical turn's ! request and receipt tallies. So a ! asked for earlier in the turn still stands, and it's taken once. The receipt at the turn's end reports every stream's deliveries, including late ones.
    • The invariant: a stream that ends without settling leaves its turn open, so an exit that keeps the logical turn open needs no explicit carry step. A new logical end must still settle it. Settling only detaches the turn from future starts: the settling stream keeps its own object, which its remaining deliveries, its receipt and its ! check go on using. So every logical end closes inheritance once, before the next fresh start, and only the stream that holds the open turn closes it. A tool-ended turn settles before the next turn can start. stop() clears every open turn.
    • Main kept the ! across such a start in hybrid mode and cleared it in explicit mode, and it counted only the deliveries that landed after the start. Here both modes keep the !, and the receipt reports all of the turn's deliveries.
  • Disposal. A conversation fork's or ephemeral run's routing object and chain are dropped when it is disposed.

Not covered

  • A tool_results_ready re-inference, or a retry after a stream error, starts the turn's sticky destination and hold afresh, because beginAgentTurn treats it as a fresh start, as main does. So after one, hybrid's unprefixed prose goes to the turn's locus rather than an earlier >>> pin, on main and here alike. Those starts share the turn's logical turn here (its ! and receipt tallies); keeping the sticky destination and hold across them is a change of its own.
  • A stream replaced by a later one doesn't keep its late deliveries as the agent's latest link, so the replacement's posts can go out alongside them, as on main. Their routing is right, but the turn's receipt can be written before they land, and then doesn't report them.
  • A stream that ends in an error with no retry, or that an operator quiesce abandons or an abort ends, asks for no continuation and writes no receipt. On main, hybrid mode's ! carried into the agent's next turn, which then started the continuation, and deliveries that landed after that turn began were reported in its receipt as its own.
  • A publish request with no timeout (requestTimeoutMs: 0) that never settles holds the agent's later prose deliveries, and the completion of every later turn that completes normally, which waits for them. On main it already holds its own turn.
  • A framework stopped and started again on the same instance, with a store it doesn't own (so the stop doesn't close it), while an explicit tool-ended turn's delivery is still queued, can take that turn's ! after the restart: the check can't tell the two lifecycles apart. In hybrid mode the turn waits for its deliveries inside its stream, and stop() waits for that stream, so a completed stop doesn't leave one behind.

Runs

From clean checkouts of 39cb2054, this branch with main at 197a8491 (#263) merged in, on Node 24.21.0, every file in its own unforced node --test:

  • Focused files: typecheck clean; explicit-prose-routing 50/50, tool-result-guard 54/54, prose-stream-router 23/23, same-round-think-policy 19/19, present-while-acting 21/21, conversation-routing 15/15, ephemeral-settle 8/8, and fix(lifecycle): late tool results and MCPL arrivals at stop() settle instead of rejecting unhandled #263's stop-with-work-in-flight 25/25.

  • These twelve removals each fail a test:

    • reading the live turn's routing instead of the queuing turn's;
    • a fresh turn not waiting behind the earlier turn's posts;
    • resetting a shared routing object at a fresh turn instead of starting a new one;
    • dropping a tool-ended turn's !;
    • writing the receipt before the queued deliveries;
    • the late ! check starting a turn while the framework is stopping or stopped, or after disposal;
    • a tool-ended hybrid turn not waiting for its deliveries before its receipt and check;
    • a start while a logical turn is open not continuing it;
    • settling not closing the logical turn;
    • a silent start joining the open logical turn;
    • a settle closing the open turn whatever logical turn it passes;
    • stop() leaving open logical turns.

    A failure before a stream exists settles the logical turn its own start was given, not the agent's live one. That was checked by reading; no test drives a held start whose routing is replaced before it fails.

  • The suite: 1671/1671 across 132 files, with no file failing, in our run and in a reviewer's independent one. main at 197a8491 is 1648/1648 in the reviewer's run.

Not verified by us

Written by Petra-1012 (Opus), contributing through nissa-seru, from Ada-1017's issue. Mabel-2366 built the second round (the receipt tallies, capturing the turn's object at stream entry, disposal, and tests that drive the framework) and found that hybrid mode already serializes its turns, which narrowed this PR's claim. Ada-1017 reviewed the design, which replaced an earlier approach that parked the next turn's start. Arthur-2378 (GPT) reviewed the final rounds. They found the late continuation's lifecycle gap and an inaccurate account of explicit-mode receipts, designed the shared continuation holder, and wrote the probes the error-retry and producer tests are adapted from. Automated reviews by Claude Haiku agents read each round through 6015f2c1; the later small fixes and the merge with main were read by Ada-1017 and Arthur-2378.

Petra-1012 and others added 6 commits October 13, 2026 00:02
…ueues behind the agent's earlier posts

agent-framework#305. A turn that a tool ends (skip_reply) settles at
once, before the prose its earlier rounds queued has posted. The next
turn's beginAgentTurn then cleared the per-agent maps those posts read
when they run: the sticky destination, hybrid's hold and the ` !`
continuation. In explicit mode a queued unprefixed segment found no
destination and bounced into the next turn; in hybrid mode it fell back
to the locus.

- TurnRouting holds one turn's routing state: the sticky destination
  (pin), the turn's destination a resumed stream starts at (destination,
  from roundDestination), hybrid's hold and the continuation flag. It
  replaces proseTargetPins, proseTurnDestinations, proseHybridSuppressed
  and proseContinuations. A fresh turn, or a silent wake, starts a new
  object in turnRouting instead of clearing the old one, in every mode. A
  stream that resumes the turn keeps the object.
- A delivery keeps the object of the turn that queued it: the live round
  boundary takes routingOf() when it queues a round, explicit and hybrid
  alike, and the 'complete' case takes it before it waits. So late prose
  follows its own turn's destination, and a continuation it asks for late
  lands on its own turn's object instead of the next turn's.
- Readers outside delivery read the live turn's object: the resumed
  stream's seed and the retargets, the turn-end continuation check, and
  the sleep announcement.
- Every stream now queues its deliveries behind the agent's latest link
  (proseSpeechChains), a fresh turn's included, not only a resumed one.
  So the next turn's posts wait behind the earlier turn's late ones, and
  the agent's posts keep their order. The next turn itself starts at
  once.

Tests:
- explicit-prose-routing, in explicit and hybrid mode: round one names
  #alpha and its post is held, round two's unprefixed text queues behind
  it, and skip_reply ends the turn. The next turn starts at once and
  writes to #alpha. While the post is held nothing else goes out. Then
  the three posts go to #alpha in the order they were written, and
  nothing bounces.
- tool-result-guard: a fresh turn in explicit, hybrid and locus mode gets
  a new routing object, and the earlier turn's object is left as it was.
- The continuation and roundDestination tests read the routing object.
…eliveries

Store successful prose destinations and suppression counts in TurnRouting rather than independent name-keyed maps. A physical stream retains its logical turn object at driveStream entry, before awaits, and passes it through queued publication, suppression, final dispatch and both receipt paths. Receipt generation reads and clears that object. Budget restarts keep the same logical turn; fresh turns create new collections. The cross-turn unsent clipboard stays per agent.

The completing stream reads its own continuation flag. Hybrid delivery requires its turn object explicitly. Remove routing and speech-chain entries when a conversation fork is disposed and when an ephemeral generation is deregistered, with ephemeral cleanup inside the existing exact-Agent guard. The stream-start identity checks prevent entry after deregistration, and queued deliveries retain their objects rather than rebuilding map entries. Restore the explicit gateway documentation to its method and describe private rounds as requesting neither publication nor continuation.

Repair the queued-prose tests to drive the event loop and wait on bounded turn traces. Their successive destinations differ, so misrouting is visible directly. Explicit tool-ended turns permit a fresh turn while their posts remain queued. Hybrid retains its existing turn-alive guard until the ended stream drains its publication and receipt tail; its integration test asserts that the queued wake is blocked first. This change does not loosen the guard protecting predecessor flushes from a successor's compiled history.

Exercise late explicit continuation through real queued turns. Exercise held hybrid callbacks and receipts directly with a newer object installed, since ordinary hybrid fresh-turn scheduling serializes these tails. Verify that a late malformed envelope changes only the supplied object, and that a held earlier delivery and newer suppression produce separate receipts. The fresh-state test checks every earlier routing field and tally remains intact. The restart test now uses a releasable prior delivery and verifies prior, fresh-stream and resumed-stream posts keep their order, replacing an indefinitely pending-chain premise. Existing disposal tests also check the two routing maps lose the retired name.
…eceipt waits for queued deliveries

From a review of the round before.

- A ' !' continuation that an earlier round asked for was dropped when a
  tool (skip_reply) ended the turn: only the normal completion read the
  flag, and with per-turn routing state the next turn no longer inherits
  it. On main, hybrid and locus mode carried the flag into the next
  turn's end, and explicit mode dropped it unless its delivery ran late.
  Now the turn that asked starts it, once its deliveries have run:
  outside explicit mode right after its receipt, which waits for them;
  in explicit mode, which doesn't hold the turn for them, at the end of
  its chain. requestProseContinuation() reads and clears the asking
  turn's own object, for the normal completion too.
- The normal completion now awaits the turn's chain once before the
  receipt and the continuation check, whatever its final text. Before,
  only the text-only and trailing paths awaited it, so a stream whose
  last response was empty (a budget restart's, say) wrote its receipt
  before the earlier stream's queued deliveries had posted, and those
  were never receipted.
- Explicit envelope delivery requires its turn's routing object, as
  hybrid's does. deliverProse, the direct entry, passes the live turn's.
- The comment on the text-only wait says that every stream's chain
  starts from the agent's latest link.
- The changelog fragment is rewritten: the bounce and the misattributed
  receipt counts are explicit mode's (hybrid and locus already hold the
  next turn until the ended turn's receipt is written), the per-turn
  state includes the receipt tallies, the ordering is of the agent's
  prose deliveries, and the two changes above are listed.

Tests:
- explicit-prose-routing: a queued round's late ' !' starts its
  continuation once, from its own turn's object, after its tool-ended
  turn's deliveries have run; in explicit and hybrid mode, a ' !' an
  earlier round asked for starts one more turn when skip_reply ends the
  turn.
- tool-result-guard: in hybrid mode, a turn restarted for its budget
  whose restart response is empty writes one receipt, after the earlier
  stream's held delivery, and it names that delivery.
…disposal

From a review of the round before.

- An explicit turn's ' !' check runs at the end of its chain, which can
  be after its stream has returned. If the framework has stopped by
  then, or the agent is no longer registered under its name (a disposed
  fork or ephemeral run), requestProseContinuation() now takes the flag
  and starts nothing. Before, it left a stale request behind. The
  normal completion's check goes through the same guard.
- The changelog fragment no longer says explicit mode's late deliveries
  were counted in the next turn's receipt: explicit envelopes record no
  receipt deliveries at all. What remains is the bounce, the late ' !',
  and, in hybrid and locus mode, the empty last response, whose queued
  deliveries were never receipted or were counted in the next turn's.

Tests:
- The two "' !' ... when skip_reply ends the turn" tests count turns
  with a listener registered before feedTurn's. feedTurn unsubscribes
  during the second turn's start event, and the emitter iterates its
  live listener array, so a listener registered after it missed that
  event; the counter read one turn where two had started.
- The admission test that calls deliverAdmittedProse directly passes
  its turn's routing, which that entry now requires.
- New: a tool-ended explicit turn whose ' !' delivery is still queued
  adds no continuation request when it finally runs after stop() or
  after the agent's disposal.
A ' !' continuation is parsed only in explicit and hybrid mode, so the
tool-ended continuation fix is theirs; receipts are written only in
hybrid and locus mode, so the empty-last-response receipt fix is theirs.
The fragment said 'in every mode' and that locus started a ' !' late,
which suggested locus parses the modifier.
…eady started

The continuation starts once the ended turn's deliveries have run. In
explicit mode the turn isn't held for them, so another event can start
a turn first, and the continuation follows that turn.
@greptile-apps

greptile-apps Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge, with no new actionable issues found.

Summary

Moves prose routing and receipt counts into TurnRouting, so queued posts retain the state of the turn that wrote them.

  • Queued prose keeps its turn’s destination and goes out in writing order.
  • Hybrid and locus receipts wait for every queued post.
  • A tool-ended turn can still take its ! continuation.

No new actionable issues found. Tests were inspected, not executed.

Reviews (1) · Last reviewed commit: "docs(changelog): in explicit mode a late..." · Reviewed by Greptile

Petra-1012 added 16 commits October 13, 2026 02:50
…ind a held post

From a review of the final round.

- requestProseContinuation() logs a continuation the lifecycle guard
  drops, so a dropped one can be told apart from one never asked for.
  Its doc says it runs when the turn completes or a tool ends it; a turn
  that ends in an error or is abandoned by a quiesce asks for none, as
  before.
- deliverProseEnvelope reads round.keepPrivate directly: its round is
  required.
- The changelog fragment says the completion now waits for its queued
  deliveries in every mode, before its receipt (hybrid and locus) and
  its continuation check; that the wait, like a reply's, is unbounded
  when requestTimeoutMs is 0 and a post is never answered; that a
  dropped continuation is logged; and that the ordering across turns
  leaves out a stream a later one replaced.

Tests:
- explicit-prose-routing, in explicit and hybrid mode: a tool-ended
  turn whose ' !' envelope is queued behind a held post starts its
  continuation only once that post has settled. Hybrid's teardown
  awaits its deliveries; explicit's check waits at the end of its chain.
- tool-result-guard, explicit: a turn restarted for its budget whose
  restart response is empty checks for its ' !' only after the earlier
  stream's queued envelope has run, and starts the continuation.
- The stop and disposal tests first show the held delivery was reached
  and its ' !' set on the turn's object, then that the late check took
  the flag and added no request.
…ference after unstreamed tool results

beginAgentTurn treats both as fresh starts, as main does, so they get a
new routing object: the sticky destination, the hold and the receipt
tallies start empty, as main cleared them there. Main kept a hybrid or
locus ' !' across those starts, though, since its continuation flag was
per agent, and a new object dropped it. The fresh object now carries
the previous one's continuation when the start is a retry (attempt > 0)
or a tool_results_ready re-inference. A late delivery of the earlier
stream that sets the flag after the new start still sets it on the
earlier stream's object.

Test (explicit-prose-routing, hybrid): a turn whose routing asked for a
continuation goes on in a tool_results_ready re-inference, and the
continuation starts after it completes. The retry path shares the
condition; the mock membrane can't fail a stream mid-turn to drive it.
…it's checked, not when the new stream starts

The previous commit copied the earlier stream's continuation flag into
the fresh object at a retry or a tool_results_ready re-inference. A
' !' envelope still queued behind a held post at that moment sets the
flag later, on the earlier object, so the copy missed it, where main's
per-agent flag would have been read at the check.

The fresh object now links the earlier one (carriedFrom), and
requestProseContinuation() takes the flag from the stream's own object
and every object it carries from. The check runs after the stream's
chain has settled, and every stream's chain starts from the agent's
latest link, so the earlier stream's queued deliveries have run by then.

Test (explicit-prose-routing, hybrid), twice: the earlier stream's flag
already set as the re-inference starts, and set only when its queued
delivery runs after the re-inference has begun. Either way the
continuation starts after the re-inference completes, and the check
took the earlier flag.
…inuation holder, not a link to the earlier routing

A reviewer's suggestion on the previous commit. Every routing object's
continuation is now a holder, { requested }. A fresh turn gets a new
holder with its new routing; a framework retry or a tool_results_ready
re-inference, which beginAgentTurn starts with fresh routing, shares
the earlier stream's holder. A ' !' that the earlier stream's queued
delivery sets after the new stream began lands in the shared holder,
and requestProseContinuation() takes it once. Before, the new object
linked the whole earlier routing object (carriedFrom) and the check
walked the links to recover that one bit, keeping old routing objects
alive for nothing else.

Tests:
- explicit-prose-routing, hybrid: an error retry keeps a ' !' whose
  envelope is delivered after the retry starts. The first stream's
  terminal event is replaced by a retryable error, and the retry's own
  end waits for the first stream's queued deliveries, so this drives the
  real attempt > 0 path. Adapted from a reviewer's probe.
- tool-result-guard: a fresh turn's continuation holder isn't the
  earlier turn's.
- The tests' routing shapes take the holder.
… ' !' and the receipt's tallies

From the review of 36b7a94 (sw-af305e):

- A framework retry or a tool_results_ready re-inference gets fresh
  routing, but the receipt tallies started empty there too, so an earlier
  stream's delivery that landed after the new stream began recorded into
  an object no receipt read. On main the per-agent tallies were reset at
  that start and counted such a late delivery in the next receipt.
  TurnRouting now keeps what such a start shares in one LogicalTurn: the
  ' !' request and the receipt's deliveries and suppressed count. A
  fresh turn gets a new one; a retry or re-inference shares it, so the
  receipt at the turn's end reports every stream's deliveries.
- A tool_results_ready re-inference took the agent's latest routing as
  the turn it continues, but another turn can run between its scheduling
  and its start. The non-streaming fallback now records the scheduling
  stream's logical turn (toolResultsLogical), and the re-inference takes
  that one. A retry follows its stream at once, so it still takes the
  latest. Disposal drops the record with the agent's other state.
- requestProseContinuation's doc says a retry carries an errored
  stream's request; the changelog bounds the wait for an earlier turn's
  posts by their timeouts one after another, and gives the retry and
  re-inference behaviour its own bullet.

Tests (explicit-prose-routing):
- the error-retry test runs in explicit mode as well as hybrid;
- a re-inference receipts the earlier stream's delivery that lands after
  it began, with its own;
- a re-inference continues the logical turn that scheduled it though
  another turn's routing is the latest.
The tests' routing shapes take the logical turn.
…heir logical turn, whichever wake starts it

From the review of 03d6bc3 (sw-af305f). The scheduler batches a
tool_results_ready request with the agent's other pending wakes, and the
batch's trigger may be another wake, so the re-inference's own start
needn't carry that reason. The record the non-streaming fallback writes
(toolResultsLogical) was taken only by a start whose trigger was
tool_results_ready, so a batched one left it stranded: the scheduling
turn's ' !' and receipt tallies were lost, and the record lingered.

The agent's next fresh start now takes the record, whatever wake starts
it: that turn consumes the tool results. A retry follows its stream at
once and still shares the latest routing's logical turn; nothing else
falls back to it. A second scheduling can't overwrite the record before
a start takes it, since scheduling happens inside a running stream.

Tests (explicit-prose-routing):
- in explicit and hybrid mode, an ordinary wake that starts the agent
  with tool results pending continues the scheduling turn: its ' !'
  starts a continuation, and the record is taken once;
- the re-inference's receipt test seeds the record as the fallback
  writes it, and releases the earlier stream's late delivery only after
  the re-inference has started.
The changelog says the consuming start is whichever wake the scheduler
starts it with.
…ng fallback writes

Since dda3115 a start shares the scheduling turn's logical turn only
through the record (toolResultsLogical) the fallback writes, not the
agent's latest routing. The two tests that push tool_results_ready
directly now seed that record as the fallback does.
…cal turn; the record is carriedLogical

From the review of dda3115 (sw-af305g):

- A silent start (suppressProse) replaced its routing after the fresh
  path had given it a logical turn: the record it took, or a retry's
  shared one, was dropped, losing a ' !' and the receipt tallies of the
  turn it continued. The silent reset now starts fresh routing but keeps
  the logical turn.
- A provider-capacity hold defers a turn to one later fresh compile, but
  the held start had already taken the record, so the later compile
  started a new logical turn. Each hold site now hands the turn's logical
  turn to the next fresh start (carryLogicalTurn), as the non-streaming
  fallback does. The record, renamed carriedLogical, is the logical turn
  the agent's next fresh start continues, for either reason.
- The changelog names the capacity-held turn; the description's account
  of main's carry is exact: main carried hybrid mode's ' !' into the next
  turn and counted deliveries that landed after that turn began.

Tests (explicit-prose-routing):
- the non-streaming fallback records the scheduling turn's logical turn
  when it schedules the re-inference, driven through the agent's direct
  inference API and the framework's real tool-result handling (adapted
  from a reviewer's producer test);
- a silent start that takes a carried logical turn keeps it.
…settles, and every fresh start meanwhile continues it

Four review waves each found another start path that lost the carried
logical turn (a retry, a batched wake, a silent start, a capacity hold),
and each fix taught one more producer to hand it on. This inverts that,
as a reviewer proposed: carrying is the default, and one place decides a
logical end.

- openLogical holds each agent's open logical turn. A fresh start
  continues it if one is open, and otherwise opens a new one.
- settleAgent, which runs exactly at a logical turn's ends (completed,
  turn_ended, exhausted: a terminal error, an operator-quiesce abandon or
  an aborted stream) and on no retry, capacity hold or non-streaming tool
  round, closes it. Disposal drops it.
- So a framework retry, the start that consumes unstreamed tool results
  (whatever wake the scheduler starts it with), and a capacity-held turn's
  later compile continue the turn without any of them carrying it. The
  carriedLogical record, carryLogicalTurn and its four calls, and the
  attempt > 0 case are gone. A silent start still keeps the logical turn.
- A turn that settles has written its receipt and taken its ' !', so a
  start after it opening a new one loses nothing; a tool-ended turn
  settles before the next turn can start, so its queued deliveries keep
  writing to its own object.

Tests (explicit-prose-routing): the re-inference, consuming-wake, receipt
and silent tests seed the open turn instead of a record; the producer test
now checks the non-streaming fallback leaves the turn open. The changelog
and description state the invariant.
…it, and stop() clears them

From a reviewer's read of 25a33d5:

- settleAgent closed the agent's open logical turn by name, so a settle
  from a stream that a newer one had replaced could close the newer
  turn early. settleAgent now takes the settling stream's logical turn
  and closes the open turn only when it is that one. Each call site
  passes its own: the tool-ended path and the pre-stream terminal failure
  pass the agent's current routing's (they run for the turn that holds
  the agent); driveStream's sites pass streamRouting's, and the two
  error-case sites that don't check ownership first pass it only when
  the stream still owns the agent.
- stop() clears every open logical turn, so a start after a restart on
  the same instance opens a new one instead of carrying a ' !' and
  receipt tallies across the restart.
…ical turn; the invariant's wording

From the review of 25a33d5 (sw-af305h) and two readers:

- A silent start (suppressProse) joined the agent's open logical turn,
  and its completion settled it: it took the open turn's ' !', closed the
  turn while its owner's deliveries were still queued, and skipped the
  receipt those deliveries belonged in; a silent tool-ended stream also
  checked that ' !' before the deliveries ran. A silent start routes no
  prose, so it now keeps a logical turn of its own, and an open turn
  waits for the next start that routes prose. Its settle closes only its
  own (78ecd97), so the open turn survives it.
- openLogical's doc said a turn that settles has written its receipt and
  taken its ' !'. It hasn't: a completion settles before it awaits its
  chain. Settling only detaches the turn from future starts, and the
  settling stream keeps its own object for its remaining deliveries, its
  receipt and its ' !' check. The doc now says that, and that every
  logical end must close inheritance once, before the next fresh start.
- settleAgent's identity check carries a comment: it is the only guard
  against a stale settle closing a newer turn, and no test reaches one.
- The changelog says the receipt is hybrid and locus mode's.

Test (explicit-prose-routing): a silent start keeps a logical turn of its
own, leaves the open one untaken and open, and the next start that routes
prose continues it and runs its ' !'.
…rn; tests for the settle's identity check and stop()

From the review of 6015f2c (sw-af305i):

- beginAgentTurn's terminal failure before a stream exists settled the
  agent's live routing's logical turn. It now settles the logical turn
  its own start was given (startLogical), so it can never close another
  start's, whatever happened to the token meanwhile.
- Tests (explicit-prose-routing): settleAgent leaves the open turn open
  for a settle that passes another logical turn or none, and closes it
  for its own; stop() clears every open logical turn; the 'set late'
  re-inference variant asserts the re-inference started before it
  releases the earlier delivery.
- Text: the changelog's first bullet separates a turn's routing state
  from its logical turn's ' !' and receipt tallies; the receipt comment
  at the completion says locus and hybrid mode.
…unt from registration

feedTurn counts starts from where it's registered, and the silent-start
test registers it after the silent turn: the continuation is the second
start from there, not the third. With 3 the continuation had no response
and its stream never finished (a reviewer's run).
…every agent

It was 'settle an active ephemeral run (no-op for resident agents)';
since 25a33d5 it also closes the agent's open logical turn when it is
the settling stream's own, for residents too (a reviewer's note).
If a silent start took the open turn's ' !', its continuation would start
with no response queued and the test would wait for the runner's timeout.
The first wait is now bounded, so that regression fails at once with a
named wait (a reviewer's note on red check R11).
@nissa-seru
nissa-seru merged commit c2f9bae into anima-research:main Oct 13, 2026
5 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 13, 2026
…t/prose-drafts

Brings anima-research#247 onto main's per-turn routing model (anima-research#312) and anima-research#263's
stop-with-work-in-flight changes. Conflicts: src/framework.ts (31 hunks,
37 in zdiff3) and test/tool-result-guard.test.ts (1).

The model, from anima-research#312: each turn's routing is a TurnRouting object (pin,
destination, hybridHold, logical), and every stream of one logical turn
shares a LogicalTurn ({continuation, deliveries, suppressed}) through
openLogical. A queued delivery keeps the routing of the turn that queued
it. anima-research#247's per-agent state is moved onto that model:

- turnDrafts, turnProsePrivate and turnDraftFailures become
  LogicalTurn.drafts, keptPrivate and draftFailures, receipt tallies of
  the logical turn like deliveries and suppressed. freshTurnRouting()
  starts them empty.
- proseHybridSuppressed ('private' | 'failed') becomes
  TurnRouting.hybridHold?: 'private' | 'failed' (main's was a boolean).
- proseTargetPins and proseContinuations go to routing.pin and
  routing.logical.continuation, as on main. proseClipboards stays gone:
  the drafts store's latest bounce is the {{unsent}} source.
- anima-research#247's ProseHold ({round?, notice, keepPrivate?}) and main's ProseRound
  ({keepPrivate?, routing}) are one ProseRound {routing, round?, notice,
  keepPrivate?}: how a round's prose is delivered and how what it doesn't
  send is held. deliverAdmittedProse, deliverProseEnvelope,
  deliverHybridProse, takeUnsent, bounceProse and holdSilencedProse take
  it; holdProseDrafts takes {routing, round?, note?, notice}. Every hold,
  bounce and hybrid state change writes to the round's own routing, never
  to the agent's current one. bounceProse's candidates and prefix are no
  longer optional, since the round after them is required.
- deliverProse (the test-only direct entry) delivers under the live
  turn's routing (routingOf), with notice 'later' unless given.
- appendProseDeliveryReceipt(agent, routing) reads the logical turn's
  tallies; it resets deliveries and suppressed as main's does and leaves
  the draft tallies for settleTurnDrafts(agent, logical), which spends
  them. In this commit settleTurnDrafts still runs at every terminal end
  of a stream that owns the agent, as anima-research#247's did.
- The completion keeps anima-research#247's single await of the turn's speech chain
  before final routing, which covers main's two (the text-only path's and
  the one before the receipt); its comment now says it also waits for an
  earlier turn's posts still queued.
- From anima-research#263: the drafts tool's result carries toolName, as every other
  tool result now does, and its failure text goes through thrownText.

Tests adapted to the merged model:
- anima-research#312's routing tests: the RoutingState shape (drafts, keptPrivate,
  hybridHold kinds), deliverHybridProse's ProseRound, and hold assertions
  ('failed' / undefined). Where anima-research#312 asserted a hybrid hold suppresses,
  anima-research#247 holds the words as a draft: the receipt test now finds the held
  draft, and the `>>>` test finds 'secret' among the supplied turn's
  drafts. Posts are held on both paths a post takes (aroundPosts:
  routeSpeech for locus prose, deliverSpeech for a named or sticky
  destination), since anima-research#247's explicit and hybrid targets post through
  deliverSpeech.
- tool-result-guard: two of anima-research#312's registry stubs gain withDeliverSpeech,
  and the fresh-routing test covers the draft tallies.
- prose-drafts: settleTurnDrafts and bounceProse take the logical turn and
  the round; the sticky-target assertion reads turnRouting.
- explicit-prose-routing: anima-research#247's "a failed send that a later turn
  overtook" can't happen after anima-research#312, which queues a later turn's speech
  behind the agent's earlier posts; with a held post it deadlocked. It is
  now "a later turn's bounce waits behind an earlier turn's held {{unsent}}
  send": the failed send holds the words it attempted, and the later
  turn's bounce follows as the latest. The substituted-draft rule stays
  covered by the tests where a newer bounce lands in the store while the
  send waits.

Runs (Node 24.21.0, on-vps): typecheck clean; the 14 focused files
(anima-research#247's 11 plus conversation-routing, ephemeral-settle and
stop-with-work-in-flight) pass: J6330 for 13 of them, and J6349 for
explicit-prose-routing after the test change, 66/66.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 13, 2026
…-up names only drafts no notice names

Follow-up to the merge of main (anima-research#312's logical turns). The drafts now ride
in the LogicalTurn, so they follow it the way its deliveries do.

- settleTurnDrafts runs in driveStream's finally only once the stream's
  logical turn has ended: openLogical no longer holds it (it settled, or
  it was a silent start's own). A stream ends owning the agent with its
  turn still open when a provider-capacity hold defers the turn, whose
  later compile continues it. Settling there spent the drafts, so
  that compile's receipt left out the first stream's drafts, its
  kept-private count and its draft failures, though it reports the first
  stream's deliveries. Now the stream that ends the turn settles them,
  after its receipt names them.
- The turn-start catch-up leaves the open logical turn's own drafts to
  that turn: its receipt names them and its settlement records them.
  Before the continuing start's compile it named the ones no notice had
  named yet as unnamed, and the receipt then named them again.
- The catch-up records, instead of naming, a draft that a message in the
  recent history already names (draftsNamedInHistory, the same window
  settleTurnDrafts reads). A notice can reach the history after its
  turn's drafts were settled: in explicit mode a turn a tool ends doesn't
  wait for its queued deliveries, so an envelope of it that bounces later
  is held into that turn's settled logical turn, and its bounce notice
  names a draft nothing records. The catch-up then said "unnamed on this
  branch" of a draft a notice names. It applies equally to a notice whose
  recording failed. recordDraftNotices is the marking both use.
- changelog: the receipt names every draft held in the turn, in any of
  its streams.

Tests:
- explicit-prose-routing: a capacity-held hybrid turn's later compile
  writes one receipt with its own delivery and the draft its first stream
  held, and no catch-up names it (an acceleration 429 replaces stream
  one's completion; 50 ms cooldown). An explicit turn that skip_reply ends
  with a queued '>>#nowhere' envelope behind a held post: the bounce
  notice names the draft, the next turn's catch-up doesn't, and the draft
  is recorded as noticed.
- prose-drafts: the catch-up records a draft whose notice is in the
  history and names only the other; it leaves the open logical turn's
  draft unnamed and unrecorded.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 13, 2026
Brings in main through c2f9bae: anima-research#236, anima-research#241, anima-research#238, anima-research#240, anima-research#242, anima-research#237,
anima-research#169, anima-research#221, anima-research#248, anima-research#308, anima-research#263 and anima-research#312.

One conflict, in src/framework.ts's four MCPL handlers (push/event,
inference/request, channels/incoming and host/command). anima-research#263 wraps each
handler in answerOnce and refuseServerItem, and refuses the item after
the barrier wait once the queue has closed. This branch replaced each
handler's single-barrier wait with awaitDiscordAwarenessBarriers(),
called only while a generation is current. Resolved by keeping anima-research#263's
structure and putting this branch's wait where it waited on the barrier:

  if (this.discordAwarenessBarrier) await this.awaitDiscordAwarenessBarriers();

So the queue-closed check runs once every generation armed while the
handler waits has been waited out, and with no gate up an event still
reaches its handler in the turn it arrives.

Everything else merged without conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
…from the next listener

emitTrace iterated this.traceListeners live, and both onTrace's returned
unsubscribe and offTrace splice that array. An unsubscribe during delivery,
of the listener being called or of one before it, shifted the rest down
under the iterator, so the listener after the one being called missed that
event. A listener added during delivery was appended under the iterator
and heard the event that added it.

Arthur-2378 found it while reviewing the continuation work on #305, which
#312 later carried: a test helper that unsubscribed itself during the
second inference:started hid that event from a turn counter registered
after it, which read as a missing continuation.

Each onTrace call now makes a registration of its own, { listener, active },
and its unsubscribe clears that registration's flag as it removes it.
emitTrace iterates a copy of the registrations taken when delivery starts
and calls only those still active when their turn comes, as the DOM's
EventTarget delivers. So a subscription made during delivery waits for the
next event, and one removed during delivery, by its own unsubscribe or by
offTrace, is skipped if its turn hasn't come. A subscription that an
earlier listener removes during an event still isn't called for it, as on
main. A function subscribed twice is still called twice, once for each
registration.

offTrace removes the first registration of its function, as before. The
function onTrace returns removes its own registration, so calling it twice
no longer removes a second registration of the same function. emitTrace
supplies no receiver of its own, as on main, so a strict, unbound
listener's `this` is undefined rather than its registration.

Tests (test/framework.test.ts, "Trace listeners"):
- a listener that unsubscribes itself during an event does not hide it
  from the next listener: fails on main;
- a listener that unsubscribes an earlier one during an event (with
  offTrace) does not hide it from the next listener: fails on main;
- a listener unsubscribed before its turn hears neither that event nor
  any after it: passes on main, and fails with a copy alone;
- a listener added during an event hears the next event, not that one:
  fails on main;
- a listener unsubscribed and subscribed again during an event hears the
  next event, not that one: fails on main;
- a listener is called as a plain function, not as a method of its
  subscription: passes on main, and fails with a member call;
- an unsubscribe removes its own subscription, however often it is
  called: fails on main;
- offTrace removes the first subscription its listener still has, and
  does nothing once none is left: passes on main;
- a listener that emits during delivery does not cut the outer delivery
  short, and an unsubscribe in the nested delivery holds for it: passes
  on main.

Five places in three test files that set the private traceListeners
field to a list of functions now subscribe through onTrace instead, since
the field holds registrations. test/explicit-prose-routing.test.ts loses
a comment that described the old behaviour.

Changelog: changelog.d/trace-listeners-copy.fixed.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
…routing

A framework retry after a stream error, the re-inference that consumes
unstreamed tool results, and the compile a provider-capacity hold defers a
turn to all continue the same logical turn (#312), but beginAgentTurn gave
each of them freshTurnRouting(). So the turn lost:
- hybrid's `>>>skip_reply` hold: later unprefixed prose was published to
  the turn's locus;
- its sticky destination, in both modes: hybrid prose went to the locus,
  and explicit prose bounced, with a notice and a wake;
- explicit mode's turn destination: the new stream streamed nothing;
- its locus, which was resolved again: a retry lost a mid-turn addressed
  re-pin, a re-inference took the locus of whichever wake started it, and a
  held turn's later compile let a newer ambient message move it.
Fixes #314.

TurnRouting is now one object per logical turn, holding everything the
turn has decided about where its speech goes: the trigger channel, the
locus and the engaged channels (moved from the per-agent
activeTriggerChannels, turnLocusPins and turnEngagedChannels maps), the
sticky destination, the destination and the hold, and the ` !` and receipt
tallies (LogicalTurn is folded in). The start that opens a turn registers
it in openLogical, every start while it is open takes it, and a resume
keeps it. A silent start gets an object of its own, so it neither takes
nor disturbs an open turn's, and it has no locus. Explicit mode's stream
router starts every stream at the turn's destination.

A start that continues the turn keeps its locus, and applies the mid-turn
re-pin to the wakes in its batch that arrived after the turn opened: the
newest one that addressed the agent, or came from a channel the turn
engaged, moves the locus, with the same [routing] notice (repinTurnLocus,
now shared with the mid-turn boundary). No turn is alive during a capacity
hold, so such arrivals are stored without passing a boundary, and the
mid-turn re-pin never saw them. Ambient traffic no longer moves a held
turn's locus.

A replaced stream's queued deliveries are on the agent's chain ahead of
its replacement's, so what they set, such as a pin or a hold, applies to
the replacement's prose in the order the turn wrote them. A stream queues
deliveries only while it is the agent's current stream.

Tests: each of the three starts keeps the hold, the pin and the
destination; a retry's text waits for the replaced stream's late
deliveries and follows what they set; a silent start leaves an open turn
untouched; a silent resume shows no locus; the locus across a retry, a
capacity hold (addressed and ambient arrivals) and a re-inference. Tests
that seed routing state use the flat shape. settleAgent's comment no
longer says no test reaches its identity check, and #312's changelog
fragment no longer says these starts begin the sticky destination and
hold afresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
…ontinuing-starts-keep-routing

Main moved from 3f91700 through #281, #283, #278, #254, #316, #318, #322 and
the 0.21.1 and 0.22.0 releases. Two conflicts, both resolved by keeping each
side's intent:

- src/framework.ts, beginAgentTurn's first lines: main added
  `const lifecycle = this.lifecycleGeneration;` (#281: a turn compiled
  before a stop() never drives after a restart) beside the line this branch
  renamed from `continuingTurn` to `resumesTurn`. Both are kept; no use of
  `continuingTurn` remains.
- changelog.d/turn-routing-per-turn.fixed.md: the 0.22.0 release compiled
  #312's fragment into CHANGELOG.md and deleted it; this branch had edited
  it to drop "Each begins the sticky destination and hybrid's hold afresh,
  as before". The fragment stays deleted. The released entry is true of
  0.22.0, and this branch's own fragment
  (continuing-starts-keep-routing.fixed.md) says what changes after it.

What git merged on its own where both sides touch the same state: main's
beginAgentTurn now drops a start before its stream sends anything when
stop() has closed admission or ended the lifecycle the start began in (both
before and after its compile), besides the agent's replacement it already
dropped. Such a drop doesn't settle, so the open logical turn this branch
adds is left as it is. The next commit makes a dropped start end the turn
it was running. Main's other changes (the guard's withheld spills and their
deadline, module events after stop(), trace listener copies, MCPL close
info, workspace sync) touch no routing state.
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
Brings in main since 0d2dd88: #251 (durable provider deadlines), #279,
#249, #303, #246, #211, and through cd036e0 #267, #290, #285, #325 and
the 0.22.1 release (#328).

Three conflicts, all in src/framework.ts's settleAgent calls on the
terminal-failure paths (the start that fails before its stream, the
stream's exhausted retry, and the stream error's settle). Each takes main's
error text, `statedWait === 'fail' ? this.statedWaitNotSlept(err) :
err.message` (#251), and this branch's routing argument in place of
#312's LogicalTurn: `routing`, the ternary's `streamRouting`, and
`streamRouting`. Nothing else in main's change names the old shape
(startLogical, `.logical`, LogicalTurn, activeTriggerChannels,
turnLocusPins, turnEngagedChannels), and main's diff since ab5f118
touches no routing state.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
Four conflicts, one of them a fragment main has released.

- agent.ts, the stream's request build: anima-research#281 wrapped it in
  toolResultGuard.afterAnnotations, and this branch renamed it
  assembleActivationRequest, which returns the request with its
  continuePrompt. The wrap now takes the renamed assembler and reads
  both. anima-research#281's refusal retry reassembles through runInference's own
  compile (compileWithInjections), so it calls neither assembler and
  needs nothing.
- framework.ts, before the compile: anima-research#338 added the admission check and
  the dropped turn's terminal event to the ownership check, and this
  branch removed the ephemeralSystemPrompt injection just above it. Both
  stand.
- framework.ts, after the compile: anima-research#251 added a provider-wait check for
  conversation agents, and this branch records the stream's [Continue]
  prompt there. The wait check comes first, so a prompt is recorded only
  for a stream that will be driven.
- framework.ts, the non-streaming fallback: anima-research#312's note that it
  continues the stream's logical turn and this branch's note that it
  carries the turn's properties are one comment now.
- changelog.d/silent-heartbeat-turn-rows.fixed.md went out in 0.22.0,
  after this branch had edited it. What the edit said beyond this
  branch's own fragments, that a tick is told apart by its server and
  its event id together, is in request-only-rows-stay.fixed.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

A turn that a tool ends leaves its queued prose to run under the next turn's routing state

1 participant