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:
@@ -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
|
||||
62
packages/compaction/compaction-tool-result-pruner/README.md
Normal file
62
packages/compaction/compaction-tool-result-pruner/README.md
Normal 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.
|
||||
@@ -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` 仍负责判定剪枝是否缓解了请求压力。
|
||||
- **剪枝只基于语法**:它保留开头与结尾,不解释中间哪些行在语义上重要。
|
||||
- **字素簇可能被拆分**:按码点切片可保护代理项对,但不会执行感知区域设置的字素簇分割。
|
||||
@@ -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:^"
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
187
packages/compaction/compaction-tool-result-pruner/src/index.ts
Normal file
187
packages/compaction/compaction-tool-result-pruner/src/index.ts
Normal 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
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"/)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user