mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 01:32:40 +00:00
81 lines
7.8 KiB
Markdown
81 lines
7.8 KiB
Markdown
# Agent stream performance
|
||
|
||
How assistant text gets from a provider to the screen, and why it is paced on the way. Read this before changing `packages/server/src/server/agent/agent-stream-coalescer.ts`, the reducer queue in `packages/app/src/timeline/session-stream-reducers.ts`, or the reveal in `packages/app/src/hooks/use-revealed-text.ts`.
|
||
|
||
For terminal output, which is a separate pipeline with separate budgets, see [terminal-performance.md](terminal-performance.md).
|
||
|
||
## The pipeline
|
||
|
||
```
|
||
provider deltas (every provider streams incrementally)
|
||
→ AgentStreamCoalescer (daemon, leading + trailing, ≤1 message per 60ms per agent)
|
||
→ recordTimeline: one canonical row per flushed item
|
||
→ agent_stream ws message
|
||
→ reducer queue (app, one commit per frame) → session store
|
||
→ source-item plugin transforms → Markdown presentation / tool grouping
|
||
→ paced reveal (app, per displayed item) → paint
|
||
```
|
||
|
||
Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`.
|
||
|
||
## Why the reveal is paced
|
||
|
||
Arrival is lumpy and there is no fixing that at the source. A 60ms coalescing window carries however many characters the model produced in those 60ms, which swings by an order of magnitude within a single turn. Painting each delta as it lands makes the size of those lumps visible, and that is what reads as jagged.
|
||
|
||
So arrival sets a _target_ and the reveal rate is derived from the backlog instead. A burst makes the text catch up faster; it does not make the text jump. Shrinking the coalescing window does not fix this — it makes the lumps smaller and more frequent, at the cost of message rate on a daemon loop that already contends with terminal frames and per-message relay encryption.
|
||
|
||
## Invariants
|
||
|
||
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
|
||
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
|
||
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
|
||
- **Markdown rows belong to presentation, and every assistant message is a block group.**
|
||
`agent-stream/presentation.ts` keeps the source message whole for plugins, then splits it for
|
||
display. Native groups adjacent prose into selectable runs; web retains individual blocks.
|
||
Code, tables, images, HTML-like content and blockquotes end a native run. See
|
||
[native text selection](native-text-selection.md) for renderer ownership and library constraints.
|
||
Appends reparse the growing last block. A completed reference definition at its start can
|
||
belong to the preceding block, so that case reparses the full message. Completed rows retain
|
||
object identity and unchanged history stays cached. Prose rows retain their original source
|
||
blocks so native preparation cannot broaden reference scope. Paced reveal reuses prepared
|
||
paragraph prefixes and prepares only the remaining open inline tail.
|
||
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}`; native prose runs use `${messageId}:run:${n}` with
|
||
`blockGroupId = messageId`, including single-row messages. Inline images split a source block into offset-keyed parts, so nothing can come to rely on the two
|
||
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
|
||
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
|
||
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
|
||
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
|
||
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
|
||
occurrence keys.
|
||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||
- **A history row re-renders only when its item or layout item identity changes.** The inverted
|
||
FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a
|
||
memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of
|
||
JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed,
|
||
`useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state,
|
||
or breakpoint changed, and `HistoryStreamRow` memoizes on both. Every viewport runs its history
|
||
through that hook; the web viewport once skipped it and history hosts of a live tool group went
|
||
stale. A new field on `StreamLayoutItem` must be added to `areLayoutItemsEquivalent`, or sharing
|
||
silently stops.
|
||
|
||
## Measuring
|
||
|
||
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
|
||
- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly.
|
||
- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer.
|
||
|
||
Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken:
|
||
|
||
| | paint on arrival | paced |
|
||
| -------------------------------- | ---------------- | ----- |
|
||
| frames that advanced the text | 6% | 87% |
|
||
| chars-per-frame CV | 4.11 | 1.86 |
|
||
| gap between visible updates, p50 | 317ms | 17ms |
|
||
| gap between visible updates, p95 | 383ms | 17ms |
|
||
|
||
Total characters painted is roughly the same either way — the reveal changes when they land, not how many arrive.
|
||
|
||
Measure the **total** length across every `assistant-message` element, not the last one. A turn emits many assistant messages, so the tail element keeps changing identity and its length is not monotonic; sampling only the tail reads those handovers as resets and reports almost no growth. `sampleStreamFrames` does this correctly.
|