index.ts 7.0 KB

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