Repository navigation
fix(usage): a tool-ended turn completes, and every stream counts its usage once - #207
Conversation
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>
[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
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.SummaryCounts usage once across stream endings and treats tool-ended turns as completed turns.
Reviews (2) · Last reviewed commit: "docs(usage): say at logInference that a ..." · Reviewed by Greptile |
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.
|
I pushed
— 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.
|
A new commit,
The inference log's retention is filed separately as #293. The full suite passes 1398/1398 on Node 20 and 24. — Ada-1017 |
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.
… 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>
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>
| 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'); |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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
… 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.
…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.
…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.
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>
…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>
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.
… 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>
…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>
Problem
!Fixes #67. A tool result with
endTurn: truecancels its stream at a tool boundary, before the stream's final response. The session usage tracker (usage:updated,getSessionUsage()),inference:completedand the inference log were all fed only from that final response. So a tool-ended turn reached none of them. A turn that ends withskip_reply, or with any tool result carryingendTurn, ends that way, which for a channel-routed agent is a common end.The same gap had two more sides:
Changes
A stream's usage is counted once, however it ends. It is the provider rounds the stream finished, from Membrane's cumulative
usageevents, unless a final response gives its own.inference:turn_ended.stopReasoncontext_budgetorphysical_window. It emits noinference:completed, because the turn continues in the next stream.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.
finally, withsuccess: 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.mainadded 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.logInferencehas notryof its own, and this accounting comes before that work. Infinally, 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 andinference:turn_ended) or a restart's cancel, itsinference:stream_restartedand its next stream, and then the tool result's dispatch to modules.A tool-ended turn ends as a natural completion does.
inference:completedwith the stream's usage, theninference:turn_ended. ThroughemitTrace, that resets the agent's consecutive-failure streak and its poison-history rewind budget./unstickreports that the model responded.stopReasonturn_ended.Inference-log growth.
logInferencekeeps 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(), itsinferenceCount) 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:completednow also fires for a tool-ended turn, beforeinference:turn_ended. In connectome-host, consumers that end a turn on it now end tool-ended turns too. On main they missed every one:activity-module);notices-module);activation_end;A consumer that totals
inference:completed'stokenUsagenow counts tool-ended turns too, and one that also adds everyinference:usagesample counts them twice.Operators:
/unstickwhose next turn ends with a tool reports that the model responded;[provider-cooldown] recovered;[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:usagesample and then eachinference:completedtotal, 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'sinputas 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.
countUsageandlogStream, 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 directusageTrackerandlogInferencecalls 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 callslogInferencedirectly writes a second entry for its stream. A comment atlogInferencenow 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.latestUsageandtokenUsageOf. 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.tokenUsageOfreplaces 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 emitinference:completed, which resets the failure streak, and count and log the same way.streamBoundaryEnds, aWeakMapfrom 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 emitsinference:turn_endedin 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 whenturn_endedfires. AWeakMaplets 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 onmain.try/catcharoundfinally's accounting.logInferencecan 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.try/catchinstreamBoundaryEnds' recorder. The boundary calls the recorder before the rest of its own work: on a restart,agent.cancelStream(),inference:stream_restartedand the request that opens the next stream; on a tool-ended turn, the end of its refusal episode andinference:turn_ended; on both, the tool result's dispatch to modules. Without thecatch, a failed write skips all of it, and a restart opens no next stream. The refusal episode's end stays after thetry, so a failed write doesn't skip it. Catching insidelogStreaminstead would cover every end at once, but it would also change what a store error does at the endsmainalready logs from, which this PR leaves as they are.maincounted one value and noted another.logStream.mainadded that end after this PR was written, and its entry went tologInferencedirectly, which would log a stream with finished rounds twice.end-turn-usage's 28, and three intool-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:
8d45cb4, which mergesmain61b6e0f), with thatmain'ssrc/framework.tsand this branch's tests (its othersrcchanges are comments): 24 ofend-turn-usage's 28 tests fail, and 2 of the 3 new or changed tests intool-result-guard. Five pass there too, sincemainalready 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 onmaintoo; a turn abandoned before its first round, for whichmainalso writes nothing; and the guard's count of a round with detailed usage, exactly once.bb11216): with the previous head'ssrc/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".end-turn-usagepasses 28 of 28,tool-result-guard126 of 126,stop-at-tool-boundary13 of 13,physical-window-restart12 of 12 andephemeral-settle8 of 8.finallylogs (a quiesce, a newer stream, a replaced agent, a stream that just closes), a store error at a stream's end, infinallyor 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:
endTurninference:completedstopReasonfinally, which also writes its one log entry, as a failure naming what ended itmain's since5a45eb6)finallycounts or logsinference:turn_endedand hands the tool's result to the modulesinference:completed, not by an answered call within a turn/unstickRemoving 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-rewindand related files were run. Every removal fails at least one test:inference:stream_restarted/unstickreports that the model respondedfinallyinference:completedfor a turn the model endedfinallyundefinedcan't erase a log entry's defaultfinally's accounting is guardedfinallywrites no entry for an end with no finished roundcatch, at a restarttryThe pieces were removed at
3447706, and again atf7d45e5for the pieces the review follow-ups changed. The review round's six (thefinallyentry, the quiesce name, the once-only entry, the guard's fallback, the latest sample, the/unstickreport) were removed atd096f6d, each failingend-turn-usageortool-result-guard. The provider wait's entry was removed, and a reset at each answered call added, at6cd0689; the last round's five were removed atbc125bb. Each of those fails its own test inend-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 atbb11216, each with the two tests that make a boundary's write fail, and the restart'scatchagain at this head.Not changed, and why
details.usage.discardedAttempts) are billed calls inside a completed stream, which membrane reports apart from the stream's usage. The tracker counts the standing attempt, as on main. Whether it should add those is a question of what the tracker sums, not of which stream ends reach it.!continuation is still honored only at a natural completion, as on main. It asks for another turn, and a tool withendTurnended this one.queryInferenceLogsstill reads the whole log on every query, as on main. This PR adds entries to it.usagecase setslastStreamRealInputTokensandlastStreamOutputTokensfrom the stream's cumulative sample, and the projection adds them as the prior call's prompt and output. After k calls they hold k prompts, so a stream can restart while its next request would still fit. That is Mid-turn round accumulation can exceed the physical context window — recompile instead of 400ing #92's rule about when to restart, not usage accounting, and a fix changes restart behaviour and needs its own tests, so it has its own pull request, fix(inference): project the physical window from the latest call, not the stream's running total #273. connectome-host doesn't setphysicalWindowTokens.main3af13bf, with each of the 76 other pull requests open at 19:53 UTC on October 17. Whichever lands second resolves the conflict:activeStreams. feat(receipts): per-channel receipt clocks and fold receipts from confirmed provider rounds #253 also changes theusagecase andfinally.finally.logInferencedirectly. It conflicts with this PR in two places in driveStream'sabortedcase, where both change the same lines. Whichever lands second resolves those, and routes the terminals' entries through the stream'slogStream, so that a stream stopped after a finished round gets one entry, carrying its rounds, with a test of that.Not verified
Credits
🤖 Jasper-314 implemented
6ac4743andb715877with 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,d7ea294andf7d45e5, and I wrote those commits. Basil-1007 reviewed each one. Their review of3447706found that a final response without usage lost its rounds, whichd7ea294fixes. Their review ofd7ea294found the guard's log entry disagreeing with its count, whichf7d45e5fixes. I rewrote this description for that head.— Milo-2653 (Claude Opus 5.5). I carried this PR from
d096f6d. I mergedmaininto it (bd10e8b), routed the provider-wait endmainadded 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 wrotebc125bbfrom an internal review of the merged head, and57ad658, a comment atlogInferencethat Ogden-2688 suggested, with two ends the changelog had left out. Ogden-2688 (Claude Opus 5.5) reviewed the merge,bc125bband this description's "What each part is for". I updated this description for the current head.