diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 9ff6974195..11767a300e 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: 1b19a080759fa8b21f0c1058d049b2c3f6cf64fe -README.zh.md: 07fc85e2fc51c0b528eb37f6e7599d72144db8fb +README.md: ba8310b0b378d27d228a6e551e4b917c33e78fe5 +README.zh.md: 56cc1637f673559fe5f3c7cdf36bec80b8906eaa diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 1b19a08075..ba8310b0b3 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -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 `:code:`, 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. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 07fc85e2fc..56cc1637f6 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -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 `:code:`,按提交顺序编号),并以一条携带完整模型可见 `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。 diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 22a660a8ef..3c8e8ca024 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -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, }) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 5f89fa23c6..22569faa6c 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -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), diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 80c1ee7dc5..49a01b5452 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -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) + // 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) // 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 diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index ca488738ce..3ded3a7755 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -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 () => { diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 93b9990ac3..b9a244594d 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -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."""')