feat(compact): move pairing helpers (PR1 round 1)

This commit is contained in:
Hypatia May
2026-07-15 13:22:44 +08:00
parent 34bb75b2d2
commit cc546c4580
17 changed files with 525 additions and 376 deletions

View File

@@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
| `@deepseek-ai/dsh-compact-basic` | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
@@ -23,6 +23,12 @@ Both methods are **abstract** — the backend owns the entire strategy (token es
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
## Tool-pairing boundaries
The interface exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper validates the node's seq against current surface membership and resolves the trailing edge from its cached positional successor, so a stale caller-held `node.next` cannot choose the cut.
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-node count. An unchanged generation extends the fold with unseen tail nodes only; a log-only append with no new surface node does no event reads, while a replacement generation rebuilds current membership, successors, and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
## Surface contract
`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead:

View File

@@ -14,6 +14,7 @@ import type { CompactionResult } from './types.ts'
export type { CompactionResult } from './types.ts'
export { renderContentBlocks, renderTranscript } from './render.ts'
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
/** Minimal agent context compaction needs without depending on the agent package. */
export interface CompactAgentContext {
@@ -67,6 +68,8 @@ export abstract class CompactService extends Service {
* balanced so assistant tool calls remain paired with their results. A model-
* backed implementation forwards cancellation and rejects active, missing,
* reversed, or unbalanced ranges.
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
* for the edge checks.
*
* @param session - session to mutate.
* @param start - first surface seq, inclusive.

View File

@@ -0,0 +1,160 @@
/**
* Tool-pairing balance over a session surface. Compaction changes surface
* positions, so safe cuts are derived from tool-call/result content in current
* surface order rather than step markers or linked-list fields supplied by a
* caller.
* @module @deepseek-ai/dsh-compact/tool-pairing
*/
import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session'
/** Incremental balance state for one session surface generation. */
interface BalanceCache {
/** Surface rewrite generation this state describes. */
generation: number
/** Number of surface nodes already folded into the state. */
processedNodes: number
/** Balance of the cut immediately before each current surface node. */
beforeSeq: Map<number, boolean>
/** Current positional successor of each surface node. */
successorBySeq: Map<number, number | null>
/** Unanswered tool-call count after the processed surface tail. */
depth: number
}
const balanceCacheBySession = new WeakMap<Session, BalanceCache>()
/** Return how one surface event changes the unanswered tool-call count. */
function nodeDelta(event: SessionEvent): number {
switch (event.type) {
case 'assistant/message':
return event.data.content.filter(block => block.type === 'tool-call').length
case 'tool/result':
return -1
default:
return 0
}
}
/** Read and validate the event named by a surface node. */
function eventForNode(events: readonly SessionEvent[], node: SurfaceNode): SessionEvent {
const event = events[node.seq]
if (event === undefined || event.seq !== node.seq) {
throw new Error(`tool-pairing balance: surface seq ${node.seq} has no matching session event (corrupt surface)`)
}
return event
}
/** Build balance state for a complete current surface. */
function rebuildCache(
session: Session,
nodes: readonly SurfaceNode[],
generation: number,
): BalanceCache {
const beforeSeq = new Map<number, boolean>()
const successorBySeq = new Map<number, number | null>()
const events = session.events
let depth = 0
let previousSeq: number | undefined
for (const node of nodes) {
beforeSeq.set(node.seq, depth === 0)
successorBySeq.set(node.seq, null)
if (previousSeq !== undefined) successorBySeq.set(previousSeq, node.seq)
depth += nodeDelta(eventForNode(events, node))
if (depth < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
}
previousSeq = node.seq
}
return { generation, processedNodes: nodes.length, beforeSeq, successorBySeq, depth }
}
/** Fold a pure surface tail append into existing balance state. */
function extendCache(
session: Session,
cache: BalanceCache,
nodes: readonly SurfaceNode[],
): BalanceCache {
const tail = nodes.slice(cache.processedNodes)
// Validate the unseen tail before mutating the live cache, so a corrupt
// append cannot leave a partially advanced state behind.
const events = session.events
const pending: Array<{ seq: number; before: boolean }> = []
let depth = cache.depth
for (const node of tail) {
pending.push({ seq: node.seq, before: depth === 0 })
depth += nodeDelta(eventForNode(events, node))
if (depth < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
}
}
let previousSeq = nodes[cache.processedNodes - 1]?.seq
for (const entry of pending) {
if (previousSeq !== undefined) cache.successorBySeq.set(previousSeq, entry.seq)
cache.beforeSeq.set(entry.seq, entry.before)
cache.successorBySeq.set(entry.seq, null)
previousSeq = entry.seq
}
cache.processedNodes = nodes.length
cache.depth = depth
return cache
}
/** Return balance state synchronized with the current session surface. */
function balanceCache(session: Session): BalanceCache {
const surface = session.surface
const nodes = surface.nodes
const generation = surface.replaceGeneration
const cached = balanceCacheBySession.get(session)
if (cached === undefined || cached.generation !== generation || cached.processedNodes > nodes.length) {
const rebuilt = rebuildCache(session, nodes, generation)
balanceCacheBySession.set(session, rebuilt)
return rebuilt
}
if (cached.processedNodes < nodes.length) return extendCache(session, cached, nodes)
return cached
}
/**
* Whether the cut immediately before a current surface node is tool-pairing balanced.
* @param session - session whose surface is checked.
* @param node - surface node whose leading cut is checked; only its seq identifies it.
* @returns true when no unanswered tool call crosses the cut.
* @throws when the seq is absent from the current surface, a surface node has no
* matching log event, or a tool result has no preceding open call.
*/
export function toolPairingBalancedBefore(session: Session, node: SurfaceNode): boolean {
const cache = balanceCache(session)
const balanced = cache.beforeSeq.get(node.seq)
if (balanced === undefined) {
throw new Error(`tool-pairing balance: surface seq ${node.seq} not found`)
}
return balanced
}
/**
* Whether the cut immediately after a current surface node is tool-pairing balanced.
* @param session - session whose surface is checked.
* @param node - surface node whose trailing cut is checked; only its seq identifies it.
* @returns true when no unanswered tool call crosses the cut.
* @throws when the seq is absent from the current surface, a surface node has no
* matching log event, or a tool result has no preceding open call.
*/
export function toolPairingBalancedAfter(session: Session, node: SurfaceNode): boolean {
const cache = balanceCache(session)
const successor = cache.successorBySeq.get(node.seq)
if (successor === undefined) {
throw new Error(`tool-pairing balance: surface seq ${node.seq} not found`)
}
if (successor === null) return cache.depth === 0
// Current membership and positional successors are cache-owned. A caller may
// retain a node across surface changes, so its mutable-looking `next` field is
// never authoritative for this query.
// The successor map and balance map are committed together.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return cache.beforeSeq.get(successor)!
}

View File

@@ -0,0 +1,327 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session'
const SURFACE = { surfaceOp: 'append' as const }
function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number {
return session.events.filter(event => event.type === type)[nth]!.seq
}
function nodeAt(session: Session, seq: number): SurfaceNode {
const node = session.surface.nodes.find(candidate => candidate.seq === seq)
if (node === undefined) throw new Error(`seq ${seq} is not a surface node`)
return node
}
function before(session: Session, type: SessionEvent['type'], nth = 0): boolean {
return toolPairingBalancedBefore(session, nodeAt(session, seqOf(session, type, nth)))
}
function after(session: Session, type: SessionEvent['type'], nth = 0): boolean {
return toolPairingBalancedAfter(session, nodeAt(session, seqOf(session, type, nth)))
}
function closedToolStep(): Session {
const session = new Session(SessionId('closed-tool-step'))
session.append('user/message', {
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}, SURFACE)
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, SURFACE)
session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('c1'),
content: [{ type: 'text', text: 'done' }],
isError: false,
}, SURFACE)
return session
}
describe('tool-pairing boundaries', () => {
it('classifies closed and open single-call steps', () => {
const closed = closedToolStep()
expect(before(closed, 'user/message')).toBe(true)
expect(after(closed, 'user/message')).toBe(true)
expect(before(closed, 'assistant/message')).toBe(true)
expect(after(closed, 'assistant/message')).toBe(false)
expect(before(closed, 'tool/result')).toBe(false)
expect(after(closed, 'tool/result')).toBe(true)
const open = new Session(SessionId('open-tool-step'))
open.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('open'), name: 'bash', arguments: '{}' }],
}, SURFACE)
expect(toolPairingBalancedAfter(open, open.surface.nodes[0]!)).toBe(false)
})
it('requires every result from a multiple-call assistant message', () => {
const session = new Session(SessionId('multiple-calls'))
session.append('assistant/message', {
turn: 1,
step: 1,
content: [
{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' },
{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' },
],
}, SURFACE)
session.append('tool/result', {
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
}, SURFACE)
session.append('tool/result', {
turn: 1, step: 1, callId: CallId('c2'), content: [], isError: false,
}, SURFACE)
expect(after(session, 'tool/result', 0)).toBe(false)
expect(after(session, 'tool/result', 1)).toBe(true)
})
it('keeps neutral nodes inside an open pair unbalanced and free nodes balanced', () => {
const midStep = new Session(SessionId('neutral-mid-step'))
midStep.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, SURFACE)
midStep.append('context/message', {
content: [{ type: 'text', text: 'background update' }],
source: { kind: 'plugin', plugin: 'test' },
}, SURFACE)
midStep.append('tool/result', {
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
}, SURFACE)
expect(before(midStep, 'context/message')).toBe(false)
expect(after(midStep, 'context/message')).toBe(false)
const free = new Session(SessionId('neutral-free'))
free.append('context/message', {
content: [{ type: 'text', text: 'idle injection' }],
source: { kind: 'user' },
}, SURFACE)
expect(before(free, 'context/message')).toBe(true)
expect(after(free, 'context/message')).toBe(true)
})
})
describe('tool-pairing surface identity', () => {
it('rebuilds after replace and rejects nodes removed from current membership', () => {
const session = closedToolStep()
const staleTail = nodeAt(session, seqOf(session, 'tool/result'))
expect(toolPairingBalancedAfter(session, staleTail)).toBe(true)
const nodes = session.surface.nodes
session.append('user/message', {
content: [{ type: 'text', text: 'checkpoint' }],
source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes.at(-1)!.seq } })
const checkpoint = session.surface.nodes[0]!
expect(toolPairingBalancedBefore(session, checkpoint)).toBe(true)
expect(toolPairingBalancedAfter(session, checkpoint)).toBe(true)
expect(() => toolPairingBalancedBefore(session, staleTail)).toThrow(/surface seq .* not found/)
expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/)
})
it('uses the cached positional successor instead of a caller node next field', () => {
const session = closedToolStep()
const assistant = nodeAt(session, seqOf(session, 'assistant/message'))
expect(toolPairingBalancedAfter(session, { ...assistant, next: null })).toBe(false)
expect(toolPairingBalancedAfter(session, { ...assistant, next: 999 })).toBe(false)
})
it('rejects missing seqs before and after, including an empty surface', () => {
const session = new Session(SessionId('missing-membership'))
const missing: SurfaceNode = { seq: 999, prev: null, next: null }
expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/)
expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/)
session.append('user/message', {
content: [{ type: 'text', text: 'first node after empty cache' }],
source: { kind: 'user' },
}, SURFACE)
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
})
})
describe('tool-pairing cache refresh', () => {
it('does no event reads for unchanged or log-only growth, folds only appended nodes, and rebuilds on replace', () => {
const events: SessionEvent[] = [
{
type: 'user/message', seq: 0, time: 0,
data: { content: [{ type: 'text', text: 'user' }], source: { kind: 'user' } },
surfaceOp: 'append',
},
{
type: 'assistant/message', seq: 1, time: 1,
data: { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }] },
surfaceOp: 'append',
},
{
type: 'tool/result', seq: 2, time: 2,
data: { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false },
surfaceOp: 'append',
},
]
const nodes: SurfaceNode[] = [
{ seq: 0, prev: null, next: 1 },
{ seq: 1, prev: 0, next: 2 },
{ seq: 2, prev: 1, next: null },
]
let generation = 0
let eventCollectionReads = 0
let eventIndexReads = 0
const trackedEvents = new Proxy(events, {
get(target, property, receiver) {
if (typeof property === 'string' && /^\d+$/.test(property)) eventIndexReads += 1
return Reflect.get(target, property, receiver) as unknown
},
})
const surface = {
get nodes() { return nodes },
get replaceGeneration() { return generation },
}
const session = {
surface,
get events() {
eventCollectionReads += 1
return trackedEvents
},
} as unknown as Session
expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
expect(eventCollectionReads).toBe(1)
expect(eventIndexReads).toBe(3)
expect(toolPairingBalancedBefore(session, nodes[0]!)).toBe(true)
expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(false)
expect(eventCollectionReads).toBe(1)
expect(eventIndexReads).toBe(3)
events.push({
type: 'turn/end', seq: 3, time: 3,
data: { turn: 1, reason: { kind: 'completed' } },
})
expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
expect(eventCollectionReads).toBe(1)
expect(eventIndexReads).toBe(3)
events.push({
type: 'user/message', seq: 4, time: 4,
data: { content: [{ type: 'text', text: 'tail' }], source: { kind: 'user' } },
surfaceOp: 'append',
})
nodes.push({ seq: 4, prev: 2, next: null })
expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true)
expect(eventCollectionReads).toBe(2)
expect(eventIndexReads).toBe(4)
events.push(
{
type: 'assistant/message', seq: 5, time: 5,
data: { turn: 2, step: 1, content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }] },
surfaceOp: 'append',
},
{
type: 'tool/result', seq: 6, time: 6,
data: { turn: 2, step: 1, callId: CallId('c2'), content: [], isError: false },
surfaceOp: 'append',
},
)
nodes.push(
{ seq: 5, prev: 4, next: 6 },
{ seq: 6, prev: 5, next: null },
)
expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true)
expect(eventCollectionReads).toBe(3)
expect(eventIndexReads).toBe(6)
events.push({
type: 'user/message', seq: 7, time: 7,
data: { content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' } },
surfaceOp: { op: 'replace', start: 0, end: 6 },
})
nodes.splice(0, nodes.length, { seq: 7, prev: null, next: null })
generation += 1
expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
expect(eventCollectionReads).toBe(4)
expect(eventIndexReads).toBe(7)
})
it('rebuilds defensively when a same-generation surface node count regresses', () => {
const events: SessionEvent[] = [
{
type: 'user/message', seq: 0, time: 0,
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
},
{
type: 'user/message', seq: 1, time: 1,
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
},
]
const nodes: SurfaceNode[] = [
{ seq: 0, prev: null, next: 1 },
{ seq: 1, prev: 0, next: null },
]
const session = {
events,
surface: { nodes, replaceGeneration: 0 },
} as unknown as Session
expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(true)
nodes.pop()
expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
})
})
describe('tool-pairing corrupt surfaces', () => {
it('throws for an orphan result during a rebuild', () => {
const session = new Session(SessionId('orphan-rebuild'))
session.append('tool/result', {
turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false,
}, SURFACE)
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toThrow(/no matching tool-call/)
})
it('retries an orphan result in an appended tail without committing partial cache state', () => {
const session = new Session(SessionId('orphan-tail'))
session.append('user/message', {
content: [{ type: 'text', text: 'safe head' }], source: { kind: 'user' },
}, SURFACE)
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
session.append('tool/result', {
turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false,
}, SURFACE)
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
})
it('throws when a current surface seq has no matching event or indexes the wrong event', () => {
const missingNode: SurfaceNode = { seq: 1, prev: null, next: null }
const missing = {
events: [{
type: 'user/message', seq: 0, time: 0,
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
} satisfies SessionEvent],
surface: { nodes: [missingNode], replaceGeneration: 0 },
} as unknown as Session
expect(() => toolPairingBalancedBefore(missing, missingNode)).toThrow(/no matching session event/)
const mismatchedNode: SurfaceNode = { seq: 0, prev: null, next: null }
const mismatched = {
events: [{
type: 'user/message', seq: 99, time: 0,
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
} satisfies SessionEvent],
surface: { nodes: [mismatchedNode], replaceGeneration: 0 },
} as unknown as Session
expect(() => toolPairingBalancedBefore(mismatched, mismatchedNode)).toThrow(/no matching session event/)
})
})