region.ts 19 KB

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