Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
16 KiB
Agent Note: Code Mode language dispatch and the Python SDK renderer
Status: implemented
English | 中文
Problem
Code Mode generated one SDK flavor: TypeScript. ToolRuntime 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 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 itstools:sdkrenderer —typescript → renderToolsSdk,python → renderToolsSdkPy. Thetools:sdksection reads the loaded runtime's language and picks the renderer;requireCodeRuntimerejects amode: code/bothruntime whose language is absent from the table, naming the known languages.RUN_CODE_FLAVORS(code-mode.ts) maps a language to its two model-facingrun_codestrings (tooldescriptionand thecodeparameter 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. The two guards differ in reachability: SDK_RENDERERS' in-callback guard is unreachable because requireCodeRuntime validated the same const table earlier in the same callback (it carries a /* v8 ignore */), while RUN_CODE_FLAVORS' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through run_code's language-aware getters, which the public schemas() reaches without passing requireCodeRuntime first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in SDK_RENDERERS but not RUN_CODE_FLAVORS is drift the shared CodeSdkLanguage satisfies pins reject at typecheck, so it is not an input either guard can see; what the guards still own is a mounted runtime reporting a language absent from both tables. Schema emission reads the runtime through peekRuntime() rather than requireRuntime(): undefined (no runtime mounted, reached by definition readers and schemas(), of which the doc-catalog harvest is the only shipped one and none of which feeds a model because assembly passes requireCodeRuntime first) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is three parallel edits — a CodeSdkLanguage member and the two table entries — plus its renderer and the prose that names the well-known values instead of deriving them (the seam's dsh-code-runtime README pair, its CodeRuntime.language JSDoc, and the docs/subsystems/code-runtime.md pair; this package's own README pair and its Config.mode JSDoc — no gate checks any of it), with no agent-loop or registry-structure change.
code-mode.ts depends only on the runtime Service Definition (@deepseek-ai/dsh-code-runtime), never on a concrete backend; dispatch is by runtime.language at run time. The tool layer is therefore independent of the Python protocol and backend — it needs only the service's language field.
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 TypedDicts 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. Lexicographic means one ordered member stream: a tool whose name is not a legal attribute is listed as a tools[name] comment in its sorted position rather than partitioned to the end, matching how the TypeScript flavor quotes an exotic key in place. That stream forces one thing directly — comment lines are not statements, so a tool set that emits no method at all still needs an explicit pass. Three further rules are Python-specific rather than consequences of the ordering. The usage contract states that the declarations are static stubs and arguments are plain dict/list values: a TypedDict reads as a constructible class, so a model that writes FooArgs(field=1) gets a NameError — TypeScript's interface is visibly a type, and the TS flavor's "runs type-stripped" clause already covers it. A description becomes the method's docstring emitted as the FIRST statement of its body: above the async def the first one would document the Tools class and the rest would be dead expressions, leaving every method undocumented. And a list[…] chain degrades to Any past MAX_LIST_NESTING, because CPython's tokenizer rejects a line with more than 200 open brackets and the block must stay parseable Python — the same reason docLines escapes quotes and backslashes. ts-types needs neither: TypeScript attaches a leading /** … */ to the member that follows it and bounds nesting nowhere in its grammar.
The standard that cap serves is grammatical validity, and the boundary is deliberate: a long A | B | … union is valid Python at any length and is left uncapped, even though CPython's compile() exhausts its C recursion walking the left-nested BinOp spine (measured on 3.9: 1,000 branches compile, 5,000 raise RecursionError). Nothing compiles this block — it is prompt text — so that limit costs nothing, whereas capping union length would retire the deep-chain tests that pin the walk's linear time and the class-name propagation cap. A future renderer that does need compilable output should flatten unions rather than truncate them.
renderType validates the whole schema once (assertSupportedJsonSchema) and then trusts it, wrapping the walk in one try/catch that degrades to Any — the same trusted-after-validation stance the sibling ts-types renderer takes at this typed same-process boundary (Trust TypeScript at typed same-process boundaries). It deliberately carries NO defenses against a schema whose accessors mutate between reads (post-validation cycles, TOCTOU on const/enum, self-referential functions): the input is a first-party registration (a defineTool literal or a raw registration) or a wire-derived plain JSON schema — the former is trusted per AGENTS.md, the latter is a JSON.parse product that physically cannot carry accessors, and renderType re-validates the whole tree on every call regardless — so such inputs are unreachable, and adding per-shape guards here would break symmetry with ts-types (which has none) for values the static interface forbids. jsonSchemaToPy(schema: unknown) accepts unknown and returns Any on a malformed schema — the Python counterpart of the TS flavor's unknown — but its contract is "degrade an unsupported schema", not "survive an adversarial mutating one".
Alternatives considered
- A
languageconfig field onToolRuntime. 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.tsto detect it. That would couple the tool layer to a concrete backend and force the protocol/backend PRs to land first. Runtime dispatch onlanguagekeeps 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.
Consequences
Adding a backend language is three parallel edits — a CodeSdkLanguage member, an SDK_RENDERERS entry, and a RUN_CODE_FLAVORS entry — plus the renderer function the second points at, with no change to agent-loop or the registry structure. The two tables (SDK_RENDERERS, RUN_CODE_FLAVORS) must stay in step, and that invariant is checked statically rather than left to review: both are satisfies-checked against that one union, so a language added to one and not the other fails typecheck. This is the mechanical form the drift risk deserves — the runtime Object.hasOwn guards would catch it too, but only once a backend reporting that language ships: at the Consumer's integration point rather than where the drift was introduced — and never while no second backend exists. The tables keep their Record<string, …> declared type because CodeRuntime.language is an unconstrained string; the union pins what the harness ships, the guards reject what a runtime reports. What stays outside that check is the prose that names the well-known values instead of deriving them: dsh-code-runtime's README pair, its CodeRuntime.language JSDoc, and the docs/subsystems/code-runtime.md pair at the seam, plus this package's own README pair and its Config.mode JSDoc. Earlier notes name the values as they stood at the time and are not on that list. Two separate reasons keep it ungated. Prose is not type-checked at all, wherever the union lives. And no type-level pin can stand in for it here: the Service Definition package must not import its Consumer's table, and CodeRuntime.language stays an unconstrained string by design, so moving the union into the Service Definition would not apply it either. A unit test pinning the two key sets equal was rejected in favor of this: it would buy the same check at the cost of a test-only export of two private tables, and would run later than the compiler does. Which of the two runtime failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because wireSchemas calls requireCodeRuntime before projecting, while the public schemas() reaches run_code's language-aware getters first and reports the missing flavor. The tool layer stays free of any concrete backend dependency, so it ships and is testable ahead of the Python protocol and backend.
The cost is that the Python branch of both tables is unreachable in the shipped tree: CodeRuntime.language is set by the loaded backend, the only published backend is dsh-code-runtime-worker-thread ('typescript'), and the registry reads the loaded runtime rather than a config field, so no assembled application can select renderToolsSdkPy or PYTHON_FLAVOR. The model-visible surface is therefore unchanged by this note's work until a backend reporting 'python' is published, and this change's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the change that publishes that backend, because only there does a real cordis.yml over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which docs/testing.md rejects as a substitute for the assembled application transcript.
Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly tools and ToolCallError are bound and that the declared TypedDict classes are not, so the backend must inject those two names — with ToolCallError.toolName populated per the seam's errorClass contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: requireCodeRuntime resolves ctx.codeRuntime separately at assembly and at run_code execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. The split is finer than those two points — run_code's description and parameters getters each call resolveFlavor(peekRuntime()), and schemaOf destructures both, so one projection reads the runtime twice; both reads are for run_code's own schema, since the getters are installed on that one definition and every other definition carries plain data properties. A reload between those two reads yields a single schema whose two halves name different languages. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists.
Third, that PR owns the CPython floor, and with it the renderer's Unicode-table skew. Four expressions read the running engine's tables (Node 22.23.1: Unicode 17.0) while the interpreter uses its own (CPython 3.9.6: 13.0.0): isBareIdentifier's IDENTIFIER, and camelCase's split set, head test, and toUpperCase(). An interpreter older than the engine is the failing direction — the engine emits a character its tokenizer refuses, taking the whole block down — and it arrives by three independent paths. Through the predicate, a bare method or field name carrying a character added between the two versions — to XID_Start at its head, or to XID_Continue in any tail position, the middle of a name included. Through camelCase's XID reads, a class name, which reaches emitted text whenever any object shape in the tool's schema declares a TypedDict, and which the predicate's verdict on the tool name does not gate: zz- plus U+1E4D0 never reaches the predicate's skew, since the - rejects it outright, yet it still declares class Zz𞓐xArgs. Through the case mapping, a class name derived from a tool the predicate accepted — a different table and a wider window than XID membership: U+019B is XID_Start and NFKC-stable, so async def ƛ compiles on 3.9.6, but Node uppercases it to U+A7DC (unassigned there; CPython's own .upper() is the identity) and class Args fails with invalid non-printable character U+A7DC. The exposure window is the characters and mappings that changed between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and pinning all four read points to tables for that floor — pinning the predicate alone leaves both class-name paths open. Nothing here can decide it: the floor does not exist yet, and a table pinned to a guess would be a deployment-varying constant with no configurability behind it. A second axis rides along with the floor and is not one of the four: the names and syntax the block would evaluate at definition time. TypedDict needs 3.8, the PEP 585 builtin generics dict[str, Any] and list[…] need 3.9, an A | B annotation 3.10, and NotRequired 3.11. These are not parse failures — the block parses on any version, which is the standard the MAX_LIST_NESTING cap serves — but definition-time evaluation failures, and nothing in the product evaluates this text. Recording them with the read points keeps "parseable on the supported range" from being read as "executable on it".