Repository navigation
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 intoOct 13, 2026
Conversation
…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.
|
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.!request and receipt tallies stay with it across a framework retry or a re-inference after unstreamed tool results.!continuation now starts, and a receipt waits for the deliveries it reports.The problem
At each round's
tool-callsevent 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:>>destination found none, and bounced into the next turn;!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:
!asked for before a tool ended the turn started only after the next turn;The change
TurnRoutingholds 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.!(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.!they carried was missed.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.!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.!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.!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.Not covered
tool_results_readyre-inference, or a retry after a stream error, starts the turn's sticky destination and hold afresh, becausebeginAgentTurntreats 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.!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.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.!after the restart: the check can't tell the two lifecycles apart. In hybrid mode the turn waits for its deliveries inside its stream, andstop()waits for that stream, so a completed stop doesn't leave one behind.Runs
From clean checkouts of
39cb2054, this branch withmainat197a8491(#263) merged in, on Node 24.21.0, every file in its own unforcednode --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:
!;!check starting a turn while the framework is stopping or stopped, or after disposal;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.
mainat197a8491is 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 withmainwere read by Ada-1017 and Arthur-2378.