fix(client): bound nested Tool call depth

This commit is contained in:
imccyu
2026-08-08 18:15:34 +08:00
parent 091b993198
commit 4de2de98e4
5 changed files with 64 additions and 8 deletions

View File

@@ -10,6 +10,9 @@ interface ProjectedBlock {
value: ToolCallBlock
}
/** Fixed wire-safety ceiling for every recursive Tool call consumer. */
export const MAX_TOOL_CALL_TREE_DEPTH = 256
function sameReferences<T>(
left: readonly T[],
right: readonly T[],
@@ -24,6 +27,7 @@ function sameReferences<T>(
*/
export class ToolCallTree {
private readonly childrenByParent = new Map<string, readonly ToolCallBlock[]>()
private readonly depthByCall = new Map<string, number>()
private readonly projectedByCall = new Map<string, ProjectedBlock>()
private revision = 0
private nodesCache: {
@@ -40,6 +44,7 @@ export class ToolCallTree {
/** Forget all event-derived child calls before replaying a new window. */
reset(): void {
this.childrenByParent.clear()
this.depthByCall.clear()
this.projectedByCall.clear()
this.revision++
}
@@ -68,7 +73,7 @@ export class ToolCallTree {
subCalls: [],
}
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
if (this.wouldCreateCycle(data.parentCallId, data.subCallId)) return true
if (!this.acceptEdge(data.parentCallId, data.subCallId)) return true
this.childrenByParent.set(data.parentCallId, [...siblings, running])
this.revision++
return true
@@ -84,7 +89,7 @@ export class ToolCallTree {
}
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
if (at === -1 && this.wouldCreateCycle(data.parentCallId, data.subCallId)) return true
if (at === -1 && !this.acceptEdge(data.parentCallId, data.subCallId)) return true
const started = at === -1 ? undefined : siblings[at]
const settled: ToolResultNode = {
kind: 'tool-result',
@@ -163,6 +168,33 @@ export class ToolCallTree {
return value
}
/**
* Accept an edge only when every recursive consumer can traverse it safely.
* Host-minted ids exclude cycles and current bindings emit one level; a
* malformed wire/history edge is consumed without hiding the rest of the session.
*/
private acceptEdge(parentCallId: string, subCallId: string): boolean {
if (this.wouldCreateCycle(parentCallId, subCallId)) return false
const pending = [{
callId: subCallId,
depth: (this.depthByCall.get(parentCallId) ?? 1) + 1,
}]
const updates = new Map<string, number>()
for (const candidate of pending) {
const knownDepth = updates.get(candidate.callId)
?? this.depthByCall.get(candidate.callId)
?? 1
if (candidate.depth <= knownDepth) continue
if (candidate.depth > MAX_TOOL_CALL_TREE_DEPTH) return false
updates.set(candidate.callId, candidate.depth)
for (const child of this.childrenByParent.get(candidate.callId) ?? []) {
pending.push({ callId: child.callId, depth: candidate.depth + 1 })
}
}
for (const [callId, depth] of updates) this.depthByCall.set(callId, depth)
return true
}
private wouldCreateCycle(parentCallId: string, subCallId: string): boolean {
if (parentCallId === subCallId) return true
const pending = [subCallId]