Resumable Streams
Let a client that lost the stream of an agent run reconnect with Last-Event-ID, replay what it missed and follow the run to its end, or cancel the run on purpose.
A streamed agent run can take minutes. A flaky network, a phone that switches cells or a laptop that goes to sleep cuts the SSE connection, and by default the adapter then stops the run. With resumableStreams the run goes on without the client, every event is kept, and the client reconnects to pick the stream up exactly where it stopped. A client that wants the run stopped says so on the cancel route.
It works the same in every adapter: Express, Fastify, Hono, Koa, Tetsu and the Next.js handlers, on /agents/:name/stream and /agents/:name/resume/stream.
Turn it on
// Express
new CogitatorServer({ app, cogitator, agents, config: { resumableStreams: true } });
// Fastify
await fastify.register(cogitatorPlugin, { cogitator, agents, resumableStreams: true });
// Hono and Koa
cogitatorApp({ cogitator, agents, resumableStreams: true });
// Tetsu
cogitatorController({ cogitator, agents, resumableStreams: true });
// Next.js (owner is required next to beforeRun, see below)
export const POST = createChatHandler(cogitator, agent, { resumableStreams: true });true keeps the events in the memory of the process with the default limits. An object sets them:
| Option | Default | What it does |
|---|---|---|
maxEvents | 1000 | Events kept of each stream, past it the oldest are evicted. Also what may wait for a client that reads slowly |
retentionMs | 5 minutes | How long a finished stream stays replayable |
maxStreams | 1000 | Streams kept at once. A new one evicts the stream that finished first, and is refused while every stream kept still goes on |
detachedTimeoutMs | 5 minutes | How long a run goes on with no client following its stream before it is stopped |
owner | whole identity | The key a stream is bound to, from what auth returned, see Who may reconnect |
until | none | Stops every run of a resumable stream when it aborts, such as the signal of a server that shuts down |
store | in-memory | Where the events are kept, see Several processes |
Off by default: events carry no ids and a client that disconnects stops the run, as before.
Event ids
Every event frame of a resumable stream carries an SSE id:
id: stream_6f1c2b7e-3a4d-4e8f-9b0a-1c2d3e4f5a6b:3
data: {"type":"text-delta","id":"txt_1","delta":"Hel"}The id is <streamId>:<seq>. The stream id is random and cannot be guessed, the sequence number starts at 1 and grows by one with each event, so the id alone names the stream and the place in it. Heartbeat comments carry no id.
Reconnect
Send the same route again with the id of the last event the client got, in the Last-Event-ID header:
const res = await fetch('/cogitator/agents/assistant/stream', {
method: 'POST',
headers: { 'Last-Event-ID': lastEventId },
});The request starts no run and its body is not read. The answer replays the events after that id, with their ids, then follows the run live until finish (and data: [DONE]) or error. Several clients may follow the same stream at once. The lastEventId query parameter works too, for clients that cannot set headers, but it puts the id into URLs and access logs: prefer the header.
| Case | Answer |
|---|---|
| A stream of the caller | 200, the events after the id, then the live ones |
| An unknown or expired stream, another caller's, or an id it never sent | 404 STREAM_NOT_FOUND, before any SSE header |
A thread authorizeThread turns the caller away from | 403 THREAD_FORBIDDEN |
An id that is not <streamId>:<seq> | 400 INVALID_INPUT (Tetsu: 422 VALIDATION_FAILED) |
| Events after the id are no longer kept | 200 with one error event STREAM_RESUME_GAP, no id |
On STREAM_RESUME_GAP the client should fetch the thread (GET /threads/:id) to catch up, the message names the thread. A client that reads slowly holds at most maxEvents events, one that falls further behind catches up from the store, and gets the gap only for events the store no longer keeps. A store that lost an event in the middle is caught by its sequence numbers the same way: nothing is skipped silently.
A client on another origin sends Last-Event-ID after a CORS preflight, which must allow the header. The Express adapter allows it by default. With the CORS middleware of Fastify, Hono, Koa, Tetsu or Next.js, add it to the allowed headers: CORS_ALLOWED_HEADERS from @cogitator-ai/server-shared lists every header a client of the API sends. The cancel route also needs DELETE among the allowed methods.
Cancel
Stopping the client's fetch only disconnects it, the run goes on. To stop the run, send DELETE to the cancel route of its stream, the stream id being the part of an event id before the last ::
await fetch(`/cogitator/agents/assistant/stream/${streamId}`, { method: 'DELETE' });It answers 204 and stops the run on whatever process runs it, its stream then ends with an error event STREAM_CANCELLED. A stream that already finished is left as it is. The cancel takes the same checks as a reconnect (404 STREAM_NOT_FOUND, 403 THREAD_FORBIDDEN). In Next.js the cancel is DELETE on the route of the handler with ?streamId=, and useCogitatorChat sends it on stop(), reload() and every other interrupt.
Who may reconnect
A stream is bound to the caller who started it, and only that caller may reconnect to it or cancel its run:
- By default the key is every field of what the adapter's
authreturned: user id, roles, permissions and metadata such as a tenant. Callers without a user id are still told apart by the rest of their identity, and a caller with no identity (noauth, orauththat returned{}) is the anonymous caller, who reaches only anonymous streams. owner: (auth) => keybinds streams to a key of your own, for exampleauth?.userIdwhen the identity carries values that change between requests, such as the expiry of a token.- After the owner check, a reconnect and a cancel ask
authorizeThreadabout the stream's thread, so a caller it turns away since the run started is refused. - In Next.js a reconnect and a cancel run neither
beforeRunnordeps, whose side effects would repeat.owner(req)reads the request instead, and a handler withbeforeRunthrows without it. See Next.js.
Detached runs
When the last client leaves, the run goes on and its events keep going into the store. If nobody reconnects within detachedTimeoutMs, the run is stopped, so a client that never comes back does not run up cost. Every run is also stopped when until aborts: pass the signal your server stops on, and abort it before the HTTP server closes, since a close waits for the streams still open. Fastify stops them by itself as the instance starts closing (preClose), Tetsu also on its controller's until, and Next.js also on the signal beforeRun returns. Such a stream ends with an error event STREAM_ABORTED, which a late reconnect still gets.
The clients of the process that runs a stream get every event straight from it, the last one too, also when the store fails. A store that cannot keep an event stops the run.
Room for streams
A stream whose run goes on is never evicted, since its client could no longer reconnect. When the store keeps maxStreams streams that all go on, a new resumable stream is refused with 503 STREAM_LIMIT_REACHED before anything is sent, and the client may try again once a run finished.
Several processes
The default store lives in the memory of one process. Behind a load balancer, a reconnect or a cancel may land on a process that did not start the run. Give every process one shared store, an implementation of StreamReplayStore from @cogitator-ai/server-shared:
import type { StreamReplayStore } from '@cogitator-ai/server-shared';
const store: StreamReplayStore = {
create: (stream) => redisCreateUnlessFull(stream),
append: (streamId, entry) => redisAppendAndPublish(streamId, entry),
read: (streamId, afterSeq) => redisRead(streamId, afterSeq),
finish: (streamId) => redisFinishAndPublish(streamId),
get: (streamId) => redisGet(streamId),
subscribe: (streamId, subscriber) => redisSubscribe(streamId, subscriber),
cancel: (streamId) => redisPublishCancel(streamId),
onCancel: (streamId, listener) => redisSubscribeCancel(streamId, listener),
};
cogitatorApp({ cogitator, agents, resumableStreams: { store } });createanswersfalsewhen the store has no room without evicting a stream that goes on.readanswers the events after a sequence number, whether some were evicted (gap) and whether the stream finished.subscribeandonCancelmust reach listeners on every process, which takes pub/sub (RedisPUBLISH/SUBSCRIBEor similar), andsubscribedelivers events in order.getreports theowner, thesubscribersacross every process anddetachedAt, when the last of them left on any process: a run checks them before it stops as detached, so a client following it on another process keeps it going, and one that came and went there restarts the count. StampdetachedAtwith the clock the hubs use (now,Date.nowby default).
retentionMs and maxStreams belong to the in-memory store, next to a store of your own they throw. maxEvents still bounds what waits for one client.
WebSocket
The WebSocket endpoints do not resume. Their frames use their own vocabulary (token, tool-call, complete, cancelled) rather than the stream protocol the store keeps, and a run lives as long as its socket: closing it or sending stop cancels the run. Use the SSE routes for runs a client must be able to resume.