fix(client): bound nested Tool call depth
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||||
README.md: fad0f6e4f948ebf412b2bd837a4f68e6dc805ec8
|
README.md: a9b604974595b1b7856f74b72d36093491ec1bd1
|
||||||
README.zh.md: 4ede12a320082a5023ec121306650fce72a12663
|
README.zh.md: 6eee14bdbe7a9fb86b0a12355e7d0a45cd500774
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
|
|||||||
|
|
||||||
## Code Mode child-call tree
|
## 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
|
## Session title projection
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
|||||||
|
|
||||||
## Code Mode 子调用树
|
## 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 标题投影
|
## Session 标题投影
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ interface ProjectedBlock {
|
|||||||
value: ToolCallBlock
|
value: ToolCallBlock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fixed wire-safety ceiling for every recursive Tool call consumer. */
|
||||||
|
export const MAX_TOOL_CALL_TREE_DEPTH = 256
|
||||||
|
|
||||||
function sameReferences<T>(
|
function sameReferences<T>(
|
||||||
left: readonly T[],
|
left: readonly T[],
|
||||||
right: readonly T[],
|
right: readonly T[],
|
||||||
@@ -24,6 +27,7 @@ function sameReferences<T>(
|
|||||||
*/
|
*/
|
||||||
export class ToolCallTree {
|
export class ToolCallTree {
|
||||||
private readonly childrenByParent = new Map<string, readonly ToolCallBlock[]>()
|
private readonly childrenByParent = new Map<string, readonly ToolCallBlock[]>()
|
||||||
|
private readonly depthByCall = new Map<string, number>()
|
||||||
private readonly projectedByCall = new Map<string, ProjectedBlock>()
|
private readonly projectedByCall = new Map<string, ProjectedBlock>()
|
||||||
private revision = 0
|
private revision = 0
|
||||||
private nodesCache: {
|
private nodesCache: {
|
||||||
@@ -40,6 +44,7 @@ export class ToolCallTree {
|
|||||||
/** Forget all event-derived child calls before replaying a new window. */
|
/** Forget all event-derived child calls before replaying a new window. */
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.childrenByParent.clear()
|
this.childrenByParent.clear()
|
||||||
|
this.depthByCall.clear()
|
||||||
this.projectedByCall.clear()
|
this.projectedByCall.clear()
|
||||||
this.revision++
|
this.revision++
|
||||||
}
|
}
|
||||||
@@ -68,7 +73,7 @@ export class ToolCallTree {
|
|||||||
subCalls: [],
|
subCalls: [],
|
||||||
}
|
}
|
||||||
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
|
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.childrenByParent.set(data.parentCallId, [...siblings, running])
|
||||||
this.revision++
|
this.revision++
|
||||||
return true
|
return true
|
||||||
@@ -84,7 +89,7 @@ export class ToolCallTree {
|
|||||||
}
|
}
|
||||||
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
|
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
|
||||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
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 started = at === -1 ? undefined : siblings[at]
|
||||||
const settled: ToolResultNode = {
|
const settled: ToolResultNode = {
|
||||||
kind: 'tool-result',
|
kind: 'tool-result',
|
||||||
@@ -163,6 +168,33 @@ export class ToolCallTree {
|
|||||||
return value
|
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 {
|
private wouldCreateCycle(parentCallId: string, subCallId: string): boolean {
|
||||||
if (parentCallId === subCallId) return true
|
if (parentCallId === subCallId) return true
|
||||||
const pending = [subCallId]
|
const pending = [subCallId]
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { RunningToolCall } from '../src/client/sessions/conversation.ts'
|
import type { RunningToolCall, ToolCallBlock } from '../src/client/sessions/conversation.ts'
|
||||||
import { ToolCallTree } from '../src/client/sessions/tool-call-tree.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 =>
|
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
|
({ 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}`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user