Merge remote-tracking branch 'origin/compact-tool-pairing' into token-meter-service

This commit is contained in:
Hypatia May
2026-07-16 16:06:27 +08:00
6 changed files with 51 additions and 79 deletions

View File

@@ -25,9 +25,9 @@ Both methods are **abstract** — the backend owns trigger policy, retention, ev
## 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 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 identifies the node by seq alone and answers from balances cached per cut in current surface order, 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.
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 and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
## Surface contract

View File

@@ -12,19 +12,21 @@ import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-sessio
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
/**
* Balance of every surface cut in current order: a surface of N nodes has
* N + 1 cuts, entry `i` being the cut before node `i` and the final entry
* the cut after the surface tail.
*/
cutBalanced: readonly boolean[]
/** Current surface position of each node seq, indexing {@link cutBalanced}. */
indexBySeq: Map<number, number>
/** In-progress tool-call count after the processed surface tail. */
inProgressToolCalls: number
}
const balanceCacheBySession = new WeakMap<Session, BalanceCache>()
/** Return how one surface event changes the unanswered tool-call count. */
/** Return how one surface event changes the in-progress tool-call count. */
function nodeDelta(event: SessionEvent): number {
switch (event.type) {
case 'assistant/message':
@@ -45,61 +47,30 @@ function eventForNode(events: readonly SessionEvent[], node: SurfaceNode): Sessi
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. */
/** Fold surface nodes not yet in the cache into its balance state. */
function extendCache(
session: Session,
cache: BalanceCache,
nodes: readonly SurfaceNode[],
): BalanceCache {
const tail = nodes.slice(cache.processedNodes)
const processed = cache.cutBalanced.length - 1
const tail = nodes.slice(processed)
// 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
const pendingCuts: boolean[] = []
let inProgressToolCalls = cache.inProgressToolCalls
for (const node of tail) {
pending.push({ seq: node.seq, before: depth === 0 })
depth += nodeDelta(eventForNode(events, node))
if (depth < 0) {
inProgressToolCalls += nodeDelta(eventForNode(events, node))
if (inProgressToolCalls < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
}
pendingCuts.push(inProgressToolCalls === 0)
}
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
tail.forEach((node, offset) => cache.indexBySeq.set(node.seq, processed + offset))
cache.cutBalanced = cache.cutBalanced.concat(pendingCuts)
cache.inProgressToolCalls = inProgressToolCalls
return cache
}
@@ -110,15 +81,32 @@ function balanceCache(session: Session): BalanceCache {
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)
if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > nodes.length) {
// A rebuild is the same fold started from the empty-surface state, whose
// single leading cut is trivially balanced.
const rebuilt = extendCache(session, {
generation,
cutBalanced: [true],
indexBySeq: new Map(),
inProgressToolCalls: 0,
}, nodes)
balanceCacheBySession.set(session, rebuilt)
return rebuilt
}
if (cached.processedNodes < nodes.length) return extendCache(session, cached, nodes)
if (cached.cutBalanced.length - 1 < nodes.length) return extendCache(session, cached, nodes)
return cached
}
/** Balance of the cut at a node's position plus offset, rejecting seqs outside current membership. */
function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean {
const index = cache.indexBySeq.get(seq)
const balanced = index === undefined ? undefined : cache.cutBalanced[index + offset]
if (balanced === undefined) {
throw new Error(`tool-pairing balance: surface seq ${seq} not found`)
}
return balanced
}
/**
* Whether the cut immediately before a current surface node is tool-pairing balanced.
* @param session - session whose surface is checked.
@@ -128,12 +116,7 @@ function balanceCache(session: Session): BalanceCache {
* 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
return cutBalance(balanceCache(session), node.seq, 0)
}
/**
@@ -145,16 +128,5 @@ export function toolPairingBalancedBefore(session: Session, node: SurfaceNode):
* 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)!
return cutBalance(balanceCache(session), node.seq, 1)
}

View File

@@ -131,7 +131,7 @@ describe('tool-pairing surface identity', () => {
expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/)
})
it('uses the cached positional successor instead of a caller node next field', () => {
it('ignores a caller-held node next field and answers from cached balances', () => {
const session = closedToolStep()
const assistant = nodeAt(session, seqOf(session, 'assistant/message'))
expect(toolPairingBalancedAfter(session, { ...assistant, next: null })).toBe(false)