assembler.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  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 { brandString } from '@deepseek-ai/dsh-brand'
  9. import { assertNever } from '@deepseek-ai/dsh-util-values'
  10. import type { ToolCallId } from './brand.ts'
  11. import { createMessage } from './message.ts'
  12. import type { Message, MessageSource } from './message.ts'
  13. import type { ContentBlock, FinishReason, ReplayEnvelope, StreamChunk, TokenUsage } from './types.ts'
  14. interface PartialBlock {
  15. blockType: string
  16. text: string
  17. toolCallId?: ToolCallId
  18. toolCallName?: string
  19. toolCallArguments: string
  20. /** Set by `block-end` — authoritative, and freezes the partial. */
  21. block?: ContentBlock
  22. }
  23. /**
  24. * Incrementally assembles raw {@link StreamChunk}s into complete
  25. * {@link ContentBlock}s and a final assistant {@link Message}.
  26. *
  27. * The agent loop feeds it while logging raw chunks for replay fidelity, then
  28. * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends,
  29. * or `interruptedBlocks()` when cancellation cut the stream short.
  30. *
  31. * Tolerant of delta-only protocols (no block-start/end); deltas arriving for
  32. * an index already closed by `block-end` are ignored (malformed stream) so a
  33. * misbehaving adapter cannot grow memory or corrupt a completed block.
  34. */
  35. export class BlockAssembler {
  36. private partials = new Map<number, PartialBlock>()
  37. private order: number[] = []
  38. private _usage: TokenUsage | undefined
  39. private _finish: FinishReason | undefined
  40. private _replayState: ReplayEnvelope | undefined
  41. /**
  42. * Feed one chunk into the assembly state.
  43. * @param chunk - the next raw chunk, in stream order.
  44. */
  45. push(chunk: StreamChunk): void {
  46. switch (chunk.type) {
  47. case 'block-start': {
  48. if (!this.partials.has(chunk.index)) {
  49. this.order.push(chunk.index)
  50. this.partials.set(chunk.index, {
  51. blockType: chunk.blockType,
  52. text: '',
  53. toolCallArguments: '',
  54. })
  55. }
  56. return
  57. }
  58. case 'text-delta':
  59. case 'reasoning-delta': {
  60. const partial = this.ensure(chunk.index, chunk.type === 'text-delta' ? 'text' : 'reasoning')
  61. if (partial.block) return // closed by block-end; ignore stragglers
  62. partial.text += chunk.text
  63. return
  64. }
  65. case 'tool-call-delta': {
  66. const partial = this.ensure(chunk.index, 'tool-call')
  67. if (partial.block) return // closed by block-end; ignore stragglers
  68. partial.toolCallId = chunk.id
  69. if (chunk.name) partial.toolCallName = chunk.name
  70. partial.toolCallArguments += chunk.argumentsDelta
  71. return
  72. }
  73. case 'block-end': {
  74. const partial = this.ensure(chunk.index, chunk.block.type)
  75. // First close wins; ignoring re-close stragglers keeps streamed output
  76. // and the final assembled block in agreement.
  77. if (partial.block) return
  78. partial.block = chunk.block
  79. return
  80. }
  81. case 'usage': {
  82. this._usage = chunk.usage
  83. return
  84. }
  85. case 'finish': {
  86. this._finish = chunk.reason
  87. this._replayState = chunk.replayState
  88. return
  89. }
  90. default: return assertNever(chunk, 'BlockAssembler.push')
  91. }
  92. }
  93. private ensure(index: number, blockType: string): PartialBlock {
  94. let partial = this.partials.get(index)
  95. if (!partial) {
  96. partial = { blockType, text: '', toolCallArguments: '' }
  97. this.partials.set(index, partial)
  98. this.order.push(index)
  99. }
  100. return partial
  101. }
  102. private assemble(partial: PartialBlock, index: number): ContentBlock {
  103. if (partial.block) return partial.block
  104. switch (partial.blockType) {
  105. case 'text': return { type: 'text', text: partial.text }
  106. case 'reasoning': return { type: 'reasoning', text: partial.text }
  107. case 'tool-call': return {
  108. type: 'tool-call',
  109. id: partial.toolCallId ?? brandString<ToolCallId>(`call-${index}`),
  110. name: partial.toolCallName ?? '',
  111. arguments: partial.toolCallArguments,
  112. }
  113. default: throw new Error(`cannot assemble incomplete block of type "${partial.blockType}"`)
  114. }
  115. }
  116. /** Invariant accessor: every index in `order` has a partial. */
  117. private mustGet(index: number): PartialBlock {
  118. const partial = this.partials.get(index)
  119. if (!partial) throw new Error(`BlockAssembler invariant violated: no partial for index ${index}`)
  120. return partial
  121. }
  122. /**
  123. * The one shared keep/drop decision over all seen blocks: max-token
  124. * truncation drops tool calls that cannot be executed safely. Emitted blocks
  125. * and replay metadata both derive from this result, so they cannot disagree.
  126. */
  127. private assembled(): { blocks: ContentBlock[]; replay: ReplayEnvelope | undefined } {
  128. const all = this.order.map(index => this.assemble(this.mustGet(index), index))
  129. const kept = this.finish.kind === 'max-tokens'
  130. ? all.map(block => block.type !== 'tool-call')
  131. : undefined
  132. const blocks = kept === undefined ? all : all.filter((_, position) => kept[position])
  133. const envelope = this._replayState
  134. if (envelope?.blocks === undefined) return { blocks, replay: envelope }
  135. if (envelope.blocks.length !== all.length) return { blocks, replay: undefined }
  136. return {
  137. blocks,
  138. replay: kept === undefined || blocks.length === all.length
  139. ? envelope
  140. : { response: envelope.response, blocks: envelope.blocks.filter((_, position) => kept[position]) },
  141. }
  142. }
  143. /**
  144. * Assemble all blocks seen so far, in stream order.
  145. * @returns one block per seen index, except that max-token truncation drops
  146. * tool calls that cannot be executed safely; an open block assembles from
  147. * its accumulated deltas (an unknown block type never closed by `block-end` throws).
  148. */
  149. blocks(): ContentBlock[] {
  150. return this.assembled().blocks
  151. }
  152. /**
  153. * Assemble the prefix an interrupted stream can safely finalize: closed and
  154. * open text/reasoning blocks with non-whitespace content, in stream order.
  155. * Tool calls are omitted because interruption precedes dispatch; retaining
  156. * one would require a fabricated result. Open unknown blocks are also omitted.
  157. * @returns the kept blocks; empty when nothing streamed before the interruption.
  158. */
  159. interruptedBlocks(): ContentBlock[] {
  160. return this.order
  161. .map((index) => {
  162. const partial = this.mustGet(index)
  163. const type = partial.block?.type ?? partial.blockType
  164. if (type !== 'text' && type !== 'reasoning') return undefined
  165. return this.assemble(partial, index)
  166. })
  167. .filter((block): block is ContentBlock =>
  168. (block?.type === 'text' || block?.type === 'reasoning') && block.text.trim() !== '')
  169. }
  170. /** Usage from the `usage` chunk; undefined until one arrives. */
  171. get usage(): TokenUsage | undefined {
  172. return this._usage
  173. }
  174. /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */
  175. get finish(): FinishReason {
  176. return this._finish ?? { kind: 'stop' }
  177. }
  178. /**
  179. * Replay metadata from the terminal finish chunk, if any, with per-block
  180. * entries pruned in step with {@link blocks}. Undefined when the envelope's
  181. * entries do not align with the emitted blocks.
  182. */
  183. get replayState(): ReplayEnvelope | undefined {
  184. return this.assembled().replay
  185. }
  186. /**
  187. * The assembled assistant message.
  188. * @param source - producer attribution for the assembled message.
  189. * @returns a frozen assistant-role message over `blocks()` (same open-block assembly rules).
  190. */
  191. message(source: MessageSource = { kind: 'plugin', plugin: 'dsh-llm/assembler' }): Message {
  192. return createMessage({ role: 'assistant', content: this.blocks(), source })
  193. }
  194. }