Files
deepseek-harness/docs/core-data-structures/llm-streaming.md
Tianyi Cui ca2207e26c Fix doc cross-links for the hierarchy; add package-path + shape gates
Merge brought in the RFC-classification reorg and two new doc gates;
rewrite every drifted packages/<name> cross-link (Markdown link targets,
moved-README relative depths, and .ts comment paths) to the grouped paths.

Add two doc-sync/hygiene gates so the manual checks this restructure
needed become automated:
- verify-package-paths.ts: flags a packages/<path> reference (in Markdown
  or a .ts comment/string) that does not resolve AND names a real package
  in a segment — i.e. a stale path to a MOVED package. A path naming a
  non-existent package (a forward-looking proposal) is left alone, so it
  applies uniformly across proposed/implemented/rejected.
- check-workspace-constraints: assert the packages/<group>/<pkg> depth-2
  shape (group dirs carry no package.json; no flat or over-nested
  packages). Group names stay open; only the shape is fixed.
2026-06-20 23:12:14 +08:00

4.4 KiB

LLM Streaming

The wire-level streaming vocabulary of dsh-llm. core.md introduces StreamChunk, Message, and ContentBlock; this page owns the full chunk protocol, the adapter contract every adapter must obey, and the shared assembler.

Source: packages/llm/llm/src/types.ts

StreamChunk — the raw protocol

A streaming response interleaves several typed blocks (text, reasoning, multiple tool calls). index ties each delta to its block; block-end carries the fully-assembled ContentBlock so consumers don't have to re-assemble deltas themselves. It is a closed discriminated union — a switch over type ends with assertNever, so adding a variant breaks compilation at every consumer that must handle it.

type StreamChunk =
  | { type: 'block-start'; index: number; blockType: ContentBlockType }
  | { type: 'text-delta'; index: number; text: string }
  | { type: 'reasoning-delta'; index: number; text: string }
  | { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string }
  | { type: 'block-end'; index: number; block: ContentBlock }
  | { type: 'usage'; usage: TokenUsage }
  | { type: 'finish'; reason: FinishReason }

The adapter contract

Every adapter MUST obey these, and every consumer may rely on them:

  • usage before finish, nothing after finish. Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering.
  • Tool-call arguments stay raw JSON strings end-to-end. Partial fragments stream via argumentsDelta; a provider that hands back parsed objects re-stringifies at block-end.
  • Two sanctioned error paths. A failure may either THROW from stream() (transport/protocol errors) or end the stream with finish {kind:'error'|'aborted'} (provider in-band errors, for adapters that can't throw mid-stream). Consumers must handle both. The agent loop translates a finish-error/aborted into a turn error — it never logs a normal completed assistant message for a failed step.

This contract is why two adapters exist as a deliberate pair: dsh-llm-deepseek (hand-rolled fetch/SSE) and dsh-llm-pi-ai (the same endpoint through @earendil-works/pi-ai). Two independent internals over one contract is what pinned the protocol down — the library-backed adapter can't throw mid-stream, so it exercises the finish-chunk error path the hand-rolled one might not.

TokenUsage

Per-call token accounting. Counts are disjoint: inputTokens is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's prompt_tokens) subtract them back out.

interface TokenUsage {
  inputTokens: number
  outputTokens: number
  cacheReadTokens?: number
  cacheWriteTokens?: number
  reasoningTokens?: number
}

BlockAssembler

BlockAssembler (packages/llm/llm/src/assembler.ts) is the single shared implementation that folds a StreamChunk stream back into ContentBlocks and a final Message. The loop logs the raw chunks (for replay fidelity) while feeding the same chunks through an assembler — so the canonical log keeps token-level detail and the derived message is rebuilt deterministically. A consumer that needs the assembled result without re-implementing the fold uses this.

The seam

LlmAdapter is the provider seam: subclass, implement stream(), register with ctx.llm.registerAdapter(models, adapter). The block-start / block-end index correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (ctx.llm.stream() / streamBlocks() / generate()) and the llm/stream waterfall are described in architecture.md § The vocabulary.

ContentBlockType (the key set the index-correlated blocks carry) derives from ContentBlockMap:

interface ContentBlockMap {
  'text': TextBlock
  'reasoning': ReasoningBlock
  'tool-call': ToolCallBlock
  'tool-result': ToolResultBlock
  'image': ImageBlock
}

See core.md § Content blocks and messages for the block interfaces.