fix(tools): correct Python SDK fidelity and language-dispatch contract

Address ds-review-bot v5/v6 review on the Python SDK renderer:
- resolveFlavor now takes a peekRuntime() reader: undefined (no runtime,
  the doc-catalog harvest) degrades to the TS flavor, but a mounted
  runtime whose language is absent from RUN_CODE_FLAVORS fails loud. This
  removes the try/catch that silently swallowed the invalid-language path
  and drops the /* v8 ignore */ that hid the flavor guard from coverage;
  wireSchemas validates the runtime before projecting schemas so the
  renderer-table rejection stays the canonical assembly error.
- py-types RESERVED drops the soft keywords match/case: they are legal as
  TypedDict fields and methods, so keeping them needlessly degraded
  common search/regex arg objects to dict[str, Any].
- py-types treats an object with omitted properties as {} like the unified
  validator and TS renderer do, so a closed empty object declares an empty
  TypedDict instead of a permissive dict[str, Any].
- README: symmetric jsonSchemaToPy->Any note; a stale zh SDK bullet and
  limitation corrected; link the service-wide-language limitation to its
  Agent Note.
This commit is contained in:
Chinesezjc
2026-07-31 18:55:07 +08:00
parent 85a831259c
commit 683c92cb2e
8 changed files with 130 additions and 57 deletions
+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: 1b19a080759fa8b21f0c1058d049b2c3f6cf64fe
README.zh.md: 07fc85e2fc51c0b528eb37f6e7599d72144db8fb
README.md: ba8310b0b378d27d228a6e551e4b917c33e78fe5
README.zh.md: 56cc1637f673559fe5f3c7cdf36bec80b8906eaa
+1 -1
View File
@@ -116,7 +116,7 @@ Returning `undefined` selects generic fallback. Presenters depend only on their
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 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 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). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute).
- **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.
+1 -1
View File
@@ -116,7 +116,7 @@ ctx.tools.register(defineTool({
在 `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`,绝不会在提示词组装期间抛出。
- **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态发出 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,整个对象降级为 `dict[str, Any]`)。
- **分发桥接层**(`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` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。
- **结算纪律**:桥接层拥有一个运行作用域的中止机制;该中止会跟随传入的外层信号,并在运行因任何原因结算时触发,因此预算耗尽会中止正在运行的子工具,而不会将其遗留。桥接层随后会在返回之前排空队列,使每个 `tool/code-dispatch` 都落在仍打开的轮次内。失败的运行会抛出 `CodeRunFailedError`(`code: 'CODE_RUN_FAILED'`,message = 失败类型 + 已捕获日志),流水线会将其转换为模型可据以自我修正的结构化 `isError`。
- **结果边界**:中间绑定值会完整跨越 worker 边界,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该账本。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。
+20 -20
View File
@@ -120,29 +120,23 @@ const RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION
/**
* 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, or one whose language
* has no renderer is, the schema harvest degrades to {@link TYPESCRIPT_FLAVOR}
* (a doc-only path — a real assembly always mounts a valid runtime, and
* `requireCodeRuntime` rejects an invalid language there first). A mounted
* runtime whose language passes that guard but is absent from this table fails
* loud, keeping this table coupled to `SDK_RENDERERS`.
* the SDK section's language. `peekRuntime` returns `undefined` only when no
* runtime is mounted — the static schema harvest (doc catalog), which never
* reaches a model — so that path degrades to {@link TYPESCRIPT_FLAVOR}. A
* mounted runtime whose language has no flavor entry fails loud, exactly as
* `requireCodeRuntime` rejects it at assembly: this keeps the table coupled to
* `SDK_RENDERERS` and never emits a wrong-language schema for a real runtime.
*/
function resolveFlavor(requireRuntime: () => CodeRuntime): RunCodeFlavor {
let runtime: CodeRuntime
try {
runtime = requireRuntime()
} catch {
// Reached only by the static schema harvest (doc catalog), which never
// feeds a model: either no runtime is mounted, or requireRuntime rejected
// a language with no renderer. Both degrade to the TS default here; a real
// assembly hits requireCodeRuntime's loud rejection before this runs.
function resolveFlavor(peekRuntime: () => CodeRuntime | undefined): RunCodeFlavor {
const runtime = peekRuntime()
if (runtime === undefined) {
// No runtime mounted: reached only by the doc-catalog schema harvest,
// which never feeds 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)}`)
}
@@ -287,6 +281,12 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue }
export interface RunCodeBridgeOptions {
/** Resolves `ctx.codeRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */
requireRuntime: () => CodeRuntime
/**
* Reads `ctx.codeRuntime` without throwing: `undefined` when none is
* mounted. Lets schema emission tell "no runtime" (the doc-catalog harvest,
* degrade to TS) apart from "unknown language" (fail loud).
*/
peekRuntime: () => CodeRuntime | undefined
/** The run's overlap cap for parallel-classified sub-calls (the registry passes its validated `maxParallelSubCalls`). */
maxParallel: number
/** Runs the contained `tools/code-dispatch-log` waterfall over one settled sub-dispatch (the registry's private invoker). */
@@ -305,7 +305,7 @@ export interface RunCodeBridgeOptions {
* @returns the registry-ready definition.
*/
export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridgeOptions): ToolDefinition {
const { requireRuntime, maxParallel, shapeDispatchLog } = options
const { requireRuntime, peekRuntime, maxParallel, shapeDispatchLog } = options
const definition = defineTool({
name: RUN_CODE_NAME,
// The description and `code` parameter description are placeholders here:
@@ -668,14 +668,14 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
// is the least invasive point that still emits the loaded runtime's language.
Object.defineProperty(definition, 'description', {
enumerable: true,
get: () => resolveFlavor(requireRuntime).description,
get: () => resolveFlavor(peekRuntime).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 },
code: { type: 'string', required: true, description: resolveFlavor(peekRuntime).codeDescription },
description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION },
}) as unknown as Record<string, unknown>,
})
+11 -8
View File
@@ -768,6 +768,7 @@ export class ToolRegistry extends Service {
? undefined
: createRunCodeTool(this, {
requireRuntime: () => this.requireCodeRuntime(),
peekRuntime: () => this.ctx.get('codeRuntime'),
maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls),
shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
})
@@ -778,9 +779,9 @@ export class ToolRegistry extends Service {
order: SDK_SECTION_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).
// `requireCodeRuntime` already validated the language is in the table,
// so the guard below is defense-in-depth against a caller that bypassed
// it (impossible under normal composition).
text: (context) => {
const runtime = this.requireCodeRuntime()
// Own-property read: a language like `toString`/`constructor` would
@@ -802,15 +803,17 @@ export class ToolRegistry extends Service {
*/
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
const view = this.view(scope)
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
if (this.mode === 'native') {
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
return { schemas, knownNames: [...view.knownNames] }
}
// Redundant with the per-getter resolveFlavor path (schemaOf's run_code
// description/parameters getters call requireCodeRuntime again): kept as a
// single explicit gate so a mode collapse rejects here regardless of
// whether any getter runs. The call is idempotent (ctx.get + Object.hasOwn).
// Validate the runtime language BEFORE projecting schemas: schemaOf reads
// run_code's language-aware description/parameters getters, whose own
// flavor-table guard would otherwise surface first. This keeps the
// renderer-table rejection the canonical assembly-time error for a
// language with no SDK renderer.
this.requireCodeRuntime()
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
if (this.mode === 'code') {
return {
schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
+21 -18
View File
@@ -21,22 +21,24 @@ import type { ToolSdkSchema } from './ts-types.ts'
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]``.
* Python hard keywords: reserved everywhere, so a tool or field named
* ``class`` or ``lambda`` is legal on the wire but not as an attribute
* (``tools.class`` would be a SyntaxError in the model program) and not as a
* class-syntax `TypedDict` field. Such a tool renders under subscript access
* and such an object degrades to ``dict[str, Any]`` — the model still reaches
* every tool and field without collisions.
* Soft keywords (``match``, ``case``, ``type``, ``_``) are deliberately
* ABSENT: they are only special in statement position, so ``match: str`` as a
* field and ``async def match(...)`` as a method are both legal, and including
* them would needlessly degrade common search/regex tool fields to
* ``dict[str, Any]``. Underscore-leading names are handled separately (dunders
* name-mangle or resolve on ``object`` before the proxy hook), not here.
*/
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',
'return', 'try', 'while', 'with', 'yield',
// 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.
@@ -310,13 +312,14 @@ function renderType(schema: unknown, className: string, state: RenderState): str
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>)
// A missing `properties` is an empty property map, exactly as the
// unified validator and the TS renderer read it — NOT an unknown
// shape. assertSupportedJsonSchema already rejected a non-object
// `properties` (degraded to `Any` above), so the only non-map case
// left is omission. The openness of the resulting empty object is
// decided below, so a closed empty object still declares an empty
// TypedDict rather than a permissive `dict[str, Any]`.
const entries = Object.entries((node.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
+21 -7
View File
@@ -373,13 +373,27 @@ describe('mode-aware wire contribution', () => {
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('resolves the run_code schema flavor lazily and fails loud on a language absent from the flavor table', async () => {
// The flavor getter reads the runtime directly (peekRuntime), so it — not
// requireCodeRuntime — owns the flavor-table guard. A language with no
// flavor entry throws when the schema is projected, keeping
// RUN_CODE_FLAVORS coupled to SDK_RENDERERS. Assembly's requireCodeRuntime
// rejects such a language earlier; this reaches the guard on its own.
const { ctx } = await setup({ mode: 'code', runtime: { language: 'ruby' } })
const definition = ctx.tools.get(RUN_CODE_NAME)
expect(() => definition?.description).toThrow(/no run_code schema flavor registered for runtime language "ruby"/)
})
it('degrades the run_code flavor to TypeScript when no runtime is mounted (doc-catalog schema harvest)', async () => {
// The tool-catalog generator boots the registry under `mode: code` and
// reads run_code's schema WITHOUT a runtime; peekRuntime returns undefined
// there, so the flavor getter degrades to the TS default rather than
// throwing (that harvest never feeds a model).
const { ctx } = await setup({ mode: 'code', runtime: false })
const definition = ctx.tools.get(RUN_CODE_NAME)
expect(definition?.description).toContain('Execute a TypeScript program')
const params = definition?.parameters as { properties: { code: { description: string } } }
expect(params.properties.code.description).toBe('The program: the body of an async TypeScript function.')
})
it("rejects the assembly when toolOrder names a native tool that mode 'code' no longer contributes", async () => {
@@ -260,6 +260,59 @@ describe('renderToolsSdkPy', () => {
expect(text).not.toContain('WeirdFieldsArgs')
})
it('keeps soft-keyword field names as TypedDict fields (match/case/type are only special in statement position)', () => {
const tool: ToolSdkSchema = {
name: 'search',
description: 'Soft keywords as fields.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
match: { type: 'string' },
case: { type: 'boolean' },
type: { type: 'string' },
},
required: ['match'],
},
output: { type: 'string' },
}
const text = renderToolsSdkPy([tool])
// The object keeps its shape rather than degrading to dict[str, Any].
expect(text).toContain('class SearchArgs(TypedDict):')
expect(text).toContain('match: str')
expect(text).toContain('case: NotRequired[bool]')
expect(text).toContain('type: NotRequired[str]')
expect(text).not.toContain('dict[str, Any]')
})
it('declares a closed empty object with omitted properties as an empty TypedDict, not dict[str, Any]', () => {
// `{ type: 'object', additionalProperties: false }` with no `properties`
// is a closed empty object — no key accepted — exactly as the validator
// and the TS renderer read it. It must not degrade to a permissive dict.
const tool: ToolSdkSchema = {
name: 'closed',
description: 'Closed empty object with omitted properties.',
parameters: {
type: 'object',
additionalProperties: false,
properties: { inner: { type: 'object', additionalProperties: false } },
required: ['inner'],
},
output: { type: 'string' },
}
const text = renderToolsSdkPy([tool])
expect(text).toMatch(/class ClosedArgsInner\(TypedDict\):\n pass/)
expect(text).toContain('inner: ClosedArgsInner')
expect(text).not.toContain('dict[str, Any]')
})
it('degrades an open object with omitted properties to dict[str, Any]', () => {
// An OPEN empty object (default additionalProperties) is any dict.
const type = jsonSchemaToPy({ type: 'object', properties: {} })
expect(type).toBe('dict[str, Any]')
expect(jsonSchemaToPy({ type: 'object' })).toBe('dict[str, Any]')
})
it('renders docstrings for descriptions and orders emissions lexicographically', () => {
const text = renderToolsSdkPy([bash, exotic])
expect(text).toContain('"""Run a shell command."""')