agents
Version:
A home for your AI agents
214 lines (174 loc) • 8.71 kB
Markdown
# Streams
> **Experimental.** Everything exported from `agents/streams` may change
> between releases while the durable output surface stabilizes.
`agents/streams` adds durable incremental output to a [Lifecycle
Object](./lifecycle.md): an ordered, durable chunk log per stream with a
monotonic cursor, replay-then-tail reads, and terminal status. A consumer
that reconnects replays from its cursor; a producer that dies mid-stream
leaves exactly the chunks it durably appended, ready for a replayed
producer to resume from. The capability needs no alarm, so it also works
on facets.
## Install and use
```ts
import { DurableObject } from "cloudflare:workers";
import { Lifecycle } from "agents/lifecycle";
import { Streams } from "agents/streams";
export class ReportObject extends DurableObject<Env> {
readonly streams = new Streams();
readonly lifecycle = Lifecycle.install(this).use(this.streams);
}
```
On an `Agent`, install it onto the composition root in the constructor —
the pattern for adding any extra capability to an Agent:
```ts
export class ReportAgent extends Agent<Env> {
readonly streams = new Streams();
constructor(ctx: AgentContext, env: Env) {
super(ctx, env);
this.lifecycle.use(this.streams);
}
}
```
## Producing
```ts
const stream = await this.streams.open("reply:123", { metadata });
stream.append(chunk); // synchronous durable write; wakes live readers
stream.close(); // or stream.error(reason)
```
Chunks are JSON values (1 MiB default ceiling, configurable via
`maxChunkBytes`); each append assigns the next monotonic sequence number —
the stream's **cursor**. `open()` is idempotent on the id: reopening a live
stream returns a writer at its cursor, reopening a settled stream throws
`StreamClosedError`, and settling twice is a no-op so recovery callers stay
idempotent.
## Consuming
```ts
for await (const chunk of this.streams.read("reply:123", { from, signal })) {
// replays persisted chunks from `from`, then tails live appends,
// ends when the stream settles
}
const status = await this.streams.status("reply:123");
// { state: "streaming" | "completed" | "errored", cursor, ... } | null
```
Reads are independent of producer liveness. `list()` filters by state and
by `tag`, and `delete()` removes a settled stream and its chunk log (a live
stream must be settled first).
**Tags** are the lookup side of the id: `open(id, { tag })` stamps a stream
with an indexed application key — a request id, a session — that is
deliberately _not_ unique. An operation that produces successive streams (a
retried turn, a regenerated reply) tags each one, and
`list({ tag, limit: 1 })` finds the latest (results are newest-first). The
tag is fixed at creation; reopening a live stream with a different tag
throws. Use the id alone until one operation can own more than one stream —
that's the moment tags exist for.
`readBatches` also accepts `onUpToDate`, invoked once when the reader first
reaches the durable tail. Caught-up is distinct from ended: a live stream is
up to date while tailing — use it to flush replayed UI or flip on a "live"
indicator.
When the consumer pays per write — an SSE flush, an RPC hop, a history
append — read in batches instead of chunk by chunk:
```ts
for await (const batch of this.streams.readBatches("reply:123", {
from,
batchSize: 50 // per-array ceiling during replay; default 100
})) {
flush(batch); // StreamChunk[] — one write per backlog, not per chunk
}
```
`readBatches()` has the same lifecycle as `read()` (replay, then tail, end
on settlement); the difference is granularity: replay yields up to
`batchSize` chunks per array, and a live tail yields everything that
accumulated since the last wakeup as one array.
## Composing with Tasks
The contract [Tasks](./tasks.md) replay was designed around: a task step
appends to a stream it does not own, and because the producer starts its
loop at the stream's own durable cursor, a replay after interruption is a
resume — the stream is the recovery evidence.
```ts
readonly tasks = new Tasks({
definitions: {
"generate@v1": async (input: GenerateInput, step: TaskStep) => {
return step.do("stream", async () => {
const stream = await this.streams.open(input.streamId);
// Resuming producers start from the stream's own cursor, so a
// replay after interruption never duplicates a chunk.
for (let i = stream.cursor; i < input.total; i++) {
stream.append(await this.produce(i));
}
stream.close();
});
}
}
});
```
Neither capability imports the other. The composition survives a real
process kill: the chunks appended before death are exactly what `status()`
reports afterward (proven by the SIGKILL e2e suite).
## Serving
For SSE, one call serves the whole lifecycle:
```ts
import { sseResponse } from "agents/streams";
async onRequest(request: Request) {
return sseResponse(this.streams, "reply:123", { request });
}
```
Each chunk's sequence number rides the SSE `id:` field, so resume is native
to the protocol: a reconnecting `EventSource` sends `Last-Event-ID`
automatically and the helper continues from the next chunk — cursor
persistence with zero client code (`?from=` works too). The response
replays, emits an `up-to-date` control event at the tail, tails live
appends (with periodic heartbeat comments to survive idle proxies), and
finishes with `done` or `error` (carrying the recorded reason). The
request's signal aborts the tail when the client disconnects.
`examples/next/streams` is the end-to-end demo. For other transports,
`read()`/`readBatches()` remain the raw async iterables to pipe yourself.
## Storage: blocks, and the cutover to a message
Chunks are stored as **rollover blocks**: one row per stream holds chunks
until it reaches 256 KB, then the next append opens a new row. An append
is one billed row either way (an UPDATE that grows the block, or the INSERT
of the next one), the same as a row-per-chunk log, but a stream of
thousands of chunks is a handful of rows, so deleting it is a handful of
writes instead of thousands. Replay parses one block at a time.
A stream is temporary: once its content has become something else (a
session message, a report), its rows are dead weight. The **cutover** ends
the stream, runs your own synchronous writes, and deletes its rows in one
SQLite transaction:
```ts
stream.close({
commit: () => sessionSync.upsert(message), // synchronous writes only
discard: true // delete the stream's rows in the same transaction
});
```
Either the message exists and the stream is gone, or `commit` threw, the
settle rolled back and the stream is still live. Nothing is left for a
retention sweep. `error(reason, { commit, discard })` is the same for a
failed producer. `commit` must not await; a Session handle's
`__DO_NOT_USE_WILL_BREAK__sync().upsert()` is the matching synchronous
message write, and returns a `notify()` to dispatch the change feed after
the transaction commits.
Measured on a real Durable Object (400-chunk chat turn, 10 chunks per
write): the old log paid 42 rows to write and another 42 to sweep; blocks
pay 42 to write and 3 to cut over.
## Chat runs on this
`AIChatAgent` and `Think` store their in-flight turn output here:
`ResumableStream` (from `agents/chat`) is a thin adapter over Streams that
packs ~10 wire chunks into one stored segment for write economy, and ends
every turn with the cutover: the assistant message, the stream's
settlement and the deletion of its rows commit in one transaction. Nothing
is swept on an alarm any more. A stream a crash left behind is either
still `streaming` (recovery rebuilds the message from it) or reclaimed by
the next stream start, together with in-flight rows abandoned for over an
hour. Existing `cf_ai_chat_stream_*` tables migrate onto the capability
automatically. The packing pattern is worth copying for any
high-frequency producer: buffer what you already hold synchronously, append
one packed chunk, and unpack on read — durability is unchanged (nothing is
held across an await at settlement) and rows written drop by ~an order of
magnitude versus per-token appends.
## Current limits
Live fanout is in-isolate (sufficient: a Durable Object executes in one
isolate at a time; reconnecting readers replay from their cursor). Retention
is explicit: `delete()`, or the cutover's `discard`; age-based sweeping in
the capability itself, producer-generation fencing on `open()`,
and transport helpers extracted from chat's resume protocol are future work.
The design record is
[`design/rfc-streams.md`](https://github.com/cloudflare/agents/blob/main/design/rfc-streams.md).