fix(code-mode): keep deep host boundaries iterative
This commit is contained in:
@@ -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
|
||||
2026-07-20-code-mode-typed-tool-returns.md: f902aa4ff8cdaa0979f09850ea15deede5fdf576
|
||||
2026-07-20-code-mode-typed-tool-returns.zh.md: 4bdec90960e270f5be03b0a4c30d7c829dd27f6a
|
||||
2026-07-20-code-mode-typed-tool-returns.md: bcaefb196d12660177ad2bbc9c20ad71f6537eae
|
||||
2026-07-20-code-mode-typed-tool-returns.zh.md: 72946f4cc1ab7569fc1e3f90785debf8314f1d35
|
||||
|
||||
@@ -49,7 +49,7 @@ declare const tools: {
|
||||
|
||||
### Binding values and failures
|
||||
|
||||
Before dispatch the bridge snapshots binding arguments as lossless JSON and makes independent clones for execution and the durable summary event. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program.
|
||||
Before dispatch the bridge snapshots binding arguments as lossless JSON and snapshots the detached value again for an independent durable summary event. Host-side detachment, immutable execution, and output-schema projection all use iterative traversals rather than nested structured clone or recursive freezing. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program.
|
||||
|
||||
The worker exposes the actual `ToolCallError` constructor used for `tools` binding failures, so `error instanceof ToolCallError` works. The error has the standard `Error` message plus the exact `toolName`; it deliberately omits `ToolFailure.info`, error codes, and Native content. This is an exception contract for control flow, not a failure union for programmatic classification.
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ declare const tools: {
|
||||
|
||||
### 绑定值与失败
|
||||
|
||||
分发前,桥接层会把绑定参数快照为无损 JSON,并为执行和持久摘要事件分别创建独立副本。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前 reject。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。
|
||||
分发前,桥接层会把绑定参数快照为无损 JSON,再对分离后的值生成一次快照,供独立的持久摘要事件使用。宿主侧的值分离、执行数据的不可变处理与输出 schema 投影均采用迭代遍历,而不使用嵌套结构化克隆或递归冻结。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前 reject。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。
|
||||
|
||||
worker 暴露的是真正用于 `tools` 绑定失败的 `ToolCallError` 构造函数,因此 `error instanceof ToolCallError` 能够成立。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常契约,而不是供程序分类的失败联合。
|
||||
|
||||
|
||||
@@ -85,9 +85,9 @@ function summarize(text: string, cwd: string | undefined): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot one binding call's argument as lossless JSON, then clone it into
|
||||
* independent dispatch/log values so a tool mutation cannot desynchronize the
|
||||
* durable event from what was called.
|
||||
* Snapshot one binding call's argument as lossless JSON, then snapshot that
|
||||
* detached value again so dispatch and logging stay independent without
|
||||
* reintroducing structured-clone's platform-specific nesting limit.
|
||||
*/
|
||||
function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
|
||||
let snapshot: JsonValue | undefined
|
||||
@@ -99,7 +99,12 @@ function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unkno
|
||||
if (snapshot === undefined) {
|
||||
throw new Error('tool arguments must be lossless JSON (call the tool with an arguments object, e.g. `{}`)')
|
||||
}
|
||||
return { dispatched: structuredClone(snapshot), logged: structuredClone(snapshot) }
|
||||
const logged = snapshotJsonValue(snapshot)
|
||||
/* v8 ignore next -- snapshot is already a detached lossless JSON value. */
|
||||
if (logged === undefined) {
|
||||
throw new Error('tool arguments could not be detached for durable logging')
|
||||
}
|
||||
return { dispatched: snapshot, logged }
|
||||
}
|
||||
|
||||
/** Two-space JSON presentation, matching the existing shallow `run_code` text contract. */
|
||||
|
||||
@@ -864,10 +864,17 @@ export class ToolRegistry extends Service {
|
||||
private sdkSchemas(scope?: ScopeKey): ToolSdkSchema[] {
|
||||
return [...this.view(scope).visible.values()]
|
||||
.filter(definition => definition.name !== RUN_CODE_NAME)
|
||||
.map((definition): ToolSdkSchema => ({
|
||||
...this.schemaOf(definition, true),
|
||||
output: structuredClone(definition.output.schema),
|
||||
}))
|
||||
.map((definition): ToolSdkSchema => {
|
||||
const output = snapshotJsonValue(definition.output.schema)
|
||||
/* v8 ignore next -- registration already validated and retained this schema as lossless JSON. */
|
||||
if (output === undefined) {
|
||||
throw new Error(`tool "${definition.name}" output schema must be lossless JSON before SDK projection`)
|
||||
}
|
||||
return {
|
||||
...this.schemaOf(definition, true),
|
||||
output,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Project one definition onto the model-facing schema fields. */
|
||||
|
||||
@@ -7,7 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { Config, JsonSchemaNode, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue, SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
@@ -130,6 +130,30 @@ describe('mode-aware wire contribution', () => {
|
||||
expect(sdk?.text).not.toContain('run_code:')
|
||||
})
|
||||
|
||||
it('projects deeply nested output schemas into the Code Mode SDK without structured-clone recursion', async () => {
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'code' })
|
||||
let output: JsonSchemaNode = { type: 'string' }
|
||||
for (let depth = 0; depth < 5_000; depth++) {
|
||||
output = { oneOf: [output, { type: 'null' }] }
|
||||
}
|
||||
ctx.tools.register({
|
||||
name: 'deep_output',
|
||||
description: 'Return a deeply nested output union.',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
output: {
|
||||
schema: output,
|
||||
render: (_args, value) => [{ type: 'text', text: typeof value === 'string' ? value : 'null' }],
|
||||
},
|
||||
execute() { return Promise.resolve('ok') },
|
||||
})
|
||||
|
||||
const assembly = await systemPrompt.assemble()
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
|
||||
|
||||
expect(sdk).toContain('deep_output: Record<string, JsonValue>;')
|
||||
expect(sdk).toContain('deep_output: string | null')
|
||||
})
|
||||
|
||||
it.each(['code', 'both'] as const)('treats expert assembly output as authoritative in mode %s', async (mode) => {
|
||||
const { ctx, systemPrompt } = await setup({ mode })
|
||||
registerEcho(ctx)
|
||||
@@ -811,6 +835,57 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
|
||||
})
|
||||
|
||||
it('dispatches and durably logs binding arguments deeper than the structured-clone call stack', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const depth = 5_000
|
||||
let observedDepth = 0
|
||||
let observedLeaf: JsonValue | undefined
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'deep_args',
|
||||
description: 'Measure a deeply nested JSON argument.',
|
||||
parameters: { nested: { type: 'json', required: true } },
|
||||
output: {
|
||||
schema: { type: 'integer' },
|
||||
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
||||
},
|
||||
execute(args) {
|
||||
let cursor = args.nested
|
||||
while (Array.isArray(cursor)) {
|
||||
if (cursor.length !== 1) throw new Error('expected one item per nesting layer')
|
||||
observedDepth++
|
||||
cursor = cursor[0]!
|
||||
}
|
||||
observedLeaf = cursor
|
||||
return Promise.resolve(observedDepth)
|
||||
},
|
||||
}))
|
||||
const session = new Session(SessionId('deep-code-arguments'))
|
||||
const agent = { session } as Agent
|
||||
runtime.behavior = async (request) => {
|
||||
let nested: JsonValue = 'leaf'
|
||||
for (let index = 0; index < depth; index++) nested = [nested]
|
||||
const value = await request.bindings[0]!.functions.deep_args!({ nested })
|
||||
return { logs: [], value }
|
||||
}
|
||||
|
||||
const result = await runCode(ctx, 'return tools.deep_args(...)', { agent })
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.isError ? undefined : result.value).toEqual({ logs: [], result: depth })
|
||||
expect({ observedDepth, observedLeaf }).toEqual({ observedDepth: depth, observedLeaf: 'leaf' })
|
||||
const dispatch = session.events.find(event => event.type === 'tool/code-dispatch')
|
||||
if (dispatch === undefined) throw new Error('expected a durable tool/code-dispatch event')
|
||||
const logged = dispatch.data.arguments as { nested: JsonValue }
|
||||
let loggedDepth = 0
|
||||
let loggedCursor = logged.nested
|
||||
while (Array.isArray(loggedCursor)) {
|
||||
if (loggedCursor.length !== 1) throw new Error('expected one logged item per nesting layer')
|
||||
loggedDepth++
|
||||
loggedCursor = loggedCursor[0]!
|
||||
}
|
||||
expect({ loggedDepth, loggedCursor }).toEqual({ loggedDepth: depth, loggedCursor: 'leaf' })
|
||||
})
|
||||
|
||||
it('gives the tool and durable log the same immutable argument value', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const { agent, events } = fakeAgent()
|
||||
|
||||
@@ -58,7 +58,8 @@ export function isAgentLoopRequest(request: GenerateOptions): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-freeze a value in place, guarding cycles, so later mutation throws.
|
||||
* Deep-freeze a value in place with an iterative traversal, guarding cycles,
|
||||
* so later mutation throws without imposing a JavaScript call-stack depth cap.
|
||||
* {@link AbortSignal} objects are deliberately skipped because they are the
|
||||
* request's live cancellation channel and freezing them breaks abort.
|
||||
* @param value - the value to freeze in place.
|
||||
@@ -66,16 +67,31 @@ export function isAgentLoopRequest(request: GenerateOptions): boolean {
|
||||
*/
|
||||
export function deepFreeze<T>(value: T): T {
|
||||
const seen = new WeakSet<object>()
|
||||
const walk = (node: unknown): void => {
|
||||
if (node === null || typeof node !== 'object') return
|
||||
if (node instanceof AbortSignal) return
|
||||
if (seen.has(node)) return
|
||||
const pending: (
|
||||
| { kind: 'visit'; node: unknown }
|
||||
| { kind: 'property'; source: Record<string, unknown>; key: string }
|
||||
)[] = [{ kind: 'visit', node: value }]
|
||||
while (pending.length > 0) {
|
||||
const task = pending.pop()
|
||||
/* v8 ignore next -- the loop condition guarantees one pending task. */
|
||||
if (task === undefined) continue
|
||||
if (task.kind === 'property') {
|
||||
pending.push({ kind: 'visit', node: task.source[task.key] })
|
||||
continue
|
||||
}
|
||||
const node = task.node
|
||||
if (node === null || typeof node !== 'object') continue
|
||||
if (node instanceof AbortSignal) continue
|
||||
if (seen.has(node)) continue
|
||||
seen.add(node)
|
||||
Object.freeze(node)
|
||||
for (const key of Object.keys(node)) {
|
||||
walk((node as Record<string, unknown>)[key])
|
||||
const keys = Object.keys(node)
|
||||
for (let index = keys.length - 1; index >= 0; index--) {
|
||||
const key = keys[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured key count. */
|
||||
if (key === undefined) continue
|
||||
pending.push({ kind: 'property', source: node as Record<string, unknown>, key })
|
||||
}
|
||||
}
|
||||
walk(value)
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -56,6 +56,26 @@ describe('deepFreeze', () => {
|
||||
deepFreeze(cyclic)
|
||||
expect(Object.isFrozen(cyclic)).toBe(true)
|
||||
})
|
||||
|
||||
it('freezes nesting deeper than the JavaScript call stack', () => {
|
||||
const depth = 5_000
|
||||
const root: unknown[] = []
|
||||
let cursor = root
|
||||
for (let index = 0; index < depth; index++) {
|
||||
const child: unknown[] = []
|
||||
cursor.push(child)
|
||||
cursor = child
|
||||
}
|
||||
|
||||
deepFreeze(root)
|
||||
|
||||
cursor = root
|
||||
for (let index = 0; index < depth; index++) {
|
||||
expect(Object.isFrozen(cursor)).toBe(true)
|
||||
cursor = cursor[0] as unknown[]
|
||||
}
|
||||
expect(Object.isFrozen(cursor)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent-loop request identity', () => {
|
||||
|
||||
Reference in New Issue
Block a user