|
|
@@ -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)
|
|
|
}
|