Merge remote-tracking branch 'origin/master' into codex/invariant-service-seam
This commit is contained in:
60
packages/compact/compact-tool-result-prune/README.md
Normal file
60
packages/compact/compact-tool-result-prune/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# @deepseek-ai/dsh-compact-tool-result-prune
|
||||
|
||||
The replay-safe model-free pruning service (`ctx.toolResultPrune`). 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-compact-basic`](../compact-basic/README.md), not a compaction backend or model-facing tool. Compact-basic reads it through optional `ctx.get('toolResultPrune')`, 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 'cordis'
|
||||
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.plugin(ToolResultPruneService)
|
||||
}
|
||||
```
|
||||
|
||||
## 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; compact-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.
|
||||
40
packages/compact/compact-tool-result-prune/package.json
Normal file
40
packages/compact/compact-tool-result-prune/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-compact-tool-result-prune",
|
||||
"description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
77
packages/compact/compact-tool-result-prune/src/config.ts
Normal file
77
packages/compact/compact-tool-result-prune/src/config.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
159
packages/compact/compact-tool-result-prune/src/index.ts
Normal file
159
packages/compact/compact-tool-result-prune/src/index.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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 type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
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 {
|
||||
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`,
|
||||
* and points at the shadowed node for durable provenance and replay.
|
||||
* @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 content = this.pruneContent(event.data.content)
|
||||
if (content === null) continue
|
||||
const charsBefore = this.measureContent(event.data.content)
|
||||
const charsAfter = this.measureContent(content)
|
||||
const replacement = session.append('tool/result', {
|
||||
...event.data,
|
||||
content,
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: seq, end: seq },
|
||||
sourceEventSeqs: [seq],
|
||||
})
|
||||
pruned.push({
|
||||
originalSeq: seq,
|
||||
replacementSeq: replacement.seq,
|
||||
callId: event.data.callId,
|
||||
charsBefore,
|
||||
charsAfter,
|
||||
})
|
||||
charsRemoved += charsBefore - charsAfter
|
||||
}
|
||||
return { pruned, charsRemoved }
|
||||
}
|
||||
}
|
||||
|
||||
export default ToolResultPruneService
|
||||
40
packages/compact/compact-tool-result-prune/src/types.ts
Normal file
40
packages/compact/compact-tool-result-prune/src/types.ts
Normal 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
|
||||
}
|
||||
|
||||
/** Provenance 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,67 @@
|
||||
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 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
|
||||
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('compact-tool-result-prune 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-compact-tool-result-prune'",
|
||||
' 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-compact-tool-result-prune') {
|
||||
throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
}
|
||||
return ToolResultPruneService
|
||||
},
|
||||
} 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('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
|
||||
expect(context.toolResultPrune.config).toEqual({
|
||||
thresholdChars: 100,
|
||||
headChars: 20,
|
||||
tailChars: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects stale config after plugin schema normalization', async () => {
|
||||
context = new Context()
|
||||
await expect(context.plugin(ToolResultPruneService, {
|
||||
maxChars: 100,
|
||||
} as never)).rejects.toThrow(/unknown key "maxChars"/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } 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 InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import ToolResultPruneService, {
|
||||
codePointLength,
|
||||
DEFAULTS,
|
||||
PRUNE_MARKER,
|
||||
resolveConfig,
|
||||
} from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
import type { ToolResultPruneConfig } from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
|
||||
const MODEL = 'test-model'
|
||||
const SMALL: ToolResultPruneConfig = {
|
||||
thresholdChars: 50,
|
||||
headChars: 4,
|
||||
tailChars: 3,
|
||||
}
|
||||
|
||||
function service(config: ToolResultPruneConfig = SMALL): ToolResultPruneService {
|
||||
return new ToolResultPruneService(new Context(), config)
|
||||
}
|
||||
|
||||
function appendToolStep(
|
||||
session: Session,
|
||||
turn: number,
|
||||
call: string,
|
||||
content: ContentBlock[],
|
||||
extra: Record<string, unknown> = {},
|
||||
): number {
|
||||
const callId = CallId(call)
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
|
||||
provenance: { 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,
|
||||
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('ToolResultPruneService 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('ToolResultPruneService session transaction', () => {
|
||||
it('prunes a stable snapshot, preserves all data, and records provenance', () => {
|
||||
const session = new Session(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,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
|
||||
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: { content: [{ type: 'text', text: 'x'.repeat(100) }] },
|
||||
})
|
||||
expect(replacement).toMatchObject({
|
||||
type: 'tool/result',
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('one'),
|
||||
isError: true,
|
||||
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)
|
||||
})
|
||||
|
||||
it('prunes multiple results, skips short ones, and converges in one pass', () => {
|
||||
const session = new Session(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,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
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 = new Session(SessionId('replay'))
|
||||
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
service().pruneSession(session)
|
||||
const replay = new Session(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(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
const prune = new ToolResultPruneService(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,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
expect(() => prune.pruneSession(session)).not.toThrow()
|
||||
})
|
||||
})
|
||||
15
packages/compact/compact-tool-result-prune/tsconfig.json
Normal file
15
packages/compact/compact-tool-result-prune/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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": "../../core/session" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user