Explorar el Código

fix(client): bound nested Tool call depth

imccyu hace 1 mes
padre
commit
4de2de98e4

+ 2 - 2
packages/client/runtime/README.i18n.yaml

@@ -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
packages/client/runtime/README.md

@@ -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
packages/client/runtime/README.zh.md

@@ -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 标题投影
 

+ 34 - 2
packages/client/runtime/src/client/sessions/tool-call-tree.ts

@@ -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]

+ 26 - 2
packages/client/runtime/tests/tool-call-tree.spec.ts

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