1
/**2
* Replay-safe, model-free tool-result pruning service.3
*4
* @module @deepseek-ai/dsh-compaction-tool-result-pruner5
*/7
import { Context, Service } from '@deepseek-ai/cordis'8
import z from '@deepseek-ai/schemastery'9
import { freezeMessage } from '@deepseek-ai/dsh-llm'10
import type { ContentBlock } from '@deepseek-ai/dsh-llm'11
import type { Session, SessionEvent, SessionSeq, ToolResultMessage } from '@deepseek-ai/dsh-session'12
// Type-only: the `compaction/*` SessionEventMap merges (the shadow-price event).13
import type {} from '@deepseek-ai/dsh-compaction'14
// Type-only: the `ctx.tokenMeter` Context merge for the declared injection.15
import type {} from '@deepseek-ai/dsh-token-meter'16
import { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'17
import type {18
PrunedEntry,19
PruneResult,20
ResolvedConfig,21
ToolResultPruneConfig,22
} from './types.ts'24
export { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'25
export type {26
PrunedEntry,27
PruneResult,28
ResolvedConfig,29
ToolResultPruneConfig,30
} from './types.ts'32
declare module '@deepseek-ai/cordis' {33
interface Context {34
toolResultPruner: ToolResultPruner35
}36
}38
interface SnapshotCandidate {39
readonly seq: SessionSeq40
readonly event: SessionEvent<'tool/result'>41
}43
/** Deterministic head/middle/tail pruning for current tool-result surface nodes. */44
export class ToolResultPruner extends Service {45
// The token meter prices each shadowed node for its logged shadow-price46
// event, so pruning genuinely requires the pricing capability.47
static inject = ['tokenMeter']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
})55
/** Resolved and immutable character budgets. */56
readonly config: ResolvedConfig58
constructor(ctx: Context, config: ToolResultPruneConfig = {}) {59
super(ctx, 'toolResultPruner')60
this.config = resolveConfig(config)61
}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 = 070
for (const block of blocks) {71
if (block.type === 'text') chars += codePointLength(block.text)72
}73
return chars74
}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 retained79
* 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 null87
const removedStart = this.config.headChars88
const removedEnd = totalChars - this.config.tailChars89
const pruned: ContentBlock[] = []90
let consumed = 091
let markerInserted = false93
for (const block of blocks) {94
if (block.type !== 'text') {95
pruned.push(block)96
continue97
}99
const points = Array.from(block.text)100
const blockStart = consumed101
const blockEnd = blockStart + points.length102
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 > removedStart105
const marker = intersectsRemoved && !markerInserted ? PRUNE_MARKER : ''106
if (marker.length > 0) markerInserted = true107
const text = points.slice(0, headEnd).join('')108
+ marker109
+ points.slice(tailStart).join('')110
if (text.length > 0) pruned.push({ ...block, text })111
consumed = blockEnd112
}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 pruned122
}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 is128
* immediately preceded by a `compaction/prune` shadow-price event pricing the129
* shadowed node through the injected token meter, so pure consumers can130
* 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 committed134
* 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
}145
const pruned: PrunedEntry[] = []146
let charsRemoved = 0147
for (const { seq, event } of candidates) {148
const original = session.deriveEventMessage(event) as ToolResultMessage149
const content = this.pruneContent(original.content)150
if (content === null) continue151
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 are158
// appended synchronously adjacent, so pure consumers subtract the159
// 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 - charsAfter180
}181
return { pruned, charsRemoved }182
}183
}185
export default ToolResultPruner