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
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: fad0f6e4f948ebf412b2bd837a4f68e6dc805ec8
README.zh.md: 4ede12a320082a5023ec121306650fce72a12663
README.md: a9b604974595b1b7856f74b72d36093491ec1bd1
README.zh.md: 6eee14bdbe7a9fb86b0a12355e7d0a45cd500774
+1 -1
View File
@@ -46,7 +46,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
## Code Mode child-call tree
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity.
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity. Wire or history edges that would introduce a cycle or exceed the fixed 256-call recursive-depth safety limit are consumed without mutating the tree, so the rest of the session remains renderable.
## Session title projection
+1 -1
View File
@@ -46,7 +46,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## Code Mode 子调用树
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引:`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode`,其 `callTime` 来自成对 start 事件;start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引:`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode`,其 `callTime` 来自成对 start 事件;start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。会引入环,或使递归深度超过 256 个调用这一固定安全上限的协议或历史记录边会被视为已消费,但不会修改树,因此会话其余部分仍可渲染。
## Session 标题投影
@@ -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]
@@ -1,7 +1,9 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import type { RunningToolCall } from '../src/client/sessions/conversation.ts'
import { ToolCallTree } from '../src/client/sessions/tool-call-tree.ts'
import type { RunningToolCall, ToolCallBlock } from '../src/client/sessions/conversation.ts'
import {
MAX_TOOL_CALL_TREE_DEPTH, ToolCallTree,
} from '../src/client/sessions/tool-call-tree.ts'
const at = (seq: number, type: string, data: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, type, data }) as unknown as SessionEvent
@@ -62,4 +64,26 @@ describe('ToolCallTree', () => {
}],
}])
})
it('rejects an edge beyond the recursive depth safety limit', () => {
const tree = new ToolCallTree()
for (let depth = 1; depth < MAX_TOOL_CALL_TREE_DEPTH; depth++) {
tree.apply(start(depth, `call-${depth - 1}`, `call-${depth}`))
}
expect(tree.apply(start(
MAX_TOOL_CALL_TREE_DEPTH,
`call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`,
`call-${MAX_TOOL_CALL_TREE_DEPTH}`,
))).toBe(true)
let current: ToolCallBlock = tree.projectRunningCalls([root('call-0')])[0]!
let depth = 1
while (current.subCalls.length > 0) {
current = current.subCalls[0]!
depth++
}
expect(depth).toBe(MAX_TOOL_CALL_TREE_DEPTH)
expect(current.callId).toBe(`call-${MAX_TOOL_CALL_TREE_DEPTH - 1}`)
})
})