Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md
Chinesezjc cabeaed1eb refactor(tools): type the py-types render frame and tighten the note
Follow-ups from the bot's review of the trusted-after-validation revert:
- renderType's Frame now carries JsonSchemaNode (the root schema is asserted
  before any frame is built), dropping the `as Record<string, unknown>` casts,
  the `node.oneOf as unknown[]` cast, and the runtime `required` filter — the
  same typed-frame shape as the sibling ts-types renderer, so the "symmetric
  with ts-types" claim holds structurally, not just behaviorally.
- The language-dispatch note broadens the trusted-input argument to cover all
  real sources (first-party defineTool/raw registration and wire-derived plain
  JSON), and the zh side uses full-width punctuation per translation-rules.md.
py-types.ts stays at 100% per-file coverage.
2026-08-02 16:42:32 +08:00

6.6 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 its tools:sdk renderer — typescript → renderToolsSdk, python → renderToolsSdkPy. The tools:sdk section reads the loaded runtime's language and picks the renderer; requireCodeRuntime rejects a mode: code/both runtime whose language is absent from the table, naming the known languages.
  • RUN_CODE_FLAVORS (code-mode.ts) maps a language to its two model-facing run_code strings (tool description and the code parameter 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.

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 language config field on ToolRegistry. 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.ts to detect it. That would couple the tool layer to a concrete backend and force the protocol/backend PRs to land first. Runtime dispatch on language keeps 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 — a SDK_RENDERERS renderer and a RUN_CODE_FLAVORS entry — plus the renderer itself, 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 a python runtime cannot actually be exercised end to end until that backend ships, so this PR's coverage is unit-level (the renderer output and the dispatch/rejection paths) rather than a real Python run.