mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
The contextBreakdown and contextPressure units carried the full priced surface, so each session's persisted projection checkpoint grew without bound. A surface replacement is now priced by the shadow-price event logged directly before it — compact/summary for compaction, the new compact/prune from tool-result pruning (priced through the injected token meter) — and the unit states shrink to a fixed handful of numbers. Regenerate the persistence/cordis/module/config catalogs.
188 lines
7.0 KiB
TypeScript
188 lines
7.0 KiB
TypeScript
/**
|
|
* Replay-safe, model-free tool-result pruning service.
|
|
*
|
|
* @module @deepseek-ai/dsh-compact-tool-result-prune
|
|
*/
|
|
|
|
import { Context, Service } from 'cordis'
|
|
import z from 'schemastery'
|
|
import { freezeMessage } from '@deepseek-ai/dsh-llm'
|
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
import type { Session, SessionEvent, ToolResultMessage } from '@deepseek-ai/dsh-session'
|
|
// Type-only: the `compact/*` SessionEventMap merges (the shadow-price event).
|
|
import type {} from '@deepseek-ai/dsh-compact'
|
|
// Type-only: the `ctx.tokenMeter` Context merge for the declared injection.
|
|
import type {} from '@deepseek-ai/dsh-token-meter'
|
|
import { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
|
|
import type {
|
|
PrunedEntry,
|
|
PruneResult,
|
|
ResolvedConfig,
|
|
ToolResultPruneConfig,
|
|
} from './types.ts'
|
|
|
|
export { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
|
|
export type {
|
|
PrunedEntry,
|
|
PruneResult,
|
|
ResolvedConfig,
|
|
ToolResultPruneConfig,
|
|
} from './types.ts'
|
|
|
|
declare module 'cordis' {
|
|
interface Context {
|
|
toolResultPrune: ToolResultPruneService
|
|
}
|
|
}
|
|
|
|
interface SnapshotCandidate {
|
|
readonly seq: number
|
|
readonly event: SessionEvent<'tool/result'>
|
|
}
|
|
|
|
/** Deterministic head/middle/tail pruning for current tool-result surface nodes. */
|
|
export class ToolResultPruneService extends Service {
|
|
// The token meter prices each shadowed node for its logged shadow-price
|
|
// event, so pruning genuinely requires the pricing capability.
|
|
static inject = ['tokenMeter']
|
|
|
|
static Config: z<ToolResultPruneConfig> = z.object({
|
|
thresholdChars: z.number().step(1).min(1).default(DEFAULTS.thresholdChars),
|
|
headChars: z.number().step(1).min(0).default(DEFAULTS.headChars),
|
|
tailChars: z.number().step(1).min(0).default(DEFAULTS.tailChars),
|
|
})
|
|
|
|
/** Resolved and immutable character budgets. */
|
|
readonly config: ResolvedConfig
|
|
|
|
constructor(ctx: Context, config: ToolResultPruneConfig = {}) {
|
|
super(ctx, 'toolResultPrune')
|
|
this.config = resolveConfig(config)
|
|
}
|
|
|
|
/**
|
|
* Measure text content in Unicode code points; non-text blocks cost zero.
|
|
* @param blocks - tool-result content to measure.
|
|
* @returns total Unicode code points across text blocks.
|
|
*/
|
|
measureContent(blocks: readonly ContentBlock[]): number {
|
|
let chars = 0
|
|
for (const block of blocks) {
|
|
if (block.type === 'text') chars += codePointLength(block.text)
|
|
}
|
|
return chars
|
|
}
|
|
|
|
/**
|
|
* Replace an over-budget text middle while retaining rich-block order.
|
|
* Text slicing is by Unicode code point, not UTF-16 code unit, so a retained
|
|
* boundary cannot split a surrogate pair. Grapheme clusters may still split.
|
|
* @param blocks - original tool-result content.
|
|
* @returns pruned content, or `null` when the text is within budget.
|
|
*/
|
|
pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null {
|
|
const totalChars = this.measureContent(blocks)
|
|
if (totalChars <= this.config.thresholdChars) return null
|
|
|
|
const removedStart = this.config.headChars
|
|
const removedEnd = totalChars - this.config.tailChars
|
|
const pruned: ContentBlock[] = []
|
|
let consumed = 0
|
|
let markerInserted = false
|
|
|
|
for (const block of blocks) {
|
|
if (block.type !== 'text') {
|
|
pruned.push(block)
|
|
continue
|
|
}
|
|
|
|
const points = Array.from(block.text)
|
|
const blockStart = consumed
|
|
const blockEnd = blockStart + points.length
|
|
const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart))
|
|
const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart))
|
|
const intersectsRemoved = blockStart < removedEnd && blockEnd > removedStart
|
|
const marker = intersectsRemoved && !markerInserted ? PRUNE_MARKER : ''
|
|
if (marker.length > 0) markerInserted = true
|
|
const text = points.slice(0, headEnd).join('')
|
|
+ marker
|
|
+ points.slice(tailStart).join('')
|
|
if (text.length > 0) pruned.push({ ...block, text })
|
|
consumed = blockEnd
|
|
}
|
|
|
|
/* v8 ignore next -- totalChars > threshold and valid budgets guarantee a removed text span. */
|
|
if (!markerInserted) throw new Error('tool-result prune: failed to locate the removed text span')
|
|
const charsAfter = this.measureContent(pruned)
|
|
/* v8 ignore next -- config validation fixes the emitted head + marker + tail budget. */
|
|
if (charsAfter > this.config.thresholdChars || charsAfter >= totalChars) {
|
|
throw new Error('tool-result prune: replacement must be smaller and within threshold')
|
|
}
|
|
return pruned
|
|
}
|
|
|
|
/**
|
|
* Prune every over-budget tool result from one stable current-surface snapshot.
|
|
* Each replacement preserves the complete event data except for `content`,
|
|
* points at the shadowed node for durable provenance and replay, and is
|
|
* immediately preceded by a `compact/prune` shadow-price event pricing the
|
|
* shadowed node through the injected token meter, so pure consumers can
|
|
* subtract it without per-node state.
|
|
* @param session - session whose current surface is rewritten.
|
|
* @returns landed replacements and aggregate Unicode-code-point savings.
|
|
* @throws when the session rejects a replacement; replacements committed
|
|
* earlier in the pass remain durable.
|
|
*/
|
|
pruneSession(session: Session): PruneResult {
|
|
const candidates: SnapshotCandidate[] = []
|
|
for (const seq of [...session.surface.nodes]) {
|
|
const event = session.events[seq]
|
|
/* v8 ignore next -- surface seqs are validated contiguous log references. */
|
|
if (event?.type === 'tool/result') candidates.push({ seq, event })
|
|
}
|
|
|
|
const pruned: PrunedEntry[] = []
|
|
let charsRemoved = 0
|
|
for (const { seq, event } of candidates) {
|
|
const result = event.data.message.content[0]
|
|
const content = this.pruneContent(result.content)
|
|
if (content === null) continue
|
|
const charsBefore = this.measureContent(result.content)
|
|
const charsAfter = this.measureContent(content)
|
|
const message = freezeMessage<ToolResultMessage>({
|
|
...event.data.message,
|
|
content: [{
|
|
...result,
|
|
content,
|
|
}] as [typeof result],
|
|
})
|
|
// Shadow-price protocol: the metering event and its replacement are
|
|
// appended synchronously adjacent, so pure consumers subtract the
|
|
// shadowed node's heuristic price without retaining per-node state.
|
|
session.append('compact/prune', {
|
|
shadowedRange: { start: seq, end: seq },
|
|
shadowedSeqs: [seq],
|
|
shadowedTokenCount: this.ctx.tokenMeter.estimateMessage(event.data.message),
|
|
})
|
|
const replacement = session.append('tool/result', {
|
|
...event.data,
|
|
message,
|
|
}, {
|
|
surfaceOp: { op: 'replace', start: seq, end: seq },
|
|
sourceEventSeqs: [seq],
|
|
})
|
|
pruned.push({
|
|
originalSeq: seq,
|
|
replacementSeq: replacement.seq,
|
|
callId: event.data.message.source.callId,
|
|
charsBefore,
|
|
charsAfter,
|
|
})
|
|
charsRemoved += charsBefore - charsAfter
|
|
}
|
|
return { pruned, charsRemoved }
|
|
}
|
|
}
|
|
|
|
export default ToolResultPruneService
|