region.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. /**
  2. * Surface retention selection and the shared log-recorded compaction
  3. * transaction for automatic open-turn and manual idle-session compaction.
  4. *
  5. * @module @deepseek-ai/dsh-compaction-basic/region
  6. */
  7. import { randomUUID } from 'node:crypto'
  8. import { isDeepStrictEqual } from 'node:util'
  9. import {
  10. CompactionId,
  11. ManualCompactionError,
  12. compactCheckpointSource,
  13. toolPairingBalancedAfter,
  14. toolPairingBalancedBefore,
  15. } from '@deepseek-ai/dsh-compaction'
  16. import type { CompactionResult } from '@deepseek-ai/dsh-compaction'
  17. import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
  18. import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
  19. import type { Message, UserMessage } from '@deepseek-ai/dsh-llm'
  20. import type { TokenMeasurement, TokenMeter } from '@deepseek-ai/dsh-token-meter'
  21. import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
  22. import type { Agent } from '@deepseek-ai/dsh-agent'
  23. import { frameSummary } from './summarizer.ts'
  24. import type { SummarizationInput, SummaryResult } from './summarizer.ts'
  25. interface RegionDependencies {
  26. readonly meter: TokenMeter
  27. summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
  28. }
  29. /** One validated inclusive span of current surface positions. */
  30. interface SurfaceSelection {
  31. readonly start: number
  32. readonly end: number
  33. readonly startIdx: number
  34. readonly endIdx: number
  35. readonly shadowedSeqs: readonly number[]
  36. }
  37. /** A selection with its priced snapshot and the replay input built from it. */
  38. interface PreparedCompaction extends SurfaceSelection {
  39. readonly measurement: TokenMeasurement
  40. readonly selectedNodes: TokenMeasurement['nodes']
  41. readonly shadowedTokenCount: number
  42. readonly input: SummarizationInput
  43. }
  44. type SummarizedCompaction = PreparedCompaction & SummaryResult & {
  45. readonly checkpointMessage: UserMessage
  46. }
  47. interface CompactionTransactionOptions {
  48. /** `current-turn` derives a numbered owner; `null` writes a standalone bracket. */
  49. readonly owner: 'current-turn' | null
  50. /** Surface relationship that must survive asynchronous summarization. */
  51. readonly stability: 'whole-surface' | 'selected-span'
  52. /** Optional durability checkpoint after a successfully closed bracket. */
  53. readonly flush?: () => Promise<void>
  54. /** Manual command that initiated this transaction, when present. */
  55. readonly sourceCommandId?: CommandId
  56. }
  57. interface CompactionEntryState {
  58. readonly openTurn: number | null
  59. readonly unmatchedCompactionStart: SessionEvent<'compaction/start'> | undefined
  60. readonly latestEndSeedSeq: number | undefined
  61. }
  62. /**
  63. * Rejects a summary whose replacement boundaries are no longer the ones it was
  64. * built from, distinguished from summarizer and shrink failures so a manual
  65. * caller can report the two causes differently.
  66. */
  67. class SurfaceChangedError extends Error {}
  68. /** Whether the summary may still replace the span it was built from. */
  69. type StabilityCheck = (
  70. dependencies: RegionDependencies,
  71. session: Session,
  72. prepared: PreparedCompaction,
  73. ) => void
  74. /** Failure captured after `compaction/start` has committed. */
  75. interface TransactionFailure {
  76. readonly error: unknown
  77. readonly stage: 'summary' | 'commit'
  78. }
  79. /**
  80. * Resolve the next head-anchored range while retaining a priced recent tail
  81. * and never splitting an assistant tool-call/result pair.
  82. * @param session - session supplying authoritative current surface positions.
  83. * @param measurement - unified pressure and surface measurement from the conversation meter.
  84. * @param retainTokens - minimum recent tail budget retained verbatim.
  85. * @returns the inclusive positional seq range to compact, or `null`.
  86. */
  87. export function selectCompactableRange(
  88. session: Session,
  89. measurement: TokenMeasurement,
  90. retainTokens: number,
  91. ): { start: number; end: number } | null {
  92. const pricedNodes = measurement.nodes
  93. if (pricedNodes.length === 0) return null
  94. const surfaceNodes = session.surface.nodes
  95. if (surfaceNodes.length !== pricedNodes.length
  96. || surfaceNodes.some((seq, index) => seq !== pricedNodes[index]?.seq)) {
  97. throw new Error('compaction: token-meter surface does not match the current session surface')
  98. }
  99. let accumulated = 0
  100. let keepFromIdx = pricedNodes.length
  101. for (let index = pricedNodes.length - 1; index >= 0; index -= 1) {
  102. // oxlint-disable-next-line typescript/no-non-null-assertion
  103. accumulated += pricedNodes[index]!.tokens
  104. keepFromIdx = index
  105. if (accumulated >= retainTokens) break
  106. }
  107. if (keepFromIdx === 0) return null
  108. while (keepFromIdx > 0) {
  109. // oxlint-disable-next-line typescript/no-non-null-assertion
  110. if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break
  111. keepFromIdx -= 1
  112. }
  113. if (keepFromIdx === 0) return null
  114. // oxlint-disable-next-line typescript/no-non-null-assertion
  115. const first = surfaceNodes[0]!
  116. // oxlint-disable-next-line typescript/no-non-null-assertion
  117. const cutoff = surfaceNodes[keepFromIdx - 1]!
  118. return { start: first, end: cutoff }
  119. }
  120. /**
  121. * Run the single compaction transaction over one selected positional span.
  122. * Selection and validation are read-only. Idle/log validation and
  123. * `compaction/start` are synchronously adjacent, so the durable opening marker is
  124. * the compaction lock before summarization yields. Every later failure makes
  125. * exactly one `compaction/end` attempt; a failed close deliberately leaves the
  126. * unmatched start detectable.
  127. * @param dependencies - conversation meter and dynamically dispatched summarizer hook.
  128. * @param session - session whose surface is mutated.
  129. * @param start - inclusive first surface-node seq.
  130. * @param end - inclusive last surface-node seq.
  131. * @param agent - agent used by the summarizer.
  132. * @param options - bracket owner, stability rule, and optional durability checkpoint.
  133. * @param signal - optional summarization cancellation signal.
  134. * @returns the successful durable compaction result.
  135. */
  136. export async function compactSurfaceRegion(
  137. dependencies: RegionDependencies,
  138. session: Session,
  139. start: number,
  140. end: number,
  141. agent: Agent,
  142. options: CompactionTransactionOptions,
  143. signal?: AbortSignal,
  144. ): Promise<CompactionResult> {
  145. if (options.owner === null) signal?.throwIfAborted()
  146. const selection = validateSurfaceRegion(session, start, end)
  147. const entryState = inspectCompactionEntryState(session.events)
  148. assertCompactionInactive(
  149. entryState.unmatchedCompactionStart,
  150. entryState.latestEndSeedSeq,
  151. 'compaction',
  152. )
  153. let owner: number | null
  154. if (options.owner === null) {
  155. if (entryState.openTurn !== null) {
  156. throw new ManualCompactionError('busy', 'manual compaction: the session already has an open turn')
  157. }
  158. owner = null
  159. } else {
  160. if (entryState.openTurn === null) {
  161. throw new Error('compactRegion: no open turn — automatic compaction events must be enclosed in a turn')
  162. }
  163. owner = entryState.openTurn
  164. }
  165. const compactionId = CompactionId(randomUUID())
  166. const lifecycle = {
  167. compactionId,
  168. ...options.sourceCommandId === undefined ? {} : { sourceCommandId: options.sourceCommandId },
  169. turn: owner,
  170. }
  171. const startEvent = session.append('compaction/start', lifecycle)
  172. const assertStable: StabilityCheck = options.stability === 'whole-surface'
  173. ? assertWholeSurfaceUnchanged
  174. : assertSelectedSpanStable
  175. let failure: TransactionFailure | undefined
  176. let flushFailure: unknown
  177. let result: CompactionResult | undefined
  178. let closed = false
  179. let closing = false
  180. let stage: TransactionFailure['stage'] = 'summary'
  181. try {
  182. const prepared = prepareCompaction(dependencies, session, selection)
  183. const summarized = await summarizeCompaction(
  184. dependencies,
  185. prepared,
  186. agent,
  187. compactionId,
  188. options.sourceCommandId,
  189. signal,
  190. )
  191. if (options.owner === null) signal?.throwIfAborted()
  192. assertStable(dependencies, session, summarized)
  193. stage = 'commit'
  194. const pending = commitCompactionBody(session, startEvent, summarized)
  195. closing = true
  196. const endEvent = session.append('compaction/end', lifecycle)
  197. closed = true
  198. result = completeCompaction(pending, endEvent)
  199. } catch (error: unknown) {
  200. failure = { error, stage: closing ? 'commit' : stage }
  201. if (!closing) {
  202. closing = true
  203. try {
  204. session.append('compaction/end', { ...lifecycle, error: errorChain(error) })
  205. closed = true
  206. } catch (closeError: unknown) {
  207. failure = { error: closeError, stage: 'commit' }
  208. }
  209. }
  210. }
  211. if (closed && options.flush !== undefined) {
  212. try {
  213. await options.flush()
  214. } catch (error: unknown) {
  215. flushFailure = error
  216. }
  217. }
  218. if (options.owner === null) signal?.throwIfAborted()
  219. if (failure !== undefined) {
  220. if (options.owner === null) throwManualFailure(failure)
  221. throw failure.error
  222. }
  223. if (flushFailure !== undefined) {
  224. throw new ManualCompactionError(
  225. 'persistence',
  226. 'manual compaction durability checkpoint failed',
  227. { cause: flushFailure },
  228. )
  229. }
  230. /* v8 ignore next -- every path without a result records and throws a failure above. */
  231. if (result === undefined) throw new Error('compaction committed without a result')
  232. return result
  233. }
  234. /** Classify one closed manual attempt without weakening cancellation precedence. */
  235. function throwManualFailure(failure: TransactionFailure): never {
  236. if (failure.stage === 'commit') {
  237. throw new ManualCompactionError(
  238. 'commit',
  239. 'manual compaction did not commit cleanly',
  240. { cause: failure.error },
  241. )
  242. }
  243. if (failure.error instanceof SurfaceChangedError) {
  244. throw new ManualCompactionError(
  245. 'changed',
  246. 'the compacted history changed during manual compaction',
  247. { cause: failure.error },
  248. )
  249. }
  250. throw new ManualCompactionError(
  251. 'summary',
  252. 'manual compaction could not produce a smaller summary',
  253. { cause: failure.error },
  254. )
  255. }
  256. /**
  257. * Reject a durable unmatched compaction marker unless a later constructor-seed
  258. * boundary proves that its owner belongs to an earlier session lifecycle.
  259. * @param unmatchedCompactionStart - latest unmatched opening marker, if any.
  260. * @param latestEndSeedSeq - newest constructor-seed boundary, if any.
  261. * @param stage - operation label included in the busy diagnostic.
  262. */
  263. function assertCompactionInactive(
  264. unmatchedCompactionStart: SessionEvent<'compaction/start'> | undefined,
  265. latestEndSeedSeq: number | undefined,
  266. stage: string,
  267. ): void {
  268. if (unmatchedCompactionStart === undefined
  269. || (latestEndSeedSeq !== undefined
  270. && latestEndSeedSeq > unmatchedCompactionStart.seq)) return
  271. throw new ManualCompactionError(
  272. 'busy',
  273. `${stage}: compaction already in progress; the session compaction lock is already active`,
  274. )
  275. }
  276. /**
  277. * Recheck the durable compaction lock after an asynchronous policy decision.
  278. * @param session - session whose latest marker state is inspected.
  279. * @param stage - operation label included in the busy diagnostic.
  280. */
  281. export function assertNoActiveCompaction(session: Session, stage: string): void {
  282. const entryState = inspectCompactionEntryState(session.events)
  283. assertCompactionInactive(
  284. entryState.unmatchedCompactionStart,
  285. entryState.latestEndSeedSeq,
  286. stage,
  287. )
  288. }
  289. /** Validate one requested surface-position span before asynchronous work begins. */
  290. function validateSurfaceRegion(session: Session, start: number, end: number): SurfaceSelection {
  291. const nodes = session.surface.nodes
  292. const startIdx = nodes.indexOf(start)
  293. const endIdx = nodes.indexOf(end)
  294. if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
  295. if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
  296. if (startIdx > endIdx) {
  297. throw new Error(
  298. `compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`,
  299. )
  300. }
  301. // oxlint-disable-next-line typescript/no-non-null-assertion
  302. if (!toolPairingBalancedBefore(session, nodes[startIdx]!)) {
  303. throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
  304. }
  305. // oxlint-disable-next-line typescript/no-non-null-assertion
  306. if (!toolPairingBalancedAfter(session, nodes[endIdx]!)) {
  307. throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
  308. }
  309. return { start, end, startIdx, endIdx, shadowedSeqs: nodes.slice(startIdx, endIdx + 1) }
  310. }
  311. /** Snapshot pricing and replay input for a validated surface range. */
  312. function prepareCompaction(
  313. dependencies: RegionDependencies,
  314. session: Session,
  315. selection: SurfaceSelection,
  316. ): PreparedCompaction {
  317. const measurement = dependencies.meter.measure(session)
  318. const selectedNodes = measurement.nodes.slice(selection.startIdx, selection.endIdx + 1)
  319. if (selectedNodes.length !== selection.shadowedSeqs.length
  320. || selectedNodes.some((node, index) => node.seq !== selection.shadowedSeqs[index])) {
  321. throw new SurfaceChangedError('compaction: selected surface changed before summarization began')
  322. }
  323. return {
  324. ...selection,
  325. measurement,
  326. selectedNodes,
  327. shadowedTokenCount: selectedNodes.reduce((total, node) => total + node.tokens, 0),
  328. input: buildSummarizationInput(session, selection.shadowedSeqs),
  329. }
  330. }
  331. /** Run the summarizer and frame its replacement checkpoint. */
  332. async function summarizeCompaction(
  333. dependencies: RegionDependencies,
  334. prepared: PreparedCompaction,
  335. agent: Agent,
  336. compactionId: CompactionResult['compactionId'],
  337. sourceCommandId: CommandId | undefined,
  338. signal?: AbortSignal,
  339. ): Promise<SummarizedCompaction> {
  340. const summaryResult = await dependencies.summarize(prepared.input, agent, signal)
  341. const checkpointMessage = createUserMessage({
  342. content: frameSummary(summaryResult.summary),
  343. source: compactCheckpointSource(compactionId, sourceCommandId),
  344. })
  345. const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
  346. if (framedSummaryTokenCount >= prepared.shadowedTokenCount) {
  347. throw new Error(
  348. `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${prepared.shadowedTokenCount})`,
  349. )
  350. }
  351. return {
  352. ...prepared,
  353. ...summaryResult,
  354. checkpointMessage,
  355. }
  356. }
  357. /** Reject a summary prepared against any earlier surface generation. */
  358. function assertWholeSurfaceUnchanged(
  359. dependencies: RegionDependencies,
  360. session: Session,
  361. prepared: PreparedCompaction,
  362. ): void {
  363. const current = dependencies.meter.measure(session)
  364. if (!isDeepStrictEqual(current.nodes, prepared.measurement.nodes)) {
  365. throw new SurfaceChangedError('compaction: session surface changed during summarization')
  366. }
  367. }
  368. /**
  369. * Require only that the selected span remain the same present, contiguous,
  370. * equally priced, balanced replacement target. Nodes added outside it remain
  371. * visible and do not invalidate the summary.
  372. */
  373. function assertSelectedSpanStable(
  374. dependencies: RegionDependencies,
  375. session: Session,
  376. prepared: PreparedCompaction,
  377. ): void {
  378. let current: SurfaceSelection
  379. try {
  380. current = validateSurfaceRegion(session, prepared.start, prepared.end)
  381. } catch (error: unknown) {
  382. throw new SurfaceChangedError(
  383. 'compaction: the selected span is no longer a valid replacement target',
  384. { cause: error },
  385. )
  386. }
  387. if (!isDeepStrictEqual([...current.shadowedSeqs], [...prepared.shadowedSeqs])) {
  388. throw new SurfaceChangedError('compaction: the selected span changed during summarization')
  389. }
  390. const measured = dependencies.meter.measure(session).nodes.slice(current.startIdx, current.endIdx + 1)
  391. if (!isDeepStrictEqual(measured, prepared.selectedNodes)) {
  392. throw new SurfaceChangedError('compaction: the selected span was rewritten during summarization')
  393. }
  394. }
  395. /** Append one completed summary record and replacement body without yielding. */
  396. function commitCompactionBody(
  397. session: Session,
  398. startEvent: SessionEvent<'compaction/start'>,
  399. summarized: SummarizedCompaction,
  400. ): Omit<CompactionResult, 'endSeq'> {
  401. const {
  402. start,
  403. end,
  404. shadowedSeqs,
  405. shadowedTokenCount,
  406. summary,
  407. provider,
  408. model,
  409. maxTokens,
  410. usage,
  411. checkpointMessage,
  412. } = summarized
  413. const callProvenance = summarized.llmStreamCall === true
  414. ? { rawOutput: summarized.rawOutput, llmStreamCall: true as const }
  415. : summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput }
  416. const summaryEvent = session.append('compaction/summary', {
  417. compactionId: startEvent.data.compactionId,
  418. ...startEvent.data.sourceCommandId === undefined
  419. ? {}
  420. : { sourceCommandId: startEvent.data.sourceCommandId },
  421. summary,
  422. ...callProvenance,
  423. shadowedRange: { start, end },
  424. shadowedSeqs: [...shadowedSeqs],
  425. shadowedTokenCount,
  426. provider,
  427. model,
  428. ...maxTokens === undefined ? {} : { maxTokens },
  429. ...usage === undefined ? {} : { usage },
  430. })
  431. session.append('user/message', checkpointMessage, {
  432. surfaceOp: { op: 'replace', start, end },
  433. sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
  434. })
  435. return {
  436. compactionId: startEvent.data.compactionId,
  437. ...startEvent.data.sourceCommandId === undefined
  438. ? {}
  439. : { sourceCommandId: startEvent.data.sourceCommandId },
  440. startSeq: startEvent.seq,
  441. summarySeq: summaryEvent.seq,
  442. summary,
  443. shadowedRange: { start, end },
  444. shadowedSeqs: [...shadowedSeqs],
  445. shadowedTokenCount,
  446. }
  447. }
  448. /** Attach the successfully appended close event to a pending result. */
  449. function completeCompaction(
  450. pending: Omit<CompactionResult, 'endSeq'>,
  451. endEvent: SessionEvent<'compaction/end'>,
  452. ): CompactionResult {
  453. return { ...pending, endSeq: endEvent.seq }
  454. }
  455. /**
  456. * Reconstruct the last routed request's cacheable prefix for the shadowed
  457. * region: its system prompt and tool schemas, then the region's own derived
  458. * messages in surface order. The summarizer appends only the compaction
  459. * instruction after this, so the call is a genuine prefix of the conversation
  460. * and reuses the provider's KV cache.
  461. * @param session - session supplying the request header and per-node projection.
  462. * @param shadowedSeqs - the surface-node seqs, in order, being compacted.
  463. * @returns the replayed conversation prefix to condense.
  464. */
  465. function buildSummarizationInput(
  466. session: Session,
  467. shadowedSeqs: readonly number[],
  468. ): SummarizationInput {
  469. const header = session.requestHeader()
  470. const events = session.events
  471. const regionMessages = shadowedSeqs
  472. // shadowedSeqs are current surface seqs, so each is a valid log index.
  473. // oxlint-disable-next-line typescript/no-non-null-assertion
  474. .map(seq => session.deriveEventMessage(events[seq]!))
  475. .filter((message): message is Message => message !== null)
  476. return {
  477. ...header?.system === undefined ? {} : { system: header.system },
  478. ...header?.tools === undefined ? {} : { tools: header.tools },
  479. messages: regionMessages,
  480. }
  481. }
  482. /** Inspect open-turn, unmatched-compaction, and latest seed-boundary state independently. */
  483. function inspectCompactionEntryState(events: readonly SessionEvent[]): CompactionEntryState {
  484. let openTurn: number | null = null
  485. let openTurnStateKnown = false
  486. let unmatchedCompactionStart: SessionEvent<'compaction/start'> | undefined
  487. let compactionEntryStateKnown = false
  488. let latestEndSeedSeq: number | undefined
  489. for (let index = events.length - 1; index >= 0; index -= 1) {
  490. // oxlint-disable-next-line typescript/no-non-null-assertion
  491. const event = events[index]!
  492. if (latestEndSeedSeq === undefined && event.type === 'session/end-seed') {
  493. latestEndSeedSeq = event.seq
  494. }
  495. if (!compactionEntryStateKnown) {
  496. if (event.type === 'compaction/start') {
  497. unmatchedCompactionStart = event
  498. compactionEntryStateKnown = true
  499. } else if (event.type === 'compaction/end') {
  500. compactionEntryStateKnown = true
  501. }
  502. }
  503. if (!openTurnStateKnown) {
  504. if (event.type === 'turn/start') {
  505. openTurn = event.data.turn
  506. openTurnStateKnown = true
  507. } else if (event.type === 'turn/end') {
  508. openTurnStateKnown = true
  509. }
  510. }
  511. if (openTurnStateKnown
  512. && compactionEntryStateKnown
  513. && latestEndSeedSeq !== undefined) break
  514. }
  515. return { openTurn, unmatchedCompactionStart, latestEndSeedSeq }
  516. }