The list-nesting cap guards against a tokenizer SyntaxError, which makes the text not Python. A long `A | B | …` union is valid at any length and only defeats CPython's compile-time C recursion (measured: 1,000 branches compile, 5,000 raise RecursionError); nothing compiles this block, and capping would retire the deep-chain tests pinning the walk's linear time. Records that boundary at the `oneOf` arm and in the Agent Note (both languages). Also documents that the context-free degrade marker reads the call's className rather than the frame's — frames propagate a derived name, so a per-frame read would declare classes the caller cannot receive — and pins that path with oneOf-of-objects and array-of-oneOf assertions.
9.0 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 — reading ctx.tools.schemas() under a runtime whose language has a renderer but no flavor entry hits it, and a test covers it. 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. Two further rules are Python-specific rather than consequences of the ordering. 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. 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.