region.ts 9.3 KB

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