返回源码地图

packages/compaction/compaction-tool-result-pruner/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 09;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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