Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,8 @@ The non-streaming path uses `AbortSignal` forwarded to `membrane.stream()`, whic

A turn whose stream is cancelled this way, or by `agent.cancelStream()`, ends as a stop, whether the stream reports the cancel as `aborted`, as an `error`, or by just closing: one `inference:aborted` carrying the caller's reason (`user` when none was given), a `[turn-interrupted]` note in the agent's context, and never `inference:exhausted`, which is for failed inferences. A stream that membrane aborts on its own, at a deadline or on an error (reason `timeout` or `error`), is a failed inference. `stop()` and an operator's quiesce abandon end a turn as a stop too, with their own reasons, `shutdown` and `quiesce_abandoned`, and write no note. A stopped stream still writes its inference-log row, with `success: false`.

A caller's stop and an operator's quiesce abandon also reach a turn that has no live stream: one running its modules' `gatherContext`, its MCPL hooks or its compile, one waiting to retry a failed inference or compile (`abortInference()` and an operator's abandon end the wait, and a stop through `Agent.cancelStream()` waits for it to run out), and one whose tool-result guard is waiting to retry after a refusal, which holds only the round's finished stream. The turn ends at its next check, before it sends anything more, as a stop on a live stream ends it, and a turn whose compile fails after the stop ends at the failure, before a provider hold, the error policy's retry or `inference:exhausted`. A caller's stop leaves the note saying the turn was stopped before it sent anything or before it finished, and an operator's abandon leaves none. `abortInference()` returns `true` for such a turn. In the guard's wait, `stop()` ends the turn as a stop with reason `shutdown`, and an operator's abandon ends it as the abandon, even when a caller's stop came first; with both, it ends as `stop()`'s. `stop()` drops a turn in its hooks, its compile or a wait to retry before it sends anything, as before, and drops one whose compile fails while it runs, once a caller's stop has reached it, rather than ending it as that stop. While the failure of a turn's stream is reported (`inference:failed`), the agent no longer holds that stream, so `abortInference()` from a listener reports `false`. A failure to build a turn's request is reported while the turn's check is still ahead, so there `abortInference()` reports `true`, and the turn ends at that check. A stop made where no check of a turn is ahead stops nothing, and doesn't end the agent's next turn: while the agent is idle, while a turn finishes its last delivery, while a budget restart or a non-streaming turn's tool results wait to start the turn's next call, and while a provider-capacity hold or a provider's stated wait has parked a turn until the hold ends.

## API Server

WebSocket server for external clients (UIs, scripts, other agents).
Expand Down
68 changes: 68 additions & 0 deletions changelog.d/stop-without-stream.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
- **A stop reaches a turn that has no live stream.** A turn can be alive
with no stream to carry a stop: while it runs its modules'
`gatherContext`, its MCPL hooks or its compile, while it waits to retry a
failed inference or a failed compile, and while the tool-result guard
waits for its speech to go out before retrying a refused round, when it
holds only the round's finished stream. In the first two,
`Agent.cancelStream()` had nothing to cancel, the framework's
`abortInference()` returned `false`, and an operator's quiesce abandon
left the turn running. In the guard's wait, the round's finished stream
was cancelled and `abortInference()` returned `true`, but the retry was
sent anyway. The turn went on to send its request or its retry, and
answered, and an abandoned one did so while the host was quiesced. Now
it ends at its next check, before it sends anything more.
- **What a host sees:** one `inference:aborted` with the stop's reason,
instead of a turn that goes on. The agent's `[turn-interrupted]` note
says the turn was stopped before it sent anything, or before it
finished; an operator's abandon writes no note. The inference log gets
the stop's row, as for a stop on a live stream:
`Stream cancelled (<reason>)`, or `Stream aborted: quiesce_abandoned`
for an operator's abandon. The framework's `abortInference()` returns
`true` for such a turn. `abortInference()` and an operator's abandon end
a turn's wait to retry, however long, so the turn ends at once; a stop
through `Agent.cancelStream()` ends it when the wait runs out. An MCPL
server granted `inferenceLifecycle` sees a failed inference whose retry
such a stop cancels end `failed`, as when `stop()` cancels it. In the
tool-result guard's wait, `stop()` and an operator's abandon end the
turn as their own stop even when a caller's stop came first, and as
`stop()`'s when both reach it. `host:quiesce`'s `unabandonable` lists a
turn that the abandon hasn't ended when its grace runs out: one with no
stream and no check ahead, such as a puppet tool turn, or one that
hasn't reached its check yet, the tool-result guard's wait for its
speech included. A turn whose stream the abandon cancelled while it was
still streaming isn't listed, even one still running.
- **A failure's report:** a turn's stream that failed or threw no longer
holds the agent while its `inference:failed` is delivered, nor does a
compiled stream held for a provider's wait. A listener sees the agent
idle, and `abortInference()` from a listener reports `false`: no stream
or check is there to take the stop. A stop through
`Agent.cancelStream()` made then ends the turn at the check after its
wait to retry, if the error policy retries it, and otherwise ends
nothing. A failure to build a turn's request is reported while the
turn's check is still ahead: `abortInference()` from a listener reports
`true` there, and the turn ends at that check.
- **A stop followed by a failure:** when a turn's compile fails after a
stop reached it, the turn ends at the failure: its `inference:failed`,
then its `inference:aborted`. It isn't held by a provider hold, and the
error policy doesn't retry it. If by then the agent was replaced, or the
framework is stopping or was stopped and started again, the turn is
dropped before it sends anything, as at the check after the compile. A
failure that would have exhausted the turn emits no
`inference:exhausted`, the event from which the hard-down alert's
failure streak, the over-budget drain and the poison-history breaker
count failures, so none of them counts it.
- **A stop made where no check of a turn is ahead ends nothing:** the
agent's next turn runs, as after a host's interrupt of every agent.
That covers an idle agent, a turn finishing its last delivery, a budget
restart or a non-streaming turn's tool results waiting to start the
turn's next call, and a turn a provider-capacity hold or a provider's
stated wait has parked.
- **A start whose framework was stopped and started again since it began is
dropped before it retries a failed compile.** Its `inference:aborted`
reads `a retry waiting while the framework was stopped and started again
is dropped before it sends anything`; before, the retry compiled and was
sent in the new lifecycle. A start whose hooks ran across a `stop()` and a
`start()` is dropped before its compile too, as `a turn assembled while
the framework was stopped and started again …`, where before the check
after its compile dropped it. Either way a stop kept for the turn goes
with it, as for a turn dropped while `stop()` runs.
27 changes: 24 additions & 3 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1613,21 +1613,42 @@ export class Agent {
* the only provenance a host-side cancel has. */
private _pendingCancel: { reason?: string } | undefined;

/** The most recent cancelStream() made while this agent held no stream:
* during a turn's hooks or compile, while it waits to retry, or while no
* check of a turn is ahead at all. The framework collects it at the check
* ahead of it, which ends the turn, and the next start drops one no check
* collected, so a stop made while none was ahead, such as a host's
* interrupt of every agent, ends nothing. */
private _stopWithoutStream: { reason?: string } | undefined;

/**
* Cancel the stream this agent's state holds (streaming, waiting on its
* tools, or ready at a tool boundary with every result in), and reset to
* idle. `reason` is provenance for the framework's inference:aborted trace
* and marker metadata (e.g. 'zombie_reclaim', 'subagent_cancel'); it never
* reaches the provider.
* idle. With no stream, the stop is kept for the turn in progress, which
* the framework ends at its next check, before it sends anything more, if
* one is still ahead; otherwise the next start drops it. `reason` is
* provenance for the framework's inference:aborted trace and marker
* metadata (e.g. 'zombie_reclaim', 'subagent_cancel'); it never reaches
* the provider.
*/
cancelStream(reason?: string): void {
if ('stream' in this._state && this._state.stream) {
this._pendingCancel = { reason };
this._state.stream.cancel();
} else {
this._stopWithoutStream = { reason };
}
this._state = { status: 'idle' };
}

/** Collect (and clear) the cancelStream() made while this agent held no
* stream, if one was made since the last collection. */
takeStopWithoutStream(): { reason?: string } | undefined {
const s = this._stopWithoutStream;
this._stopWithoutStream = undefined;
return s;
}

/** Collect (and clear) the cancel that ended the current stream, if this
* side issued one. The stream driver calls it once, while the stream
* still owns the agent, at the stream's terminal event (`aborted`
Expand Down
Loading
Loading