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

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 100–199 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
}

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
}

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\`\`\``
}