diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index bb64a12d4e..1edac07d5e 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/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 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index fad0f6e4f9..a9b6049745 100644 --- a/packages/client/runtime/README.md +++ b/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 diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 4ede12a320..6eee14bdbe 100644 --- a/packages/client/runtime/README.zh.md +++ b/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 标题投影 diff --git a/packages/client/runtime/src/client/sessions/tool-call-tree.ts b/packages/client/runtime/src/client/sessions/tool-call-tree.ts index 574a10dd92..fbc32e0cef 100644 --- a/packages/client/runtime/src/client/sessions/tool-call-tree.ts +++ b/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( left: readonly T[], right: readonly T[], @@ -24,6 +27,7 @@ function sameReferences( */ export class ToolCallTree { private readonly childrenByParent = new Map() + private readonly depthByCall = new Map() private readonly projectedByCall = new Map() 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() + 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] diff --git a/packages/client/runtime/tests/tool-call-tree.spec.ts b/packages/client/runtime/tests/tool-call-tree.spec.ts index 56e7f96694..541e6b2e19 100644 --- a/packages/client/runtime/tests/tool-call-tree.spec.ts +++ b/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): 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}`) + }) })