region.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. /**
  2. * Surface retention selection and the log-recorded compaction transaction.
  3. *
  4. * @module @deepseek-ai/dsh-compact-basic/region
  5. */
  6. import { isDeepStrictEqual } from 'node:util'
  7. import {
  8. COMPACT_CHECKPOINT_SOURCE,
  9. toolPairingBalancedAfter,
  10. toolPairingBalancedBefore,
  11. } from '@deepseek-ai/dsh-compact'
  12. import type { CompactionResult } from '@deepseek-ai/dsh-compact'
  13. import type { Message } from '@deepseek-ai/dsh-llm'
  14. import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
  15. import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
  16. import type { Agent } from '@deepseek-ai/dsh-agent'
  17. import { frameSummary } from './summarizer.ts'
  18. import type { SummarizationInput, SummaryResult } from './summarizer.ts'
  19. interface RegionDependencies {
  20. readonly meter: TokenMeterService
  21. summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
  22. }
  23. /**
  24. * Resolve the next head-anchored range while retaining a priced recent tail
  25. * and never splitting an assistant tool-call/result pair.
  26. * @param session - session supplying authoritative current surface positions.
  27. * @param measurement - unified pressure and surface measurement from the conversation meter.
  28. * @param retainTokens - minimum recent tail budget retained verbatim.
  29. * @returns the inclusive positional seq range to compact, or `null`.
  30. */
  31. export function selectCompactableRange(
  32. session: Session,
  33. measurement: TokenMeasurement,
  34. retainTokens: number,
  35. ): { start: number; end: number } | null {
  36. const pricedNodes = measurement.nodes
  37. if (pricedNodes.length === 0) return null
  38. const surfaceNodes = session.surface.nodes
  39. if (surfaceNodes.length !== pricedNodes.length
  40. || surfaceNodes.some((seq, index) => seq !== pricedNodes[index]?.seq)) {
  41. throw new Error('compaction: token-meter surface does not match the current session surface')
  42. }
  43. let accumulated = 0
  44. let keepFromIdx = pricedNodes.length
  45. for (let index = pricedNodes.length - 1; index >= 0; index -= 1) {
  46. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  47. accumulated += pricedNodes[index]!.tokens
  48. keepFromIdx = index
  49. if (accumulated >= retainTokens) break
  50. }
  51. if (keepFromIdx === 0) return null
  52. while (keepFromIdx > 0) {
  53. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  54. if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break
  55. keepFromIdx -= 1
  56. }
  57. if (keepFromIdx === 0) return null
  58. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  59. const first = surfaceNodes[0]!
  60. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  61. const cutoff = surfaceNodes[keepFromIdx - 1]!
  62. return { start: first, end: cutoff }
  63. }
  64. /**
  65. * Validate and compact one positional surface span.
  66. * @param dependencies - conversation meter and dynamically dispatched summarizer hook.
  67. * @param session - session whose surface is mutated.
  68. * @param start - inclusive first surface-node seq.
  69. * @param end - inclusive last surface-node seq.
  70. * @param agent - agent used by the summarizer.
  71. * @param signal - optional summarization cancellation signal.
  72. * @returns the successful durable compaction result.
  73. */
  74. export async function compactSurfaceRegion(
  75. dependencies: RegionDependencies,
  76. session: Session,
  77. start: number,
  78. end: number,
  79. agent: Agent,
  80. signal?: AbortSignal,
  81. ): Promise<CompactionResult> {
  82. const nodes = session.surface.nodes
  83. const startIdx = nodes.indexOf(start)
  84. const endIdx = nodes.indexOf(end)
  85. if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
  86. if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
  87. if (startIdx > endIdx) {
  88. throw new Error(
  89. `compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`,
  90. )
  91. }
  92. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  93. if (!toolPairingBalancedBefore(session, nodes[startIdx]!)) {
  94. throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
  95. }
  96. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  97. if (!toolPairingBalancedAfter(session, nodes[endIdx]!)) {
  98. throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
  99. }
  100. const tail = inspectTurnTail(session.events)
  101. if (tail.compactionInProgress) throw new Error('compaction already in progress')
  102. if (tail.turn === null) {
  103. throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
  104. }
  105. const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
  106. const startEvent = session.append('compact/start', { turn: tail.turn })
  107. try {
  108. // Capture after the lock event so a later surface mutation invalidates the
  109. // async selection before replacement. Unrelated log-only facts may append.
  110. const lockedMeasurement = dependencies.meter.measure(session)
  111. const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1)
  112. if (selected.length !== shadowedSeqs.length
  113. || selected.some((node, index) => node.seq !== shadowedSeqs[index])) {
  114. throw new Error('compaction: selected surface changed before summarization began')
  115. }
  116. const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0)
  117. const summarizationInput = buildSummarizationInput(session, shadowedSeqs)
  118. const { summary, provider, model, maxTokens } = await dependencies.summarize(summarizationInput, agent, signal)
  119. const currentMeasurement = dependencies.meter.measure(session)
  120. if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) {
  121. throw new Error('compaction: session surface changed during summarization')
  122. }
  123. const framedSummary = frameSummary(summary)
  124. const framedSummaryTokenCount = dependencies.meter.estimateMessage({
  125. role: 'user',
  126. content: framedSummary,
  127. })
  128. if (framedSummaryTokenCount >= shadowedTokenCount) {
  129. throw new Error(
  130. `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
  131. )
  132. }
  133. const summaryEvent = session.append('compact/summary', {
  134. summary,
  135. shadowedRange: { start, end },
  136. shadowedSeqs,
  137. shadowedTokenCount,
  138. provider,
  139. model,
  140. ...maxTokens === undefined ? {} : { maxTokens },
  141. })
  142. session.append('user/message', {
  143. content: framedSummary,
  144. source: COMPACT_CHECKPOINT_SOURCE,
  145. }, {
  146. surfaceOp: { op: 'replace', start, end },
  147. sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
  148. })
  149. const endEvent = session.append('compact/end', { turn: tail.turn })
  150. return {
  151. startSeq: startEvent.seq,
  152. summarySeq: summaryEvent.seq,
  153. endSeq: endEvent.seq,
  154. summary,
  155. shadowedRange: { start, end },
  156. shadowedSeqs,
  157. shadowedTokenCount,
  158. }
  159. } catch (error: unknown) {
  160. const message = error instanceof Error ? error.message : String(error)
  161. session.append('compact/end', { turn: tail.turn, error: message })
  162. throw error
  163. }
  164. }
  165. /**
  166. * Reconstruct the last routed request's cacheable prefix for the shadowed
  167. * region: its system prompt and tool schemas, then the request-only message
  168. * prefix followed by the region's own derived messages in surface order. The
  169. * summarizer appends only the compaction instruction after this, so the call
  170. * is a genuine prefix of the conversation and reuses the provider's KV cache.
  171. * @param session - session supplying the request header and per-node projection.
  172. * @param shadowedSeqs - the surface-node seqs, in order, being compacted.
  173. * @returns the replayed conversation prefix to condense.
  174. */
  175. function buildSummarizationInput(
  176. session: Session,
  177. shadowedSeqs: readonly number[],
  178. ): SummarizationInput {
  179. const header = session.requestHeader()
  180. const events = session.events
  181. const regionMessages = shadowedSeqs
  182. // shadowedSeqs are current surface seqs, so each is a valid log index.
  183. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  184. .map(seq => session.deriveEventMessage(events[seq]!))
  185. .filter((message): message is Message => message !== null)
  186. return {
  187. ...header?.system === undefined ? {} : { system: header.system },
  188. ...header?.tools === undefined ? {} : { tools: header.tools },
  189. messages: [...header?.messagePrefix ?? [], ...regionMessages],
  190. }
  191. }
  192. /** Inspect the current turn boundary and latest compaction bracket once. */
  193. function inspectTurnTail(
  194. events: readonly SessionEvent[],
  195. ): { turn: number | null; compactionInProgress: boolean } {
  196. let compactionInProgress = false
  197. let compactionStateKnown = false
  198. for (let index = events.length - 1; index >= 0; index -= 1) {
  199. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  200. const event = events[index]!
  201. if (!compactionStateKnown) {
  202. if (event.type === 'compact/start') {
  203. compactionInProgress = true
  204. compactionStateKnown = true
  205. } else if (event.type === 'compact/end') {
  206. compactionStateKnown = true
  207. }
  208. }
  209. if (event.type === 'turn/start') return { turn: event.data.turn, compactionInProgress }
  210. if (event.type === 'turn/end') return { turn: null, compactionInProgress }
  211. }
  212. return { turn: null, compactionInProgress }
  213. }