Merge remote-tracking branch 'origin/compact-tool-pairing' into token-meter-service
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user