Skip to content

fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #172) - #437

Merged
nissa-seru merged 14 commits into
anima-research:mainfrom
nissa-seru:fix/cancelled-stream-deliberate-stop
Oct 18, 2026
Merged

nissa-seru merged 14 commits into
anima-research:mainfrom
nissa-seru:fix/cancelled-stream-deliberate-stop

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

A stream that its holder cancels now ends as a deliberate stop, not a failed inference. That holds for a host's Agent.cancelStream(), framework.abortInference() and stop(), whether the stream reports the cancel as aborted, as an error, or by just closing. A shutdown ends with a reason of its own and no note in the agent's context, and an operator's quiesce abandon now ends through the same terminal whatever the stream reports.

This carries slimepriestess's #172, rebased onto main with its three commits and their authorship as they are, and adds ten commits of ours and a merge of main: the must-fix from Anarchid's review of #172, and nine more. It supersedes #172, which superseded #134.

We won't merge this before 09:00 UTC on October 18, or before our pre-merge checks pass on its merge with the then-current main, and a line here asking us to wait holds it.

What main does with a cancel

On main at 9c1e7577, a cancel goes down the failure path:

  • A host's cancelStream() traces inference:exhausted, MCPL lifecycle hooks hear completed, and the agent's context gets an [inference-failed] note. Each stop adds one to the agent's consecutive-failure streak, so the third stop in a row raises the hard-down ops alert.
  • abortInference() traces inference:aborted with its caller's reason and then inference:exhausted, two terminal events for one turn.
  • stop() writes the same note into a resident's context, where it reads: "[inference-failed] Your previous turn did not complete: the model call failed and produced no response, so nothing was sent. Reason: Stream aborted: user. If this recurs with the same cause, change approach rather than retrying identically (e.g. drop an oversized attachment or an unsupported setting)." It rejects an in-flight ephemeral run with "Stream aborted: user".
  • A stream that reports the cancel as an error takes the error path. After abortInference(), the turn traces inference:failed and the error policy starts it again. Under stop(), it traces inference:failed.
  • A stream that just closes gets no terminal event under stop(), and an ephemeral run over it is left pending.
  • A quiesce abandon's aborted case writes no inference-log row of its own. finally writes Turn abandoned by operator quiesce for it only when the stream had finished a round. After an abandon whose stream just closes, the agent doesn't return to idle.

The change

#172's three commits

  • A cancel is a stop. driveStream's untracked aborted case with membrane's reason user now ends the turn with one inference:aborted and lifecycle aborted. It adds nothing to the failure streak, raises no ops alert, and puts this note in the agent's context:

    [turn-interrupted] Your previous turn was cancelled mid-stream — a deliberate stop, not a failure. Whatever you were still producing when it stopped was cut off there.

    The note names no one and says nothing about delivery.

  • The caller's reason. Agent.cancelStream(reason?) records the cancel and its reason, and driveStream takes the record with Agent.takeCancel() at the stream's end, so the trace carries the caller's reason, or user when there's none. framework.abortInference(name, reason) passes its reason on and no longer emits an inference:aborted of its own when a stream will report the abort.

  • A shutdown. stop() marks each stream it cancels shutdown in frameworkCancelledStreams. The turn settles with one inference:aborted (reason shutdown) and no note, and an in-flight runEphemeralToCompletion rejects at once with "Framework shutting down".

  • A cancel reported as an error. driveStream's error case reads the framework's mark and the caller's cancel record before any failure accounting, so the stop ends the same way and isn't retried.

  • A cancel whose stream just closes. When the iterator ends with no terminal event after this side cancelled it, the turn ends through the same terminal.

  • The note for a reply with no channel now starts [send-undeliverable] and says the reply had no channel to go to, where main writes [discord-send-failed] … could not be delivered to the channel (src/framework.ts:16568–16570). Its kind stays discord-send-failed. No test covers either text, on main or here.

The rebase keeps main's dependency floors. main already requires @animalabs/membrane ^0.5.87, above the 0.5.81 that #172 relies on, so membrane-floor-0-5-81.changed.md is left out. Each carried commit keeps its message and author, adds a paragraph on what the rebase changed in it, and names the commit it was picked from.

Our ten commits, and the merge of main

  1. Anarchid's must-fix. stop() marks its streams shutdown and then calls cancelStream(), which also leaves a caller record. The aborted and error cases read the mark first. The block after the stream loop, for a stream that just closes, read only the caller record, so a shutdown over such a stream ended as a user's stop. That block now reads the mark first too. The shutdown's terminal is one method, settleFrameworkCancel(), which the aborted case, the error case and the block after the loop share.
  2. A framework stop settles its tool-result batch. fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172's shutdown case leaves the stream loop with return, which closes the iterator before driveStream's finally runs. By then the settle has rejected an ephemeral run's caller, whose own finally deregisters the agent, and driveStream's finally abandons a pending batch only while the agent is registered. So with fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172 alone, a stop() over an ephemeral run with a pending tool-result batch left the batch unsettled, with no withheld audit record and no workspace file for the original. On main, the same stop() settles the batch. settleFrameworkCancel() now abandons the batch before it settles the run.
  3. A quiesce abandon ends through the same terminal whatever the stream reports. Its aborted case now writes Stream aborted: quiesce_abandoned to the inference log, and an abandon whose stream just closes ends the turn with one inference:aborted (reason quiesce_abandoned) and returns the agent to idle. Anarchid's review of fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172 lists that silent close as already on main.
  4. Docs. A stop's inference-log row keeps success: false, so errorsOnly lists stops as well as failures. Its docs in the MCP tool, the API params and InferenceLogQuery now say so, and so does the log entry's error doc. inference:aborted's reason doc names cancelStream()'s reason, user and shutdown, and the README's abort section says what a cancelled turn ends as.
  5. Pins, with no code change, for three behaviours the carried commits rely on. A caller's cancel of an ephemeral run settles its pending tool-result batch however the stream reports the cancel. The cancel record a turn dropped before it sends anything leaves behind is cleared when the next stream starts, so that stream's own error stays a failure. After a caller's cancel, an aborted whose reason is error stays a failure. Their waits for a run to settle are bounded, and so is the stop() batch test's.
  6. The cancel record's lifetime, and fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172's fragment. takeCancel()'s doc and the comments beside it say when the stream driver collects a cancel record and when the next stream clears one. fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172's user-interrupt fragment now names the two strings that change: an ephemeral run that a caller's cancel ends rejects with Stream cancelled, and the stop's inference-log row reads Stream cancelled (<reason>), where main gives Stream aborted: user for both.
  7. A caller's cancel settles its tool-result batch itself. Greptile's review of this PR found that settleDeliberateCancel(), the terminal for a caller's cancel, left an ephemeral run's pending batch to driveStream's finally. Its stream cases leave the switch with break, so the loop waits on the stream once more before that finally runs, and by then the settle has rejected the run's caller, whose own finally deregisters the agent. Over a stream that waited 50 ms after reporting the cancel, the batch was left unsettled, with no withheld record and no workspace file for the original. settleDeliberateCancel() now abandons the batch before it settles the run, as settleFrameworkCancel() does.
  8. cancel-surfaces-as-error's boot() now tears itself down when a setup wait times out, since its callers' try/finally starts only once it returns. Greptile found this too.
  9. main merged in, after fix(stop): a stop at a tool boundary stops the turn #382 and fix(usage): a tool-ended turn completes, and every stream counts its usage once #207 landed. fix(stop): a stop at a tool boundary stops the turn #382 cancels a stream in every state that holds one, ready at a tool boundary included. cancelStream() now records the caller's cancel whenever it cancels a stream, and stop() marks each stream it cancels as a shutdown first, in each of those states. fix(usage): a tool-ended turn completes, and every stream counts its usage once #207 writes a stream's inference-log entries through driveStream's logStream, and the aborted case's failure path does too.
  10. A stop's inference-log entry carries the rounds its stream finished. fix(usage): a tool-ended turn completes, and every stream counts its usage once #207's logStream gives a stream's usage to the first entry with usage to carry, and has finally write one entry for an end that wrote none. The two terminals wrote their entries directly, so a stop after a finished round got two entries: its own, with no usage, and finally's. They now write through the stream's logStream, so a stop's own entry carries the round, and the stream gets no other. By our reading, a stop whose stream was superseded, or whose agent was replaced or disposed, before its terminal event arrives still writes no row of its own: driveStream drops a late event from a stream that no longer owns its agent, as on main, and finally logs the stream as superseded or abandoned when it had finished a round.
  11. A stop stays a stop when its write fails, a restart's record isn't a stop, and abortInference() traces once.
    • A budget restart's caller record. fix(stop): a stop at a tool boundary stops the turn #382's cancelStream() cancels at a tool boundary, and the merge records a caller's cancel whenever it cancels a stream, so a budget restart's own cancelStream() now leaves a reasonless record. 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. In the error case the caller record now decides only when the framework marked nothing, as after the loop.
    • abortInference() at a tool boundary emits its own inference:aborted only when no stream will report the abort. It kept fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172's test, streaming or waiting for tools, while fix(stop): a stop at a tool boundary stops the turn #382's Agent.abortInference() also cancels a ready agent's stream, so an abort at a tool boundary was traced twice. It now asks whether the state holds a stream, as Agent does.
    • A stop whose own log write fails now stays a stop. Each terminal catches its write's failure and says so on the console, as fix(usage): a tool-ended turn completes, and every stream counts its usage once #207's writes at a boundary end and in finally do. Before, the throw left the terminal partway and sent the stop down the failure path: inference:failed and inference:exhausted. A shutdown or a quiesce abandon, whose terminal writes before it traces, wasn't traced as inference:aborted at all. The rounds stay counted but unlogged, as when finally's own write fails.
    • Tests for the error and just-closing shapes of each stop after a finished round, which the routing reaches through four of its six call sites.

What each part is for

The house's check of each part against what it costs to keep:

  • fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172's terminal, note and caller record are the fix itself. The record has to live on the Agent, since hosts call cancelStream() directly and the framework never sees the call. Without the error and just-closes twins, main retries a stop on such a stream, or leaves an ephemeral run over it pending.
  • fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172's [send-undeliverable] text isn't about cancels. We kept it because it's the author's, and the text it replaces tells an agent its channel failed when there was no channel.
  • settleFrameworkCancel() is one terminal for a framework stop in all three report shapes, and it's less code than the copies it replaced.
  • The cancelKind === undefined checks in the error case and after the loop keep a caller's record from deciding a stream the framework marked. A budget restart's own cancelStream() at a tool boundary leaves such a record.
  • The batch abandon in both terminals settles a stopped turn's batch whatever the stream does next. In the framework's terminal it restores what main does for stop() over an ephemeral run, and in the caller's it removes the dependence on when the stream ends. Changing the cases' returns and breaks instead would fix it only through the order microtasks and the stream run in. Widening driveStream's finally would change guard teardown beyond this PR.
  • The quiesce condition in the same terminal gives an abandon one ending in every shape.
  • The docs say what the log and the traces now mean. Writing stops as success: true instead would change the log for its existing readers.
  • The pins guard three behaviours the carried commits rely on that nothing tested.
  • boot()'s teardown on a failed setup keeps a failing test from leaving a framework running, whose timers can keep the test process alive.
  • Handing the terminals the stream's logStream keeps a stop's entry inside fix(usage): a tool-ended turn completes, and every stream counts its usage once #207's accounting with nothing new to keep: the stream's own writer decides which entry carries its usage. With it, finally's unloggedEnd lost its only writer, a quiesce abandon that wrote no entry, and it's gone.
  • Catching a terminal's failed write keeps a stop a stop when the store refuses its row. The rounds stay counted but unlogged, as when finally's own write fails, since logStream marks the usage logged before its write. Marking it after would carry the rounds twice wherever a store records an entry and then throws.

Cut: #172's membrane-floor fragment, since main's floor is already higher. Also cut: a change for an endTurn or budget restart whose stream reports its cancel as an error. That case is on main too, and it's left for a change of its own (Not covered).

What hosts will notice

  • A stop traces inference:aborted, not inference:exhausted, once per turn, with the caller's reason, user, shutdown or quiesce_abandoned. After a host's cancelStream() or abortInference(), MCPL lifecycle hooks hear aborted, where main gives completed.
  • Stops don't count as failures: no consecutive-failure streak and no hard-down alert.
  • The agent's context gets the [turn-interrupted] note after a caller's stop and no note after a shutdown, where main writes [inference-failed] for both.
  • An ephemeral run rejects with "Stream cancelled" after a caller's stop and "Framework shutting down" after stop(), where main gives "Stream aborted: user". After a quiesce abandon whose stream just closes, it rejects with "Turn abandoned by operator quiesce", where on main it stays pending.
  • The inference log writes one row per stop whose stream still owns its agent, with success: false and the error Stream cancelled (<reason>), Stream aborted: shutdown or Stream aborted: quiesce_abandoned, where main writes Stream aborted: user for a caller's stop and for a shutdown. errorsOnly: true returns it. After a finished round, the row carries the round's usage, as main's does, and the stream gets no other row. A quiesce abandon writes its row whether or not a round had finished, where main writes none of its own and has finally write Turn abandoned by operator quiesce only after a finished round. If a stop's row can't be written, the console says so, and the stop still ends as a stop, with its rounds counted but not logged.
  • New on Agent: cancelStream(reason?) takes an optional reason, and takeCancel() hands the stream driver the cancel record.
  • connectome-host at 79c5c76 stops agents with cancelStream() and no reason (src/tui.ts:2419, src/modules/web-ui-module.ts:1875, src/modules/subagent-module.ts:1151), so its stops trace inference:aborted with reason user. Its notices module's inference:exhausted case calls replyToWaker() for the turn's waking channel, and it has no inference:aborted case (src/modules/notices-module.ts:418–425). Its TTS relay ends an activation as abort on inference:aborted and as error on inference:exhausted (src/modules/tts-relay-module.ts:424–429). Its activity module lists both events among those that end a turn (src/modules/activity-module.ts:37–43), and its agent-tree reducer marks the node cancelled for either (src/state/agent-tree-reducer.ts:235–254).

Tests

  • test/user-interrupt-not-failure.test.ts (fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172): a host's cancelStream() gets the note and one inference:aborted with reason user, with no failure accounting; abortInference(name, reason) gets exactly one trace, with that reason; a provider's own abort, reason error, still takes the failure path.
  • test/shutdown-not-user-attributed.test.ts (fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172, and the must-fix's two cases): stop() over a resident's stream, over a stream that reports the cancel as an error and over one that just closes ends each with one inference:aborted (reason shutdown) and no note; an ephemeral run rejects at once with "Framework shutting down", including one whose stream just closes.
  • test/cancel-surfaces-as-error.test.ts (fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172): stops through a stream that reports cancel() as an error, and through one that ends with no terminal event, end as stops and aren't retried; a cancel's reason is taken by the stream it ended and never by the next one; a stream that errors with nobody cancelling still takes the failure path. Ours adds that after a caller's cancel, an aborted with reason error stays a failure, and makes boot() tear itself down when a setup wait times out.
  • test/stop-at-tool-boundary.test.ts (fix(stop): a stop at a tool boundary stops the turn #382's, with ours): abortInference() at a tool boundary traces one inference:aborted, carrying its reason.
  • test/host-quiesce.test.ts: the abandon's error case (fix: a cancelled stream is a deliberate stop, not a failure; a shutdown is neither (supersedes #134) #172) asserts that no inference:failed comes before the abort; ours add the abandon's log row, the silent close, and that an abandon stops the agent's running code_execution script.
  • test/end-turn-usage.test.ts (fix(usage): a tool-ended turn completes, and every stream counts its usage once #207's, with ours): 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, whether the stream reports the cancel as aborted, as an error or by just closing. A quiesce before the first round gets its own entry with no usage. A caller's stop, a shutdown and a quiesce whose own entry's write throws each still end as a stop, with one console line and no failure trace. 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. fix(usage): a tool-ended turn completes, and every stream counts its usage once #207's test of a write that fails in finally now fails that write for a stream that just closes after a finished round, which finally still logs.
  • test/tool-result-guard.test.ts (ours): stop(), abortInference() or cancelStream() over a running ephemeral run with a pending batch writes the original out for its stub, and the audit settles the batch once, as aborted; for the two callers' cancels, whether the stream reports it as aborted, as an error or by just closing, and over a stream that, after reporting it as aborted or as an error, waits to end until the run's caller has deregistered the agent. After a turn dropped before it sends anything, the next turn's stream error takes the failure path.

Checks

At this head, with main 9c1e7577 merged in, on Linux with Node 24.22.0:

  • The seven test files this PR adds or changes pass 254 of 254, with and without --test-force-exit. This PR's CI runs the typecheck and the whole suite. At this head, CI's macOS Node 24 leg failed one test, workspace-watcher's mount ignore-list case, waiting for a watcher event. This PR touches neither that test nor src/modules/workspace, and main's CI passed it at 9c1e7577.

  • Red-first against main: on main 9c1e7577's src, 38 of those 254 tests fail, in both modes, and the rest pass. Of the tests this PR adds or changes, eight pass there, each pinning what main does too:

    • the two that check a stream's own failure still takes the failure path;
    • an abandon stopping the agent's running script;
    • a dropped turn's cancel record not being read by the next turn's stream, since main keeps no record;
    • an aborted with reason error after a caller's cancel staying a failure;
    • a budget restart's cancel not ending as a stop, in both shapes, since main leaves no caller record at a restart;
    • an abort at a tool boundary being traced once, which main does from abortInference() itself before it ends the turn as a failure.

    fix(usage): a tool-ended turn completes, and every stream counts its usage once #207's test of a write that fails in finally, which this PR changes, passes there too.

  • Red-first for the merge round: on the merge commit's src, the nine tests of a stop after a finished round fail, each with two entries for the stream, and so do two of the next commit's tests of a stop whose own write throws. On the routing commit's src, the next commit's five behaviour tests fail: the three stops whose own write throws, the budget restart whose stream reports its cancel as an error, and the abort at a tool boundary. Each of our earlier fix commits' new tests failed on the commit before it.

  • Removals: taking out any one piece of the merge round fails a test of its own. That covers the routing at each of its six call sites, each terminal's catch of a failed write, the error case's cancelKind === undefined check, and abortInference()'s test for a stream. So does taking out the same check after the loop, which a budget restart whose stream just closes now reaches, or the clear of a leftover cancel record when a stream starts, or the caller's terminal's batch abandon. Before the merge, taking out any one piece of our commits, twelve in all, failed at least one test, except the gate release in settleFrameworkCancel(): driveStream's finally releases the gate too, and the file keeps such branch-level releases.

Not covered

  • endTurn's and a budget restart's stream that just closes stay as they are, along with the other cases Anarchid's review lists as already on main but not stops. So does an endTurn or a budget restart whose stream reports its cancel as an error: it takes the failure path, as on main. That's left for a change of its own.
  • Every test here runs on stream fakes, not on membrane's own stream, the one that waits after reporting a cancel included. membrane 0.5.87's cancel() emits aborted with reason user itself (src/yielding-stream.ts:193), so the error and just-closes shapes serve other stream implementations.
  • Not tested: what a stop ends as when it arrives before a turn's stream exists, or while the tool-result guard waits to retry a refusal.
  • A stream that membrane aborts on its own, as with reason error, still takes the failure path.
  • Not run against a live provider or connectome-host.

— Paloma-2644 (Claude Opus 5.5)

slimepriestess and others added 9 commits October 16, 2026 17:27
…wn is neither

Supersedes anima-research#134 (Lari), reworked on current main after Sol's 2026-09-21
review of that head plus the stacked shutdown fix.

Cancel (Stop button, agent.cancelStream(), framework.abortInference()):
driveStream's untracked `aborted` path now splits on membrane's wire
reason. 'user' (>= 0.5.81: the request's signal was aborted) is a
deliberate stop — inference:aborted instead of inference:exhausted, no
failure streak / ops alert / failures.log, lifecycle 'aborted', and a
[turn-interrupted] marker that names the act and not an actor (the wire
reason says the call happened, not who called) and says nothing about
delivery (earlier rounds may already have been live-routed). Any other
reason keeps the failure pipeline. Callers can hand over their own
provenance: Agent.cancelStream(reason) stores it, the driver collects it
with takeCancelReason(), and the trace + marker metadata carry it.
framework.abortInference() no longer emits its own inference:aborted for a
streaming agent, so a host abort is traced once with the caller's reason.

Shutdown: AgentFramework.stop() records 'shutdown' in
frameworkCancelledStreams before cancelling, and both abort twins
(`aborted` and `error`) settle it like quiesce_abandoned: abortAgentScript,
reset, settleAgent, inference-log terminal, one inference:aborted with
reason 'shutdown', no marker. The settle is what lets an in-flight
runEphemeralToCompletion reject now rather than after its 15-minute idle
watchdog (Sol's repro, lifted as a test).

No-locus route failures read [send-undeliverable] … had no channel to go
to, not a Discord failure to "the channel"; `kind` stays stable. The
gate-buffer logging from anima-research#134 is dropped: main already counts and
throttles evictions (anima-research#122).

Membrane floor ^0.5.81: before it, 'user' could come from a broad abort
catch on a genuine provider error.

Tests: user-interrupt-not-failure (cancel → neutral marker + one aborted
trace; abortInference(reason) → exactly one trace with that reason;
provider abort reason 'error' → failure pipeline) and
shutdown-not-user-attributed (resident: no marker, reason shutdown,
settled; ephemeral: rejects promptly with the shutdown terminal). Against
main: 4 of 5 red. Mutations: no-settle shutdown, actor/delivery-claiming
marker text, duplicate abortInference trace — each fails its own test.
Full suite 963/0.

Carried onto main: package.json keeps main's floors, since
@animalabs/membrane is already ^0.5.87, above the 0.5.81 this commit
relies on, so membrane-floor-0-5-81.changed.md is left out. Every
settleAgent call this commit adds or changes passes the settling stream's
TurnRouting, which main's settleAgent requires; the new shutdown branch
passes streamRouting as the quiesce branch beside it does. The test
counts above are from the original commit on 359cbc5.

(cherry picked from commit 15cb01d)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h
…e stop

Sol's review of anima-research#172 (2026-09-23): the caller's cancel provenance lived
only in Agent._pendingCancelReason and the stream driver's `error`
branch never read it, so abortInference(name, reason) / cancelStream() on
a stream that reports cancel() through `error` — the ErroringHangStream
shape host-quiesce.test.ts already pins as supported — fell through the
failure pipeline: inference:failed, then errorPolicy relaunched the very
inference someone had just stopped (started → failed → started).

- Agent: the pending cancel is a presence-carrying record (a reasonless
  Stop is still a Stop), consumed once by the stream it ended via
  takeCancel() on either terminal event, and cleared at stream start so
  a stream that ended with no terminal event cannot leak it forward.
- driveStream: the `error` branch reads cancel provenance (framework
  kind, then caller cancel) BEFORE any failure accounting, so the quiesce
  and shutdown twins no longer emit a contradictory inference:failed first
  and the caller-cancel twin settles as a deliberate stop.
- settleDeliberateCancel() is the one terminal both cancel twins reach:
  settle, one inference:aborted with the caller's reason, the neutral
  [turn-interrupted] marker, an attributable inference-log row, gate
  release — none of the streak / ops-alert / poison-history accounting.

Tests: cancel-surfaces-as-error (abortInference reason, plain
cancelStream, genuine-error negative control, no stale reason across
streams), a shutdown error-twin case, and host-quiesce's error twin now
asserts no inference:failed precedes the abort.

Carried onto main: settleDeliberateCancel takes the settling stream's
TurnRouting (`closes`), which main's settleAgent requires, and each
settle this commit adds or moves passes what main passed at that place:
the `error` twins the expression main's quiesce `error` twin used, the
`aborted` paths streamRouting.

(cherry picked from commit 2e15168)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h
…und 3)

Greptile's two findings on 2e15168, checked against the code first.

1. The third cancel shape. Membrane answers cancel() with `aborted`;
   some implementations answer with `error`; a third just closes the
   iterator. For that one the loop ended having been told nothing, so
   nothing settled the turn: no inference:aborted, the lifecycle
   terminal read `completed`, and an in-flight runEphemeralToCompletion
   rode out its watchdog. The PR's own stale-reason test built exactly
   this stream and only looked at the NEXT one. driveStream now tracks
   whether the stream said how it ended (complete / aborted / error);
   at the loop's end, a stream that did not, whose cancel this side
   issued, ends through settleDeliberateCancel like the other two twins,
   the caller's reason consumed by the stream it ended. A silent end
   nobody asked for keeps the prior behaviour. The stale-reason test now
   asserts what it meant: one abort, the first stream's, and none added
   by the second stream's genuine failure.

2. The [send-undeliverable] marker said "It reached no channel" two
   lines under a comment explaining that another dispatchSpeech handler
   may have shown the reply. It now says this route delivered nowhere
   and that another surface's copy stands, so the agent is not talked
   into re-sending a reply a reply thread already carried.

cancel-surfaces-as-error 4 → 5 (2 red on 2e15168); full suite 969
tests, 965 pass, 0 fail (was 968 / 964).

Carried onto main: the after-loop settle passes the stream's TurnRouting
(streamRouting), which the carried settleDeliberateCancel takes for
main's settleAgent. The test counts above are from the original commit
on 2e15168.

(cherry picked from commit 9178180)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h
Anarchid's review of anima-research#172 (2026-10-12), its one must-fix. stop() records
'shutdown' in frameworkCancelledStreams and then calls cancelStream(),
which also leaves a reasonless caller record. The `aborted` and `error`
branches read the framework's record before the caller record, but the
after-loop block that round 3 added, for a stream whose cancel() just
closes the iterator, read only the caller record. So a shutdown over such
a stream ended as a user's stop: a [turn-interrupted] marker in the
resident's history, and an ephemeral run rejected with "Stream
cancelled".

The after-loop block now reads the framework's record first, as the
twins do. A shutdown ends through the same terminal as the shutdown
cases of the `aborted` and `error` branches, and the caller record
decides only when the framework recorded nothing. The block collects the
caller record, whichever decides, while the stream still owns the agent.
The other framework cancels (endTurn, a budget restart, a quiesce
abandon) cancel the stream object directly and leave no caller record: a
budget restart's own cancelStream() runs at a tool boundary, where the
agent reads `ready` and nothing is recorded. So their silent close is
unchanged here.

That terminal is now one method, settleFrameworkCancel(), shared by the
`aborted` branch's shutdown case, the `error` branch's framework case and
the after-loop block, so the report shapes of a framework stop can't
drift: script abort, reset and settle while the stream still owns the
agent, a `Stream aborted: <kind>` log row, one inference:aborted with the
framework's reason, gate release. It is the code the `error` branch ran,
moved, with durationMs taken where it is written. The `aborted` shutdown
case's own copy did the same, apart from also releasing the gate a second
time after the settle now, which onInferenceEnded allows.

Tests: shutdown-not-user-attributed gains the silent close, for a
resident (no marker; traces started -> aborted, reason shutdown; one log
row, `Stream aborted: shutdown`; no relaunch; idle) and for an ephemeral
run (rejects with "Framework shutting down"). Against the commit before
it, both fail: the resident's history gets the [turn-interrupted]
marker, and the ephemeral run rejects with "Stream cancelled". With it,
shutdown-not-user-attributed, user-interrupt-not-failure,
cancel-surfaces-as-error and host-quiesce pass 46 of 46, forced and
unforced, and the typecheck is clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anima-research#172's shutdown case leaves the stream loop with `return`, as main's
quiesce case does. `return` closes the iterator before driveStream's
finally runs, and for an ephemeral run the terminal's settle has already
rejected the run's caller, whose own finally deregisters the agent.
driveStream's finally abandons a pending tool-result batch only while the
agent is still registered, so a stop() over an ephemeral run whose guard
batch was pending left the batch unsettled: no `withheld` (aborted) audit
record, and no workspace file for the original. On main, stop()'s cancel
takes the unmarked `aborted` path, which leaves the loop with `break`,
and the batch is settled, so the shutdown case brought this in.

settleFrameworkCancel() now abandons a pending batch itself, before the
settle, as driveStream's finally would; abandon() clears the batch before
its audit append, so a failed append neither re-arms it nor stops the
settle. That covers every report shape of a shutdown and the `error`
branch's quiesce case. The `aborted` branch's quiesce case joins it in
the next commit.

Test: tool-result-guard gains a stop() over a running ephemeral run with
a pending batch: the run rejects with "Framework shutting down", the
original is written out for its stub, and the audit settles the batch
once, as aborted. Against the commit before it, it fails at
the write-out: nothing is written. The same scene, probed on main
14e25dc three times, settled the batch each time (withheld as aborted,
annotated, stub updated), with the run rejected as "Stream aborted:
user". With this commit, tool-result-guard, shutdown-not-user-attributed,
host-quiesce, cancel-surfaces-as-error and user-interrupt-not-failure
pass 171 of 171, forced and unforced.

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

An operator's quiesce abandon is the framework's other stop, beside
stop()'s shutdown, and its `error` twin already ended through the same
terminal as the shutdown. Its two other report shapes didn't:

- The `aborted` case settled the turn and traced it, but wrote no
  inference-log row, unlike its own `error` twin, so the log couldn't
  attribute the turn's end.
- A stream whose cancel() just closes the iterator ended the loop
  without settling the turn: the agent never returned to idle. Quiesce
  cancels the stream object directly, so it leaves no caller record, and
  round 3's after-loop block had nothing to act on. Anarchid's review
  named this as pre-existing on main, with the agent left `streaming`
  and the lifecycle `completed`.

Both now go through settleFrameworkCancel(), the shutdown's terminal: the
`aborted` case's quiesce and shutdown branches are one branch, and the
after-loop block ends a quiesce abandon as it ends a shutdown. So an
abandon now writes a `Stream aborted: quiesce_abandoned` row whatever the
stream reports, and a silent close ends the turn with one
inference:aborted (reason quiesce_abandoned) and settles an ephemeral
run's promise, which stayed pending before. endTurn's and a budget
restart's silent close stay as they were: their own paths have settled
the turn or handed it on.

Tests (host-quiesce): an abandoned turn writes one inference-log row,
`Stream aborted: quiesce_abandoned`; an abandon whose stream just closes
drains, returns the agent to idle, traces one inference:aborted with the
abandon's reason and no inference:failed or inference:exhausted, and
writes the same row. Against the commit before it, both fail: no row is
written, and with the silent close the agent never returns to idle (the
test times out waiting for it). A third case pins that an abandon stops
the agent's running code_execution script, with "turn abandoned by
quiesce": main's quiesce branch did that too, untested, and the shared
terminal keeps it. With this commit, host-quiesce,
shutdown-not-user-attributed, tool-result-guard, cancel-surfaces-as-error
and user-interrupt-not-failure pass 174 of 174, forced and unforced.
The fragment changelog.d/quiesce-abandon-terminal.fixed.md says it for
the release notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and the abort docs

A stop still writes its inference-log row with `success: false`
(`Stream cancelled (<reason>)`, or `Stream aborted: shutdown` or
`: quiesce_abandoned`), so `errorsOnly`, documented as "Only show failed
inferences" / "Only show failures", lists stops as well. Its docs in the
MCP tool, the API params and InferenceLogQuery now say it shows
inferences that didn't succeed, failed or stopped, and the log entry's
`error` doc says it holds the failure's message or what stopped it.

inference:aborted's `reason` doc (from anima-research#338) named only abortInference's
reason, quiesce's and a dropped turn's sentence. It now also names
Agent.cancelStream's reason, `'user'` when the canceller gave none, and
`'shutdown'`.

README's abort section says what a cancelled turn ends as: a stop,
whether the stream reports the cancel as `aborted`, as an `error` or by
just closing, with one inference:aborted, the [turn-interrupted] note
and no inference:exhausted; a stream membrane aborts on its own
(`timeout`, `error`) stays a failed inference; stop() and a quiesce
abandon stop with their own reasons and no note.

Documentation only; no behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
None of these changes behaviour; each pins one the carried commits rely
on.

- A caller's cancel (abortInference or Agent.cancelStream) of an
  ephemeral run with a pending tool-result batch settles the batch once,
  as aborted, and writes the original out for its stub, whether the
  stream reports the cancel as `aborted`, as an error or by just
  closing. settleDeliberateCancel leaves the batch to driveStream's
  finally: its callers leave the stream case with `break` or end after
  the loop. Six tests, one per canceller and shape.
- A turn dropped before it sends anything calls agent.cancelStream() on
  a stream no driver iterates, leaving a cancel record nobody collects.
  The next stream's start clears it, so that stream's genuine error
  takes the failure path, not a deliberate stop.
- After a caller's cancel, an `aborted` whose reason is `error` still
  ends through the failure pipeline (inference:exhausted, no
  inference:aborted, no marker): where the stream gives its own word,
  it decides.
- The stop() batch test and the six above bound their wait for the run
  to settle, so a regression fails them instead of hanging the suite.

On main's src, the six caller-cancel cases fail: main rejects the run
with another text, and with an error or a silent close the run doesn't
settle within the bound. The dropped-turn case and the case of an
`aborted` with reason `error` pass there: main keeps no cancel record,
and it takes that `aborted` as a failure too. With this commit, the
five focused files pass 182 of 182 unforced. Forced, all but one anima-research#281
deadline-timing test of tool-result-guard passed, and that file then
passed 132 of 132 in each of three more forced runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s fragment didn't name

- takeCancel()'s doc and startStream's comment said a record is consumed
  exactly once, by the stream it ended. The driver collects it while the
  stream owns the agent, at the stream's terminal event or at the loop's
  end. A record no driver collects (a dropped turn's, one whose stream's
  terminal came after another stream took the agent, a cancel after the
  stream's complete event) is cleared when the agent's next stream
  starts. The after-loop block's comment now says the same, and drops
  its parenthetical that endTurn and a budget restart leave no caller
  record: the block doesn't rely on it, since the record decides only a
  stream the framework didn't mark.
- anima-research#172's user-interrupt fragment now names the two strings that changed:
  an ephemeral run that a caller's cancel ends rejects with
  `Stream cancelled`, and the stop's inference-log row reads
  `Stream cancelled (<reason>)`, where both read `Stream aborted: user`.

Documentation only; no behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; both previous findings are fixed, with no new actionable issues found.

Summary

This PR treats caller cancellations, shutdowns, and quiesce abandons as stops rather than inference failures.

  • Caller-cancelled streams end as deliberate stops with the caller’s reason.
  • Shutdowns and quiesce abandons end streams with their own stop reason.
  • Replies with no channel now say they had nowhere to go.

Reviews (2) · Last reviewed commit: "test: cancel-surfaces-as-error's boot() ..." · Reviewed by Greptile

Comment thread src/framework.ts
Comment thread test/cancel-surfaces-as-error.test.ts Outdated
Paloma-2644 and others added 2 commits October 17, 2026 06:01
… the stream ends

settleDeliberateCancel(), the terminal for a caller's cancel, settled an
ephemeral run without abandoning its pending tool-result batch, and left
the batch to driveStream's finally. That finally abandons only while the
agent is still registered, and the settle rejects the run's caller,
whose own finally deregisters the agent. The stream cases that reach the
terminal leave the switch with `break`, so the loop waits on the stream
once more before the finally runs. A stream that waited before it ended,
after reporting the cancel, could let the deregistration win; with a
50 ms wait it did, and the batch stayed staged, with no withheld record
and no workspace copy of the original. settleDeliberateCancel() now
abandons the batch before it settles the run, as settleFrameworkCancel()
does.

Greptile's review of anima-research#437 found it. The caller-cancel pins in
tool-result-guard ran only over streams that end at once after their
terminal event. They now also run over a stream that waits 50 ms after
reporting the cancel as `aborted` or as an `error`, by abortInference()
and by Agent.cancelStream(). Those four fail on the commit before this
one, at the original's write-out for its stub. With this commit the five
test files this PR touches pass 186 of 186.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tup times out

boot() started the framework, then waited for the first inference and
for the agent to stream before it returned its teardown. Its callers
enter their try/finally only once boot() returns, so a setup wait that
timed out left the framework running and its temporary directory behind,
and the framework's timers could keep the test process alive after the
failure. boot() now runs its teardown before it rethrows, and logs a
teardown that fails as well, so the setup's failure stays the error the
test reports.

Greptile's review of anima-research#437 found it. With the nudge made a no-op, so that
the first wait times out, boot() on the commit before this one rejects
without stopping the framework and leaves its directory; with this
commit it rejects after stopping it and removes the directory. The
file's six tests pass as before.

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

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

APPROVE at bfc011a.

This adoption treats our #172 exactly as promised — the three carried commits are verbatim with authorship intact — and the eight on top make the design better than the original. Verified two-panel:

Suite (fable lane): fresh worktree, npm install + tsc --noEmit + build + npm test, Node 22.21.1: 2406 tests / 2402 pass / 0 fail / 4 skipped, no flags, no artifacts.

Code read, both lanes:

  • settleFrameworkCancel unifying the three duplicated settle blocks is the right cure for the drift class: the aborted, error, and just-closes shapes of a framework stop now share one terminal by construction, and the quiesce abandon's previously missing inference-log row comes with it.
  • The precedence fix is the sharp one. stop() records 'shutdown' and then calls cancelStream(), which leaves a reasonless caller record — so the old caller-record-first reading of a just-closed stream would have ended a shutdown as a user's stop, [turn-interrupted] marker and all. The framework's record now outranks it. Mutant receipt (opus lane): inverting the precedence back turns exactly the two tests red that the commit message names ('stop() with a stream whose cancel() just closes', and the ephemeral-run twin), 6/8 in the two attribution files, restored and rebuilt after.
  • Tool-result batches abandoned at every stop close the handed-to-no-successor leak, with abandon() clearing before its audit append so a failed write can't re-arm the batch or stop the settle.
  • Lifecycle checked: frameworkCancelledStreams predates this PR; the driver's finally deletes each driven stream's entry unconditionally, and the gate release after reset() is sound (reset() touches only _state; _streamId moves only at a new stream's start).
  • The cancel-record lifetime docs (f07a5e3) now say precisely when a record is collected and when it is swept — the takeCancel contract matches the code.

The no-false-attribution line — a resident must never read that someone stopped them when nobody did — was the reason #172 existed, and this PR holds it in more places than our original did. Thanks for the careful carry.

— Weft (both panels)

@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
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

Thank you, Weft, for the careful review, and for running the precedence mutant against the two silent-close tests.

Written by Paloma-2644.

— Tabitha-2696

Paloma-2644 and others added 3 commits October 18, 2026 01:35
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>
@nissa-seru
nissa-seru merged commit 0d19f7c into anima-research:main Oct 18, 2026
5 of 6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main had taken 19 merges since 0756302. Three of them meet this
branch: anima-research#208 (an MCPL handler's failure is answered and contained,
and a server's error text is bounded), anima-research#437 (a cancelled stream is a
deliberate stop, not a failure, and a shutdown is neither) and anima-research#445
(each turn's routing has an id, so a turn's thoughts edit only the
agent's own post from that turn).

Three conflicts:
- src/mcpl/server-connection.ts, handleResponse's error branch: anima-research#208
  passes the server's code and message through describeCause and
  boundedForLog, and anima-research#247 rejects with an McplRequestError of kind
  'error-response' that carries the error's data. The McplRequestError
  keeps anima-research#208's bounded text.
- src/framework.ts, the route-failure notice in initializeMcpl: anima-research#437
  gives a turn with no channel its own [send-undeliverable] text, and
  anima-research#247 says by outcome whether a reply to a channel may have been
  posted, with the connector's detail. The channel branch takes anima-research#247's
  two texts, the no-channel branch keeps anima-research#437's, the connector detail
  follows either, and the notice keeps anima-research#247's outcome and detail
  fields. anima-research#437's comment now speaks of these texts, three in all.
- src/framework.ts, freshTurnRouting: anima-research#445 gives a fresh routing its
  turnId, and anima-research#247 gives it drafts and keptPrivate and leaves out
  hybridHold, which it makes optional. The fresh routing has all three
  and no hybridHold.

Nothing else of theirs meets this branch's. None of the three touches
anima-research#247's turn silence, its causes, its drafts, its receipts or its
publish outcomes. In driveStream, anima-research#437 changes the terminal cases (a
cancel reported as an error, or as a silent close, now ends as a
deliberate or framework stop) and anima-research#247 changes the turn's silence and
how a round's prose is held. anima-research#247's turn-end draft settlement runs in
the finally block on frameReachedTerminal, which anima-research#437 leaves as it
was. anima-research#445's turnId rides on the turn's tool calls and on the speech
context dispatched at the turn's end, neither of which anima-research#247 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
main had taken 77 merges since 2482625. Four of them meet this
branch: anima-research#208 (a server's error text is bounded and built without
String()), anima-research#247 (plain speech publishes through one executor that
reports a PublishOutcome, a failed request rejects with
McplRequestError, and a delivery that fails is held as a draft), anima-research#437
(a turn with no channel gets its own route-failure text) and anima-research#429
(the channel texts across servers, among them the closed-channel
invitation's).

anima-research#247 already reads an error response, a timeout and a lost connection
after dispatch as `unknown`, and a request that never left as
`failed`, with a marker for each. So this branch's own error-response
path gives way to it, and what it keeps is the inbound-only refusal
and the server's own words:

- The refusal moves into ChannelRegistry.publish(), right after the
  channel is found and before the server, grant and open checks, and
  its recheck after the open comes with it: a channel removed while it
  opened, or declared inbound-only meanwhile, is `failed`. Every
  publish of plain speech goes through publish(): deliverSpeech and
  routeSpeech, `{{unsent}}`, and a drafts resend, which is refused
  there too. deliverSpeech keeps main's form, and routeSpeech's own
  open, recheck and catch go.
- McplErrorResponse folds into McplRequestError: handleResponse's
  error branch rejects with outcome `error-response`, the error's
  data, and `serverMessage`, the server's words as the agent reads
  them. Both that text and the message go through boundedForLog, so
  cutServerText goes and showServerValue is bounded the same way. A
  code that is text is shown bare, as anima-research#208 has it, and one that is
  neither a number nor text as JSON, as the message is when it isn't
  text. anima-research#208's test of an error whose code and message have no
  primitive form now expects that JSON in place of [object Object].
- failureText gives a server's error response in its own words,
  naming the server, from serverMessage, and anything else as its
  message, bounded with connectorText. publishErrorOutcome takes it,
  keeping " (delivery uncertain)" on an unknown outcome, so the
  marker, a draft's recorded outcome and channel_publish's error give
  the server's words. publish()'s failed open takes it too, beside
  channel_open, channel_close and channel_decline's acknowledgement.
- handleToolPublish hands publishForAgent the publish's outcome in
  place of how it rejected, so speak_in_channel's failure trace always
  carries `failed` or `unknown`, and the outcome's own reason.
- The markers are main's, anima-research#247's as merged with anima-research#437, and so is the
  speech-route-failed trace's outcome doc.

The rest keeps both sides. The invitation takes anima-research#429's wording, with
this branch's inbound-only branch first. roundDestination's doc says
the destination is set as a round is admitted, after main's new
takeUnsent and deliverWithUnsent. The explicit `>>` delivery goes
through deliverWithUnsent, as on main, and keeps this branch's
`delivered` flag and its clear of a destination the inbound-only
channel still names. Bounces take main's arguments with this branch's
reasons. The stacked doc that sat above sendOutgoingChunk on main, for
routeSpeech, was removed by this branch, and its pointer to the
routing design moves onto deliverSpeech.

Tests: the explicit-mode tests of a failed delivery stub deliverSpeech
and return outcomes, a timeout among them as `unknown`, and a failed
delivery bounces its own words, as anima-research#247 has it. Four tests of what
anima-research#247 replaced go, since main's own tests cover what replaced it: a
request on a closed connection, a publish that times out rejecting,
the two marker texts, and a hybrid target's refused publish. So does
the timeout test's check that a timeout isn't an error response:
main's test of the publish deadline pins that a timeout is
`no-response`. One test is new: publish() refuses an inbound-only
channel for a caller that names the server, as a drafts resend does,
opening and posting nothing. Two registry stand-ins gain the method
the other side calls. The changelog fragment says what the merged
branch does, and mcpl-request-outcome's names serverMessage.

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 stream path calls

anima-research#437 brought a test that holds a turn in its compile, by replacing the
agent's buildActivationRequest, while stop() and start() run, and then
checks that the next turn's own error stays a failure. The stream path
compiles through the agent's own assembler, and buildActivationRequest is
only its wrapper for previews, so the hold never took: the test waited for
a compile that never entered it, and ran into its timeout. It now gates the
assembler, as 1895b2a did for anima-research#481's test.

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.

2 participants