feat(tools): render a Python SDK and dispatch Code Mode by runtime language

Code Mode generated only a TypeScript SDK and rejected any runtime whose
language was not "typescript". Add py-types.ts (jsonSchemaToPy /
renderToolsSdkPy) and select the SDK-section renderer and the run_code
schema flavor by ctx.codeRuntime.language through two parallel tables
(SDK_RENDERERS, RUN_CODE_FLAVORS), read with Object.hasOwn and failing
loud on a language with no renderer. The tool layer depends only on the
code-runtime seam's language field, so it lands independently of the
Python protocol and backend.
This commit is contained in:
Chinesezjc
2026-07-31 18:16:15 +08:00
parent d6853a667e
commit 4fdfa89d51
11 changed files with 1176 additions and 29 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md
2026-07-31-code-mode-language-dispatch.md: 6726842741988d71f5bce1885ebe7e8e7b3564d3
2026-07-31-code-mode-language-dispatch.zh.md: 0bb381c410fbbb468d65076518861923021a4453
@@ -0,0 +1,32 @@
# Agent Note: Code Mode language dispatch and the Python SDK renderer
Status: implemented
English | [中文](2026-07-31-code-mode-language-dispatch.zh.md)
## Problem
Code Mode generated one SDK flavor: TypeScript. `ToolRegistry` hard-coded `renderToolsSdk` for the `tools:sdk` section and `requireCodeRuntime` rejected any `ctx.codeRuntime.language !== 'typescript'`. Adding a CPython backend means a program's source language is no longer fixed: the same visible tool registry must project a Python SDK when a Python runtime is loaded, and the model-facing `run_code` schema strings ("Execute a Python program …") must match the SDK section's language so the model never sees a TypeScript instruction over a Python runtime.
This is the tool-facing half of the multi-language Code Mode split; the [code-runtime seam](../../../../packages/code-runtime/code-runtime/README.md) already carries `CodeRuntime.language`. This note owns only how `dsh-tools` dispatches on that field. The backend that implements `language: 'python'` is owned by its own note, delivered separately.
## Decision
Language selection is a lookup on `ctx.codeRuntime.language`, resolved lazily at prompt assembly, against two parallel tables in `dsh-tools`:
- `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: code`/`both` runtime whose language is absent from the table, naming the known languages.
- `RUN_CODE_FLAVORS` (code-mode.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree.
Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer; a language present on neither table but reaching the read fails loud (defense-in-depth against a caller bypassing the guard). Adding a backend language is two table entries plus its renderer — no `agent-loop` or registry-structure change.
`code-mode.ts` depends only on the runtime seam (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer therefore lands independently of the protocol and backend PRs — it needs only the seam's `language` field, which is already on master.
### The Python SDK renderer
`py-types.ts` renders the same unified tool-schema vocabulary `jsonSchemaToTs` covers, targeting Python: `jsonSchemaToPy` emits a type expression per JSON-schema node, and `renderToolsSdkPy` assembles named `TypedDict`s for each visible tool's arguments and canonical output plus a `tools` object with usage instructions equivalent to the TypeScript flavor. Unsupported raw constructs degrade rather than throwing during assembly, matching the TypeScript renderer's contract. The output is deterministic — lexicographic tool order, byte-identical text for an unchanged tool set — so the prompt stays prefix-cache-friendly.
## Rejected alternatives
- **A `language` config field on `ToolRegistry`.** Deployment would then have two places to name the language (the loaded runtime and the tools config) that can disagree; the loaded runtime is the single source of truth, so the registry reads it rather than duplicating it.
- **Importing the Python backend into `code-mode.ts` to detect it.** That would couple the tool layer to a concrete backend and force the protocol/backend PRs to land first. Runtime dispatch on `language` keeps the layer backend-agnostic and independently shippable.
- **A default renderer for an unknown language.** A silent fallback would emit a TypeScript SDK over, e.g., a Ruby runtime — the model would see instructions in the wrong language. Failing loud at assembly is the repository's misconfiguration stance.
@@ -0,0 +1,32 @@
# Agent Note: Code Mode 语言分发与 Python SDK 渲染器
Status: implemented
[English](2026-07-31-code-mode-language-dispatch.md) | 中文
## 问题
Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry``tools:sdk` 段硬编码了 `renderToolsSdk`,且 `requireCodeRuntime` 会拒绝任何 `ctx.codeRuntime.language !== 'typescript'`。引入 CPython 后端后,程序的源语言不再固定:同一个可见工具注册表在加载 Python 运行时时必须投射出 Python SDK,而面向模型的 `run_code` schema 字符串("Execute a Python program …")也必须与 SDK 段的语言一致,模型才不会在 Python 运行时下看到 TypeScript 指令。
这是多语言 Code Mode 拆分中面向工具的那一半;[代码运行时 seam](../../../../packages/code-runtime/code-runtime/README.md) 已经携带 `CodeRuntime.language`。本 Note 只负责 `dsh-tools` 如何在该字段上分发。实现 `language: 'python'` 的后端由它自己的 Note 负责,单独交付。
## 决策
语言选择就是对 `ctx.codeRuntime.language` 的查表,在 prompt 装配时惰性解析,查 `dsh-tools` 里两张平行的表:
- `SDK_RENDERERS`index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk``python → renderToolsSdkPy``tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。
- `RUN_CODE_FLAVORS`code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description``code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。
两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器;一个两张表都没有、却仍走到读取处的语言会 fail loud(对绕过守卫的调用方的纵深防御)。新增一门后端语言就是两条表项加它的渲染器——不动 `agent-loop`,也不动注册表结构。
`code-mode.ts` 只依赖运行时 seam`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。
### Python SDK 渲染器
`py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。
## 被否决的备选方案
- **在 `ToolRegistry` 上加一个 `language` 配置字段。** 那样部署方就会有两处命名语言(所加载的运行时与 tools 配置)且可能相互矛盾;所加载的运行时是唯一真相来源,故注册表读取它而不复制它。
- **把 Python 后端 import 进 `code-mode.ts` 来检测它。** 那会把工具层耦合到具体后端,并迫使协议/后端 PR 先落地。按 `language` 运行时分发使该层保持后端无关、可独立发布。
- **为未知语言提供默认渲染器。** 静默回退会在比如 Ruby 运行时上发出 TypeScript SDK——模型会看到错误语言的指令。在装配处 fail loud 是本仓库对错误配置的立场。
+2 -2
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: 15fc5839a3b0e3fa2d20c5a9cc50577e9807ffda
README.zh.md: 8547ee4a796dcd93945dfa40373c14c10d7d0c8a
README.md: 0c6b5ec5bc213e8a568592f3aca7c79b52d73907
README.zh.md: 80397e37c6e92053d825d4aa7d61e20455cd881a
+5 -5
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 TypeScript `ctx.codeRuntime`, 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 via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md), Python via [`dsh-code-runtime-python`](../../code-runtime/code-runtime-python/README.md)); 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
@@ -114,9 +114,9 @@ Returning `undefined` selects generic fallback. Presenters depend only on their
### Code Mode
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only the program's outer logs and return value re-enter model context. The SDK declares exact `ToolArgsMap` and `ToolOutputMap` entries for every visible tool, and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). Only the program's outer logs and return value re-enter model context. The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly.
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The TypeScript codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly.
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `<parent>:code:<n>`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
@@ -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 `declare const tools` block. `both` exposes normal schemas and this Code Mode surface.
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`](../../code-runtime/code-runtime-python/README.md)) 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 is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only.
- **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.
- **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).
+3 -3
View File
@@ -13,7 +13,7 @@ tools:
mode: native # native (default) | code | both
```
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求存在 TypeScript `ctx.codeRuntime`;如果 `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 经 [`dsh-code-runtime-python`](../../code-runtime/code-runtime-python/README.md));没有渲染器的运行时语言会让提示词组装响亮失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
### 公开 API
@@ -114,7 +114,7 @@ ctx.tools.register(defineTool({
### Code Mode
`code``both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和确定性的 TypeScript SDK只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的 `ToolArgsMap``ToolOutputMap` 条目,每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度契约下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName``message`;Native 内容和内部错误码留在 Code 契约之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回契约](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。
`code``both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK`python` → Python SDK)。只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的参数与规范输出类型(TypeScript 为 `ToolArgsMap`/`ToolOutputMap`Python 为具名 `TypedDict`,每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度契约下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName``message`;Native 内容和内部错误码留在 Code 契约之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回契约](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。
- **SDK 段**`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap``ToolName``ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。导出的代码生成器 `jsonSchemaToTs` 会处理统一 schema 的每种构造,并将不受支持的原始构造降级为 `unknown`,绝不会在提示词组装期间抛出。
- **分发桥接层**`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined``BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发契约的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `<parent>:code:<n>`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。
@@ -145,7 +145,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e
#### 模型看到的内容
Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及生成的精确 `declare const tools` 块。`both` 会同时公开普通 schema 与此 Code Mode 接口
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`](../../code-runtime/code-runtime-python/README.md))形状相同,只是换成 Python 语法(`await tools.name(args)`、异体名用下标访问、`print(...)` 与顶层 `return`
##### Code Mode SDK 说明
+118 -11
View File
@@ -11,7 +11,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import { defineTool } from './schema.ts'
import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts'
import { TOOL_REGISTRY_SCHEDULER } from './index.ts'
import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts'
@@ -56,6 +56,95 @@ export const RUN_CODE_NAME = 'run_code'
/** The `tools:sdk` section order: inside the 100199 tool-guidance band, after per-tool guidance sections. */
export const SDK_SECTION_ORDER = 150
/**
* The language-specific `run_code` schema text: the tool `description` and its
* `code` parameter description, kept together so a language's two model-facing
* strings share one source of truth. Keyed by `CodeRuntime.language`, mirroring
* `SDK_RENDERERS` in {@link ./index.ts}. The emitted flavor MUST match the
* semantics the same language's SDK instructions promise, so the model never
* receives a TypeScript-shaped schema beside a Python SDK (or vice versa).
*/
interface RunCodeFlavor {
/** The tool `description` the model sees for this language. */
readonly description: string
/** The `code` parameter's description for this language. */
readonly codeDescription: string
}
/**
* The TypeScript flavor: the historical default, and the fallback the schema
* harvest degrades to when no runtime is mounted (the doc-catalog generator
* reads `schemas()` without one). A real assembly always resolves a runtime
* first, so the model never sees this fallback outside its own language.
*/
const TYPESCRIPT_FLAVOR: RunCodeFlavor = {
description:
'Execute a TypeScript program against the available tools. Write the BODY of an '
+ 'async function (erasable syntax only; top-level `await` and `return` work) and '
+ 'call tools as `await tools.name(args)` per the declarations in the system prompt. '
+ 'Only what you print or return comes back — curate it.',
codeDescription: 'The program: the body of an async TypeScript function.',
}
/**
* The Python flavor: the body of an async function, top-level `await` and
* `return`, answer via `print` and/or the returned value, matching
* {@link ./py-types.ts}'s SDK instructions.
*/
const PYTHON_FLAVOR: RunCodeFlavor = {
description:
'Execute a Python program against the available tools. Write the BODY of an '
+ 'async function (top-level `await` and `return` work) and call tools as '
+ '`await tools.name(args)` per the declarations in the system prompt. Answer '
+ 'with `print(...)` and/or `return <value>` — only that comes back, so curate it.',
codeDescription: 'The program: the body of an async Python function.',
}
/** Per-language `run_code` schema flavors (see {@link RunCodeFlavor}); one entry per `SDK_RENDERERS` language. */
const RUN_CODE_FLAVORS: Record<string, RunCodeFlavor> = {
typescript: TYPESCRIPT_FLAVOR,
python: PYTHON_FLAVOR,
}
/**
* The `description` parameter's model-facing description: language-independent
* (the UI label contract is the same for every runtime), shared between the
* static spec and the language-aware `parameters` getter so the two emissions
* can never drift.
*/
const RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION
= 'Clear, concise description of what this program does in active voice, '
+ '5-10 words (shown in the UI). Examples: "Count TODO markers across packages"; '
+ '"Read failing test and its fixture"; "Rename config key in every cordis.yml".'
/**
* Resolve the {@link RunCodeFlavor} for the loaded runtime's language, read at
* schema-emission time so the model-visible `run_code` schema always matches
* the SDK section's language. When no runtime is mounted the schema harvest
* degrades to {@link TYPESCRIPT_FLAVOR} (a doc-only path — an assembly always
* has one). A mounted runtime whose language has no flavor entry fails loud,
* keeping this table coupled to `SDK_RENDERERS`.
*/
function resolveFlavor(requireRuntime: () => CodeRuntime): RunCodeFlavor {
let runtime: CodeRuntime
try {
runtime = requireRuntime()
} catch {
// No runtime mounted: the only reader here is the static schema harvest
// (doc catalog), which never reaches a model — degrade to the TS default.
return TYPESCRIPT_FLAVOR
}
// Own-property read: a language like `toString`/`constructor` would otherwise
// resolve an inherited Object.prototype member as a flavor.
const flavor = RUN_CODE_FLAVORS[runtime.language]
/* v8 ignore next 3 -- requireRuntime rejects a language absent from SDK_RENDERERS, whose keys
mirror RUN_CODE_FLAVORS; the guard is defense-in-depth against the two tables drifting. */
if (!Object.hasOwn(RUN_CODE_FLAVORS, runtime.language) || flavor === undefined) {
throw new Error(`dsh-tools: no run_code schema flavor registered for runtime language ${JSON.stringify(runtime.language)}`)
}
return flavor
}
/**
* Thrown by `run_code` when the program run itself failed — a program
* exception, a budget expiry, an abort, or substrate death. Extends
@@ -213,21 +302,21 @@ export interface RunCodeBridgeOptions {
*/
export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridgeOptions): ToolDefinition {
const { requireRuntime, maxParallel, shapeDispatchLog } = options
return defineTool({
const definition = defineTool({
name: RUN_CODE_NAME,
description:
'Execute a TypeScript program against the available tools. Write the BODY of an '
+ 'async function (erasable syntax only; top-level `await` and `return` work) and '
+ 'call tools as `await tools.name(args)` per the declarations in the system prompt. '
+ 'Only what you print or return comes back — curate it.',
// The description and `code` parameter description are placeholders here:
// the language-aware getters installed below replace both, resolving the
// loaded runtime's flavor at schema-emission time so the schema the MODEL
// sees matches the SDK section's language. Argument VALIDATION still keys
// off this static spec (defineTool closes over it), which is language-
// independent (one required string `code`).
description: TYPESCRIPT_FLAVOR.description,
parameters: {
code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
code: { type: 'string', required: true, description: TYPESCRIPT_FLAVOR.codeDescription },
description: {
type: 'string',
required: true,
description: 'Clear, concise description of what this program does in active voice, '
+ '5-10 words (shown in the UI). Examples: "Count TODO markers across packages"; '
+ '"Read failing test and its fixture"; "Rename config key in every cordis.yml".',
description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION,
},
},
output: {
@@ -569,4 +658,22 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
// title and reads durable result content without duplicating a large raw
// result into the host view payload.
})
// Resolve the language flavor lazily, at the moment the registry projects the
// schema (`schemaOf` destructures `description`/`parameters`). The definition
// is minted once at registration, before a runtime is known; deferring here
// is the least invasive point that still emits the loaded runtime's language.
Object.defineProperty(definition, 'description', {
enumerable: true,
get: () => resolveFlavor(requireRuntime).description,
})
Object.defineProperty(definition, 'parameters', {
enumerable: true,
// Recompile through the same spec→schema projection defineTool used, so
// the emitted shape can never drift from the validated one.
get: () => parameterSchemaSpecToJsonSchema({
code: { type: 'string', required: true, description: resolveFlavor(requireRuntime).codeDescription },
description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION },
}) as unknown as Record<string, unknown>,
})
return definition
}
+31 -5
View File
@@ -24,6 +24,19 @@ import type { JsonSchemaNode } from './json-schema.ts'
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
import { renderToolsSdk } from './ts-types.ts'
import type { ToolSdkSchema } from './ts-types.ts'
import { renderToolsSdkPy } from './py-types.ts'
/**
* Language → SDK-section renderer. The registry looks up the loaded
* `ctx.codeRuntime.language` in this table when assembling the `tools:sdk`
* section under a non-native mode; a runtime whose language is not a key
* fails the assembly loudly (same idiom as `toolOrder` violations). Adding a
* new backend language is a table entry plus its renderer, nothing else.
*/
const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = {
typescript: renderToolsSdk,
python: renderToolsSdkPy,
}
export {
defineTool,
@@ -65,6 +78,7 @@ export type { JsonValue } from '@deepseek-ai/dsh-session'
export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts'
export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts'
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
@@ -762,10 +776,21 @@ export class ToolRegistry extends Service {
ctx.systemPrompt.section({
name: 'tools:sdk',
order: SDK_SECTION_ORDER,
// Regenerate from the calling scope's visible tools in stable order.
// Regenerate from the calling scope's visible tools in stable order,
// picking the renderer that matches the loaded runtime's language.
// `requireCodeRuntime` already validated the language is in the
// table, so the fallback here is defense-in-depth against a caller
// that bypassed the guard (impossible under normal composition).
text: (context) => {
this.requireCodeRuntime()
return renderToolsSdk(this.sdkSchemas(context.scope))
const runtime = this.requireCodeRuntime()
// Own-property read: a language like `toString`/`constructor` would
// otherwise resolve an inherited Object.prototype member as a renderer.
const render = SDK_RENDERERS[runtime.language]
/* v8 ignore next 3 -- requireCodeRuntime rejects an unknown language before this ever runs. */
if (!Object.hasOwn(SDK_RENDERERS, runtime.language) || render === undefined) {
throw new Error(`dsh-tools: no SDK renderer registered for runtime language "${runtime.language}"`)
}
return render(this.sdkSchemas(context.scope))
},
})
}
@@ -804,8 +829,9 @@ export class ToolRegistry extends Service {
if (!runtime) {
throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`)
}
if (runtime.language !== 'typescript') {
throw new Error(`dsh-tools: mode "${this.mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`)
if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) {
const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')
throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`)
}
return runtime
}
+440
View File
@@ -0,0 +1,440 @@
/**
* Code Mode codegen — Python flavor. The pure projection from registered tool schemas to the
* Python SDK text the model programs against under `runtime.language === 'python'`. Sibling of
* {@link ./ts-types.ts | ts-types.ts}; the two files are two projections of the same registry
* store, keyed by the loaded {@link @deepseek-ai/dsh-code-runtime#CodeRuntime.language | code
* runtime's language}.
*
* In Code Mode the native tool schemas are omitted from the request, so this generated SDK is
* the model's ONLY source for each tool's argument names, required fields, types, descriptions,
* and canonical output shapes. Object-shaped arguments and outputs therefore render as one named
* `TypedDict` per tool (and per nested object), not an opaque `dict[str, Any]`, so the shape
* survives into the program.
* @module @deepseek-ai/dsh-tools/src/py-types
*/
import { assertSupportedJsonSchema } from './json-schema.ts'
import type { JsonSchemaScalar } from './json-schema.ts'
import type { ToolSdkSchema } from './ts-types.ts'
/** Property names that are valid bare Python identifiers; anything else is subscripted. */
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
/**
* Python 3.x soft-keyword-inclusive reserved set. A tool named ``class`` or
* ``lambda`` is legal on the wire but not as an attribute (``tools.class``
* would be a SyntaxError in the model program), so we render it under
* subscript access — the model still reaches every tool without collisions.
* Underscore-leading names (``_x``, ``__class__``) are also subscript-only:
* dunders resolve on ``object`` before the proxy's fallback hook, and the
* subscript path is the one guaranteed bridge route for them.
* The same set rejects an argument field whose name would be an illegal
* class-syntax `TypedDict` attribute, degrading that object to
* ``dict[str, Any]``.
*/
const RESERVED = new Set([
'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class',
'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global',
'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise',
'return', 'try', 'while', 'with', 'yield', 'match', 'case',
// Not a keyword, but CPython refuses to ASSIGN it at compile time
// (`SyntaxError: cannot assign to __debug__`), which is what a TypedDict
// field, a parameter name, and a keyword argument all are.
'__debug__',
])
/** `typing` symbols this module may emit, in the deterministic import order. */
const TYPING_ORDER = ['Any', 'Literal', 'NotRequired', 'Protocol', 'TypedDict'] as const
/** `indent`-deep line prefix (four spaces per level to match PEP 8 output). */
function pad(indent: number): string {
return ' '.repeat(indent)
}
/**
* Collector threaded through {@link renderType}: the emitted `TypedDict` class
* declarations (nested classes precede the parent that references them), the
* class names already taken (for collision suffixing), and the `typing`
* symbols the render actually used.
*/
interface RenderState {
readonly classes: string[]
readonly usedClassNames: Set<string>
readonly typing: Set<string>
}
/**
* Control characters that survive the whitespace collapse in {@link describe}
* and have no printable form. CPython rejects source containing a NUL outright
* (`SyntaxError: source code string cannot contain null bytes`), whether it
* sits in a docstring or in a comment, so one such byte anywhere in a schema
* description would make the whole generated SDK unparseable — the model's only
* declaration of the tools. The rest are legal but invisible; escaping them
* with the same rule keeps the emitted text readable and the treatment uniform.
*/
const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f]/g
/**
* The collapsed one-line `description` of a schema node (byte-stable across
* formatting churn), or `undefined` when the node carries none. Every caller
* passes an object (validated property nodes, or the ToolSdkSchema itself),
* so only the description field needs guarding.
*
* Control characters left over after the whitespace collapse are rendered as
* their `\xNN` escapes (see {@link UNPRINTABLE}); the escape's own backslash is
* emitted literally by both consumers, since {@link docLines} doubles it into a
* Python source escape and a `#` comment carries it verbatim.
*/
function describe(schema: object): string | undefined {
const description = (schema as Record<string, unknown>).description
if (typeof description !== 'string' || description.length === 0) return undefined
return description
.replace(/\s+/g, ' ')
.replace(UNPRINTABLE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
.trim()
}
/**
* One-line docstring for a tool `description`, or no lines when there is none.
* Backslashes are doubled first, every quote is escaped, and a trailing
* backslash cannot survive: a description ending in `"` or an odd backslash
* would otherwise merge with (or escape) the closing triple quote and make
* the generated block — Code Mode's only SDK — syntactically invalid Python.
*/
function docLines(description: unknown, indent: number): string[] {
const collapsed = describe({ description })
if (collapsed === undefined) return []
const escaped = collapsed.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
return [`${pad(indent)}"""${escaped}"""`]
}
/** CamelCase a name into a Python type identifier (non-identifier chars split words; a non-letter head is prefixed). */
function camelCase(raw: string): string {
const joined = raw
.split(/[^A-Za-z0-9]+/)
.filter(part => part.length > 0)
.map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join('')
return /^[A-Za-z]/.test(joined) ? joined : `Tool${joined}`
}
/** Reserve a unique class name, suffixing a counter on collision after CamelCase sanitization. */
function allocateClassName(base: string, state: RenderState): string {
let name = base
for (let n = 2; state.usedClassNames.has(name); n++) name = `${base}${n}`
state.usedClassNames.add(name)
return name
}
/**
* Render one validated scalar as Python literal text (`True`/`False`,
* JSON-quoted strings, bare numbers). `null` cannot reach here: the `null`
* type renders directly as `None`, and the unified validator rejects a null
* `const`/`enum` entry on every other scalar type.
*
* A beyond-safe-range integral number takes `BigInt` digits rather than
* `String`: Python integers are arbitrary-precision, so the emitted digits ARE
* the value the model programs against, and `String` gives a different integer
* than the double holds (`2 ** 60` prints the rounded `...847000`, not the
* exact `...846976`) or no integer literal at all (`1e21` prints `1e+21`). The
* Python runtime then rejects the advertised literal as not exactly
* representable as a JavaScript number, so the SDK would document a value no
* program can pass. The TS flavor needs no counterpart: its literal is re-read
* by a JS parser back into the same double.
*/
function pyScalar(value: JsonSchemaScalar): string {
if (value === true) return 'True'
if (value === false) return 'False'
if (typeof value === 'string') return JSON.stringify(value)
if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) {
return BigInt(value).toString()
}
return String(value)
}
/** Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to the broad type. */
function renderConstrainedScalar(node: Record<string, unknown>, broad: string, state: RenderState): string {
if (Object.hasOwn(node, 'const')) {
state.typing.add('Literal')
return `Literal[${pyScalar(node.const as JsonSchemaScalar)}]`
}
if (Object.hasOwn(node, 'enum')) {
state.typing.add('Literal')
return `Literal[${(node.enum as JsonSchemaScalar[]).map(pyScalar).join(', ')}]`
}
return broad
}
/**
* Map one JSON-Schema node to a Python type expression, threading `state` to
* collect the `TypedDict` declarations and `typing` symbols a full render
* needs. `className` is the name to give an object node with properties (and
* the prefix for its nested objects). Handles every unified schema construct —
* `oneOf` (→ `X | Y`), `const`/`enum` (→ `Literal[...]`), `integer` (→ `int`),
* `null` (→ `None`) — and degrades malformed or unsupported inputs to `Any`
* without throwing. {@link jsonSchemaToPy} is the context-free entry point;
* this is the collecting core.
*/
function renderType(schema: unknown, className: string, state: RenderState): string {
interface Frame {
schema: unknown
className: string
phase: 'start' | 'children'
kind?: 'oneOf' | 'array' | 'typeddict'
node?: Record<string, unknown>
children: { schema: unknown; className: string }[]
childIndex: number
childTypes: string[]
entries: [string, unknown][]
allocated?: string
validated: boolean
}
const newFrame = (schema: unknown, className: string, validated: boolean): Frame =>
({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [], validated })
const frames: Frame[] = [newFrame(schema, className, false)]
let result: string | undefined
/* 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 => {
frames.pop()
const parent = frames.at(-1)
if (parent === undefined) result = type
else parent.childTypes.push(type)
}
while (frames.length > 0) {
const frame = frames.at(-1)
/* v8 ignore next -- the loop condition guarantees a current frame. */
if (frame === undefined) break
if (frame.phase === 'children') {
if (frame.childIndex < frame.children.length) {
const child = frame.children[frame.childIndex]
/* v8 ignore next -- childIndex is bounded by children.length. */
if (child === undefined) throw new Error('missing python render child')
frame.childIndex++
frames.push(newFrame(child.schema, child.className, true))
continue
}
if (frame.kind === 'oneOf') {
finish(frame.childTypes.join(' | '))
continue
}
/* jscpd:ignore-end */
if (frame.kind === 'array') {
// `list[A | B]` needs no parentheses in Python. Array frames always
// schedule exactly one child, so its type is present.
/* v8 ignore next -- the ?? arm needs a childless array frame, which start never builds. */
finish(`list[${frame.childTypes[0] ?? 'Any'}]`)
continue
}
// typeddict: assemble AFTER the children so any nested class this one
// references is already declared (declaration order = reference order).
const node = frame.node
const name = frame.allocated
/* v8 ignore next -- typeddict frames always set node and allocated at start. */
if (node === undefined || name === undefined) throw new Error('missing typeddict frame state')
const required = new Set(Array.isArray(node.required) ? node.required.filter((n): n is string => typeof n === 'string') : [])
const lines = [`class ${name}(TypedDict):`]
for (let index = 0; index < frame.entries.length; index++) {
const entry = frame.entries[index]
const fieldType = frame.childTypes[index]
/* v8 ignore next -- entries and childTypes correspond one-to-one. */
if (entry === undefined || fieldType === undefined) throw new Error('missing typeddict field type')
const [field, fieldSchema] = entry
// The parent node passed assertSupportedJsonSchema, so every property
// value is a validated schema node (an object).
const description = describe(fieldSchema as object)
if (description !== undefined) lines.push(`${pad(1)}# ${description}`)
if (required.has(field)) {
lines.push(`${pad(1)}${field}: ${fieldType}`)
} else {
state.typing.add('NotRequired')
lines.push(`${pad(1)}${field}: NotRequired[${fieldType}]`)
}
}
// TypedDict syntax cannot express openness, so an open object states it
// in-band: the annotation is advisory either way, and Code Mode omits
// the native schemas, making this line the model's only signal that
// extra keys are accepted.
if (node.additionalProperties !== false) {
lines.push(`${pad(1)}# Additional keys beyond those declared are allowed.`)
}
// A closed empty object still needs a class body (`pass`) to be valid
// Python; the declared emptiness is the information.
if (lines.length === 1) lines.push(`${pad(1)}pass`)
state.classes.push(lines.join('\n'))
finish(name)
continue
}
frame.phase = 'children'
// Validate the WHOLE tree once at the root frame (the assertion walks it
// with an explicit stack); child frames are inside that validated tree, so
// re-asserting them would make a deep schema quadratic.
if (!frame.validated) {
try {
assertSupportedJsonSchema(frame.schema)
} catch {
state.typing.add('Any')
finish('Any')
continue
}
}
const node = frame.schema as Record<string, unknown>
if (Object.hasOwn(node, 'oneOf')) {
frame.kind = 'oneOf'
frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` }))
continue
}
if (!Object.hasOwn(node, 'type')) {
state.typing.add('Any')
finish('Any')
continue
}
switch (node.type) {
case 'string': finish(renderConstrainedScalar(node, 'str', state)); break
case 'number': finish(renderConstrainedScalar(node, 'float', state)); break
case 'integer': finish(renderConstrainedScalar(node, 'int', state)); break
case 'boolean': finish(renderConstrainedScalar(node, 'bool', state)); break
case 'null': finish('None'); break
case 'array': {
if (!Object.hasOwn(node, 'items')) {
state.typing.add('Any')
finish('list[Any]')
break
}
// An array of objects names its item type after the array field.
frame.kind = 'array'
frame.children = [{ schema: node.items, className: frame.className }]
break
}
case 'object': {
const properties = node.properties
if (typeof properties !== 'object' || properties === null) {
state.typing.add('Any')
finish('dict[str, Any]')
break
}
const entries = Object.entries(properties as Record<string, unknown>)
// An empty `className` marks the context-free `jsonSchemaToPy` entry:
// there is no naming context to declare into, so degrade. A field
// name that is not a legal Python attribute is inexpressible as a
// class-syntax `TypedDict` field, so such an object degrades whole.
// A leading-double-underscore non-dunder field (`__token`) would be
// NAME-MANGLED inside class syntax (`_ClassName__token`), describing a
// different JSON key than the registered schema — degrade like any
// other inexpressible field name.
if (className === '' || !entries.every(([name]) => IDENTIFIER.test(name) && !RESERVED.has(name) && !(name.startsWith('__') && !name.endsWith('__')))) {
state.typing.add('Any')
finish('dict[str, Any]')
break
}
// An OPEN empty object is any dict; a CLOSED empty object declares an
// empty TypedDict so "no keys accepted" survives into the SDK.
if (entries.length === 0 && node.additionalProperties !== false) {
state.typing.add('Any')
finish('dict[str, Any]')
break
}
frame.kind = 'typeddict'
frame.node = node
frame.allocated = allocateClassName(frame.className, state)
state.typing.add('TypedDict')
frame.entries = entries
// frame.allocated was assigned two statements up; the ?? arm is for the type system only.
/* v8 ignore next -- allocated is always set before children are built. */
frame.children = entries.map(([field, child]) => ({ schema: child, className: `${frame.allocated ?? ''}${camelCase(field)}` }))
break
}
/* v8 ignore next 4 -- assertSupportedJsonSchema narrowed this closed type union. */
default: {
state.typing.add('Any')
finish('Any')
}
}
}
/* v8 ignore next -- every root frame produces one expression. */
return result ?? 'Any'
}
/**
* Map one JSON-Schema node to a context-free Python type expression from the
* `typing` module. Handles every unified schema construct — `object` (degraded
* to `dict[str, Any]`: naming a `TypedDict` requires the render context that
* {@link renderToolsSdkPy} supplies), `const`/`enum` (→ `Literal[...]`),
* `oneOf` (→ union), `string`/`number`/`integer`/`boolean`/`null`, `array`
* (`items` → `list[T]`) — and returns `Any` for anything else, without
* throwing. Type annotations in the emitted SDK are advisory: Python does not
* enforce them at runtime, matching the TS flavor's advisory-type stance.
* @param schema - the JSON-Schema node (any shape; hostile inputs degrade).
* @returns the Python type text.
*/
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() })
}
/** The fixed model-facing usage contract rendered above the declarations. */
const SDK_INSTRUCTIONS = `## Writing code for run_code
Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). Inside the program:
- Call tools as \`await tools.name(args)\` — subscript access for exotic names or reserved words: \`await tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value (each method's return type below). Tool arguments must be lossless JSON.
- A FAILED tool call raises \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose message is human-readable — wrap in \`try/except\` to handle and continue.
- Independent read-only calls MAY overlap under \`asyncio.gather\` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with \`await\`.
- Emit the run's answer with \`print(...)\` and/or a top-level \`return <value>\`; the returned value must be lossless JSON. ONLY what you print and the returned value come back — intermediate tool results never enter the conversation, so extract just what you need.
The available tools:`
/**
* Render the full `tools:sdk` prompt section under `runtime.language ===
* 'python'`: the Python-flavored usage instructions plus one named `TypedDict`
* per tool argument or output object (and per nested object) and one awaitable
* method per visible tool on a `Tools` protocol — typed args in, the tool's
* canonical output value out — with a `tools: Tools` singleton the model calls
* into. The `typing` import line lists exactly the symbols the render used.
* Deterministic — tools are emitted in lexicographic name order, and class
* declarations precede the protocol in that same order (nested classes before
* the parent that references them), so an unchanged tool set produces
* byte-identical text across assemblies.
* @param schemas - the tool schemas plus canonical output schemas to declare
* (the caller excludes `run_code` itself).
* @returns the complete section text.
*/
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 inlineMembers: string[] = []
const subscriptMembers: string[] = []
for (const schema of sorted) {
const argType = renderType(schema.parameters, `${camelCase(schema.name)}Args`, state)
const outputType = renderType(schema.output, `${camelCase(schema.name)}Output`, state)
if (IDENTIFIER.test(schema.name) && !RESERVED.has(schema.name) && !schema.name.startsWith('_')) {
inlineMembers.push(...docLines(schema.description, 1))
inlineMembers.push(`${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}: ...`)
} else {
// Not a legal attribute name — the model reaches it via ``tools[name]``.
// The stub lists it as a subscript comment (referencing the named
// TypedDicts too) so a reader sees what is accessible; runtime resolution
// goes through the proxy's __getitem__.
subscriptMembers.push(`${pad(1)}# tools[${JSON.stringify(schema.name)}](args: ${argType}) -> ${outputType}`)
const description = describe(schema)
if (description !== undefined) subscriptMembers.push(`${pad(1)}# ${description}`)
}
}
// Subscript entries are COMMENTS, not statements: a class body of only
// comments fails to parse, so `pass` is required whenever no inline method
// exists — including the subscript-only tool set.
const bodyLines = inlineMembers.length > 0
? [...inlineMembers, ...subscriptMembers]
: [`${pad(1)}pass`, ...subscriptMembers]
const body = bodyLines.join('\n')
const imports = TYPING_ORDER.filter(symbol => state.typing.has(symbol))
const classBlock = state.classes.length > 0 ? `${state.classes.join('\n\n')}\n\n` : ''
const errorDeclaration = 'class ToolCallError(Exception):\n toolName: str'
const declaration = `from typing import ${imports.join(', ')}\n\n${errorDeclaration}\n\n${classBlock}class Tools(Protocol):\n${body}\n\ntools: Tools`
return `${SDK_INSTRUCTIONS}\n\n\`\`\`python\n${declaration}\n\`\`\``
}
+45 -3
View File
@@ -335,9 +335,51 @@ describe('mode-aware wire contribution', () => {
await expect(systemPrompt.assemble()).rejects.toThrow(/requires a code runtime/)
})
it("rejects every assembly when the runtime's language is not typescript", async () => {
const { systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } })
await expect(systemPrompt.assemble()).rejects.toThrow(/language is "python"/)
it('rejects every assembly when the runtime language has no registered SDK renderer', async () => {
const { systemPrompt } = await setup({ mode: 'code', runtime: { language: 'ruby' } })
await expect(systemPrompt.assemble()).rejects.toThrow(/no SDK renderer registered for runtime language "ruby"/)
})
it('assembles under a python runtime by picking the Python SDK renderer', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } })
registerEcho(ctx)
const assembly = await systemPrompt.assemble()
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')
expect(sdk?.text).toContain('class Tools(Protocol):')
expect(sdk?.text).toContain('async def echo(self, args:')
expect(sdk?.text).toContain('top-level `await`')
})
it('emits a TypeScript-flavored run_code schema under a typescript runtime', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'typescript' } })
registerEcho(ctx)
const assembly = await systemPrompt.assemble()
const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME)
expect(runCodeSchema?.description).toContain('Execute a TypeScript program')
expect(runCodeSchema?.description).toContain('BODY of an')
const codeParam = (runCodeSchema?.parameters as { properties: { code: { description: string } } }).properties.code
expect(codeParam.description).toBe('The program: the body of an async TypeScript function.')
})
it('emits a Python-flavored run_code schema under a python runtime (matches the SDK language)', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } })
registerEcho(ctx)
const assembly = await systemPrompt.assemble()
const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME)
expect(runCodeSchema?.description).toContain('Execute a Python program')
expect(runCodeSchema?.description).toContain('`return <value>`')
expect(runCodeSchema?.description).not.toContain('TypeScript')
const codeParam = (runCodeSchema?.parameters as { properties: { code: { description: string } } }).properties.code
expect(codeParam.description).toBe('The program: the body of an async Python function.')
})
it('fails loud when the runtime language has no run_code schema flavor', async () => {
// A language with an SDK renderer registered but (hypothetically) no schema
// flavor would fail here; a language with neither fails earlier at
// requireCodeRuntime. Both guards keep the two tables coupled.
const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'ruby' } })
registerEcho(ctx)
await expect(systemPrompt.assemble()).rejects.toThrow(/no SDK renderer registered for runtime language "ruby"/)
})
it("rejects the assembly when toolOrder names a native tool that mode 'code' no longer contributes", async () => {
+462
View File
@@ -0,0 +1,462 @@
import { describe, expect, it } from 'vitest'
import { jsonSchemaToPy, renderToolsSdkPy } from '@deepseek-ai/dsh-tools/src/py-types.ts'
import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
describe('jsonSchemaToPy', () => {
it('maps the defineTool DSL subset', () => {
const cases: [unknown, string][] = [
[{ type: 'string' }, 'str'],
[{ type: 'number' }, 'float'],
[{ type: 'boolean' }, 'bool'],
[{ type: 'string', enum: ['a', 'b'] }, 'Literal["a", "b"]'],
[{ type: 'array', items: { type: 'number' } }, 'list[float]'],
[{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, 'list[Literal["x", "y"]]'],
[{ type: 'array' }, 'list[Any]'],
[{ type: 'object' }, 'dict[str, Any]'],
[{ type: 'object', properties: {} }, 'dict[str, Any]'],
[{ type: 'object', properties: { x: { type: 'string' } } }, 'dict[str, Any]'],
]
for (const [schema, expected] of cases) {
expect(jsonSchemaToPy(schema), JSON.stringify(schema)).toBe(expected)
}
})
it('is total: unsupported or hostile constructs degrade to Any, never throw', () => {
const cases: unknown[] = [
undefined,
null,
42,
'string-schema',
{},
{ oneOf: 7 },
{ $ref: '#/defs/x' },
{ type: 'object', properties: 7 },
{ type: 'string', enum: [1, 2] },
{ type: 'string', enum: [] },
]
for (const schema of cases) {
expect(() => jsonSchemaToPy(schema), JSON.stringify(schema)).not.toThrow()
}
expect(jsonSchemaToPy({ type: 'integer' })).toBe('int')
expect(jsonSchemaToPy({ type: 'string', const: 'fixed' })).toBe('Literal["fixed"]')
expect(jsonSchemaToPy({ type: 'boolean', const: true })).toBe('Literal[True]')
expect(jsonSchemaToPy({ type: 'number', const: 1.5 })).toBe('Literal[1.5]')
expect(jsonSchemaToPy({ type: 'boolean', enum: [false] })).toBe('Literal[False]')
expect(jsonSchemaToPy({ type: 'null' })).toBe('None')
expect(jsonSchemaToPy({ oneOf: [{ type: 'string' }, { type: 'null' }] })).toBe('str | None')
expect(jsonSchemaToPy({ oneOf: [] })).toBe('Any')
expect(jsonSchemaToPy({ type: 'object', properties: 7 })).toBe('Any')
expect(jsonSchemaToPy({ type: 'string', enum: [1, 2] })).toBe('Any')
expect(jsonSchemaToPy({ type: 'string', enum: [] })).toBe('Any')
})
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
// ...847000, which is a DIFFERENT integer from the double's exact
// ...846976 — the Python runtime would reject the advertised literal as
// not exactly representable as a JavaScript number, so the SDK would
// document a value no program can pass.
expect(jsonSchemaToPy({ type: 'integer', const: 2 ** 60 })).toBe('Literal[1152921504606846976]')
expect(jsonSchemaToPy({ type: 'integer', enum: [2 ** 60, -(2 ** 60)] }))
.toBe('Literal[1152921504606846976, -1152921504606846976]')
// `String(1e21)` prints `1e+21`, not a Python integer literal at all. The
// rule keys off the VALUE, not the declared type, so a `number` const that
// happens to be an integral double is spelled the same exact way (both
// spellings denote the same double, and only the digits also denote the
// same Python integer).
expect(jsonSchemaToPy({ type: 'integer', const: 1e21 })).toBe('Literal[1000000000000000000000]')
expect(jsonSchemaToPy({ type: 'number', const: 1e21 })).toBe('Literal[1000000000000000000000]')
// Within the safe range, and for non-integral numbers, the plain spelling
// is already exact and stays unchanged.
expect(jsonSchemaToPy({ type: 'integer', const: 2 ** 53 - 1 })).toBe('Literal[9007199254740991]')
expect(jsonSchemaToPy({ type: 'number', const: 1e-7 })).toBe('Literal[1e-7]')
})
})
describe('renderToolsSdkPy', () => {
const bash: ToolSdkSchema = {
name: 'bash',
description: 'Run a shell command.',
parameters: parameterSchemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
output: { type: 'string' },
}
const exotic: ToolSdkSchema = {
name: 'my-mcp.tool',
description: 'Exotic name.',
parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
output: { type: 'string' },
}
const reserved: ToolSdkSchema = {
name: 'class',
description: 'Uses a reserved Python word.',
parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
output: { type: 'string' },
}
it('declares identifier tools as async methods and lists exotic/reserved names as subscript comments', () => {
const text = renderToolsSdkPy([exotic, bash, reserved])
expect(text).toContain('class Tools(Protocol):')
// The argument object is a named TypedDict, not an opaque dict.
expect(text).toContain('class BashArgs(TypedDict):')
expect(text).toContain('async def bash(self, args: BashArgs) -> str: ...')
// Empty-property tools keep the opaque dict (nothing to name).
expect(text).toContain('# tools["my-mcp.tool"](args: dict[str, Any]) -> str')
expect(text).toContain('# tools["class"](args: dict[str, Any]) -> str')
// Fixed instruction lines the model relies on.
expect(text).toContain('top-level `await`')
expect(text).toContain('ToolCallError')
expect(text).toContain('class ToolCallError(Exception):')
expect(text).toContain('MAY overlap under `asyncio.gather`')
expect(text).toContain('lossless JSON')
expect(text).toContain('```python')
expect(text).toContain('tools: Tools')
})
it('renders required as plain fields and optional as NotRequired, with per-field description comments', () => {
const tool: ToolSdkSchema = {
name: 'search',
description: 'Search for text.',
parameters: parameterSchemaSpecToJsonSchema({
query: { type: 'string', required: true, description: 'What to search for.' },
limit: { type: 'number', description: 'Max results.' },
}) as unknown as Record<string, unknown>,
output: { type: 'string' },
}
const text = renderToolsSdkPy([tool])
expect(text).toContain('class SearchArgs(TypedDict):')
expect(text).toContain(' # What to search for.')
expect(text).toContain(' query: str')
expect(text).toContain(' # Max results.')
expect(text).toContain(' limit: NotRequired[float]')
expect(text).toContain('async def search(self, args: SearchArgs) -> str: ...')
// NotRequired is imported because an optional field used it; Any is NOT,
// since every type here is concrete — the import line lists only what ran.
expect(text).toContain('from typing import NotRequired, Protocol, TypedDict')
})
it('prefixes Tool when a name CamelCases to a non-letter head, and degrades a malformed schema to Any', () => {
const tool: ToolSdkSchema = {
name: '1st-tool', // subscript path; CamelCases to "1stTool" → prefixed "Tool1stTool"
description: 'Hostile-shape probe.',
// Malformed node: the unified schema validator rejects it whole, so the
// args position degrades to Any (registration would refuse this schema;
// the renderer just must not throw on it).
parameters: { type: 'object', properties: { field: { type: 'string', description: 42 } } },
output: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] },
}
const text = renderToolsSdkPy([tool])
expect(text).toContain('# tools["1st-tool"](args: Any) -> Tool1stToolOutput')
expect(text).toContain('class Tool1stToolOutput(TypedDict):')
expect(text).toContain(' ok: bool')
})
it('treats every field as optional when the object carries no required array', () => {
const tool: ToolSdkSchema = {
name: 'all_optional',
description: 'No required array.',
parameters: { type: 'object', properties: { flag: { type: 'boolean' } } },
output: { type: 'string' },
}
const text = renderToolsSdkPy([tool])
expect(text).toContain(' flag: NotRequired[bool]')
})
it('renders an enum inside an object property as a Literal field', () => {
const tool: ToolSdkSchema = {
name: 'mode_tool',
description: 'Pick a mode.',
parameters: parameterSchemaSpecToJsonSchema({
mode: { type: 'string', required: true, enum: ['fast', 'slow'] },
}) as unknown as Record<string, unknown>,
output: { type: 'string' },
}
const text = renderToolsSdkPy([tool])
expect(text).toContain('class ModeToolArgs(TypedDict):')
expect(text).toContain(' mode: Literal["fast", "slow"]')
expect(text).toContain('from typing import Literal, Protocol, TypedDict')
})
it('renders one level of nested object as its own named TypedDict declared before the parent', () => {
const tool: ToolSdkSchema = {
name: 'workflow',
description: 'Run a workflow.',
parameters: parameterSchemaSpecToJsonSchema({
meta: {
type: 'object',
required: true,
additionalProperties: false,
description: 'Identity block.',
properties: {
name: { type: 'string', required: true, description: 'Short name.' },
phases: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: { title: { type: 'string', required: true, description: 'Phase title.' } },
},
},
},
},
}) as unknown as Record<string, unknown>,
output: { type: 'string' },
}
const text = renderToolsSdkPy([tool])
// Nested class for the `meta` object, and a further nested class for the
// array item object, each named after its field path.
expect(text).toContain('class WorkflowArgsMeta(TypedDict):')
expect(text).toContain('class WorkflowArgsMetaPhases(TypedDict):')
expect(text).toContain(' meta: WorkflowArgsMeta')
expect(text).toContain(' phases: NotRequired[list[WorkflowArgsMetaPhases]]')
// Dependency-before-dependent: the item class precedes its container,
// which precedes the top-level args class, which precedes the protocol.
expect(text.indexOf('class WorkflowArgsMetaPhases')).toBeLessThan(text.indexOf('class WorkflowArgsMeta(TypedDict):'))
expect(text.indexOf('class WorkflowArgsMeta(TypedDict):')).toBeLessThan(text.indexOf('class WorkflowArgs(TypedDict):'))
expect(text.indexOf('class WorkflowArgs(TypedDict):')).toBeLessThan(text.indexOf('class Tools(Protocol):'))
})
it('suffixes a counter when two tools CamelCase to the same class base', () => {
const a: ToolSdkSchema = {
name: 'my-tool',
description: 'Dash form.',
parameters: parameterSchemaSpecToJsonSchema({ x: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
output: { type: 'string' },
}
const b: ToolSdkSchema = {
name: 'my.tool',
description: 'Dot form.',
parameters: parameterSchemaSpecToJsonSchema({ y: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
output: { type: 'string' },
}
const text = renderToolsSdkPy([a, b])
// Both sanitize to `MyToolArgs`; the second collides and gets a suffix.
expect(text).toContain('class MyToolArgs(TypedDict):')
expect(text).toContain('class MyToolArgs2(TypedDict):')
})
it('references the named TypedDict from a reserved/subscript tool too', () => {
const tool: ToolSdkSchema = {
name: 'class',
description: 'Reserved word tool.',
parameters: parameterSchemaSpecToJsonSchema({ value: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
output: { type: 'string' },
}
const text = renderToolsSdkPy([tool])
expect(text).toContain('class ClassArgs(TypedDict):')
expect(text).toContain('# tools["class"](args: ClassArgs) -> str')
})
it('degrades an object to dict[str, Any] when a field name is not a legal Python attribute', () => {
const tool: ToolSdkSchema = {
name: 'weird_fields',
description: 'Has an illegal field name.',
parameters: { type: 'object', properties: { 'a-b': { type: 'string' } } },
output: { type: 'string' },
}
const text = renderToolsSdkPy([tool])
expect(text).toContain('async def weird_fields(self, args: dict[str, Any]) -> str: ...')
expect(text).not.toContain('WeirdFieldsArgs')
})
it('renders docstrings for descriptions and orders emissions lexicographically', () => {
const text = renderToolsSdkPy([bash, exotic])
expect(text).toContain('"""Run a shell command."""')
// Descriptions on subscript names ride as a comment beside their entry.
expect(text).toContain('# tools["my-mcp.tool"]')
expect(text).toContain('# Exotic name.')
// Lexicographic: `bash` before `my-mcp.tool` (identifier methods first,
// then subscript comments — the emitter partitions).
expect(text.indexOf('async def bash')).toBeLessThan(text.indexOf('# tools["my-mcp.tool"]'))
})
it('is deterministic: byte-identical output regardless of input order or duplication', () => {
expect(renderToolsSdkPy([bash, exotic])).toBe(renderToolsSdkPy([exotic, bash]))
expect(renderToolsSdkPy([bash, bash])).toBe(renderToolsSdkPy([bash, bash]))
})
it('renders a pass body and a minimal import for an empty tool set', () => {
const text = renderToolsSdkPy([])
expect(text).toContain('class Tools(Protocol):')
expect(text).toContain(' pass')
// Nothing but the protocol is used, so the import line is just Protocol.
expect(text).toContain('from typing import Protocol')
})
it('omits the docstring/comment when a schema has no description', () => {
const undescribedIdentifier: ToolSdkSchema = {
name: 'plain',
description: '',
parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
output: { type: 'string' },
}
const undescribedExotic: ToolSdkSchema = {
name: 'weird-name',
description: '',
parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
output: { type: 'string' },
}
const text = renderToolsSdkPy([undescribedIdentifier, undescribedExotic])
// Identifier method appears without a docstring line above it.
expect(text).toContain('async def plain(self, args: dict[str, Any]) -> str: ...')
expect(text).not.toContain('"""')
// Subscript entry appears without the "# ..." description follow-up.
expect(text).toContain('# tools["weird-name"]')
expect(text.split('\n').every(line => !line.startsWith(' # '))).toBe(true)
})
it('marks an open object TypedDict and declares a closed empty object', () => {
const t: ToolSdkSchema = {
name: 'openness',
description: '',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
open: { type: 'object', additionalProperties: true, properties: { x: { type: 'string' } }, required: ['x'] },
closedEmpty: { type: 'object', additionalProperties: false, properties: {} },
},
required: ['open', 'closedEmpty'],
},
output: { type: 'string' },
}
const text = renderToolsSdkPy([t])
// The open nested object carries the in-band openness note...
expect(text).toContain('class OpennessArgsOpen(TypedDict):')
expect(text).toMatch(/class OpennessArgsOpen\(TypedDict\):\n x: str\n # Additional keys beyond those declared are allowed\./)
// ...the closed root does not...
expect(text).toMatch(/class OpennessArgs\(TypedDict\):\n open: OpennessArgsOpen\n closedEmpty: OpennessArgsClosedEmpty\n\n/)
// ...and a closed EMPTY object declares an empty TypedDict rather than
// degrading to dict[str, Any] (which would falsely accept any keys).
expect(text).toMatch(/class OpennessArgsClosedEmpty\(TypedDict\):\n pass/)
expect(text).toContain('closedEmpty: OpennessArgsClosedEmpty')
})
it('renders a deeply nested array schema without exhausting the call stack', () => {
// The registry supports depth-unbounded schemas; the renderer must not
// reintroduce a recursion limit during prompt assembly.
let deep: Record<string, unknown> = { type: 'string' }
for (let i = 0; i < 20000; i++) deep = { type: 'array', items: deep }
const type = jsonSchemaToPy(deep)
expect(type.startsWith('list[list[')).toBe(true)
expect(type.endsWith(']]')).toBe(true)
expect(type).toContain('str')
expect(type.length).toBe('list['.length * 20000 + 'str'.length + ']'.repeat(20000).length)
})
it('emits pass for a subscript-only tool set (comments are not statements)', () => {
const t: ToolSdkSchema = {
name: 'my-exotic.tool',
description: '',
parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
output: { type: 'string' },
}
const text = renderToolsSdkPy([t])
// The class body must contain a statement before the subscript comments.
expect(text).toMatch(/class Tools\(Protocol\):\n pass\n # tools\["my-exotic\.tool"\]/)
})
it('degrades an object whose field would be name-mangled (__token) to dict[str, Any]', () => {
// Class-syntax TypedDict mangles a leading-double-underscore non-dunder
// annotation to _ClassName__token — a different JSON key than the schema.
const t: ToolSdkSchema = {
name: 'mangler',
description: '',
parameters: {
type: 'object',
additionalProperties: false,
properties: { __token: { type: 'string' } },
required: ['__token'],
},
output: { type: 'string' },
}
const text = renderToolsSdkPy([t])
expect(text).toContain('async def mangler(self, args: dict[str, Any]) -> str: ...')
expect(text).not.toContain('__token:')
// Dunder-form fields (__meta__) are NOT mangled and stay expressible.
const dunder: ToolSdkSchema = {
name: 'dunder',
description: '',
parameters: {
type: 'object',
additionalProperties: false,
properties: { __meta__: { type: 'string' } },
required: ['__meta__'],
},
output: { type: 'string' },
}
expect(renderToolsSdkPy([dunder])).toContain('__meta__: str')
})
it('degrades an object with a __debug__ field, which CPython refuses to assign', () => {
// `__debug__` is a legal identifier and dunder-form, so it clears both the
// identifier rule and the name-mangling rule, but CPython rejects the
// annotation at COMPILE time (`SyntaxError: cannot assign to __debug__`) —
// and this block is Code Mode's only SDK, so it must always parse.
const t: ToolSdkSchema = {
name: 'debugger',
description: '',
parameters: {
type: 'object',
additionalProperties: false,
properties: { __debug__: { type: 'string' } },
required: ['__debug__'],
},
output: { type: 'string' },
}
const text = renderToolsSdkPy([t])
expect(text).toContain('async def debugger(self, args: dict[str, Any]) -> str: ...')
expect(text).not.toContain('__debug__')
})
it('escapes quotes and backslashes in descriptions so the docstring stays valid Python', () => {
// A description ending in `"` or an odd backslash would otherwise merge
// with (or escape) the closing triple quote — and this block is Code
// Mode's only SDK, so it must always parse.
const make = (description: string): ToolSdkSchema => ({
name: 'weird',
description,
parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
output: { type: 'string' },
})
const trailingQuote = renderToolsSdkPy([make('ends in a quote"')])
expect(trailingQuote).toContain(String.raw`"""ends in a quote\""""`)
const trailingBackslash = renderToolsSdkPy([make('ends in a backslash\\')])
expect(trailingBackslash).toContain(String.raw`"""ends in a backslash\\"""`)
const tripleQuote = renderToolsSdkPy([make('contains """ triple quote')])
expect(tripleQuote).toContain(String.raw`"""contains \"\"\" triple quote"""`)
})
it('escapes unprintable control characters, which CPython refuses inside source at all', () => {
// `compile()` raises `SyntaxError: source code string cannot contain null
// bytes` for a NUL ANYWHERE in the source text, including inside a string
// literal or a comment, so a NUL that survives normalization into a
// docstring or a `#` field comment stops this block — Code Mode's only SDK —
// from parsing at all. The whitespace collapse does not remove it (a NUL is
// not whitespace). Rendering it as a visible escape keeps the source
// parseable and still shows the model what the schema said.
const make = (description: string): ToolSdkSchema => ({
name: 'weird',
description,
parameters: parameterSchemaSpecToJsonSchema({
field: { type: 'string', required: true, description },
}) as unknown as Record<string, unknown>,
output: { type: 'string' },
})
const nul = renderToolsSdkPy([make('before\u0000after')])
// Both emission sites: the class docstring and the `#` field comment. The
// docstring's backslash is doubled by the same escaping that keeps a literal
// backslash from escaping the closing triple quote, so Python parses it back
// to the visible `\x00` the comment shows directly. Neither carries the byte.
expect(nul).not.toContain('\u0000')
expect(nul).toContain(String.raw`"""before\\x00after"""`)
expect(nul).toContain(String.raw`# before\x00after`)
// The other C0 controls and DEL escape on the same path. Tab, newline and
// carriage return never reach it: the whitespace collapse folds them to a
// space first.
const others = renderToolsSdkPy([make('bell\u0007esc\u001bdel\u007f')])
expect(others).toContain(String.raw`bell\x07esc\x1bdel\x7f`)
expect(renderToolsSdkPy([make('tab\tnewline\ncr\r')])).toContain('"""tab newline cr"""')
})
})