Skip to content

fix(usage): a tool-ended turn completes, and every stream counts its usage once - #207

Merged
nissa-seru merged 16 commits into
anima-research:mainfrom
nissa-seru:fix/67-end-turn-accounting
Oct 17, 2026
Merged

nissa-seru merged 16 commits into
anima-research:mainfrom
nissa-seru:fix/67-end-turn-accounting

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

Problem

!Fixes #67. A tool result with endTurn: true cancels its stream at a tool boundary, before the stream's final response. The session usage tracker (usage:updated, getSessionUsage()), inference:completed and the inference log were all fed only from that final response. So a tool-ended turn reached none of them. A turn that ends with skip_reply, or with any tool result carrying endTurn, ends that way, which for a channel-routed agent is a common end.

The same gap had two more sides:

  • Other streams ended without a final response, and their billed rounds weren't counted either: a context-budget or physical-window restart, a stream error, an abort, a throw.
  • A tool-ended turn didn't count as a successful turn. It left the hard-down failure streak, the poison-history rewind budget, a refusal-rewind episode and a provider-cooldown recovery as they were. A natural completion resolves each of these.

Changes

A stream's usage is counted once, however it ends. It is the provider rounds the stream finished, from Membrane's cumulative usage events, unless a final response gives its own.

  • Natural completion: the final response's usage, as before.
  • A tool-ended turn: its rounds' usage, counted when the tool result ends it, before inference:turn_ended.
  • A context-budget or physical-window restart: its rounds' usage, counted where the restart is decided. It is logged as a success with stopReason context_budget or physical_window. It emits no inference:completed, because the turn continues in the next stream.
  • Every other end (a stream error, an abort, a throw, a provider cooldown, a turn abandoned by an operator quiesce, a superseded stream): the rounds it finished, counted when the stream ends.
  • feat(guard): add persistent tool result guard with Chronicle audit #159's guard counts its abandoned stream through the same once-only count, so nothing is counted twice. It counts and notes one value: the response's detailed usage, else the stream's latest sample, else the response's own usage. On main it counted the detailed usage alone, and noted the detailed usage, else the response's own.

And logged once. Exactly one inference-log entry of each stream carries its usage, so totals summed from the log count each stream once, as the session's do.

  • The entry its end writes: a completion's, a boundary end's, the guard's, or a failure's (a stream error, an abort, a throw, a cooldown).
  • No later entry of the same stream carries the usage again. Before, every entry without its own figures copied the stream's latest sample, so a failure entry written after a completion's counted its rounds twice in the log.
  • An end that writes no entry of its own writes one in finally, with success: false, naming what ended it, when it had finished a round: a turn abandoned by an operator quiesce ("Turn abandoned by operator quiesce"), a stream superseded by a newer one ("Stream superseded: a newer stream of the agent took over"), a stream whose agent was disposed or replaced ("Stream abandoned: its agent was disposed or replaced"), or a stream that ended with no final response and no other end ("Stream ended without a final response"). Before, these streams were counted but never logged. A stream with no finished round has nothing to carry, and writes no entry, as before.
  • A conversation agent's provider wait, an end main added in driveStream's catch (5a45eb6), wrote its entry directly. It now writes the stream's one entry, so a stream with finished rounds isn't logged twice.
  • A store error while a stream's end is counted or logged is reported on stderr ("[usage] agent=… could not record a stream's usage at its end"), and what comes after it still runs. logInference has no try of its own, and this accounting comes before that work. In finally, that's the teardown: the lifecycle terminal, the event gate's release, the turn token and the KV receipts. At a tool boundary, it's the rest of a tool-ended turn's end (its refusal episode's end and inference:turn_ended) or a restart's cancel, its inference:stream_restarted and its next stream, and then the tool result's dispatch to modules.

A tool-ended turn ends as a natural completion does.

  • It emits inference:completed with the stream's usage, then inference:turn_ended. Through emitTrace, that resets the agent's consecutive-failure streak and its poison-history rewind budget.
  • It ends a refusal-rewind episode. A forced /unstick reports that the model responded.
  • It records a provider-cooldown recovery.
  • It writes a success entry in the inference log, with stopReason turn_ended.
  • An answered call within a turn isn't a completion, as on main. The failure streak and the poison-history rewind budget reset when a turn completes, not when one of its calls answers. So a turn whose calls answer and then fail, such as on a tool result the provider refuses, still adds to the streak, and a resident that can't complete a turn still reaches the hard-down alert and the breaker.

Inference-log growth. logInference keeps a request over 10 KB as its own uncompressed chronicle blob, one per entry, with no retention. The previous head logged each tool-ended turn's compiled request, which is one whole-context blob per tool-ended turn, for turns that wrote nothing before. An entry for a stream the framework ended at a tool boundary (a tool-ended turn or a restart) now records a note in place of the request body. Natural completions and failures keep theirs, as on main. The new entries still add to the log: one small entry for every tool-ended turn and restart, which for a channel agent whose turns mostly end with a tool is most of its turns, and one entry, with its request body, for each quiesce-abandoned, superseded or disposed stream that had finished a round. A health snapshot sums its token totals over the log's newest 1,000 entries, so with these entries that window spans fewer turns. The log's retention is a question for the log as a whole, filed as #293.

What callers will notice

From changelog.d/fix-67-end-turn-accounting.fixed.md:

  • Session usage (usage:updated, getSessionUsage(), its inferenceCount) now includes what main dropped: tool-ended turns, budget and physical-window restarts, the rounds a failed stream finished, and the rounds of a completion or a guarded retry whose response carries no detailed usage.

  • inference:completed now also fires for a tool-ended turn, before inference:turn_ended. In connectome-host, consumers that end a turn on it now end tool-ended turns too. On main they missed every one:

    • the typing indicator (activity-module);
    • notices that close on success (notices-module);
    • the TTS relay's activation_end;
    • piped mode's wait;
    • the subagent display.

    A consumer that totals inference:completed's tokenUsage now counts tool-ended turns too, and one that also adds every inference:usage sample counts them twice.

  • Operators:

    • a tool-ended turn resets the hard-down failure streak and the poison-history rewind budget;
    • a forced /unstick whose next turn ends with a tool reports that the model responded;
    • a tool-ended turn after a provider cooldown logs [provider-cooldown] recovered;
    • a refusal completion's failure note carries the tokens the session counted, which fall back to the stream's latest sample when its response carries no detailed usage;
    • a store error while a stream's end is recorded is reported on stderr ([usage] agent=… could not record a stream's usage at its end), and what comes after it still runs: the rest of a tool-ended turn's end, a restart's next stream, and the stream's teardown.
  • The inference log gains entries for tool-ended turns and restarts, with a note in place of the request body, and failure entries for turns abandoned by a quiesce, superseded streams, disposed agents' streams and streams that ended with no final response, when they had finished a round. A failed stream's entry carries the rounds it finished. Exactly one entry of each stream carries its usage, so totals summed from the log count each stream once, as the session's do. A health snapshot's token totals cover the newest 1,000 entries, so that window now spans fewer turns.

  • A companion fix in connectome-host: its agent-tree reducer adds every cumulative inference:usage sample and then each inference:completed total, so it already over-counts multi-round streams, and tool-ended turns now add to that. connectome-host #230 fixes that reducer and the readers that show a sample's input as context size. It works with agent-framework v0.21 and with this PR, so the two can merge in either order; connectome-host should take executeToolCall and model dispatch resolve nested MCPL tool prefixes differently #230 before it takes a release with this PR.

What each part is for

This change exists so that every stream's usage is counted once, however it ends, and a turn the model ended counts as a completion. For each part, here is what it buys, what it costs to keep, and the simpler forms we weighed, leaving it out included.

  • countUsage and logStream, driveStream's once-only count and log. Every end of a stream goes through these two closures, so a stream is counted once and only one of its log entries carries its usage, whichever end comes first. They replace the direct usageTracker and logInference calls each end made. Counting at each end instead would need every end to know whether another had counted, which is what these closures keep. What they cost to keep: an end added later has to go through them, since one that calls logInference directly writes a second entry for its stream. A comment at logInference now says so, and fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172's stop terminals are such ends today.
  • latestUsage and tokenUsageOf. The latest cumulative sample is the stream's usage when no final response gives one: a tool-ended turn, a restart, a failure after a finished round. tokenUsageOf replaces two copies of the same conversion and serves the new ends too.
  • completeTurn, shared by a natural completion and a tool-ended turn, so both emit inference:completed, which resets the failure streak, and count and log the same way.
  • streamBoundaryEnds, a WeakMap from a stream to its end recorder. The tool boundary decides a tool-ended turn or a restart, but the count and the log live in driveStream, so the boundary calls driveStream's recorder for that stream. Counting at the boundary would repeat the once-only logic outside driveStream. Letting driveStream account for the end when the cancel reaches it wouldn't do the same: the boundary emits inference:turn_ended in the same synchronous run as its cancel, and the stream's abort reaches driveStream only after that, while the session's count has to be in place when turn_ended fires. A WeakMap lets a stream go without cleanup.
  • BOUNDARY_END_REQUEST, a note in place of the request body in a boundary end's log entry. Without it, every tool-ended turn and restart would store a whole-context blob.
  • endRefusalEpisode, the natural completion's code moved into a helper, so a tool-ended turn ends a refusal episode the same way.
  • finally's entry for an end that wrote none, naming what ended it, written only when the stream finished a round. Without it, a quiesce-abandoned, superseded or disposed stream's rounds are counted but never logged. A stream with no finished round has nothing to carry, so it writes none, as on main.
  • The try/catch around finally's accounting. logInference can throw on a store error, and the teardown after it, the lifecycle terminal, the gate's release, the turn token and the KV receipts, still has to run.
  • The try/catch in streamBoundaryEnds' recorder. The boundary calls the recorder before the rest of its own work: on a restart, agent.cancelStream(), inference:stream_restarted and the request that opens the next stream; on a tool-ended turn, the end of its refusal episode and inference:turn_ended; on both, the tool result's dispatch to modules. Without the catch, a failed write skips all of it, and a restart opens no next stream. The refusal episode's end stays after the try, so a failed write doesn't skip it. Catching inside logStream instead would cover every end at once, but it would also change what a store error does at the ends main already logs from, which this PR leaves as they are.
  • The guard's count and note through the same closures, with one value for both: the detailed usage, then the latest sample, then the response's own. main counted one value and noted another.
  • The provider wait's entry through logStream. main added that end after this PR was written, and its entry went to logInference directly, which would log a stream with finished rounds twice.
  • The failure streak stays per turn, with a comment and a test. Resetting it at each answered call would keep a resident whose turns answer and then fail from reaching the hard-down alert and the breaker.
  • The tests: end-turn-usage's 28, and three in tool-result-guard. "Removing each piece" below says which test fails without each piece.

Left out: resetting the failure streak at each answered call; counting membrane's refusal retries, which is a question of what the tracker sums; and the physical-window projection's reading of the samples, which #273 takes up.

Tests

GitHub's CI runs the typecheck, the build and the whole suite at this head. These runs add what CI doesn't, on Linux, on Node 24.22.0, from a fresh install:

  • Red first, at this head (8d45cb4, which merges main 61b6e0f), with that main's src/framework.ts and this branch's tests (its other src changes are comments): 24 of end-turn-usage's 28 tests fail, and 2 of the 3 new or changed tests in tool-result-guard. Five pass there too, since main already behaves the same for them: a natural completion's count and its end of a refusal episode; a turn whose call answers and then fails, which leaves the failure streak standing on main too; a turn abandoned before its first round, for which main also writes nothing; and the guard's count of a round with detailed usage, exactly once.
  • A store error at a tool boundary (bb11216): with the previous head's src/framework.ts (57ad658), the two tests that make that write throw both fail, each at its own assertion: "the restart opened its next stream" and "the refusal episode ends".
  • At this head, the typecheck is clean, and end-turn-usage passes 28 of 28, tool-result-guard 126 of 126, stop-at-tool-boundary 13 of 13, physical-window-restart 12 of 12 and ephemeral-settle 8 of 8.
  • Compared with main. The red-first run puts main and this branch on the same inputs: tool-ended turns, natural completions with and without final usage, both restart reasons, three failure ends after a finished round, a conversation agent's provider wait after a finished round, a guard retry with and without final usage, a provider cooldown before and during a turn, the ends finally logs (a quiesce, a newer stream, a replaced agent, a stream that just closes), a store error at a stream's end, in finally or at a tool boundary, and the failure and refusal episodes. Every difference is listed under "What callers will notice".

The rule at its other places. The rule is "a stream's billed usage is counted once, and a turn the model ended counts as a completion". These are the other places it has to hold, and what this PR does at each:

Place Disposition Test
Natural completion Counted from its final usage as on main, or from its samples when it carries none keeps natural completion authoritative …; counts the rounds' samples when a final response carries no usage
A tool result with endTurn Counted, logged and completed where the tool result ends the turn accounts once for 1 / 2 tool round(s) …; reports completion without inventing usage …
Context-budget restart Counted and logged where decided; no inference:completed a context_budget restart counts and logs the stream it cancels …
Physical-window restart The same, with its own stopReason a physical_window restart …
#159's guard retry Counted through the same once-only count, so it can't be counted twice; its log entry and refusal note carry the same value, falling back to the response's own usage usage of an abandoned guarded round … (exactly 1007); an abandoned guarded round whose response carries no usage … (cumulative samples 10, then 20); … with no usage sample counts and notes its response's own usage
A stream error, an abort, a throw Their finished rounds are counted when the stream ends, and their failure entries carry them a stream error / an abort / a thrown stream after a finished round …
A provider cooldown during a turn The same a cooldown that cuts a turn after a finished round …
A quiesce-abandoned, superseded or disposed stream, or one that just closed Counted by the same line in finally, which also writes its one log entry, as a failure naming what ended it a turn abandoned by an operator quiesce after a finished round; a stream superseded by a newer one after a finished round; a stream whose agent was replaced under its name after a finished round; a stream that just closes after a finished round
The same ends before a round finished Nothing to count or log, as on main a turn abandoned by an operator quiesce before its first round returns …
A conversation agent's provider wait (main's since 5a45eb6) Its entry is the stream's one entry a conversation agent's stated provider wait that cuts a turn after a finished round …
A store error while finally counts or logs Said on stderr; the teardown after it still runs a write that fails while finally logs the stream …
A store error while a tool boundary's end counts or logs Said on stderr; a restart still opens its next stream, and a tool-ended turn still ends its refusal episode, emits inference:turn_ended and hands the tool's result to the modules a restart still opens its next stream; a tool-ended turn still ends its refusal episode, …
A refusal completion's failure note Carries what the count counted a refusal completion's note carries what its count counted
The failure streak and poison-history budget Reset by inference:completed, not by an answered call within a turn the failure streak and the refusal-rewind episode … (both ends); turns that each fail after an answered call reach the hard-down threshold …
The refusal-rewind episode and a forced /unstick Ended for a tool-ended turn as for a completion the same test
A provider-cooldown recovery Recorded for a tool-ended turn a provider cooldown, which records its recovery

Removing each piece. Each piece was removed on its own, the branch was built, and end-turn-usage, tool-result-guard, refusal-rewind, provider-acceleration-cooldown, inference-exhaustion-rewind and related files were run. Every removal fails at least one test:

Piece A test that fails without it
A tool-ended turn's end is recorded where the tool result ends it accounts once for 1 / 2 tool round(s) …; attributes successive streams …; includes tool-ended turns in the log-backed health snapshot
A restart's end is recorded where the restart is decided a context_budget / physical_window restart …
…with the restart's own reason a physical_window restart …
…and counted there, before inference:stream_restarted a context_budget / physical_window restart …
Usage counted once per stream accounts once …; keeps natural completion authoritative …
Nothing counted without usage typecheck fails; reports completion without inventing usage …
Log entries default to the finished rounds' usage the restart and failure-end tests
A tool-ended entry's note in place of the request body accounts once for 1 / 2 tool round(s) …
A restart entry's note the restart tests
A tool-ended turn records a cooldown recovery a provider cooldown, which records its recovery
A tool-ended turn ends a refusal episode the failure streak and the refusal-rewind episode …: a tool-ended turn
A natural completion ends one (now a shared helper) …: a natural completion
A forced /unstick reports that the model responded the same two tests, which read the posted report
The rounds a failed stream finished are counted in finally a stream error / an abort / a thrown stream after a finished round …
#159's guard counts through the once-only count usage of an abandoned guarded round … (exactly 1007); an abandoned guarded round whose response carries no usage …
The guard falls back to the samples an abandoned guarded round whose response carries no usage …
A completion falls back to the samples counts the rounds' samples when a final response carries no usage
The latest cumulative sample is kept accounts once …, and nine others, including the guard's cumulative samples (10, then 20)
inference:completed for a turn the model ended accounts once …; keeps natural completion authoritative …
A natural completion is recorded keeps natural completion authoritative …; the restart tests
A failure entry carries the usage: stream error, abort, throw, cooldown the matching failure-end test; a cooldown that cuts a turn after a finished round …
A stream whose end writes no entry is logged in finally a turn abandoned by an operator quiesce …; a stream superseded by a newer one …
…naming a quiesce-abandoned end a turn abandoned by an operator quiesce …
No later entry of a stream carries its usage again a failure after a completion is logged without carrying the usage again
The guard falls back to the response's own usage an abandoned guarded round with no usage sample …
An explicit undefined can't erase a log entry's default none, and none can: each current caller passes a value that is undefined only when there are no samples, so the default is undefined too. Kept so a later caller can't erase it.
A conversation agent's provider wait writes the stream's one entry a conversation agent's stated provider wait …
finally's accounting is guarded a write that fails while finally logs the stream …
A refusal completion's note takes what the count took a refusal completion's note carries what its count counted
finally writes no entry for an end with no finished round a turn abandoned by an operator quiesce before its first round returns …; reports completion without inventing usage …
…and names a replaced agent, and an end with no final response a stream whose agent was replaced …; a stream that just closes …
(A reset of the failure streak at each answered call, added) turns that each fail after an answered call …
The boundary recorder's catch, at a restart a restart still opens its next stream
…at a tool-ended turn a tool-ended turn still ends its refusal episode, …
The refusal episode's end after the try the same test
The failure said on stderr both of those tests

The pieces were removed at 3447706, and again at f7d45e5 for the pieces the review follow-ups changed. The review round's six (the finally entry, the quiesce name, the once-only entry, the guard's fallback, the latest sample, the /unstick report) were removed at d096f6d, each failing end-turn-usage or tool-result-guard. The provider wait's entry was removed, and a reset at each answered call added, at 6cd0689; the last round's five were removed at bc125bb. Each of those fails its own test in end-turn-usage, and an entry written with no finished round also fails "reports completion without inventing usage …". The last four, from the round that answers Greptile, were removed at bb11216, each with the two tests that make a boundary's write fail, and the restart's catch again at this head.

Not changed, and why

Not verified

  • No live provider or production host was exercised. The tests drive the framework with scripted yielding streams.
  • macOS: CI's macOS legs run the suite. These runs were Linux only.

Credits

🤖 Jasper-314 implemented 6ac4743 and b715877 with OpenAI GPT-6 Astra, and Felix reviewed them.

— Ada-1017 (Claude Opus 5.5). I ran the upstream-PR check that produced 099efc9, 4484548, 3447706, dc21589, d7ea294 and f7d45e5, and I wrote those commits. Basil-1007 reviewed each one. Their review of 3447706 found that a final response without usage lost its rounds, which d7ea294 fixes. Their review of d7ea294 found the guard's log entry disagreeing with its count, which f7d45e5 fixes. I rewrote this description for that head.

— Milo-2653 (Claude Opus 5.5). I carried this PR from d096f6d. I merged main into it (bd10e8b), routed the provider-wait end main added through the stream's one log entry (bcc8d00), and kept the failure streak per turn, with a comment and a test (6cd0689, on Ada-1017's decision). I wrote bc125bb from an internal review of the merged head, and 57ad658, a comment at logInference that Ogden-2688 suggested, with two ends the changelog had left out. Ogden-2688 (Claude Opus 5.5) reviewed the merge, bc125bb and this description's "What each part is for". I updated this description for the current head.

Tool results with endTurn bypass the natural complete event, leaving usage totals and completion observers unaware of completed work. Register a stream-scoped, idempotent accounting callback that both terminal paths use. Finalize tool-ended turns after resetting the agent and before asynchronous cancellation can dispose an ephemeral or hand control to another stream.

Retain the latest cumulative Membrane usage snapshot rather than summing round snapshots. Preserve cache counts and provider-priced estimated cost. Natural completion continues to use response.details.usage as its authoritative total. Cancellation and MCPL lifecycle behavior stay unchanged.

Added five integration checks for one and multiple tool rounds, natural completion, attribution and persistence through reopen, and missing usage. On the original 03c31d9 source these yielded 1 pass / 4 fail; the fix passes all five. Built compiled output and ran the new suite plus ephemeral-settle and deferred-wake-integrity: 18 pass / 0 fail. Bounded full-suite comparison is running separately.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The change affects usage and completion records across agent turns.

Fix the boundary-log error path before merging so a failed write cannot stop a required restart.

Findings

  1. P1 Log failures prevent restarts ▶
Fix with agent prompt
### Issue 1
src/framework.ts:9107
If `logInference` throws during a context-budget or physical-window restart, this callback exits before `agent.cancelStream()` and the next `context_budget_restart` request. The stream is already canceled and marked `budget_restart`, so its abort path keeps the event gate open for a successor that was never queued. The turn stops, and debounced messages can stay buffered after storage recovers.

Catch boundary-accounting errors without skipping the reset and restart scheduling, and add a failing-write restart test.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Counts usage once across stream endings and treats tool-ended turns as completed turns.

  • Tool-ended turns now finish like model completions.
  • Every stream now counts and logs its finished usage once.

Reviews (2) · Last reviewed commit: "docs(usage): say at logInference that a ..." · Reviewed by Greptile

Comment thread src/framework.ts Outdated
Jasper and others added 7 commits October 2, 2026 02:04
Address the source-confirmed Greptile finding on PR anima-research#207: restored usage/completion telemetry still bypassed the successful inference log, which HealthModule uses for inference and token totals.

Write the terminal log inside the same per-stream idempotent completion callback. Natural completion retains its raw provider response, stop reason, and duration; tool-ended completion records turn_ended and an explicit note that the stream ended by a tool result rather than inventing a final provider response. Missing usage remains absent.

Extend regression coverage for one log per one/multiple-tool-round stream, normal completion without duplication, agent attribution, unique request IDs, store reopen, missing usage, and the real log-backed HealthModule snapshot. On 6ac4743 the six-case suite gives 1 pass / 5 fail; the fix passes 6/6. Expanded compiled focused tests pass 32/32 without force-exit. The revised full compiled suite is running separately before push.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
…ragment

The branch edited CHANGELOG.md's Unreleased section directly, which
conflicts with main (02adf27) now that main's own entries live in
changelog.d/ (CONTRIBUTING.md#changelog). Same text, moved verbatim into
changelog.d/fix-67-end-turn-accounting.fixed.md; CHANGELOG.md is back to
the merge base's version, so the branch no longer touches it.
Brings the branch onto current main so the accounting change composes
with what main gained since 03c31d9, notably anima-research#159's tool-result guard,
whose abandoned-stream usage path the stream accounting must share.
Merged cleanly (the CHANGELOG entry already moved to changelog.d/).
… turn ends as a completion does

Skill pass on anima-research#207 (house review of Jasper-314's 6ac4743/b715877, now on
current main via 4484548).

The rule anima-research#207 set at one site (a tool-ended stream's usage reaches the
session tracker) holds at its siblings now:
- A context-budget or physical-window restart cancels its stream at the
  same tool boundary as endTurn, after rounds that were billed. It is
  recorded where the restart is decided: counted once and logged as a
  success with the restart's reason (stopReason 'context_budget' or
  'physical_window'), with no inference:completed (the turn goes on; a
  completed would end typing, TTS activations and piped waits mid-turn).
- Every other end (a stream error, an abort, a throw, a provider cooldown,
  a superseded generation) counts the rounds it finished in driveStream's
  finally, once (countUsage's flag; the natural completion's and the
  guard's authoritative usage set it first). Every inference-log entry the
  stream writes carries that usage unless it names its own (logStream), so
  log-backed health totals agree with the tracker.
- main's anima-research#159 guard path counts its abandoned stream through the same
  countUsage, so the backstop can't count it twice.

A tool-ended turn is a successful turn end, so it ends what a natural
completion ends: besides inference:completed (whose emitTrace intercept
already resets the hard-down streak and the poison-history rewind budget),
the provider-cooldown recovery receipt and the refusal-rewind episode (a
forced /unstick reports "the model responded"). endRefusalEpisode() is
the natural path's else-branch, moved into a helper both call.

Growth: logInference stores a compiled request over 10 KB as an
uncompressed, fsync'd, content-addressed chronicle blob, one per entry,
with no retention. Jasper's tool-ended entry kept the request body, adding
one whole-context blob per tool-ended turn for turns that wrote nothing on
main. Boundary-ended entries (tool-ended, restarts) record a note instead
(BOUNDARY_END_REQUEST); natural completions and failures keep theirs.

Cut as not paying rent: the copy of each usage event (membrane >=0.5.78
emits a fresh snapshot each round, and AF never mutates it); the
completionAccounted flag and both WeakMap deletes (no path calls a
stream's boundary end twice or after its driveStream ends: every end
resets the agent or leaves its state to a successor; the map is weak).

Docs: InferenceLogEntry's request/tokenUsage/stopReason say what a
boundary end records; the inference:completed trace says a tool-ended
turn emits it before inference:turn_ended.

Tests: end-turn-usage gains restarts (both reasons: counted when the
restart is decided, logged, not completed), failure ends after a finished
round (error event, abort, throw), and a tool-ended turn ending the failure
streak, the refusal episode with a forced /unstick, a provider cooldown
(recovery receipt) and a cooldown that cuts a turn after a round; the
first test checks the request note and that usage is counted by
turn_ended. tool-result-guard's abandoned-round test asserts 1007 exactly.
…notice

The fragment named only the tool-ended turn's new trace, log entry and
usage. It now says, in callers' terms: a tool-ended turn emits
inference:completed then inference:turn_ended and ends what a completion
ends (failure streak, poison-history rewind budget, refusal episode and a
forced /unstick, provider-cooldown recovery), so consumers that end a
turn's display on inference:completed end it on these turns too; every
stream's billed rounds reach the session totals once however it ends
(restarts counted and logged without inference:completed; failures carry
their finished rounds on their log entries; inferenceCount and health
totals include them); and boundary-ended entries record a note instead of
the compiled request.
Basil-1007's review of 3447706: countUsage(undefined) took the once-flag
without counting, so a natural completion whose response carries no
details.usage, or anima-research#159's guard abandoning such a response, lost the rounds
already finished, and the finally backstop couldn't recover them.
countUsage now falls back to the latest cumulative sample, and
completeTurn uses the same value for inference:completed's tokenUsage and
the log entry, so the trace, the totals and the log agree. Identical to
main whenever the final response carries usage.

Tests: a natural completion whose response has no details.usage counts
the last sample in all three places; an abandoned guarded round whose
response has none counts each stream once from its samples.
Basil-1007's review of d7ea294: the guard path logged
`response.details?.usage ?? response.usage` while countUsage counted
`details.usage ?? latestUsage`, so with details.usage missing the log
entry said response.usage while the totals had the samples, and with both
missing logStream's default was overwritten by an explicit undefined.
- The guard path computes one value, `details.usage ?? latestUsage`, for
  countUsage, its log entry and noteRefusal.
- logStream defaults tokenUsage when an entry's is undefined, not only
  when the key is absent, so no caller can erase it.
- countUsage no longer falls back itself (d7ea294 added that): every
  caller now passes the one value it also reports (completeTurn's `used`,
  the guard's `abandoned`, latestUsage at a restart and in finally), so
  the fallback there was redundant.

Test: the guard test without final usage also checks that the withheld
stream's and the retry's log entries carry what the totals counted.
@nissa-seru nissa-seru changed the title fix(usage): account for tool-ended inference turns fix(usage): a tool-ended turn completes, and every stream counts its usage once Oct 10, 2026
@nissa-seru

Copy link
Copy Markdown
Contributor Author

I pushed 099efc9…f7d45e5, from a check of this PR against current main (02adf27). Jasper, who opened it, is no longer working on it, so I've taken it over.

  • 099efc9 and 4484548: the CHANGELOG entry moves into a changelog.d fragment, and main is merged in, cleanly.
  • 3447706: the rule at its other places. A stream's billed usage is now counted once however the stream ends.
    • A context-budget or physical-window restart cancels its stream at the same boundary as endTurn. It is counted and logged where it's decided, without inference:completed, since the turn goes on.
    • A stream that errors, aborts or throws after finishing rounds counts them in driveStream's finally, and its failure entry in the inference log carries them.
    • #​159's guard counts through the same once-only count.
  • 3447706: a tool-ended turn now ends what a completion ends. That covers the refusal-rewind episode (a forced /unstick reports success) and a provider-cooldown recovery, beside the failure streak and the rewind budget that inference:completed already resets.
  • Inference-log growth. A tool-ended or restarted stream's log entry records a note instead of the compiled request. logInference keeps a request over 10 KB as its own uncompressed blob per entry, with no retention, and the previous head added one per tool-ended turn.
  • d7ea294 and f7d45e5: a final response without usage counts the rounds' samples, and the guard's log entry and refusal note carry what it counts.
  • Results: 1394/1394 on Node 20.20 and Node 24.21, each test file run on its own. With main's src, 15 of the 18 new or changed tests fail. Removing each piece fails a test; the one exception is explained in the description.
  • Rewritten: the description, title and fragment.
  • Left for their own pull requests, named in the description:
    • bounding the request bodies of natural-completion and failure entries;
    • the physical-window projection reading cumulative samples as one call's.
  • Companion fix: connectome-host's agent-tree reducer double-counts cumulative usage samples. A fix is being prepared there.

— Ada-1017

…rd keeps its response-usage fallback

A review of this pull request found that the count and the log disagreed
for some ends: driveStream's finally counts every stream's usage once, but
a turn abandoned by an operator quiesce returned before any log write, a
superseded stream (its natural completion, its abort, the generation check
at the top of the loop, a throw) returned or broke without one, and an
entry written after another entry of the same stream copied the stream's
latest usage sample again. So session totals and log-backed health totals
disagreed, and the text contradicted itself.

Counted once and logged once:
- Every entry of a stream goes through logStream, which now carries the
  stream's usage on the first entry that has any (the value the entry
  names, else the latest cumulative sample) and on no later entry of that
  stream. Before, a failure entry written after a completion's carried the
  rounds again, and log-backed totals counted them twice.
- countUsage records the value it counted. In finally, after counting, a
  stream whose counted usage no entry carried writes one entry, success
  false, naming what ended it: "Turn abandoned by operator quiesce" (set
  where the quiesce-abandoned abort returns), "Stream superseded: a newer
  stream of the agent took over" when a newer stream of the same agent
  took over, or "Stream abandoned: its agent was disposed or replaced"
  when the agent itself is gone (a disposed ephemeral, a replaced
  conversation). The entry carries exactly what was counted. A stream with
  no usage writes nothing there, as before.
- InferenceLogEntry.tokenUsage's comment says that exactly one entry of a
  stream carries its usage.

anima-research#159's guard counts and notes one value again with main's fallback:
response.details?.usage ?? latestUsage ?? response.usage. Before this
commit the fallback stopped at the running sample, so a guard refusal whose
stream emitted no usage event counted and noted nothing, where main noted
the response's own usage.

Comments that list the ends taken at a tool boundary (StreamBoundaryEnd,
streamBoundaryEnds, BOUNDARY_END_REQUEST, InferenceLogEntry.stopReason) now
name the physical-window restart beside the context-budget one, as the code
handles both.

Changelog: consumers that end a turn's display on inference:completed are
a host's, such as connectome-host's; quiesce-abandoned and superseded
streams, and those of a disposed agent, now write a failure entry carrying
their rounds, and exactly one
entry of each stream carries its usage; the new boundary entries still grow
the log, which keeps no retention, by one small entry per tool-ended turn
and restart.

Tests:
- end-turn-usage: a turn abandoned by an operator quiesce after a finished
  round, and a stream superseded by a newer one after a finished round,
  each count that round once and write one failure entry carrying it,
  naming what ended it; a failure after a completion (on a resident, whose
  stream's catch logs it) is logged without carrying the usage again.
- end-turn-usage: the stream error, abort and thrown-stream tests wait for
  the run to settle, bounded, and check it failed with the stream and that
  no retry had been made by then, instead of swallowing the rejection and
  sleeping 50 ms; the provider-cooldown test waits until the retried call
  happens, with time only as the outer bound, instead of sleeping 1.1 s;
  the forced /unstick tests read the posted outcome ("the model
  responded"), not only that the session was cleared.
- tool-result-guard: the abandoned-round test scripts membrane's cumulative
  samples (10, then 20 input tokens) in the test, so a count taken from
  the first sample fails it; a new test strips the usage events, so the
  guard counts and logs the refused response's own usage.

Each of these fixes, undone alone, fails end-turn-usage or
tool-result-guard: the finally entry; the quiesce-abandoned name; the
once-only entry; the guard's response-usage fallback; keeping the latest
sample; the forced /unstick's success report.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

A new commit, d096f6d, answers a review we ran on this change.

  • A stream's usage is now logged once, however it ends, so log-backed totals agree with the session's. Three ends wrote no inference-log entry before: a turn abandoned by an operator quiesce, a stream superseded by a newer one, and a stream whose agent was disposed. Each now writes a failure entry naming what ended it and carrying its rounds. No later entry of a stream carries its usage again.
  • The guard's abandoned stream keeps main's fallback to the response's own usage when no usage event arrived.
  • The tests: the failure ends wait for the run to settle instead of a fixed sleep, the cooldown test waits for the retried call, the /unstick tests read the posted report, and the guard test scripts cumulative usage.

The inference log's retention is filed separately as #​293. The full suite passes 1398/1398 on Node 20 and 24.

— Ada-1017

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
Git merged it without conflicts: no hunk of main's 345 commits since 02adf27 overlaps this branch's. anima-research#207, still open, conflicts with this branch only in the comment both add at the usage event's case in src/framework.ts.
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
… the stream's running total

#92's projection adds agent.lastStreamRealInputTokens and
lastStreamOutputTokens as the prior round's real input and output. The
usage case set them from membrane's usage event, which is cumulative across
the native/XML tool loop (TurnUsageAccumulator.addRound), so after k calls
they held k prompts and k outputs, and a stream restarted through a fresh
compile while its next request would still fit. driveStream already derives
the per-call usage for the estimator's calibration (perCallUsage, the delta
between successive cumulative samples); set both fields from it.

lastStreamInputTokens stays the stream's accumulated fresh input: the
maxStreamTokens budget check is commented as accumulated and reads it so.
The fields' docs in agent.ts now say which is which.

Test (physical-window-restart): a two-tool-round stream whose second call's
prompt is 118k (38k fresh + 80k cache read) with 30k output and a 32k
reserve projects to ~186k of a 200k window and must not restart. Main reads
the second cumulative sample (198k prompt, 60k output) and restarts at a
projected 296,043. Taking only the input or only the output term from the
cumulative sample restarts too (both caught by the test).

Found by Nell-1783 while reviewing the connectome-host companion to #207.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Milo-2653 and others added 4 commits October 16, 2026 15:40
main moved 472 commits past 02adf27, the last main this branch merged.
Two hunks of src/framework.ts conflicted, both side by side:

- endTurn at a tool boundary: main passes the turn's routing to
  settleAgent (`this.turnRouting.get(agent.name)`); this branch records
  the completed turn after settleAgent. The resolution keeps main's call
  and this branch's record after it, in that order, as before.
- driveStream's `usage` case: this branch keeps the stream's latest
  cumulative sample (`latestUsage = event.usage`); main documents
  `lastStreamInputTokens` as the input the maxStreamTokens budget checks.
  Both are kept, this branch's line first.

Everything else merged without conflict, test/tool-result-guard.test.ts
and the type files included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e entry

main's 5a45eb6 (Oct 11) added a failure site in driveStream's catch: a
turn that doesn't own provider admission, whose stated wait holds it
(nonOwningProviderWait answers 'held'), writes a "Provider wait: …"
entry. It wrote it with logInference directly, past this branch's
once-only log, so a stream that had finished rounds got two entries: the
wait's, without usage, and the one `finally` writes for an end that wrote
none ("Stream ended without a final response"), with it.

It now writes through logStream, as the cooldown entry beside it does, so
the wait's entry is the stream's one entry and carries the rounds it
finished. That was the last direct logInference in driveStream.

Test: a conversation agent whose stream finishes a round and then throws
a stated 429 logs one entry, the wait's, carrying that round, and counts
it once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n a call answers

emitTrace clears the consecutive-failure streak and the poison-history
breaker's rewind budget at inference:completed, which this branch sends
for a tool-ended turn too. Its comment said "a successful response —
even mid-turn between tool calls — proves the agent isn't hard-down",
which the code didn't do. A reset at each answered call (inference:usage,
which membrane sends once a round's provider stream has returned) would
make it true, and would hide what the streak is for: noteInferenceExhausted
raises the hard-down alert ("it cannot complete a turn") and runs the
breaker only at a streak of 3, so an agent whose every turn has a first
call that answers and a later one that fails would stay at 1, with
neither. The comment now says what resets it and why an answered call
doesn't, and quarantinePoisonedHistory's doc says the budget resets when
a turn completes.

Test: three turns that each fail with a 400 after a call that answered
count 1, 2, 3, raise one hard-down alert and run the breaker's shed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tests for what each end logs

A review of this branch merged with main found these, each checked
against the code:

- driveStream's finally now writes to the store before its teardown: it
  counts the stream's usage and, for an end that wrote no entry, logs
  one. logInference has no try/catch, so a store error there skipped the
  lifecycle terminal, the event gate's release, the turn token and the
  KV receipts, and rejected driveStream's promise. The accounting is now
  guarded: a failed write is said on stderr ("[usage] agent=… could not
  record a stream's usage at its end") and the teardown runs.
- A refusal completion's note took its tokens from the final response's
  detailed usage alone, while the count falls back to the stream's latest
  sample. The note now carries what was counted, as the guard's refusal
  note already did.
- Texts said more than the code does:
  - an end that writes no entry of its own (a quiesce abandon, a
    superseded stream, a disposed agent's stream) gets one, and is
    counted, only when it had finished a round; with none there is
    nothing to carry, and nothing is written, as before. The changelog,
    the InferenceLogEntry doc and the finally's comment say so;
  - totals summed from the inference log count each stream once, as the
    session's do, rather than "agreeing" with them: a health snapshot's
    token totals read the log's newest 1,000 entries, which these entries
    now fill with more turns. The changelog says so;
  - the changelog's caller list says a consumer that totals
    inference:completed's tokenUsage now counts tool-ended turns, and one
    that also adds every usage sample counts them twice; its growth note
    names the failure entry, with its request, of an abandoned,
    superseded or disposed stream;
  - the module contract's onTrace doc says a tool-ended turn emits
    inference:completed and then inference:turn_ended;
  - the field docs of the failure streak and the poison-history rewind
    budget say they reset when a turn completes, and the comments on the
    stream's count and its one log entry say which value each end counts
    and which entry carries it.

Tests: the replaced-agent and closed-stream texts of the finally's entry;
a quiesce abandon before the first round returns writes no entry and
counts nothing; a write that fails in finally leaves the stream and its
turn released; a refusal completion's note carries the counted tokens.
The tool-ended and restart tests wait for every driven stream's teardown
instead of one setImmediate, and the failure-end test clears its 10 s
bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts logStream; name two more ends in the changelog

logStream, inside driveStream, gives a stream's usage to one inference-log
entry only. An end that called logInference directly for a stream would
write a second entry for it, and an entry carrying the usage would count
it twice in the log. Saying so where a new end would reach for
logInference points the next change at the rule.

The changelog fragment now names two ends it left out: a stream that
ended with no final response and no other end, which writes its failure
entry as the abandoned, superseded and disposed ones do, and a store
error while a stream's end is recorded, which is reported on stderr
while the stream's teardown still runs. Both have tests ("a stream that
just closes after a finished round", "a write that fails while finally
logs the stream doesn't skip the teardown after it").

Comments and the changelog only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/framework.ts
currentState.stream.cancel();
// Its rounds were billed; the restart opens a fresh stream
// with its own usage.
this.streamBoundaryEnds.get(currentState.stream)?.(overBudget ? 'context_budget' : 'physical_window');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Log failures prevent restarts

If logInference throws during a context-budget or physical-window restart, this callback exits before agent.cancelStream() and the next context_budget_restart request. The stream is already canceled and marked budget_restart, so its abort path keeps the event gate open for a successor that was never queued. The turn stops, and debounced messages can stay buffered after storage recovers.

Catch boundary-accounting errors without skipping the reset and restart scheduling, and add a failing-write restart test.

Knowledge Base Used: Agent lifecycle and activation

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 9107

Comment:
**Log failures prevent restarts**

If `logInference` throws during a context-budget or physical-window restart, this callback exits before `agent.cancelStream()` and the next `context_budget_restart` request. The stream is already canceled and marked `budget_restart`, so its abort path keeps the event gate open for a successor that was never queued. The turn stops, and debounced messages can stay buffered after storage recovers.

Catch boundary-accounting errors without skipping the reset and restart scheduling, and add a failing-write restart test.

**Knowledge Base Used:** [Agent lifecycle and activation](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-lifecycle.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in bb11216. The recorder that the boundary calls (streamBoundaryEnds) now catches a failed log write and reports it on stderr, with the line finally's accounting already uses ([usage] agent=… could not record a stream's usage at its end). After that, the restart still runs agent.cancelStream(), emits inference:stream_restarted and queues the request that opens its next stream.

The same call on a tool-ended turn had the same gap: a failed write skipped the end of the turn's refusal episode, inference:turn_ended and the tool result's dispatch to modules. The fix covers that call too.

Two tests in test/end-turn-usage.test.ts make that write throw: "a restart still opens its next stream" and "a tool-ended turn still ends its refusal episode, says it ended, and hands the tool's result to the modules". With the previous head's src/framework.ts (57ad658), both fail, each at its own assertion. At this head, 8d45cb4, both pass.

Written by Milo-2653.

— Lottie-2661

@nissa-seru nissa-seru added the no-greptile For maintainers and the team: Greptile skips this PR. Not for general use. label Oct 17, 2026
Milo-2653 added 2 commits October 17, 2026 14:12
… boundary's work

The boundary's ends call driveStream's accounting through
streamBoundaryEnds: a tool-ended turn's completion, and a context-budget
or physical-window restart's entry for the stream it cancels. That
accounting writes the stream's inference-log entry, and logInference
has no try/catch, so a store that refused the write threw out of the
tool-result handler, into runLoop's catch. On a restart that skipped
agent.cancelStream(), the inference:stream_restarted trace and the
context_budget_restart request. The cancelled stream's abort keeps the
event gate for its successor (preserveEventGateForSuccessor), and none
was queued, so the agent stayed in `ready` with the gate held. On a
tool-ended turn it skipped endRefusalEpisode, the inference:turn_ended
trace and, on both, the tool-result event's dispatch to modules.
Greptile found the restart case on the pull request; the turn's end is
the same call one branch up.

The closure now catches what its accounting throws and reports it on
stderr with the message finally's accounting already uses, so what the
boundary does after the call still runs. The provider-cooldown receipt
and the refusal episode's end stay outside the try, in the order they
had, since neither writes to the store. A failed entry is not written
again later: logStream has already marked the stream's usage as logged,
as with finally's failed write.

Tests, in end-turn-usage: a restart whose entry's write throws still
opens its next stream, announces the restart and ends the turn on it;
a tool-ended turn whose completion entry's write throws still ends its
refusal episode, emits inference:turn_ended and dispatches the tool's
result event to modules. Both report the failure on stderr. The
changelog's sentence on store errors now names the boundary's ends
beside the teardown.
One conflict, in the tool boundary's budget restart. anima-research#382 removed the
direct currentState.stream.cancel() and its comment there, since
agent.cancelStream() now cancels the stream at `ready` itself. This
branch had added its end recorder's call after that direct cancel. The
resolution takes main's side and keeps the recorder's call inside the
`if (currentState.stream)` block, after the framework's cancel marker
and before agent.cancelStream(): the recorder counts and logs the
stream from its own closures and reads nothing the cancel changes, and
its write can't skip the cancel, since it catches what it throws
(bb11216).

Everything else merged without conflict.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
nissa-seru pushed a commit to nissa-seru/connectome-host that referenced this pull request Oct 17, 2026
…ows the agent whose turn it shows

agent-framework 0.22.2 ends an agent's stream with one of five traces:
inference:completed; inference:turn_ended, when a tool ended the turn
(skip_reply, sleep, a subagent's return); inference:aborted;
inference:failed; and inference:exhausted. The activity module counts all
five. The TUI's status line, the Web UI's streaming message, a subagent's
peek stream, piped mode's wait and the TTS relay's activation ended a turn
only at some of them, and outage notices took only a completed turn as the
agent answering again.

- src/state/turn-trace.ts names the five (STREAM_END_TRACES) and the four
  after which nothing more of the turn runs (TURN_SETTLED_TRACES: not
  failed, which a retry or a held turn can follow), with what 0.22.2 sends
  and the three cases that send no end, and the traces besides
  inference:started that only a running turn sends (TURN_RUNNING_TRACES:
  a tool call yielded, a resumed stream). The activity module, the TUI,
  the Web UI, piped mode and TurnInterrupts read it. The TTS relay
  switches over the same five, since it gives each its own reason.
- TUI: the root agent's status line ends at each of the five. A turn a tool
  ended goes idle as a completed one does, showing a backgrounded turn's
  output. An abort goes idle with "(turn aborted: <reason>)", and an
  exhausted stream that nothing ended before goes idle with
  "(turn ended: <error>)". The flag that says a root turn is showing moves
  in rootTurnStep: a start sets it, and so do a tool call yielded and a
  resumed stream (TURN_RUNNING_TRACES); an end clears it, so an end
  that follows an end adds nothing. A failure always shows, since
  agent-framework sends one only for an attempt it started. So a turn
  whose start the TUI didn't follow, as one that began before the TUI
  followed the framework at startup or after a switch, still shows its
  end or failure. rootTurnEnd and rootTurnStep are pure and tested. The
  peek view shows a line for a subagent's turn_ended, aborted, failed and
  exhausted (peekEndLine, pure and tested).
- Interrupts: src/state/turn-interrupt.ts (TurnInterrupts) takes the TUI's
  Escape and the Web UI's interrupt to the framework's abortInference,
  which in 0.22.2, as in 0.22.1, stops a turn only while its model call
  is in flight (inferring, streaming, or waiting on tools beside its
  stream), and sends inference:aborted, so the turn ends as an abort on
  every surface. It returns false while a turn compiles its context,
  after inference:started, and while the turn waits to try a failed
  attempt again, when the agent is idle, and while it runs tools with no
  stream open. The interrupt is then kept, and the turn is aborted at the
  first trace naming its agent that comes once it can be: for a turn
  running tools, whose traces name no agent, at its next model call. A kept
  interrupt is forgotten when its turn settles, when it aborts the turn,
  and at a reset; a start never ends a turn, since a failed attempt's
  retry, a budget restart and a guard retry each start the same turn
  again with no end before it. The abort for a kept interrupt runs once
  the trace that prompted it has reached every listener, so each surface
  sees that trace before the abort's inference:aborted. A throw from an
  abort, at once or kept, is read as nothing aborted and logged once for
  each interrupt under an [interrupt] tag, which the bug report's list of
  the host's own tags now names, and the interrupt is kept while its turn
  runs. agent-framework 0.22.2 ends a turn it drops after
  inference:started with inference:aborted, which forgets its kept
  interrupt, but sends no end when another turn of the agent's name has
  already begun; traces carry no turn id, so that turn takes the
  interrupt. It sends none either for a failed attempt whose retry it
  drops (a held one when its agent is disposed or the coalesced wake it
  answers is withdrawn, any once it stops), and where the agent goes on,
  an interrupt kept for that turn aborts its next one. An agent with no
  turn running is left as it is: neither surface resets the agents it
  interrupts with cancelStream any more, though the subagent module's
  cancelAll still does for the subagents whose runs it stops. A turn
  whose start the follower missed counts as running from its first tool
  call or resumed stream, so an interrupt is kept for it; one that
  answers with neither isn't counted. Each surface's TurnInterrupts is
  fed every trace of its framework, and a session switch starts it again
  with the new framework.
  Both surfaces take the subagents' turns to TurnInterrupts before they
  stop the subagents' runs through the subagent module's cancelAll
  (interruptSubagents). Stopping a run ends a subagent's turn only while
  its stream is open: 0.22.2's cancelStream cancels an open stream and
  sets the agent idle, and an ephemeral agent's cleanup does nothing, so
  a subagent's turn whose model call was in flight before its first
  token, or that was compiling, waiting to try again or running tools
  between model calls, went on. Taken first, a turn whose model call is
  in flight, streaming or not, is aborted at once, and any other is kept
  and aborted once it can be.
- TUI's Escape acts while the status line shows a turn (escapeActs, pure
  and tested): not when it reads idle, nor while a session switch is
  under way, whose end sets the status line itself, and on an error only
  while TurnInterrupts has the root's turn running (isRunning), whatever
  set the error: as while a failed attempt waits to be tried again, or
  after the framework drops its retry with no trace, which nothing tells
  apart. An error shown with no turn running stands. Main's Escape did
  nothing on an error, and while a switch was under way said
  "(interrupted)" and set the status line idle. The status bar offers
  Escape where it acts, and repaints at an end that follows an end, which
  can settle the turn TurnInterrupts held running. Aborted, Escape shows
  how many subagents it stopped; kept, it says "(interrupting: the turn
  stops as soon as it can be aborted)", and the turn's own end, when the
  abort comes, ends it on the status line; when the status line shows a
  turn running and TurnInterrupts has none, as after a message is sent
  and before its turn starts, it interrupts nothing, says "(no turn
  interrupted)" and sets the status line idle, which a turn that then
  starts sets again (escapeLine, pure and tested). Main's Escape reset
  the agent with cancelStream and said "(interrupted)" whatever it
  stopped, where a reset stops nothing while the turn compiles. The TUI's
  root subscription follows one framework at a time through piped mode's
  traceFollower, and a session switch moves it with switchFollowing, so
  after a failed switch it follows whatever framework the host holds. The
  switch forgets the turn that was showing, as it resets TurnInterrupts,
  so a late end from the framework it follows after a failed switch shows
  nothing, unless it's a failure, which always shows; after a switch that
  succeeds, no trace of the old framework reaches the TUI. The fleet
  process peek reads a child's error as every surface does (traceError).
- Web UI: the trace dispatch, each tree node's choice of stream panel and
  that panel's routing move to src/web/agent-streams.ts, which the page
  imports and tests can reach. The main view's streaming message follows
  the conversation agent alone, the welcome's first agent, whose context
  the view shows (conversationAgentOf, which App.tsx and tree.ts call): a
  sync subagent's tokens, content blocks and tool calls add nothing to it,
  and its end doesn't end the message. The message ends at each of the
  agent's five stream ends, a failed attempt's included, so a retry streams
  as a message of its own. The quiesce re-pull still runs at any agent's
  end, since a quiesce drains when the last turn ends. Another of the
  host's framework agents (a subconscious, a conversation fork), whose
  stream used to join the main view, gets a stream panel of its own on its
  node, fed by its traces from when the panel opens; agent-framework's
  tool traces name no agent, so that panel shows the calls its agent
  yields but not their results. Stream panels show a line for a turn a
  tool ended, an abort and a turn the framework gave up on, and skip
  content-block and usage traces; their lines move to
  src/web/stream-lines.ts, which tests reach, and print an error as the
  other surfaces do (traceError). Each panel shows at most
  2,000 lines, counting lines rather than their length: its newest, and
  once it lets older lines go, a first line saying how many it no longer
  shows. The page's interrupt takes the subagents' turns, then their
  runs, then every other agent's turn to the module's TurnInterrupts, one
  per framework (interruptPage in web-ui-module.ts). Its reply names the
  agents whose turns will stop as soon as they can be aborted,
  or says "no turn interrupted" when it aborted, kept and stopped
  nothing, where main said "interrupted" (interruptReply). setApp ends
  the module's subscription to the framework it followed before, so a
  switched-away framework's late traces reach no page and no follower.
- Subagent peek stream: it passes on a stream's turn_ended, aborted (with
  its reason) and exhausted (with its error), as it passed on completed and
  failed. A failed or exhausted error that's missing, empty or not text
  passes on as "Unknown error", where main's fallback took only a missing
  one, and the TUI, its peek views, the stream panels and piped mode print
  an error the same way (traceError, in src/state/turn-trace.ts). The
  in-flight flag the reaper reads is left as it was.
- Piped mode: src/piped-turns.ts prints every agent's prose and tool calls
  as before, and each stream's first end closes its line. A line's wait
  settles at the primary agent's settled-turn traces only, the agent by
  the name the framework created it with. A failure prints its error and
  the wait goes on, since a retry or a held turn answers the line; the
  120-second cap still bounds it, so a turn held longer lets the next line
  in, with a line on stderr saying piped mode goes on without its answer.
  The traces don't say which events started a turn, so another turn of
  the primary's, such as a wake's, can still end a line's wait, as on main.
  A primary turn that is aborted or given up on prints a line saying so,
  unless its last attempt failed with no retry started since, when that
  failure's error line stands for it. An end of a stream that has
  already settled is nothing new: 0.22.2 sends an abort's trailing
  exhausted before the aborted turn releases the agent, and starts no
  next turn of the agent until then. On a session switch, piped mode
  stops following before the switch and follows whichever framework the
  host holds after it, also when the switch fails, when that can be the
  old framework, stopped (switchFollowing, with traceFollower).
- Notices: a turn a tool ended closes outage kinds that have no clear of
  their own and clears the failed turn's waker, as a completed turn does.
  An aborted turn ends with no wake, so the exhausted after its abort tells
  no channel the turn died; an operator's interrupt is now such an abort,
  where its lone exhausted read as a failure.
- TTS relay: a turn a tool ended ends its activation as "complete".
- Every surface that shows a turn's state, or waits on one, takes an end
  after an end as nothing new, so a turn a tool ended that sends completed
  and then turn_ended, as anima-research/agent-framework#207 would have
  it, ends once. A stream panel lists each end it's sent.

Tests:
- test/turn-trace.test.ts: both sets, pinned to the five ends;
  traceError's reading of an error that's missing, empty or not text.
- test/tui-root-turn-end.test.ts: rootTurnEnd at each end, an end while
  no turn shows, and traces that aren't ends; rootTurnStep through a
  start, a first end, a second end and a retry, and through a turn's
  running traces; a failure while no turn shows; an end whose error is
  empty or missing; peekEndLine's line for each end, for an abort whose
  reason is empty and for an error that's missing or empty; escapeLine's
  line for each of Escape's outcomes, and whether it ends the turn;
  escapeActs at each status, with the root's turn running and without.
- test/turn-interrupt.test.ts: an interrupt aborts a turn whose call is in
  flight at once; a compiling turn's is kept and aborted at its first
  trace once it can be, after that trace has been handled; a kept one is
  forgotten at each settled end, and kept through a failure's retry and
  through a restart with no end before it; a tool wait whose traces name
  no agent keeps it for the agent's next trace; a second interrupt that
  aborts at once takes the kept one with it; a throw from the deferred
  abort is caught, logged once while kept and the interrupt kept, and
  logged again for a later interrupt; a throw from the immediate abort is
  read as nothing aborted, kept for a running turn and idle otherwise; a
  turn that settles before its queued abort runs isn't aborted; each
  agent's is its own; a turn runs from its start until it settles,
  through a failed attempt (isRunning); reset forgets them all; a turn
  whose start it missed runs from its tool call or resumed stream, and
  one that fails before either doesn't; interruptSubagents takes each
  subagent's turn before the runs are stopped, aborting one whose stream
  is open and keeping one that compiles.
- test/piped-turns.test.ts: the wait at each settled-turn trace, through a
  failure and its retry, at a failure the framework gives up on, at an
  end whose error is empty or missing, past another agent's ends, past a
  settled turn's second end, and at the cap;
  what prints, another agent's aborted or exhausted stream closing only
  its line, and the tool-call lines with a long input cut at 120
  characters; traceFollower, which stops following a framework it leaves,
  by a switch or by stop, and switchFollowing through a switch and one
  that throws; the cap's line on stderr, and none for a turn that settles
  first.
- test/web-agent-streams.test.ts: the conversation agent; each end from
  the conversation agent and from another agent, another agent's stream
  kept out of the main view, each node's panel and the events each panel
  takes, and the page's other traces.
- test/subagent-peek-ends.test.ts: the peek stream passes on the three
  other ends, an abort's reason only when it has one, "Unknown error" for
  an error that's missing, empty or not text, leaves the in-flight flag as
  it was, and sends nothing for another agent.
- test/stream-lines.test.ts: a stream panel's line for each end, and for
  an error that's missing or empty, the traces it skips, a panel of
  exactly 2,000 lines keeping its first line,
  and the line that says how many it let go, carried on as more go.
- test/web-ui-interrupt.test.ts: interruptAgents aborts each agent's
  turn with its reason, at once or once it can be, leaves an agent with no
  turn alone, and goes on past one whose abort throws, which the follower
  reads as nothing aborted; interruptPage takes
  the subagents' turns, then their runs, then the rest, names only the
  turns it kept, a subagent whose stream was open not among them, takes a
  subagent once though the runs' stop lets go of it at once, gives every
  abort the Web UI's reason, names another agent's kept turn as well as a
  subagent's, and with no follower still stops the runs; interruptReply's
  words, "no turn interrupted" among them.
- test/subagent-own-failures.test.ts: a live attempt's agent is the
  subagent's (isSubagentAgent) until its run settles.
- test/web-ui-module.test.ts: setApp unsubscribes from the framework it
  followed before.
- test/notices-module.test.ts: a turn a tool ended closes a hard-down
  episode and clears the waker, as a completed turn does; another agent's
  doesn't; both ends, in either order, close it once; an aborted turn's
  exhausted tells no channel, while the next failure does.
- test/tts-relay-turn-end.test.ts: a turn a tool ended ends its
  activation; another agent's leaves it open; both ends, in either order,
  end it once.
- test/batch-mode.test.ts: after /session new, the next line's turn is
  printed, and Done. comes within 90 seconds of that line, inside the
  120-second cap.
- test/tui-turns.test.ts, with test/mock-tui-turns-child.ts: runTui
  itself, under a test renderer and a fake framework. Escape when idle,
  after a message is sent and before its turn starts, with a model call
  in flight, while the turn compiles and once it can be aborted, in the
  background after Ctrl+B, while a failed attempt waits and at its retry,
  and after a turn the framework gave up on: what it asks the framework,
  the lines it shows, each error's once, and the status bar's offer of
  it. A session switch that succeeds and one that fails, each started
  while a turn runs: Escape does nothing while it's under way, a late end
  with no start since shows nothing and leaves the status line, and
  traces reach the TUI only from the framework the host holds, and a late
  failure shows. A turn whose start the TUI didn't follow: its running
  traces show it, Escape keeps its interrupt, and its end and its failure
  show. With a subagent compiling: Escape takes its turn, then stops its
  run, then aborts the root's, and its interrupt aborts it once its call
  goes out. The fake framework sends an abort's trailing exhausted, as
  0.22.2 does.
- test/mock-tui-shutdown-child.ts: its framework's onTrace returns the
  listener's unsubscribe, as agent-framework's does, and either way of
  detaching counts, so test/tui-shutdown.test.ts still pins one detach at
  each shutdown now that the TUI detaches through traceFollower.

Checks at this commit's tree (Linux, Node 24.22.0 with npm 11.19.1,
Bun 1.4.3, Bun's transpiler cache off), on main as agent-framework 0.22.2
installs it, beside the suite and the web build that CI runs:
- npm ci, then root tsc --noEmit: no errors. The web tsc reports only the
  error main has at App.tsx:948, here at :950.
- test/tui-turns.test.ts, test/tui-root-turn-end.test.ts,
  test/web-ui-interrupt.test.ts, test/turn-interrupt.test.ts,
  test/tui-shutdown.test.ts and test/turn-trace.test.ts: 59 pass, 0 fail,
  three times; the isSubagentAgent case in
  test/subagent-own-failures.test.ts passes three times.
- Each of the change's pieces has a mutant that fails a test, run at this
  tree or on earlier code that reads the same here, but the Web UI's
  interrupt handler: nothing runs it, so a mutant of what it passes on
  (no agent marked a subagent) lives.
nissa-seru pushed a commit to nissa-seru/connectome-host that referenced this pull request Oct 17, 2026
…ows the agent whose turn it shows

agent-framework 0.22.2 ends an agent's stream with one of five traces:
inference:completed; inference:turn_ended, when a tool ended the turn
(skip_reply, sleep, a subagent's return); inference:aborted;
inference:failed; and inference:exhausted. The activity module counts all
five. The TUI's status line, the Web UI's streaming message, a subagent's
peek stream, piped mode's wait and the TTS relay's activation ended a turn
only at some of them, and outage notices took only a completed turn as the
agent answering again.

- src/state/turn-trace.ts names the five (STREAM_END_TRACES) and the four
  after which nothing more of the turn runs (TURN_SETTLED_TRACES: not
  failed, which a retry or a held turn can follow), with what 0.22.2 sends
  and the three cases that send no end, and the traces besides
  inference:started that only a running turn sends (TURN_RUNNING_TRACES:
  a tool call yielded, a resumed stream). The activity module, the TUI,
  the Web UI, piped mode and TurnInterrupts read it. The TTS relay
  switches over the same five, since it gives each its own reason.
- TUI: the root agent's status line ends at each of the five. A turn a tool
  ended goes idle as a completed one does, showing a backgrounded turn's
  output. An abort goes idle with "(turn aborted: <reason>)", and an
  exhausted stream that nothing ended before goes idle with
  "(turn ended: <error>)". The flag that says a root turn is showing moves
  in rootTurnStep: a start sets it, and so do a tool call yielded and a
  resumed stream (TURN_RUNNING_TRACES); an end clears it, so an end
  that follows an end adds nothing. A failure always shows, since
  agent-framework sends one only for an attempt it started. So a turn
  whose start the TUI didn't follow, as one that began before the TUI
  followed the framework at startup or after a switch, still shows its
  end or failure. rootTurnEnd and rootTurnStep are pure and tested. The
  peek view shows a line for a subagent's turn_ended, aborted, failed and
  exhausted (peekEndLine, pure and tested).
- Interrupts: src/state/turn-interrupt.ts (TurnInterrupts) takes the TUI's
  Escape and the Web UI's interrupt to the framework's abortInference,
  which in 0.22.2, as in 0.22.1, stops a turn only while its model call
  is in flight (inferring, streaming, or waiting on tools beside its
  stream), and sends inference:aborted, so the turn ends as an abort on
  every surface. It returns false while a turn compiles its context,
  after inference:started, and while the turn waits to try a failed
  attempt again, when the agent is idle, and while it runs tools with no
  stream open. The interrupt is then kept, and the turn is aborted at the
  first trace naming its agent that comes once it can be: for a turn
  running tools, whose traces name no agent, at its next model call. A kept
  interrupt is forgotten when its turn settles, when it aborts the turn,
  and at a reset; a start never ends a turn, since a failed attempt's
  retry, a budget restart and a guard retry each start the same turn
  again with no end before it. The abort for a kept interrupt runs once
  the trace that prompted it has reached every listener, so each surface
  sees that trace before the abort's inference:aborted. A throw from an
  abort, at once or kept, is read as nothing aborted and logged once for
  each interrupt under an [interrupt] tag, which the bug report's list of
  the host's own tags now names, and the interrupt is kept while its turn
  runs. agent-framework 0.22.2 ends a turn it drops after
  inference:started with inference:aborted, which forgets its kept
  interrupt, but sends no end when another turn of the agent's name has
  already begun; traces carry no turn id, so that turn takes the
  interrupt. It sends none either for a failed attempt whose retry it
  drops (a held one when its agent is disposed or the coalesced wake it
  answers is withdrawn, any once it stops), and where the agent goes on,
  an interrupt kept for that turn aborts its next one. An agent with no
  turn running is left as it is: neither surface resets the agents it
  interrupts with cancelStream any more, though the subagent module's
  cancelAll still does for the subagents whose runs it stops. A turn
  whose start the follower missed counts as running from its first tool
  call or resumed stream, so an interrupt is kept for it; one that
  answers with neither isn't counted. Each surface's TurnInterrupts is
  fed every trace of its framework, and a session switch starts it again
  with the new framework.
  Both surfaces take the subagents' turns to TurnInterrupts before they
  stop the subagents' runs through the subagent module's cancelAll
  (interruptSubagents). Stopping a run ends a subagent's turn only while
  its stream is open: 0.22.2's cancelStream cancels an open stream and
  sets the agent idle, and an ephemeral agent's cleanup does nothing, so
  a subagent's turn whose model call was in flight before its first
  token, or that was compiling, waiting to try again or running tools
  between model calls, went on. Taken first, a turn whose model call is
  in flight, streaming or not, is aborted at once, and any other is kept
  and aborted once it can be.
- TUI's Escape acts while the status line shows a turn (escapeActs, pure
  and tested): not when it reads idle, nor while a session switch is
  under way, whose end sets the status line itself, and on an error only
  while TurnInterrupts has the root's turn running (isRunning), whatever
  set the error: as while a failed attempt waits to be tried again, or
  after the framework drops its retry with no trace, which nothing tells
  apart. An error shown with no turn running stands. Main's Escape did
  nothing on an error, and while a switch was under way said
  "(interrupted)" and set the status line idle. The status bar offers
  Escape where it acts, and repaints at an end that follows an end, which
  can settle the turn TurnInterrupts held running. Aborted, Escape shows
  how many subagents it stopped; kept, it says "(interrupting: the turn
  stops as soon as it can be aborted)", and the turn's own end, when the
  abort comes, ends it on the status line; when the status line shows a
  turn running and TurnInterrupts has none, as after a message is sent
  and before its turn starts, it interrupts nothing, says "(no turn
  interrupted)" and sets the status line idle, which a turn that then
  starts sets again (escapeLine, pure and tested). Main's Escape reset
  the agent with cancelStream and said "(interrupted)" whatever it
  stopped, where a reset stops nothing while the turn compiles. The TUI's
  root subscription follows one framework at a time through piped mode's
  traceFollower, and a session switch moves it with switchFollowing, so
  after a failed switch it follows whatever framework the host holds. The
  switch forgets the turn that was showing, as it resets TurnInterrupts,
  so a late end from the framework it follows after a failed switch shows
  nothing, unless it's a failure, which always shows; after a switch that
  succeeds, no trace of the old framework reaches the TUI. The fleet
  process peek reads a child's error as every surface does (traceError).
- Web UI: the trace dispatch, each tree node's choice of stream panel and
  that panel's routing move to src/web/agent-streams.ts, which the page
  imports and tests can reach. The main view's streaming message follows
  the conversation agent alone, the welcome's first agent, whose context
  the view shows (conversationAgentOf, which App.tsx and tree.ts call): a
  sync subagent's tokens, content blocks and tool calls add nothing to it,
  and its end doesn't end the message. The message ends at each of the
  agent's five stream ends, a failed attempt's included, so a retry streams
  as a message of its own. The quiesce re-pull still runs at any agent's
  end, since a quiesce drains when the last turn ends. Another of the
  host's framework agents (a subconscious, a conversation fork), whose
  stream used to join the main view, gets a stream panel of its own on its
  node, fed by its traces from when the panel opens; agent-framework's
  tool traces name no agent, so that panel shows the calls its agent
  yields but not their results. An open panel closes when the session
  switches, since it shows the old session: a node of its tree, whose
  name a node of the new tree can carry, or its branches. Stream panels
  show a line for a turn a tool ended, an abort and a turn the framework
  gave up on, and skip content-block and usage traces; their lines move to
  src/web/stream-lines.ts, which tests reach, and print an error as the
  other surfaces do (traceError). Each panel shows at most
  2,000 lines, counting lines rather than their length: its newest, and
  once it lets older lines go, a first line saying how many it no longer
  shows. The page's interrupt takes the subagents' turns, then their
  runs, then every other agent's turn to the module's TurnInterrupts, one
  per framework (interruptPage in web-ui-module.ts). Its reply names the
  agents whose turns will stop as soon as they can be aborted,
  or says "no turn interrupted" when it aborted, kept and stopped
  nothing, where main said "interrupted" (interruptReply). setApp ends
  the module's subscription to the framework it followed before, so a
  switched-away framework's late traces reach no page and no follower.
- Subagent peek stream: it passes on a stream's turn_ended, aborted (with
  its reason) and exhausted (with its error), as it passed on completed and
  failed. A failed or exhausted error that's missing, empty or not text
  passes on as "Unknown error", where main's fallback took only a missing
  one, and the TUI, its peek views, the stream panels and piped mode print
  an error the same way (traceError, in src/state/turn-trace.ts). The
  in-flight flag the reaper reads is left as it was.
- Piped mode: src/piped-turns.ts prints every agent's prose and tool calls
  as before, and each stream's first end closes its line. A line's wait
  settles at the primary agent's settled-turn traces only, the agent by
  the name the framework created it with. A failure prints its error and
  the wait goes on, since a retry or a held turn answers the line; the
  120-second cap still bounds it, so a turn held longer lets the next line
  in, with a line on stderr saying piped mode goes on without its answer.
  The traces don't say which events started a turn, so another turn of
  the primary's, such as a wake's, can still end a line's wait, as on main.
  A primary turn that is aborted or given up on prints a line saying so,
  unless its last attempt failed with no retry started since, when that
  failure's error line stands for it. An end of a stream that has
  already settled is nothing new: 0.22.2 sends an abort's trailing
  exhausted before the aborted turn releases the agent, and starts no
  next turn of the agent until then. On a session switch, piped mode
  stops following before the switch and follows whichever framework the
  host holds after it, also when the switch fails, when that can be the
  old framework, stopped (switchFollowing, with traceFollower).
- Notices: a turn a tool ended closes outage kinds that have no clear of
  their own and clears the failed turn's waker, as a completed turn does.
  An aborted turn ends with no wake, so the exhausted after its abort tells
  no channel the turn died; an operator's interrupt is now such an abort,
  where its lone exhausted read as a failure.
- TTS relay: a turn a tool ended ends its activation as "complete".
- Every surface that shows a turn's state, or waits on one, takes an end
  after an end as nothing new, so a turn a tool ended that sends completed
  and then turn_ended, as anima-research/agent-framework#207 would have
  it, ends once. A stream panel lists each end it's sent.

Tests:
- test/turn-trace.test.ts: both sets, pinned to the five ends;
  traceError's reading of an error that's missing, empty or not text.
- test/tui-root-turn-end.test.ts: rootTurnEnd at each end, an end while
  no turn shows, and traces that aren't ends; rootTurnStep through a
  start, a first end, a second end and a retry, and through a turn's
  running traces; a failure while no turn shows; an end whose error is
  empty or missing; peekEndLine's line for each end, for an abort whose
  reason is empty and for an error that's missing or empty; escapeLine's
  line for each of Escape's outcomes, and whether it ends the turn;
  escapeActs at each status, with the root's turn running and without.
- test/turn-interrupt.test.ts: an interrupt aborts a turn whose call is in
  flight at once; a compiling turn's is kept and aborted at its first
  trace once it can be, after that trace has been handled; a kept one is
  forgotten at each settled end, and kept through a failure's retry and
  through a restart with no end before it; a tool wait whose traces name
  no agent keeps it for the agent's next trace; a second interrupt that
  aborts at once takes the kept one with it; a throw from the deferred
  abort is caught, logged once while kept and the interrupt kept, and
  logged again for a later interrupt; a throw from the immediate abort is
  read as nothing aborted, kept for a running turn and idle otherwise; a
  turn that settles before its queued abort runs isn't aborted; each
  agent's is its own; a turn runs from its start until it settles,
  through a failed attempt (isRunning); reset forgets them all; a turn
  whose start it missed runs from its tool call or resumed stream, and
  one that fails before either doesn't; interruptSubagents takes each
  subagent's turn before the runs are stopped, aborting one whose stream
  is open and keeping one that compiles.
- test/piped-turns.test.ts: the wait at each settled-turn trace, through a
  failure and its retry, at a failure the framework gives up on, at an
  end whose error is empty or missing, past another agent's ends, past a
  settled turn's second end, and at the cap;
  what prints, another agent's aborted or exhausted stream closing only
  its line, and the tool-call lines with a long input cut at 120
  characters; traceFollower, which stops following a framework it leaves,
  by a switch or by stop, and switchFollowing through a switch and one
  that throws; the cap's line on stderr, and none for a turn that settles
  first.
- test/web-agent-streams.test.ts: the conversation agent; each end from
  the conversation agent and from another agent, another agent's stream
  kept out of the main view, each node's panel and the events each panel
  takes, and the page's other traces.
- test/subagent-peek-ends.test.ts: the peek stream passes on the three
  other ends, an abort's reason only when it has one, "Unknown error" for
  an error that's missing, empty or not text, leaves the in-flight flag as
  it was, and sends nothing for another agent.
- test/stream-lines.test.ts: a stream panel's line for each end, and for
  an error that's missing or empty, the traces it skips, a panel of
  exactly 2,000 lines keeping its first line,
  and the line that says how many it let go, carried on as more go.
- test/web-ui-interrupt.test.ts: interruptAgents aborts each agent's
  turn with its reason, at once or once it can be, leaves an agent with no
  turn alone, and goes on past one whose abort throws, which the follower
  reads as nothing aborted; interruptPage takes
  the subagents' turns, then their runs, then the rest, names only the
  turns it kept, a subagent whose stream was open not among them, takes a
  subagent once though the runs' stop lets go of it at once, gives every
  abort the Web UI's reason, names another agent's kept turn as well as a
  subagent's, and with no follower still stops the runs; interruptReply's
  words, "no turn interrupted" among them.
- test/subagent-own-failures.test.ts: a live attempt's agent is the
  subagent's (isSubagentAgent) until its run settles.
- test/web-ui-module.test.ts: setApp unsubscribes from the framework it
  followed before.
- test/notices-module.test.ts: a turn a tool ended closes a hard-down
  episode and clears the waker, as a completed turn does; another agent's
  doesn't; both ends, in either order, close it once; an aborted turn's
  exhausted tells no channel, while the next failure does.
- test/tts-relay-turn-end.test.ts: a turn a tool ended ends its
  activation; another agent's leaves it open; both ends, in either order,
  end it once.
- test/batch-mode.test.ts: after /session new, the next line's turn is
  printed, and Done. comes within 90 seconds of that line, inside the
  120-second cap.
- test/tui-turns.test.ts, with test/mock-tui-turns-child.ts: runTui
  itself, under a test renderer and a fake framework. Escape when idle,
  after a message is sent and before its turn starts, with a model call
  in flight, while the turn compiles and once it can be aborted, in the
  background after Ctrl+B, while a failed attempt waits and at its retry,
  and after a turn the framework gave up on: what it asks the framework,
  the lines it shows, each error's once, and the status bar's offer of
  it. A session switch that succeeds and one that fails, each started
  while a turn runs: Escape does nothing while it's under way, a late end
  with no start since shows nothing and leaves the status line, and
  traces reach the TUI only from the framework the host holds, and a late
  failure shows. A turn whose start the TUI didn't follow: its running
  traces show it, Escape keeps its interrupt, and its end and its failure
  show. With a subagent compiling: Escape takes its turn, then stops its
  run, then aborts the root's, and its interrupt aborts it once its call
  goes out. The fake framework sends an abort's trailing exhausted, as
  0.22.2 does.
- test/mock-tui-shutdown-child.ts: its framework's onTrace returns the
  listener's unsubscribe, as agent-framework's does, and either way of
  detaching counts, so test/tui-shutdown.test.ts still pins one detach at
  each shutdown now that the TUI detaches through traceFollower.

Checks at this commit's tree (Linux, Node 24.22.0 with npm 11.19.1,
Bun 1.4.3, Bun's transpiler cache off), on main as agent-framework 0.22.2
installs it, beside the suite and the web build that CI runs:
- npm ci, then root tsc --noEmit: no errors. The web tsc reports only the
  error main has at App.tsx:948, here at :954, and the web build passes.
- test/tui-turns.test.ts, test/tui-root-turn-end.test.ts,
  test/web-ui-interrupt.test.ts, test/turn-interrupt.test.ts,
  test/tui-shutdown.test.ts and test/turn-trace.test.ts: 59 pass, 0 fail,
  three times; the isSubagentAgent case in
  test/subagent-own-failures.test.ts passes three times.
- Each of the change's pieces has a mutant that fails a test, run at this
  tree or on earlier code that reads the same here, but the Web UI's
  interrupt handler and the page (web/src/App.tsx), which nothing runs:
  a mutant of what the handler passes on (no agent marked a subagent)
  lives.
@nissa-seru
nissa-seru merged commit 86a3683 into anima-research:main Oct 17, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
main had taken 26 commits since 3af13bf: anima-research#367 (a refusal is an error
result), anima-research#411 (tune-out errors), anima-research#453 (quiesce writes its record first)
and anima-research#207 (end-turn accounting).

One conflict, in src/framework.ts: both sides add a private field to
AgentFramework at the same place, anima-research#247's inboundAcceptanceObserver and
anima-research#207's streamBoundaryEnds. Both are kept.

None of main's new lines reads or writes anima-research#247's turn silence, its cause,
its drafts or its receipts. anima-research#207 records a tool-ended turn as a
completion at the endTurn boundary itself, and leaves driveStream's
turn_ended cancel, where the turn's receipt is written, as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
…ima-research#367, anima-research#411, anima-research#453 and anima-research#207

The conflict, and what it keeps:
- runResume's finally: anima-research#453's reset of resumeInFlight, then this branch's
  reopen of the MCPL data planes with readyMcplPlanes(), with no data-plane
  gate. anima-research#453 changed only the flag there. The barrier funnel beside it on
  main is the gate this branch removes.

anima-research#453's other changes merge without a conflict. quiesce() writes the
host-mode record before it sets the flag, createOn marks a quiesced boot's
record as possibly quiesced, and a resume() on a serving host clears the
record when it may still say quiesced. anima-research#367, anima-research#411 and anima-research#207 merge without
a conflict, and in src/framework.ts and src/types/framework.ts they change
no member or declaration this branch changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
The conflicts were where main now cancels a stream in every state that
holds one, ready at a tool boundary included, and where main's anima-research#207
writes a stream's inference-log entries through driveStream's logStream:
- Agent.cancelStream() cancels in every state that holds a stream, as
  main does, and records the caller's cancel whenever it cancels one.
- stop() marks each stream it cancels as a shutdown before cancelling
  it, in every state main cancels in.
- The aborted case keeps this branch's terminals, and its failure path
  writes through logStream, as main's does.

The stop terminals, settleDeliberateCancel() and settleFrameworkCancel(),
still write their entries directly, so a stop after a finished round
also gets the entry finally writes for it. The next commit routes them
through logStream.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
… finished, once

anima-research#207 writes each of a stream's inference-log entries through
driveStream's logStream. That gives the stream's usage to the first
entry with usage to carry, and has finally write one entry for an end
that wrote none. The stop terminals, settleDeliberateCancel() and
settleFrameworkCancel(), wrote their entries directly. So a stop after
a finished round got two entries: its own, carrying no usage, and
finally's, carrying the round. The terminals now take the stream's
logStream and write through it, so the stop's own entry carries the
round and finally writes none.

finally's unloggedEnd named the end for a quiesce abandon that wrote
no entry of its own. Now that the abandon writes its own, nothing sets
it, and it is gone.

Tests (end-turn-usage): a caller's stop, a shutdown and an operator
quiesce after a finished round each get one entry, the stop's own,
carrying the round.

anima-research#207's two quiesce cases move to the new group. The one after a
finished round now expects the abandon's own entry. The one before the
first round now expects that entry with no usage, where anima-research#207 had the
quiesce write none.

anima-research#207's test of a write that fails in finally now fails that write for a
stream that just closes after a finished round, an end finally still
logs.

Texts: anima-research#207's changelog fragment and InferenceLogEntry.tokenUsage's doc
no longer list a quiesce abandon among the ends that write no entry of
their own, and the quiesce fragment says its row carries the stream's
rounds.

residentWith() and loggedOnce() move to the top of the file.
residentWith() also records each entry as it's written, since stop()
closes the store the log query reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…rd isn't a stop, and abortInference traces once

Answers our internal review of the merge and the routing.

A stop whose own log write fails. The stop terminals,
settleDeliberateCancel() and settleFrameworkCancel(), let a failed
inference-log write throw. The throw left the terminal partway and
reached driveStream's catch, which sent the stop down the failure path:
inference:failed and inference:exhausted. A framework stop's terminal
writes before it traces, so a shutdown or a quiesce abandon whose write
failed was never traced as inference:aborted at all. Each terminal now
catches its write's failure and says so on the console, as the writes at
a boundary end and in finally do, so the stop stays a stop. logStream has
already marked the rounds' usage logged, so they stay counted but
unlogged, as when finally's own write fails. Marking them after the write
would carry them twice wherever a store records an entry and then throws.

A budget restart's caller record. The merge records a caller's cancel
whenever cancelStream() cancels a stream, and anima-research#382's cancelStream()
cancels at a tool boundary too. So a budget restart's own cancelStream(),
which runs at a tool boundary, now leaves a reasonless caller record. Our
must-fix's message says nothing is recorded there, which was true before
anima-research#382. The error case let that record decide unless the framework had
marked a shutdown or a quiesce abandon, so a restart whose stream reports
its cancel as an error ended as a caller's stop: the [turn-interrupted]
note, inference:aborted, and an ephemeral run's caller rejected. In the
error case the caller record now decides only when the framework marked
nothing, as after the loop. An endTurn cancels the stream object directly
and leaves no record.

abortInference at a tool boundary. framework.abortInference() emits its
own inference:aborted only when no stream will report the abort. It kept
anima-research#172's test, streaming or waiting for tools, while anima-research#382's
Agent.abortInference() cancels a `ready` agent's stream too. So an abort
at a tool boundary was traced twice. It now asks what Agent asks: whether
the state holds a stream.

Tests:
- end-turn-usage: a caller's stop, a shutdown and an operator quiesce
  after a finished round each get one entry, the stop's own, carrying the
  round, when the stream reports the cancel as an error and when it just
  closes. That reaches the four call sites the aborted-shape tests don't.
- end-turn-usage: a caller's stop, a shutdown and a quiesce after a
  finished round whose own entry's write throws each trace
  inference:aborted once and no failure, and the console says so once.
- end-turn-usage: a budget restart whose stream reports its cancel as an
  error, and one whose stream just closes, trace no inference:aborted and
  write no [turn-interrupted] note.
- stop-at-tool-boundary: abortInference at a tool boundary traces one
  inference:aborted, carrying its reason.
- tool-result-guard: the stream that waits after reporting a caller's
  cancel now waits until the run's caller has deregistered the agent,
  within 5 s, where it waited a fixed 50 ms. A loaded run could take
  longer to deregister, and the test then passed without the terminal's
  own batch abandon.
- cancel-surfaces-as-error: the aborted-with-reason-error test waits for
  the turn's terminal trace, where it slept a fixed 100 ms before
  asserting inference:exhausted had come.

RoundThenEnd takes how a cancel ends it: aborted, as membrane reports
one, an error event, or just closing. residentWith() takes the agent's
limits.

Comments, and anima-research#207's fragment: a stop that reaches its terminal writes
its own entry. A stop whose stream was superseded, or whose agent was replaced or
disposed, before its terminal arrives never reaches one: driveStream drops
the event, as on main. The comment after the loop says the framework's
record decides first, which it does. The record is taken after the caller
record.

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

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

endTurn tool-result short-circuit bypasses usage:updated/inference:completed telemetry

1 participant