refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/compaction/compaction-tool-result-pruner/README.md
README.md: 16a5ba5fa989ab64ff8a59f72322772827bed1fb
README.zh.md: 06261760d000e177014dd7de414c5ab3be1543ef

View File

@@ -0,0 +1,62 @@
# @deepseek-ai/dsh-compaction-tool-result-pruner
English | [中文](README.zh.md)
The replay-safe model-free pruning service (`ctx.toolResultPruner`). It rewrites over-budget `tool/result` surface nodes to a bounded head, a fixed omission marker, and a bounded tail while retaining the full original event in the append-only session log.
This is a concrete companion to [`dsh-compaction-basic`](../compaction-basic/README.md), not a compaction backend or model-facing tool. Compact-basic reads it through optional `ctx.get('toolResultPruner')`, so either package remains independently composable.
## Service API
`pruneSession(session)` scans one stable snapshot of the current surface. Every over-budget tool result is replaced by one newly appended `tool/result` carrying `{ surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq }, sourceEventSeqs: [originalSeq] }`. The replacement spreads the complete original data and changes only `content`, preserving `turn`, `step`, `callId`, error fields, `meta`, and later data additions. The original event remains available for persistence, replay, and exact-log inspection.
The method throws synchronously when the session rejects a replacement. Replacements committed earlier in the pass remain durable.
`measureContent(blocks)` counts Unicode code points in `text` blocks. `pruneContent(blocks)` returns the bounded replacement or `null` when content is already within the threshold. Non-text blocks are retained at their original relative positions; text slicing never splits a UTF-16 surrogate pair, though it can split a multi-code-point grapheme cluster.
Every emitted result has exactly the configured head budget, fixed marker, and tail budget in text code points, is no larger than `thresholdChars`, and is strictly smaller than the triggering input. A second pass therefore emits no replacement.
## Config
Unrecognized keys fail at plugin construction. Resolved config is detached and deeply immutable.
| Key | Required | Meaning |
|---|---|---|
| `thresholdChars` | no (default `8192`) | Prune when combined text exceeds this many Unicode code points. |
| `headChars` | no (default `4096`) | Leading Unicode code points retained. |
| `tailChars` | no (default `1024`) | Trailing Unicode code points retained. |
All values are integers; the threshold is positive and head/tail are non-negative. `headChars + marker + tailChars` must fit within `thresholdChars`, so a valid configuration can prune every over-budget result without growth or repeated rewriting.
## Usage
```ts
import type { Context } from '@deepseek-ai/cordis'
import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner'
export function apply(ctx: Context): void {
ctx.plugin(ToolResultPruner)
}
```
## Model Experience
### Pruned tool result
#### What the model sees
Once a compaction trigger qualifies, future requests see the retained head, `\n\n[... tool result middle pruned ...]\n\n`, and retained tail in place of the removed text. Rich blocks keep their order. The model does not see a second copy of the original.
#### Token effect
Each rewritten tool result has at most `thresholdChars` text code points. Pruning itself makes no model call; compaction-basic skips summarization when the remeasured request falls below pressure, otherwise the summarizer reads the pruned surface.
#### KV Cache effect
Replacing an earlier result invalidates reuse from the first changed token. The pruned prefix is eligible for reuse while its route, envelope, and preceding history remain identical.
## Known Limitations and Deferred Work
- **Character budgets are not token budgets** — provider token density varies, so `ctx.tokenMeter` remains the authority for deciding whether pruning relieved request pressure.
- **Pruning is syntactic** — it retains the beginning and end without interpreting which middle lines are semantically important.
- **Grapheme clusters can split** — code-point slicing protects surrogate pairs but does not perform locale-aware grapheme segmentation.

View File

@@ -0,0 +1,62 @@
# @deepseek-ai/dsh-compaction-tool-result-pruner
[English](README.md) | 中文
可安全回放、不依赖模型的剪枝服务(`ctx.toolResultPruner`)。它会将超出预算的 `tool/result` 表层节点改写为长度受限的头部、固定省略标记和长度受限的尾部,同时在仅追加会话日志中保留完整原始事件。
这是 [`dsh-compaction-basic`](../compaction-basic/README.md) 的具体配套服务,不是压缩(compaction)后端或面向模型的工具。Compact-basic 通过可选的 `ctx.get('toolResultPruner')` 读取它,因此这两个包仍可各自独立组合。
## 服务 API
`pruneSession(session)` 会扫描当前表层的一个稳定快照。每个超出预算的工具结果都会被一个新追加的 `tool/result` 替换,其携带 `{ surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq }, sourceEventSeqs: [originalSeq] }`。替换会展开完整原始数据,只更改 `content`,保留 `turn`、`step`、`callId`、错误字段、`meta` 以及以后新增的数据字段。原始事件仍可用于持久化、回放和精确日志检查。
当会话拒绝替换时,该方法会同步抛出异常。本次扫描中先前已提交的替换仍会保留。
`measureContent(blocks)` 会统计 `text` 块中的 Unicode 码点。`pruneContent(blocks)` 会返回长度受限的替换;如果内容已在阈值内,则返回 `null`。非文本块保持原始相对位置;文本切片绝不会拆分 UTF-16 代理项对,但可能拆分由多个码点组成的字素簇。
每个发出的结果在文本码点上都精确包含已配置的头部预算、固定标记和尾部预算,不大于 `thresholdChars`,且严格小于触发输入。因此第二次扫描不会发出替换。
## 配置
无法识别的配置键会使插件在构造时失败。已解析配置与输入脱离,并且深度不可变。
| 配置键 | 必填 | 含义 |
|---|---|---|
| `thresholdChars` | 否(默认 `8192`) | 合并文本超过此 Unicode 码点数时剪枝。 |
| `headChars` | 否(默认 `4096`) | 保留的开头 Unicode 码点数。 |
| `tailChars` | 否(默认 `1024`) | 保留的末尾 Unicode 码点数。 |
所有值都必须是整数;阈值必须为正数,头部/尾部必须为非负数。`headChars + marker + tailChars` 之和不得超过 `thresholdChars`,因此有效配置可以剪枝每个超出预算的结果,不会增长或重复改写。
## 用法
```ts
import type { Context } from '@deepseek-ai/cordis'
import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner'
export function apply(ctx: Context): void {
ctx.plugin(ToolResultPruner)
}
```
## 模型体验
### 已剪枝的工具结果
#### 模型看到的内容
一旦满足压缩触发条件,后续请求看到的将是保留的头部、`\n\n[... tool result middle pruned ...]\n\n` 和保留的尾部,而非被移除的文本。非文本块保持原有顺序。模型不会看到原文的第二份副本。
#### Token 影响
每个已改写工具结果最多包含 `thresholdChars` 个文本码点。剪枝本身不会发起模型调用;重新测量的请求低于压力阈值时,compaction-basic 会跳过摘要,否则摘要器会读取已剪枝的表层。
#### KV Cache 影响
替换较早的结果会使从第一个改变的 token 起的复用失效。当其路由、envelope 与之前的历史保持一致时,已剪枝前缀可以复用。
## 已知限制与暂缓事项
- **字符预算不是 token 预算**:不同提供方的 token 密度各异,因此 `ctx.tokenMeter` 仍负责判定剪枝是否缓解了请求压力。
- **剪枝只基于语法**:它保留开头与结尾,不解释中间哪些行在语义上重要。
- **字素簇可能被拆分**:按码点切片可保护代理项对,但不会执行感知区域设置的字素簇分割。

View File

@@ -0,0 +1,55 @@
{
"name": "@deepseek-ai/dsh-compaction-tool-result-pruner",
"description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/compaction/compaction-tool-result-pruner"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-compaction": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-compaction": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,77 @@
/** Configuration resolution for deterministic tool-result pruning. */
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ResolvedConfig, ToolResultPruneConfig } from './types.ts'
/** Fixed marker substituted for every removed middle span. */
export const PRUNE_MARKER = '\n\n[... tool result middle pruned ...]\n\n'
/** Low-friction defaults for coding-agent tool output. */
export const DEFAULTS: ResolvedConfig = deepFreeze({
thresholdChars: 8192,
headChars: 4096,
tailChars: 1024,
})
const CONFIG_KEYS: ReadonlySet<string> = new Set([
'thresholdChars',
'headChars',
'tailChars',
])
/**
* Count Unicode code points without splitting surrogate pairs.
* @param text - text to measure.
* @returns the Unicode code-point count.
*/
export function codePointLength(text: string): number {
return Array.from(text).length
}
/**
* Resolve and validate pruning budgets.
* @param config - raw plugin configuration.
* @returns a detached deeply immutable configuration.
*/
export function resolveConfig(config: ToolResultPruneConfig = {}): ResolvedConfig {
for (const key of Object.keys(config)) {
if (!CONFIG_KEYS.has(key)) {
throw new Error(
`ToolResultPruneConfig: unknown key "${key}" `
+ '(allowed: thresholdChars, headChars, tailChars)',
)
}
}
const resolved: ResolvedConfig = {
thresholdChars: config.thresholdChars ?? DEFAULTS.thresholdChars,
headChars: config.headChars ?? DEFAULTS.headChars,
tailChars: config.tailChars ?? DEFAULTS.tailChars,
}
assertPositiveInteger('thresholdChars', resolved.thresholdChars)
assertNonNegativeInteger('headChars', resolved.headChars)
assertNonNegativeInteger('tailChars', resolved.tailChars)
const emittedChars = resolved.headChars
+ codePointLength(PRUNE_MARKER)
+ resolved.tailChars
if (emittedChars > resolved.thresholdChars) {
throw new Error(
`ToolResultPruneConfig: headChars + marker + tailChars (${emittedChars}) `
+ `must be at most thresholdChars (${resolved.thresholdChars})`,
)
}
return deepFreeze(structuredClone(resolved))
}
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`ToolResultPruneConfig: ${name} (${value}) must be a positive integer`)
}
}
function assertNonNegativeInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`ToolResultPruneConfig: ${name} (${value}) must be a non-negative integer`)
}
}

View File

@@ -0,0 +1,187 @@
/**
* Replay-safe, model-free tool-result pruning service.
*
* @module @deepseek-ai/dsh-compaction-tool-result-pruner
*/
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/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 `compaction/*` SessionEventMap merges (the shadow-price event).
import type {} from '@deepseek-ai/dsh-compaction'
// 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 '@deepseek-ai/cordis' {
interface Context {
toolResultPruner: ToolResultPruner
}
}
interface SnapshotCandidate {
readonly seq: number
readonly event: SessionEvent<'tool/result'>
}
/** Deterministic head/middle/tail pruning for current tool-result surface nodes. */
export class ToolResultPruner 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, 'toolResultPruner')
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`,
* cites the shadowed node so replay can recover the replacement input, and is
* immediately preceded by a `compaction/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('compaction/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 ToolResultPruner

View File

@@ -0,0 +1,27 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-compaction-tool-result-pruner`.
* @module @deepseek-ai/dsh-compaction-tool-result-pruner/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-compaction-tool-result-pruner'
/** Cordis companion plugin name. */
export const name = 'compaction-tool-result-pruner-invariant'
/** Services required before the companion can register. */
export const inject = ['invariants']
/** No runtime invariant: Session validates each content-only rewrite and its companion owns cross-event enclosure. */
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,40 @@
import type { CallId } from '@deepseek-ai/dsh-llm'
/** Character-budget policy for deterministic tool-result pruning. */
export interface ToolResultPruneConfig {
/** Prune when total text exceeds this many Unicode code points. Defaults to `8192`. */
thresholdChars?: number
/** Maximum leading Unicode code points retained. Defaults to `4096`. */
headChars?: number
/** Maximum trailing Unicode code points retained. Defaults to `1024`. */
tailChars?: number
}
/** Validated, detached, deeply immutable pruning configuration. */
export interface ResolvedConfig {
readonly thresholdChars: number
readonly headChars: number
readonly tailChars: number
}
/** Cited source event and size accounting for one landed surface replacement. */
export interface PrunedEntry {
/** Full-fidelity tool-result event shadowed by the replacement. */
readonly originalSeq: number
/** Newly appended pruned tool-result event. */
readonly replacementSeq: number
/** Tool call shared by the original and replacement. */
readonly callId: CallId
/** Original text size in Unicode code points. */
readonly charsBefore: number
/** Replacement text size in Unicode code points. */
readonly charsAfter: number
}
/** Aggregate outcome of one stable-surface pruning pass. */
export interface PruneResult {
/** Replacements in the snapshotted surface order. */
readonly pruned: readonly PrunedEntry[]
/** Total Unicode code points removed across replacements. */
readonly charsRemoved: number
}

View File

@@ -0,0 +1,71 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
describe('compaction-tool-result-pruner real Loader composition', () => {
it('loads and resolves the flat YAML plugin shape', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-compact-tool-result-prune-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-token-meter'",
"- name: '@deepseek-ai/dsh-compaction-tool-result-pruner'",
' config:',
' thresholdChars: 100',
' headChars: 20',
' tailChars: 10',
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (specifier === '@deepseek-ai/dsh-token-meter') return TokenMeter
if (specifier === '@deepseek-ai/dsh-compaction-tool-result-pruner') return ToolResultPruner
throw new Error(`unexpected Loader import: ${specifier}`)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
expect(context.get('toolResultPruner')).toBeInstanceOf(ToolResultPruner)
expect(context.toolResultPruner.config).toEqual({
thresholdChars: 100,
headChars: 20,
tailChars: 10,
})
})
it('rejects stale config after plugin schema normalization', async () => {
context = new Context()
// Satisfy the declared injection first: config normalization runs in the
// service constructor, which a pending fiber never reaches.
await context.plugin(TokenMeter)
await expect(context.plugin(ToolResultPruner, {
maxChars: 100,
} as never)).rejects.toThrow(/unknown key "maxChars"/)
})
})

View File

@@ -0,0 +1,273 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, {
Session,
SessionId,
} from '@deepseek-ai/dsh-session'
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import ToolResultPruner, {
codePointLength,
DEFAULTS,
PRUNE_MARKER,
resolveConfig,
} from '@deepseek-ai/dsh-compaction-tool-result-pruner'
import type { ToolResultPruneConfig } from '@deepseek-ai/dsh-compaction-tool-result-pruner'
const MODEL = 'test-model'
const SMALL: ToolResultPruneConfig = {
thresholdChars: 50,
headChars: 4,
tailChars: 3,
}
function service(config: ToolResultPruneConfig = SMALL): ToolResultPruner {
const ctx = new Context()
// Service constructors self-register, so `ctx.tokenMeter` resolves for the
// shadow-price pricing without a full plugin boot.
void new TokenMeter(ctx)
return new ToolResultPruner(ctx, config)
}
/** Pricing oracle mirroring the service's estimator for expectations. */
const METER = new TokenMeter(new Context())
function appendToolStep(
session: Session,
turn: number,
call: string,
content: ContentBlock[],
extra: Record<string, unknown> = {},
): number {
const callId = CallId(call)
session.append('turn/start', {
turn,
})
session.append('step/start', { turn, step: 1 })
session.append('assistant/message', {
turn,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: MODEL, model: MODEL },
},
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn, step: 1, callId, name: 'bash', arguments: '{}' })
const result = session.append('tool/result', {
turn,
step: 1,
message: createToolResultMessage({ callId, content, isError: false }),
...extra,
}, { surfaceOp: 'append' })
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
return result.seq
}
describe('tool-result pruning configuration', () => {
it('resolves detached immutable defaults and partial overrides', () => {
const raw = { thresholdChars: 100, headChars: 20, tailChars: 10 }
const resolved = resolveConfig(raw)
raw.headChars = 1
expect(resolved).toEqual({ thresholdChars: 100, headChars: 20, tailChars: 10 })
expect(Object.isFrozen(resolved)).toBe(true)
expect(DEFAULTS).toEqual({ thresholdChars: 8192, headChars: 4096, tailChars: 1024 })
expect(Object.isFrozen(DEFAULTS)).toBe(true)
})
it('rejects stale keys, invalid scalars, and an output budget above threshold', () => {
const bad = [
[{ thresholdChars: 0 }, /thresholdChars .* positive integer/],
[{ headChars: -1 }, /headChars .* non-negative integer/],
[{ tailChars: 1.5 }, /tailChars .* non-negative integer/],
[{ thresholdChars: 50, headChars: 20, tailChars: 20 }, /headChars \+ marker \+ tailChars/],
[{ threshold: 10 }, /unknown key "threshold"/],
] as Array<[unknown, RegExp]>
for (const [config, pattern] of bad) {
expect(() => resolveConfig(config as ToolResultPruneConfig)).toThrow(pattern)
}
})
})
describe('ToolResultPruner content transform', () => {
it('measures text code points only and skips content within threshold', () => {
const prune = service()
const blocks = [
{ type: 'text', text: 'a😀b' },
{ type: 'reasoning', text: 'not measured' },
] satisfies ContentBlock[]
expect(prune.measureContent(blocks)).toBe(3)
expect(prune.pruneContent(blocks)).toBeNull()
expect(codePointLength('a😀b')).toBe(3)
})
it('keeps configured head and tail without splitting surrogate pairs', () => {
const prune = service()
const result = prune.pruneContent([{ type: 'text', text: '😀'.repeat(60) }])
expect(result).toEqual([{
type: 'text',
text: `${'😀'.repeat(4)}${PRUNE_MARKER}${'😀'.repeat(3)}`,
}])
expect(prune.measureContent(result!)).toBeLessThanOrEqual(50)
expect(result![0]).toMatchObject({ type: 'text' })
expect((result![0] as { text: string }).text).not.toContain('\uFFFD')
})
it('preserves non-text blocks and their relative ordering across removed text', () => {
const prune = service()
const reasoning: ContentBlock = { type: 'reasoning', text: 'private-rich-block' }
const call: ContentBlock = {
type: 'tool-call',
id: CallId('nested'),
name: 'nested',
arguments: '{}',
}
const result = prune.pruneContent([
{ type: 'text', text: 'A'.repeat(40) },
reasoning,
{ type: 'text', text: 'B'.repeat(30) },
call,
{ type: 'text', text: 'C'.repeat(30) },
])
expect(result).toEqual([
{ type: 'text', text: `AAAA${PRUNE_MARKER}` },
reasoning,
call,
{ type: 'text', text: 'CCC' },
])
expect(prune.measureContent(result!)).toBeLessThanOrEqual(50)
})
it('supports zero-sized head and tail while still shrinking', () => {
const prune = service({
thresholdChars: codePointLength(PRUNE_MARKER),
headChars: 0,
tailChars: 0,
})
const result = prune.pruneContent([{ type: 'text', text: 'x'.repeat(100) }])
expect(result).toEqual([{ type: 'text', text: PRUNE_MARKER }])
expect(prune.measureContent(result!)).toBe(prune.config.thresholdChars)
})
})
describe('ToolResultPruner session transaction', () => {
it('prunes a stable snapshot, preserves all data, and cites the replaced result', () => {
const session = Session.create(SessionId('preserve'))
const originalSeq = appendToolStep(session, 1, 'one', [{
type: 'text',
text: 'x'.repeat(100),
}], {
isError: true,
error: { name: 'ExitError', code: 'EXIT_1' },
meta: { diff: ['a', 'b'] },
futureField: { nested: true },
})
session.append('turn/start', {
turn: 2,
})
const result = service().pruneSession(session)
expect(result.pruned).toHaveLength(1)
expect(result.charsRemoved).toBeGreaterThan(0)
const entry = result.pruned[0]!
expect(entry).toMatchObject({ originalSeq, callId: CallId('one'), charsBefore: 100 })
expect(entry.charsAfter).toBeLessThanOrEqual(50)
const original = session.events[originalSeq]!
const replacement = session.events[entry.replacementSeq]! as SurfaceEvent
expect(original).toMatchObject({
type: 'tool/result',
data: {
message: {
content: [{
type: 'tool-result',
content: [{ type: 'text', text: 'x'.repeat(100) }],
}],
},
},
})
expect(replacement).toMatchObject({
type: 'tool/result',
data: {
turn: 1,
step: 1,
isError: true,
message: {
source: { kind: 'tool', callId: CallId('one') },
},
error: { name: 'ExitError', code: 'EXIT_1' },
meta: { diff: ['a', 'b'] },
futureField: { nested: true },
},
surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq },
sourceEventSeqs: [originalSeq],
})
expect(session.surface.nodes).not.toContain(originalSeq)
// Shadow-price protocol: the metering event sits directly before the
// replacement and prices the shadowed node with the shared estimator.
if (original.type !== 'tool/result') throw new Error('original is not a tool/result')
expect(session.events[entry.replacementSeq - 1]).toMatchObject({
type: 'compaction/prune',
data: {
shadowedRange: { start: originalSeq, end: originalSeq },
shadowedSeqs: [originalSeq],
shadowedTokenCount: METER.estimateMessage(original.data.message),
},
})
})
it('prunes multiple results, skips short ones, and converges in one pass', () => {
const session = Session.create(SessionId('multiple'))
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
appendToolStep(session, 2, 'b', [{ type: 'text', text: 'short' }])
appendToolStep(session, 3, 'c', [{ type: 'text', text: 'C'.repeat(80) }])
session.append('turn/start', {
turn: 4,
})
const prune = service()
const first = prune.pruneSession(session)
const second = prune.pruneSession(session)
expect(first.pruned.map(entry => entry.callId)).toEqual([CallId('a'), CallId('c')])
expect(first.charsRemoved).toBe(
first.pruned.reduce((sum, entry) => sum + entry.charsBefore - entry.charsAfter, 0),
)
expect(second).toEqual({ pruned: [], charsRemoved: 0 })
})
it('replays to the identical pruned model messages', () => {
const session = Session.create(SessionId('replay'))
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
session.append('turn/start', {
turn: 2,
})
service().pruneSession(session)
const replay = Session.create(session.id, [...session.events])
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration)
})
it('runs under real invariants between closed steps but not outside a turn', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantRegistry)
await ctx.plugin(SessionInvariant)
await ctx.plugin(TokenMeter)
const prune = new ToolResultPruner(ctx, SMALL)
const session = ctx.sessions.create(SessionId('invariants'))
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
expect(() => prune.pruneSession(session)).toThrow(/outside any open turn/)
session.append('turn/start', {
turn: 2,
})
expect(() => prune.pruneSession(session)).not.toThrow()
})
})

View File

@@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../llm/llm" },
{ "path": "../../llm/token-meter" },
{ "path": "../../core/session" },
{ "path": "../compaction" },
{ "path": "../../runtime-diagnostics/invariants" }
]
}