The predicate is not the only reader of the engine's XID tables. camelCase
reads them through its split set and its head test, and the class name it
derives is emitted for EVERY tool -- including one the predicate rejected,
whose TypedDict is still declared and named. A tool named `zz-` + U+1E4D0
never reaches the skew in the predicate, since the `-` rejects it outright,
yet still emits `class Zz<U+1E4D0>xArgs`, which CPython 3.9.6 refuses the
same way. A backend PR executing "pin the predicate against tables for the
floor" literally would leave that path open, so the note and the docstring
now name all three read points.
Two corrections in the same paragraph. The failing direction is a character
added to XID_Start OR XID_Continue -- one added only to the latter passes
the trailing `\p{XID_Continue}*` in a tail position and fails identically.
And the safe direction routes a name to the subscript/`dict[str, Any]`
path: a rejected FIELD name degrades its whole enclosing object rather than
just itself, which the predicate's opening paragraph already said.
Also qualify the module header's "ONLY source" claim, which holds under
`mode: 'code'` but not `both`, where wireSchemas ships every native schema
alongside the SDK section; record the measured str.isidentifier()
equivalence (21 samples, zero divergence, Node 22.23.1 vs CPython 3.9.6)
where the versions it is relative to already live; and attribute the
`FInd` spelling in the ligature test to full case mapping rather than to
the NFKC step, which is the identity there.
Two tests. The fold-collision half of the childClassName fix: sibling joins
that are byte-distinct before NFKC and equal after, so `usedClassNames`
dedupes by raw bytes and the counter only sees the collision because the
join is normalized. And the argument-side oneOf-of-objects branch naming,
which reaches the same childClassName path the output side already pins.
13 KiB
Agent Note: Code Mode language dispatch and the Python SDK renderer
Status: implemented
English | 中文
Problem
Code Mode generated one SDK flavor: TypeScript. ToolRegistry hard-coded renderToolsSdk for the tools:sdk section and requireCodeRuntime rejected any ctx.codeRuntime.language !== 'typescript'. Adding a CPython backend means a program's source language is no longer fixed: the same visible tool registry must project a Python SDK when a Python runtime is loaded, and the model-facing run_code schema strings ("Execute a Python program …") must match the SDK section's language so the model never sees a TypeScript instruction over a Python runtime.
This is the tool-facing half of the multi-language Code Mode split; the code-runtime seam 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 the drift this guards against, not an input that exists — the two tables' key sets are identical today. Schema emission reads the runtime through peekRuntime() rather than requireRuntime(): undefined (no runtime mounted, the doc-catalog schema harvest that never reaches a model) 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 two table entries plus its renderer — no agent-loop or registry-structure change.
code-mode.ts depends only on the runtime seam (@deepseek-ai/dsh-code-runtime), never on a concrete backend; dispatch is by runtime.language at run time. The tool layer therefore lands independently of the protocol and backend PRs — it needs only the seam's language field, which is already on master.
The Python SDK renderer
py-types.ts renders the same unified tool-schema vocabulary jsonSchemaToTs covers, targeting Python: jsonSchemaToPy emits a type expression per JSON-schema node, and renderToolsSdkPy assembles named 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 seam (Trust TypeScript at typed same-process seams). 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 onToolRegistry. 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 two table entries — an SDK_RENDERERS entry and a RUN_CODE_FLAVORS entry — plus the renderer function the former points at, with no change to agent-loop or the registry structure. The two tables (SDK_RENDERERS, RUN_CODE_FLAVORS) must stay in step: a language present in one but not the other is a latent inconsistency the Object.hasOwn guards turn into a loud failure rather than a wrong-language prompt. Which of the two 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 lands and is testable on master ahead of the Python protocol and backend.
The cost is that the Python branch of both tables is unreachable on this base: CodeRuntime.language is set by the loaded backend, the only published backend is dsh-code-runtime-worker ('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 PR'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 PR 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. Three regexes 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 and head test. An interpreter older than the engine is the failing direction — a character added to XID_Start or XID_Continue in between is emitted and its tokenizer refuses the whole block — and it arrives by two independent paths. Through the predicate, a bare method or field name. Through camelCase, a class name, which is emitted for every tool including one the predicate rejected: zz- plus U+1E4D0 never reaches the predicate's skew, since the - rejects it outright, yet it still declares class Zz𞓐xArgs. The exposure window is exactly the characters added between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and pinning all three read points to tables for that floor — pinning the predicate alone leaves the class-name path 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.