diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 11767a300e..a296f1af68 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/core/tools/README.md -README.md: ba8310b0b378d27d228a6e551e4b917c33e78fe5 -README.zh.md: 56cc1637f673559fe5f3c7cdf36bec80b8906eaa +README.md: e055fac61d31e1320b051753092e9b874f62a927 +README.zh.md: edfe2032fbe00a66d1a0460a044823723dbe6796 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index ba8310b0b3..e055fac61d 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -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). diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 56cc1637f6..edfe2032fb 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -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)。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index e6c20d1027..6e904a907c 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -62,6 +62,8 @@ function pad(indent: number): string { interface RenderState { readonly classes: string[] readonly usedClassNames: Set + /** Next collision counter per capped base, so allocation is amortized O(1) instead of rescanning from `2`. */ + readonly nextClassCounter: Map readonly typing: Set } @@ -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) { diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 4131cbd571..174b8987a2 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -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