index.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. /**
  2. * Replay-safe, model-free tool-result pruning service.
  3. *
  4. * @module @deepseek-ai/dsh-compact-tool-result-prune
  5. */
  6. import { Context, Service } from 'cordis'
  7. import z from 'schemastery'
  8. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  9. import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
  10. import { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
  11. import type {
  12. PrunedEntry,
  13. PruneResult,
  14. ResolvedConfig,
  15. ToolResultPruneConfig,
  16. } from './types.ts'
  17. export { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
  18. export type {
  19. PrunedEntry,
  20. PruneResult,
  21. ResolvedConfig,
  22. ToolResultPruneConfig,
  23. } from './types.ts'
  24. declare module 'cordis' {
  25. interface Context {
  26. toolResultPrune: ToolResultPruneService
  27. }
  28. }
  29. interface SnapshotCandidate {
  30. readonly seq: number
  31. readonly event: SessionEvent<'tool/result'>
  32. }
  33. /** Deterministic head/middle/tail pruning for current tool-result surface nodes. */
  34. export class ToolResultPruneService extends Service {
  35. static Config: z<ToolResultPruneConfig> = z.object({
  36. thresholdChars: z.number().step(1).min(1).default(DEFAULTS.thresholdChars),
  37. headChars: z.number().step(1).min(0).default(DEFAULTS.headChars),
  38. tailChars: z.number().step(1).min(0).default(DEFAULTS.tailChars),
  39. })
  40. /** Resolved and immutable character budgets. */
  41. readonly config: ResolvedConfig
  42. constructor(ctx: Context, config: ToolResultPruneConfig = {}) {
  43. super(ctx, 'toolResultPrune')
  44. this.config = resolveConfig(config)
  45. }
  46. /**
  47. * Measure text content in Unicode code points; non-text blocks cost zero.
  48. * @param blocks - tool-result content to measure.
  49. * @returns total Unicode code points across text blocks.
  50. */
  51. measureContent(blocks: readonly ContentBlock[]): number {
  52. let chars = 0
  53. for (const block of blocks) {
  54. if (block.type === 'text') chars += codePointLength(block.text)
  55. }
  56. return chars
  57. }
  58. /**
  59. * Replace an over-budget text middle while retaining rich-block order.
  60. * Text slicing is by Unicode code point, not UTF-16 code unit, so a retained
  61. * boundary cannot split a surrogate pair. Grapheme clusters may still split.
  62. * @param blocks - original tool-result content.
  63. * @returns pruned content, or `null` when the text is within budget.
  64. */
  65. pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null {
  66. const totalChars = this.measureContent(blocks)
  67. if (totalChars <= this.config.thresholdChars) return null
  68. const removedStart = this.config.headChars
  69. const removedEnd = totalChars - this.config.tailChars
  70. const pruned: ContentBlock[] = []
  71. let consumed = 0
  72. let markerInserted = false
  73. for (const block of blocks) {
  74. if (block.type !== 'text') {
  75. pruned.push(block)
  76. continue
  77. }
  78. const points = Array.from(block.text)
  79. const blockStart = consumed
  80. const blockEnd = blockStart + points.length
  81. const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart))
  82. const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart))
  83. const intersectsRemoved = blockStart < removedEnd && blockEnd > removedStart
  84. const marker = intersectsRemoved && !markerInserted ? PRUNE_MARKER : ''
  85. if (marker.length > 0) markerInserted = true
  86. const text = points.slice(0, headEnd).join('')
  87. + marker
  88. + points.slice(tailStart).join('')
  89. if (text.length > 0) pruned.push({ ...block, text })
  90. consumed = blockEnd
  91. }
  92. /* v8 ignore next -- totalChars > threshold and valid budgets guarantee a removed text span. */
  93. if (!markerInserted) throw new Error('tool-result prune: failed to locate the removed text span')
  94. const charsAfter = this.measureContent(pruned)
  95. /* v8 ignore next -- config validation fixes the emitted head + marker + tail budget. */
  96. if (charsAfter > this.config.thresholdChars || charsAfter >= totalChars) {
  97. throw new Error('tool-result prune: replacement must be smaller and within threshold')
  98. }
  99. return pruned
  100. }
  101. /**
  102. * Prune every over-budget tool result from one stable current-surface snapshot.
  103. * Each replacement preserves the complete event data except for `content`,
  104. * and points at the shadowed node for durable provenance and replay.
  105. * @param session - session whose current surface is rewritten.
  106. * @returns landed replacements and aggregate Unicode-code-point savings.
  107. * @throws when the session rejects a replacement; replacements committed
  108. * earlier in the pass remain durable.
  109. */
  110. pruneSession(session: Session): PruneResult {
  111. const candidates: SnapshotCandidate[] = []
  112. for (const seq of [...session.surface.nodes]) {
  113. const event = session.events[seq]
  114. /* v8 ignore next -- surface seqs are validated contiguous log references. */
  115. if (event?.type === 'tool/result') candidates.push({ seq, event })
  116. }
  117. const pruned: PrunedEntry[] = []
  118. let charsRemoved = 0
  119. for (const { seq, event } of candidates) {
  120. const content = this.pruneContent(event.data.content)
  121. if (content === null) continue
  122. const charsBefore = this.measureContent(event.data.content)
  123. const charsAfter = this.measureContent(content)
  124. const replacement = session.append('tool/result', {
  125. ...event.data,
  126. content,
  127. }, {
  128. surfaceOp: { op: 'replace', start: seq, end: seq },
  129. sourceEventSeqs: [seq],
  130. })
  131. pruned.push({
  132. originalSeq: seq,
  133. replacementSeq: replacement.seq,
  134. callId: event.data.callId,
  135. charsBefore,
  136. charsAfter,
  137. })
  138. charsRemoved += charsBefore - charsAfter
  139. }
  140. return { pruned, charsRemoved }
  141. }
  142. }
  143. export default ToolResultPruneService