Skip to content
Open
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
5 changes: 5 additions & 0 deletions changelog.d/membrane-floor-0-5-81.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- **Hosts:** the `@animalabs/membrane` floor is now `^0.5.81`. Earlier
releases reported reason `user` from a broad abort catch for any error
whose message contained "abort", which the new cancellation path would
have recorded as a deliberate stop; from 0.5.81 `user` means exactly that
the request's signal was aborted.
9 changes: 9 additions & 0 deletions changelog.d/shutdown-cancel-provenance.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- A graceful framework shutdown (`AgentFramework.stop()`) with an inference
still streaming records its own provenance before cancelling — the same
`frameworkCancelledStreams` track that `endTurn`, budget restarts and
quiesce use — so the stream driver settles the turn as a shutdown: no
`[turn-interrupted]` marker (nobody stopped the agent; the host went
away), no `inference:exhausted`, one `inference:aborted` with reason
`shutdown`, an inference-log terminal, and the agent settled so an
in-flight `runEphemeralToCompletion` rejects immediately instead of
waiting out its idle watchdog.
28 changes: 28 additions & 0 deletions changelog.d/user-interrupt-not-failure.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
- A cancelled stream (host Stop button, `agent.cancelStream()`,
`framework.abortInference()`) is no longer recorded as an inference
failure. It emits `inference:aborted` instead of `inference:exhausted`,
leaves the consecutive-failure streak and ops alerts untouched, and writes
a `[turn-interrupted] … a deliberate stop, not a failure` chronicle marker
in place of the `[inference-failed] the model call failed…` text with
remediation advice for a failure that never happened. The marker names the
act, not an actor (membrane's `user` reason means "the signal was aborted",
not "the user did it"), and makes no claim about delivery. Callers can pass
their own provenance — `cancelStream(reason)` / `abortInference(reason)` —
which the trace and the marker's metadata carry; `abortInference` no longer
emits a second `inference:aborted` on top of the stream driver's. This
holds on both of a stream's cancel twins: a stream implementation that
reports `cancel()` through `error` rather than `aborted` reaches the same
terminal (one `inference:aborted`, the marker, no `inference:failed`, no
`errorPolicy` retry of the inference that was just stopped), and the
quiesce/shutdown twins no longer emit a contradictory `inference:failed`
before settling as aborted. A third shape — an implementation whose
`cancel()` simply closes the iterator, with no terminal event — reaches the
same terminal too, at the loop's end, instead of leaving the turn unsettled
under a `completed` lifecycle terminal. (#134, by Lari; reworked after
review.)
- Speech-route failures with no delivery locus (headless/WebUI turns with no
home or trigger channel) now read `[send-undeliverable] … had no channel
to go to` instead of claiming a Discord delivery failure to "the channel",
and say that this route delivered nowhere rather than that the reply
reached no one — another `dispatchSpeech` handler may have shown it. The
machine-readable marker `kind` is unchanged.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"dependencies": {
"@animalabs/chronicle": "^0.4.0",
"@animalabs/context-manager": "^0.10.0",
"@animalabs/membrane": "^0.5.78",
"@animalabs/membrane": "^0.5.81",
"chokidar": "^4.0.3",
"discord.js": "^14.25.1",
"ws": "^8.18.0"
Expand Down
38 changes: 35 additions & 3 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,13 @@ export class Agent {
this.failOpenKvSubmissions();

this._streamId++;
// A pending cancel belongs to the stream that was live when cancelStream()
// ran. The stream driver collects it on that stream's terminal event, or
// at the loop's end when the iterator closed without one; this clear is
// the belt for a driver that never got there (a stream that never
// started iterating). A fresh stream must not inherit it and read its
// own later error as a deliberate stop.
this._pendingCancel = undefined;
this._inferenceStartedAt = Date.now();
this.lastStreamInputTokens = 0;
// Reset the cache-inclusive counters too: a new stream must not inherit
Expand Down Expand Up @@ -958,10 +965,24 @@ export class Agent {
this._state = { status: 'streaming', stream };
}

/** The most recent cancelStream() of a live stream, until the stream
* driver collects it on that stream's terminal event. Its presence is
* the signal (a reasonless Stop is still a deliberate stop); its reason
* is the caller's own word. Membrane reports every cancel() as reason
* 'user' — the call, not the actor — and a stream implementation may
* report the cancel as `error` with no reason at all, so this record is
* the only provenance a host-side cancel has. */
private _pendingCancel: { reason?: string } | undefined;

/**
* Cancel any active stream and reset to idle.
* Cancel any active stream 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.
*/
cancelStream(): void {
cancelStream(reason?: string): void {
const hadStream = this._state.status === 'streaming' ||
(this._state.status === 'waiting_for_tools' && !!this._state.stream);
if (hadStream) this._pendingCancel = { reason };
if (this._state.status === 'streaming') {
this._state.stream.cancel();
} else if (this._state.status === 'waiting_for_tools' && this._state.stream) {
Expand All @@ -970,6 +991,17 @@ export class Agent {
this._state = { status: 'idle' };
}

/** Collect (and clear) the cancel that ended the current stream, if this
* side issued one. Called once by the stream driver on the stream's
* terminal event — `aborted` normally, `error` for implementations that
* report cancel() that way — so it is consumed exactly once, by the
* stream it ended. */
takeCancel(): { reason?: string } | undefined {
const c = this._pendingCancel;
this._pendingCancel = undefined;
return c;
}

/**
* Check if agent has pending tool calls.
*/
Expand Down Expand Up @@ -1030,7 +1062,7 @@ export class Agent {
if (this._state.status === 'streaming' ||
(this._state.status === 'waiting_for_tools' && this._state.stream)) {
const durationMs = Date.now() - this._inferenceStartedAt;
this.cancelStream();
this.cancelStream(reason);
return { aborted: true, durationMs };
}

Expand Down
Loading