fix(tools): amortize class-name allocation and tighten py-types render contract

Address ds-review-bot v5/v6 review round 5:
- allocateClassName: keep a per-base collision counter (state.nextClassCounter)
  so a deep single-field chain sharing one capped base allocates in amortized
  O(1) instead of rescanning from 2 each time (Theta(depth^2) time); remove the
  stale one-line JSDoc left above the multiline one and attach the doc to the
  function, not the constant.
- renderType's catch rolls back the typing symbols the discarded subtree added
  (not just the classes) so the import line still lists exactly the symbols the
  surviving output uses; the comment now names that the same path also degrades
  this module's internal-invariant throws to Any, the trade for never throwing.
- README (both languages) no longer describes an installable
  dsh-code-runtime-python package: the Python renderer is built in and drives
  any runtime reporting language: 'python'; the first-party backend ships
  separately.
- Tests: assert the render-phase degrade on the first call, assert the import
  line after rollback, and cover the collision-skip loop; py-types.ts stays at
  100% per-file coverage.
This commit is contained in:
Chinesezjc
2026-08-02 14:50:01 +08:00
parent d7b4b014eb
commit 1614f19686
5 changed files with 69 additions and 19 deletions

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/core/tools/README.md
README.md: ba8310b0b378d27d228a6e551e4b917c33e78fe5
README.zh.md: 56cc1637f673559fe5f3c7cdf36bec80b8906eaa
README.md: e055fac61d31e1320b051753092e9b874f62a927
README.zh.md: edfe2032fbe00a66d1a0460a044823723dbe6796

View File

@@ -13,7 +13,7 @@ tools:
mode: native # native (default) | code | both
```
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer (TypeScript via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md), Python via `dsh-code-runtime-python`); a runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
### Public API
@@ -145,7 +145,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat
#### What the model sees
Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode surface. The instructions and SDK block match the loaded runtime's language; the TypeScript flavor (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python flavor (via `dsh-code-runtime-python`) is the same shape with Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`).
Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode surface. The instructions and SDK block match the loaded runtime's language; the TypeScript flavor (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python flavor (for any runtime reporting `language: 'python'`) is the same shape with Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`).
##### Code Mode SDK instructions
@@ -190,6 +190,6 @@ Append-only; newly visible content follows the reusable request prefix and does
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
- **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root.
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
- **Code Mode's SDK language follows the one loaded runtime and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (`typescript` via the worker backend, `python` via the python backend); scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only, and a single runtime fixes the language service-wide (the [language-dispatch Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) owns why per-agent language switching is deferred).
- **Code Mode's SDK language follows the one loaded runtime and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (`typescript` via the worker backend, `python` for any runtime reporting that language); scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only, and a single runtime fixes the language service-wide (the [language-dispatch Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) owns why per-agent language switching is deferred).
- **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)).
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md).

View File

@@ -13,7 +13,7 @@ tools:
mode: native # native (default) | code | both
```
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求所加载 `ctx.codeRuntime``language` 有已注册的 SDK 渲染器TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)Python 经 `dsh-code-runtime-python`没有渲染器的运行时语言会让提示词组装响亮失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求所加载 `ctx.codeRuntime``language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会让提示词组装响亮失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
### 公开 API
@@ -145,7 +145,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e
#### 模型看到的内容
Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode 接口。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 风格(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)Python 风格(`dsh-code-runtime-python`)形状相同,只是换成 Python 语法(`await tools.name(args)`、异体名用下标访问、`print(...)` 与顶层 `return`)。
Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode 接口。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 风格(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)Python 风格(用于任何报告 `language: 'python'` 的运行时)形状相同,只是换成 Python 语法(`await tools.name(args)`、异体名用下标访问、`print(...)` 与顶层 `return`)。
##### Code Mode SDK 说明
@@ -190,6 +190,6 @@ The available tools:
- **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录和呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)中。
- **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇和工具输出支持任意 JSON 根。
- **定义上的 `timeoutMs` 仅为声明**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-timeout-policy` 包装层。
- **Code Mode 的 SDK 语言跟随唯一加载的运行时,且呈现模式在服务内统一**`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器(`typescript` 经 worker 后端,`python` 经 python 后端);作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native、另一个仅使用 Code且单个运行时把语言固定为服务级[语言分发 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) 负责说明为何暂缓逐 agent 切换语言)。
- **Code Mode 的 SDK 语言跟随唯一加载的运行时,且呈现模式在服务内统一**`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器(`typescript` 经 worker 后端,`python` 用于任何报告该语言的运行时);作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native、另一个仅使用 Code且单个运行时把语言固定为服务级[语言分发 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) 负责说明为何暂缓逐 agent 切换语言)。
- **Code Mode 中间值只存在于执行局部,且没有字节上限**:这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。每个子调用的持久日志副本则确实有上限:`tools/code-dispatch-log` waterfall 允许 spill 策略把过大的 `tool/code-dispatch` 内容替换为预览加定位符([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。
- **每次运行都会获得全新的 `run_code` 状态**MVP 不采用持久 REPL 风格内核(跨调用状态不会出现在日志中);参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。

View File

@@ -62,6 +62,8 @@ function pad(indent: number): string {
interface RenderState {
readonly classes: string[]
readonly usedClassNames: Set<string>
/** Next collision counter per capped base, so allocation is amortized O(1) instead of rescanning from `2`. */
readonly nextClassCounter: Map<string, number>
readonly typing: Set<string>
}
@@ -120,21 +122,27 @@ function camelCase(raw: string): string {
return /^[A-Za-z]/.test(joined) ? joined : `Tool${joined}`
}
/** Reserve a unique class name, suffixing a counter on collision after CamelCase sanitization. */
/**
* Reserve a unique class name from a base, suffixing `2`, `3`, … on collision.
* The base is capped at {@link MAX_CLASS_NAME_BASE} first: child class names
* derive from their parent's allocated name (`ParentChild`), so an unbounded
* schema of single-field objects would otherwise grow each name by one field
* per level and the sum of all names to Θ(depth²). Capping the base keeps each
* name — and the total emitted text — linear in depth; the collision counter
* still makes truncated bases unique.
* name — and the total emitted text — linear in depth. Collisions resume from
* the per-base counter in `state.nextClassCounter` rather than rescanning from
* `2`, so a deep chain sharing one capped base stays O(1) per allocation
* (amortized) instead of Θ(depth²) in time.
*/
const MAX_CLASS_NAME_BASE = 120
function allocateClassName(base: string, state: RenderState): string {
const capped = base.length > MAX_CLASS_NAME_BASE ? base.slice(0, MAX_CLASS_NAME_BASE) : base
let name = capped
for (let n = 2; state.usedClassNames.has(name); n++) name = `${capped}${n}`
if (state.usedClassNames.has(name)) {
let n = state.nextClassCounter.get(capped) ?? 2
while (state.usedClassNames.has(`${capped}${n}`)) n++
name = `${capped}${n}`
state.nextClassCounter.set(capped, n + 1)
}
state.usedClassNames.add(name)
return name
}
@@ -219,6 +227,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str
// validation. Any throw here degrades to `Any`, discarding classes this call
// partially emitted so no broken declaration escapes.
const classFloor = state.classes.length
const typingFloor = new Set(state.typing)
/* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels
ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */
const finish = (type: string): void => {
@@ -383,9 +392,19 @@ function renderType(schema: unknown, className: string, state: RenderState): str
}
}
} catch {
// A render-phase throw (a stateful getter that passed validation) degrades
// the whole node to `Any`; drop any classes this call had begun emitting.
// Reached by a render-phase throw the root validation could not catch:
// either a hostile stateful getter (a `type` that passes validation then
// throws on a later read) OR one of this module's own v8-ignored internal
// invariant errors (`missing python render child` etc.). Both degrade the
// whole node to `Any` — an internal renderer bug thus surfaces as a lost
// type rather than a loud crash during prompt assembly, the deliberate
// trade for the never-throw contract. Roll back the classes and typing
// symbols the discarded subtree added so the import line still lists
// exactly the symbols the surviving output uses; `usedClassNames`/counter
// retention is harmless (conservative uniqueness).
state.classes.length = classFloor
state.typing.clear()
for (const symbol of typingFloor) state.typing.add(symbol)
state.typing.add('Any')
return 'Any'
}
@@ -409,7 +428,7 @@ export function jsonSchemaToPy(schema: unknown): string {
// A throwaway state whose class collector never escapes: an object with
// properties has nowhere to declare its TypedDict and degrades to
// dict[str, Any]. renderToolsSdkPy drives the named-TypedDict path.
return renderType(schema, '', { classes: [], usedClassNames: new Set(), typing: new Set() })
return renderType(schema, '', { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set() })
}
/** The fixed model-facing usage contract rendered above the declarations. */
@@ -441,7 +460,7 @@ The available tools:`
*/
export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string {
const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
const state: RenderState = { classes: [], usedClassNames: new Set(), typing: new Set(['Protocol']) }
const state: RenderState = { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set(['Protocol']) }
const inlineMembers: string[] = []
const subscriptMembers: string[] = []
for (const schema of sorted) {

View File

@@ -54,7 +54,10 @@ describe('jsonSchemaToPy', () => {
it('degrades to Any when a stateful getter throws in the render phase after passing validation', () => {
// A hostile `type` getter returns a scalar on the validation read, then
// throws on the render read. The no-throw contract must still hold across
// the whole walk, degrading the node to Any rather than escaping.
// the whole walk, degrading the node to Any rather than escaping. Assert
// the FIRST call's result: within it, root validation reads `type` once
// and the render phase reads it again (the throw), so this exercises the
// render-phase catch, not the validation-catch path.
let reads = 0
const schema = {
get type() {
@@ -63,8 +66,9 @@ describe('jsonSchemaToPy', () => {
throw new Error('stateful getter')
},
}
expect(() => jsonSchemaToPy(schema)).not.toThrow()
expect(jsonSchemaToPy(schema)).toBe('Any')
let first: string | undefined
expect(() => { first = jsonSchemaToPy(schema) }).not.toThrow()
expect(first).toBe('Any')
})
it('rolls back partial class declarations when a nested render-phase throw degrades a tool', () => {
@@ -88,6 +92,10 @@ describe('jsonSchemaToPy', () => {
// entire renderType call); no partial TypedDict for it is declared.
expect(text).toContain('async def hostile(self, args: Any) -> str: ...')
expect(text).not.toContain('class HostileArgs(TypedDict):')
// The import line lists only symbols the surviving output uses: the
// discarded subtree's TypedDict/NotRequired must not leak into it.
expect(text).not.toContain('TypedDict')
expect(text).toContain('from typing import Any, Protocol')
})
it('keeps class names and total output linear for a deep single-field object chain', () => {
@@ -113,6 +121,29 @@ describe('jsonSchemaToPy', () => {
expect(text.length).toBeLessThan(depth * 400)
})
it('skips an already-taken counter suffix when a sibling object occupies it', () => {
// `phase` and `Phase` both CamelCase to the base `FooArgsPhase`; `phase2`
// independently allocates `FooArgsPhase2` first. When `Phase` collides, the
// counter's first candidate `FooArgsPhase2` is already taken, so the scan
// must advance to `FooArgsPhase3` (exercises the collision-skip loop).
const obj = (field: string) => ({ type: 'object' as const, additionalProperties: false, properties: { [field]: { type: 'string' } } })
const tool: ToolSdkSchema = {
name: 'foo',
description: 'Sibling objects with colliding class bases.',
parameters: {
type: 'object',
additionalProperties: false,
properties: { phase: obj('a'), phase2: obj('b'), Phase: obj('c') },
required: ['phase', 'phase2', 'Phase'],
},
output: { type: 'string' },
}
const text = renderToolsSdkPy([tool])
expect(text).toContain('class FooArgsPhase(TypedDict):')
expect(text).toContain('class FooArgsPhase2(TypedDict):')
expect(text).toContain('class FooArgsPhase3(TypedDict):')
})
it('emits exact digits for a beyond-safe-range integer literal', () => {
// Python integers are arbitrary-precision, so the emitted digits ARE the
// value the model programs against. `String(2 ** 60)` prints the rounded