feat(compact): prune tool results before summarization (round 1)

This commit is contained in:
Hypatia May
2026-07-16 18:02:15 +08:00
parent 231aeabe55
commit ce96104a77
42 files changed
+1093 -52

No files matched your search

+5 -4
View File
@@ -9,12 +9,13 @@ This is the implementation tier of the compaction capability — see the [interf
This backend owns the compaction policy:
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune.
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope.
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
- **Overflow recovery** — below-threshold overflow bypasses normal retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Overflow recovery** — below-threshold overflow bypasses normal retention and first prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including pruning-only progress on an otherwise indivisible surface; no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error.
`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`.
@@ -49,15 +50,15 @@ export function apply(ctx: Context): void {
}
```
Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly.
Loading the plugin registers `ctx.compact`. Add [`dsh-tool-result-prune`](../tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly.
## Model Experience
### Conversation history
**What the model sees**: After a successful step crosses the threshold, the next request receives the checkpoint preamble below, a blank line, `<compacted-summary>`, the data-dependent summary, and `</compacted-summary>`. Overflow recovery rebuilds the immediate retry from that replacement. This one checkpoint replaces the selected older range and is followed by the retained recent units.
**What the model sees**: After a successful step crosses the threshold, oversized tool results are first rewritten when the optional pruner is loaded. If summarization remains necessary, the next request receives the checkpoint preamble below, a blank line, `<compacted-summary>`, the data-dependent summary, and `</compacted-summary>`. Overflow recovery rebuilds the immediate retry from whatever replacement advanced the surface.
**Token effect**: The replacement reduces future input history rather than appending a second copy. The summary remains until a later compaction replaces it; one oversized indivisible unit can still exceed the budget.
**Token effect**: Model-free pruning can avoid the auxiliary call entirely; otherwise it reduces that call's transcript before the summary replaces an older range. A summary remains until a later compaction replaces it, while an indivisible non-tool unit can still exceed the budget.
#### Conversation checkpoint preamble
@@ -27,8 +27,14 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"@deepseek-ai/dsh-tool-result-prune": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-tool-result-prune": {
"optional": true
}
},
"dependencies": {
"schemastery": "^3.18.0"
},
@@ -43,6 +49,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-result-prune": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
+18 -6
View File
@@ -12,6 +12,8 @@ import type { Session } from '@deepseek-ai/dsh-session'
import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
// Type-only: makes the optional sibling service available to `ctx.get()`.
import type {} from '@deepseek-ai/dsh-tool-result-prune'
import { resolveConfig } from './config.ts'
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
import { summarizeWithLlm } from './summarizer.ts'
@@ -111,9 +113,9 @@ export class BasicCompactService extends CompactService {
return next()
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while compaction is awaited.
if (signal.aborted || result === null
if (signal.aborted
|| agent.session.surface.replaceGeneration <= generation) return next()
logResult(result, 'context overflow recovery')
if (result !== null) logResult(result, 'context overflow recovery')
return { action: 'retry' }
})
}
@@ -142,7 +144,7 @@ export class BasicCompactService extends CompactService {
* @param agent - agent whose latest durable routed request is measured.
* @param trigger - normal post-step pressure or context-overflow recovery.
* @param signal - live turn cancellation signal forwarded to summarization.
* @returns the latest compaction result, or `null` when no check/work applies.
* @returns the latest summary compaction result, or `null` when no summary ran.
*/
override async compactIfNeeded(
agent: Agent,
@@ -152,15 +154,25 @@ export class BasicCompactService extends CompactService {
const model = routedModel(agent.session)
if (model === undefined) return null
const meter = this.ctx.tokenMeter
const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
let measurement = meter.measure(agent.session)
if (trigger === 'pressure' && measurement.totalTokens < threshold) return null
// Pruning is optional so compact-basic remains independently composable.
// Once either trigger qualifies, land the model-free pass before choosing
// a summary range, then remeasure through the singleton replay fold.
const prune = this.ctx.get('toolResultPrune')
if (prune !== undefined) {
prune.pruneSession(agent.session)
measurement = meter.measure(agent.session)
}
if (trigger === 'context-overflow') {
const measurement = meter.measure(agent.session)
const range = selectCompactableRange(agent.session, measurement, 0)
if (range === null) return null
return this.compactRegion(agent.session, range.start, range.end, agent, signal)
}
const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
let measurement = meter.measure(agent.session)
if (measurement.totalTokens < threshold) return null
let result: CompactionResult | null = null
@@ -9,6 +9,7 @@ import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@d
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import ToolResultPruneService from '@deepseek-ai/dsh-tool-result-prune'
import type { Agent } from '@deepseek-ai/dsh-agent'
const SIGNAL = new AbortController().signal
@@ -94,6 +95,42 @@ function toolConversation(): Session {
return session
}
/** One closed routed tool step followed by an open turn for rewrite events. */
function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session {
const session = new Session(SessionId(`oversized-tool-${chars}`))
const callId = CallId('oversized')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
if (withCompactablePrompt) {
session.append('user/message', {
content: [{ type: 'text', text: 'older history '.repeat(200) }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}
session.append('step/start', { turn: 1, step: 1 })
session.append('request/header', {
header: { config: { model: MODEL } },
reason: 'initial',
})
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
session.append('tool/result', {
turn: 1,
step: 1,
callId,
content: [{ type: 'text', text: 'X'.repeat(chars) }],
isError: false,
meta: { presentation: 'preserved' },
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
return session
}
class TestCompactService extends BasicCompactService {
summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }]
summaryModel = 'summary-model'
@@ -401,6 +438,78 @@ describe('pressure measurement and retention', () => {
})
})
describe('optional model-free tool-result pruning', () => {
const pruneConfig = { thresholdChars: 100, headChars: 20, tailChars: 10 }
it('does not prune a below-pressure session opportunistically', async () => {
const ctx = createContext(10_000)
const prune = new ToolResultPruneService(ctx, pruneConfig)
const compact = new TestCompactService(ctx, {
auto: false,
thresholdRatio: 0.8,
retainTokens: 100,
})
const session = oversizedToolResult()
const pruneSession = vi.spyOn(prune, 'pruneSession')
expect(await compactIfNeeded(compact, session)).toBeNull()
expect(pruneSession).not.toHaveBeenCalled()
expect(compact.calls).toHaveLength(0)
expect(session.surface.replaceGeneration).toBe(0)
})
it('skips LLM summarization when pruning alone clears pressure', async () => {
const ctx = createContext(1_000)
void new ToolResultPruneService(ctx, pruneConfig)
const compact = new TestCompactService(ctx, {
auto: false,
thresholdRatio: 0.5,
retainTokens: 50,
})
const session = oversizedToolResult()
expect(ctx.tokenMeter.measure(session).totalTokens).toBeGreaterThanOrEqual(500)
expect(await compactIfNeeded(compact, session)).toBeNull()
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(500)
expect(compact.calls).toHaveLength(0)
expect(session.surface.replaceGeneration).toBe(1)
})
it('summarizes the pruned surface when pruning is insufficient', async () => {
const ctx = createContext(2_000)
void new ToolResultPruneService(ctx, pruneConfig)
const compact = new TestCompactService(ctx, {
auto: false,
thresholdRatio: 0.5,
retainTokens: 50,
})
const session = toolConversation()
expect(await compactIfNeeded(compact, session)).not.toBeNull()
expect(compact.calls).toHaveLength(1)
expect(compact.calls[0]!.text).toContain('tool result middle pruned')
expect(compact.calls[0]!.text).not.toContain('result 1 '.repeat(300))
})
it('retains the original compact-basic behavior without the optional plugin', async () => {
const ctx = createContext(2_000)
const compact = new TestCompactService(ctx, {
auto: false,
thresholdRatio: 0.5,
retainTokens: 50,
})
const session = oversizedToolResult(3_000, true)
expect(await compactIfNeeded(compact, session)).not.toBeNull()
expect(compact.calls).toHaveLength(1)
const original = session.events.find(event => event.type === 'tool/result')
expect(original?.type === 'tool/result' && original.data.content[0])
.toEqual({ type: 'text', text: 'X'.repeat(3_000) })
expect(session.events.filter(event =>
event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0)
})
})
describe('compaction region transaction', () => {
it('rejects an agent that does not own the exact target session before mutation', async () => {
const compact = service()
@@ -875,6 +984,44 @@ describe('automatic listener and loader composition', () => {
expect(session.surface.nodes.some(node => node.seq === retainedSeq)).toBe(true)
})
it('authorizes overflow retry when pruning alone advances an indivisible surface', async () => {
const ctx = createContext(10_000)
void new ToolResultPruneService(ctx, {
thresholdChars: 100,
headChars: 20,
tailChars: 10,
})
const compact = new TestCompactService(ctx, {
thresholdRatio: 1,
retainTokens: 900,
})
const session = oversizedToolResult()
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(session.surface.replaceGeneration).toBe(1)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
expect(compact.calls).toHaveLength(0)
})
it('continues overflow recovery with summarization on the pruned surface', async () => {
const ctx = createContext(10_000)
void new ToolResultPruneService(ctx, {
thresholdChars: 100,
headChars: 20,
tailChars: 10,
})
const compact = new TestCompactService(ctx, {
thresholdRatio: 1,
retainTokens: 900,
})
const session = toolConversation()
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(compact.calls).toHaveLength(1)
expect(compact.calls[0]!.text).toContain('tool result middle pruned')
})
it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => {
const ctx = createContext()
void new TestCompactService(ctx, {
@@ -9,6 +9,7 @@ import Include from '@cordisjs/plugin-include'
import LlmService from '@deepseek-ai/dsh-llm'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
import ToolResultPruneService from '@deepseek-ai/dsh-tool-result-prune'
let root: string | undefined
let context: Context | undefined
@@ -32,6 +33,7 @@ async function loadYaml(lines: readonly string[]): Promise<Context> {
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-llm', LlmService],
['@deepseek-ai/dsh-token-meter', TokenMeterService],
['@deepseek-ai/dsh-tool-result-prune', ToolResultPruneService],
['@deepseek-ai/dsh-compact-basic', BasicCompactService],
])
context.loader.internal = {
@@ -50,12 +52,17 @@ async function loadYaml(lines: readonly string[]): Promise<Context> {
}
describe('real Loader composition', () => {
it('loads the flat token-meter and compact-basic YAML shape', async () => {
it('loads the shipped token-meter, pruning, and compact-basic YAML order', async () => {
const loaded = await loadYaml([
"- name: '@deepseek-ai/dsh-llm'",
"- name: '@deepseek-ai/dsh-token-meter'",
' config:',
' contextWindow: 4096',
"- name: '@deepseek-ai/dsh-tool-result-prune'",
' config:',
' thresholdChars: 100',
' headChars: 20',
' tailChars: 10',
"- name: '@deepseek-ai/dsh-compact-basic'",
' config:',
' thresholdRatio: 0.5',
@@ -68,6 +75,7 @@ describe('real Loader composition', () => {
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
expect(loaded.tokenMeter.contextWindow).toBe(4096)
expect(loaded.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService)
expect((loaded.compact as BasicCompactService).config).toMatchObject({
thresholdRatio: 0.5,
+2 -1
View File
@@ -13,6 +13,7 @@
{ "path": "../../llm/token-meter" },
{ "path": "../../core/session" },
{ "path": "../../core/agent" },
{ "path": "../compact" }
{ "path": "../compact" },
{ "path": "../tool-result-prune" }
]
}