assembler.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. /**
  2. * Incremental chunk-to-message assembler. This is the single canonical assembly
  3. * algorithm used by the agent loop to build an assistant message from a chunk
  4. * stream while logging the raw chunks for replay fidelity.
  5. *
  6. * @module @deepseek-ai/dsh-llm/assembler
  7. */
  8. import { CallId } from './brand.ts'
  9. import { assertNever } from './never.ts'
  10. import type { ContentBlock, FinishReason, Message, StreamChunk, TokenUsage } from './types.ts'
  11. interface PartialBlock {
  12. blockType: string
  13. text: string
  14. toolCallId?: CallId
  15. toolCallName?: string
  16. toolCallArguments: string
  17. /** Set by `block-end` — authoritative, and freezes the partial. */
  18. block?: ContentBlock
  19. }
  20. /**
  21. * Incrementally assembles raw {@link StreamChunk}s into complete
  22. * {@link ContentBlock}s and a final assistant {@link Message}.
  23. *
  24. * The agent loop feeds it while logging raw chunks for replay fidelity, then
  25. * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends.
  26. *
  27. * Tolerant of delta-only protocols (no block-start/end); deltas arriving for
  28. * an index already closed by `block-end` are ignored (malformed stream) so a
  29. * misbehaving adapter cannot grow memory or corrupt a completed block.
  30. */
  31. export class BlockAssembler {
  32. private partials = new Map<number, PartialBlock>()
  33. private order: number[] = []
  34. private _usage: TokenUsage | undefined
  35. private _finish: FinishReason | undefined
  36. /**
  37. * Feed one chunk. Returns the completed block when the chunk closes one
  38. * (an explicit `block-end`), otherwise undefined.
  39. * @param chunk - the next raw chunk, in stream order.
  40. * @returns the authoritative block from the first `block-end` at its index; undefined for every other chunk.
  41. */
  42. push(chunk: StreamChunk): ContentBlock | undefined {
  43. switch (chunk.type) {
  44. case 'block-start': {
  45. if (!this.partials.has(chunk.index)) {
  46. this.order.push(chunk.index)
  47. this.partials.set(chunk.index, {
  48. blockType: chunk.blockType,
  49. text: '',
  50. toolCallArguments: '',
  51. })
  52. }
  53. return
  54. }
  55. case 'text-delta':
  56. case 'reasoning-delta': {
  57. const partial = this.ensure(chunk.index, chunk.type === 'text-delta' ? 'text' : 'reasoning')
  58. if (partial.block) return // closed by block-end; ignore stragglers
  59. partial.text += chunk.text
  60. return
  61. }
  62. case 'tool-call-delta': {
  63. const partial = this.ensure(chunk.index, 'tool-call')
  64. if (partial.block) return // closed by block-end; ignore stragglers
  65. partial.toolCallId = chunk.id
  66. if (chunk.name) partial.toolCallName = chunk.name
  67. partial.toolCallArguments += chunk.argumentsDelta
  68. return
  69. }
  70. case 'block-end': {
  71. const partial = this.ensure(chunk.index, chunk.block.type)
  72. // First close wins; ignoring re-close stragglers keeps streamed output
  73. // and the final assembled block in agreement.
  74. if (partial.block) return
  75. partial.block = chunk.block
  76. return chunk.block
  77. }
  78. case 'usage': {
  79. this._usage = chunk.usage
  80. return
  81. }
  82. case 'finish': {
  83. this._finish = chunk.reason
  84. return
  85. }
  86. default: return assertNever(chunk, 'BlockAssembler.push')
  87. }
  88. }
  89. private ensure(index: number, blockType: string): PartialBlock {
  90. let partial = this.partials.get(index)
  91. if (!partial) {
  92. partial = { blockType, text: '', toolCallArguments: '' }
  93. this.partials.set(index, partial)
  94. this.order.push(index)
  95. }
  96. return partial
  97. }
  98. private assemble(partial: PartialBlock, index: number): ContentBlock {
  99. if (partial.block) return partial.block
  100. switch (partial.blockType) {
  101. case 'text': return { type: 'text', text: partial.text }
  102. case 'reasoning': return { type: 'reasoning', text: partial.text }
  103. case 'tool-call': return {
  104. type: 'tool-call',
  105. id: partial.toolCallId ?? CallId(`call-${index}`),
  106. name: partial.toolCallName ?? '',
  107. arguments: partial.toolCallArguments,
  108. }
  109. default: throw new Error(`cannot assemble incomplete block of type "${partial.blockType}"`)
  110. }
  111. }
  112. /** Invariant accessor: every index in `order` has a partial. */
  113. private mustGet(index: number): PartialBlock {
  114. const partial = this.partials.get(index)
  115. if (!partial) throw new Error(`BlockAssembler invariant violated: no partial for index ${index}`)
  116. return partial
  117. }
  118. /**
  119. * Assemble all blocks seen so far, in stream order.
  120. * @returns one block per seen index; an open block assembles from its
  121. * accumulated deltas (an unknown block type never closed by `block-end` throws).
  122. */
  123. blocks(): ContentBlock[] {
  124. return this.order.map(index => this.assemble(this.mustGet(index), index))
  125. }
  126. /** Usage from the `usage` chunk; undefined until one arrives. */
  127. get usage(): TokenUsage | undefined {
  128. return this._usage
  129. }
  130. /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */
  131. get finish(): FinishReason {
  132. return this._finish ?? { kind: 'stop' }
  133. }
  134. /**
  135. * The assembled assistant message.
  136. * @returns an assistant-role message over `blocks()` (same open-block assembly rules).
  137. */
  138. message(): Message {
  139. return { role: 'assistant', content: this.blocks() }
  140. }
  141. }