Files
deepseek-harness/.agents/notes/implemented/feature/2026-06-15-code-mode.md
Tianyi Cui 26bfd37da0 Merge commit 'refs/codex-unblock/20260723/master' into worktree/pty-review-fixes
# Conflicts:
#	.agents/notes/implemented/feature/2026-06-30-interception-seams.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/tools.md
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/tools/README.md
#	packages/core/tools/src/index.ts
#	packages/core/tools/src/schema.ts
#	packages/core/tools/tests/tools.spec.ts
#	packages/pty/tool-pty/README.md
#	packages/pty/tool-pty/src/index.ts
#	packages/pty/tool-pty/src/render.ts
#	packages/tasks/tool-tasks/README.md
#	packages/tasks/tool-tasks/src/index.ts
2026-07-23 20:50:45 +08:00

24 KiB

Agent Note: Code Mode — the model writes TypeScript against the tool registry

Status: implemented

Problem

In the registry's native presentation, the agent loop advertises every visible capability as a JSON-schema function definition. ToolRegistry contributes its schemas to the system-prompt assembly, the assembly's tools land on the wire (and in the logged request header), the model invokes one tool-call block per step, and the loop dispatches each call through ctx.tools.execute() sequentially (parallel tool execution is an explicit open TODO in dsh-tools and docs/architecture.md), with every intermediate tool-result re-entering the model's context on the next request.

For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each round-trip drags the entire intermediate result back into context whether the model needs it or not.

Cloudflare's Code Mode proposes an alternative grounded in a simple observation: LLMs are better at writing code than at emitting tool calls, because they have seen millions of lines of real code and comparatively few contrived tool-calling traces. Instead of one tool call per step, the model writes a TypeScript program against a generated API over the tools, the program executes in a sandboxed runtime, and the model curates what comes back — only what it prints or returns — instead of every intermediate result.

Tool presentation belongs to the registry that owns tool visibility: implementing a second presentation as an after-the-fact waterfall transform would make correctness depend on listener order and fight reconstructable requests. The execution substrate is also part of the foundation rather than a placeholder: Node worker_threads provides a separate isolate, an empty environment, heap caps, and termination of a hot synchronous loop, while fitting the harness's existing trust model (§Trust posture).

Decision

Three decisions, each elaborated in its own section below:

  1. Code Mode is a first-class presentation mode of ToolRegistry (dsh-tools), selected by a validated mode config: 'native' (the default, contributing the visible capability schemas), 'code' (the registry contributes only its reserved run_code transport plus a generated SDK .d.ts in the system prompt), or 'both' (native schemas and the transport + SDK). The registry shapes its canonical contribution at the source; the cooperative prompt-assembly result remains authoritative, and the logged request header records exactly that returned presentation.
  2. Code execution is a capability seampackages/code-runtime/ contains the interface package @deepseek-ai/dsh-code-runtime, which owns ctx.codeRuntime (capability seams; consumer = dsh-tools, with core-consumes-a-seam precedent in agent-loopdsh-llm). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports { value, logs, error? }. Language and substrate are backend properties, so a future Python or container backend is another implementation package, not a redesign.
  3. The shipped implementation is @deepseek-ai/dsh-code-runtime-worker: one fresh Node worker thread per run, executing the model's TypeScript after type-strip, with bindings bridged over the message port, an empty environment, configurable heap/output/time caps, and hard termination. Its trust posture is bash-equivalent by design — no unsafe-acknowledgement flags — because the harness already ships dsh-bash-local, which executes arbitrary model-written shell commands with strictly more ambient authority.

This note owns Code Mode's presentation, composition, isolation, and settlement foundation. The later typed tool-return Agent Note owns the generated output map, canonical binding values, ToolCallError, and the lossless outer-output boundary.

The registry owns the mode

ToolRegistry gains a schemastery-validated config (static Config), its first: mode: 'native' | 'code' | 'both', default 'native'. A deployment flips it from cordis.yml (tools: { mode: code }) — no code edit, per the no-hardcoded-tunables convention.

Wire tool list. The registry contributes visible capabilities in 'native', only run_code in 'code', and both in 'both'. The final PromptAssembly.tools list is logged in the request header. run_code is a reserved presentation transport outside registration and restriction layers; direct prompt providers and the assembly waterfall remain responsible for their own contributions.

Interaction with toolOrder, stated up front: a configured systemPrompt.toolOrder naming native capabilities rejects every assembly under mode: 'code', because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using Code Mode updates its order config or drops it.

SDK prompt section. In 'code' and 'both', the lazy tools:sdk section in the tool-guidance order band renders TypeScript declarations plus fixed usage instructions for the scope's visible capabilities. It shares lookup and execution visibility, excludes run_code, and sorts tools lexicographically for byte-stable output.

Assembly ownership. run_code and tools:sdk enter the trusted system-prompt/assemble waterfall as normal assembly inputs. A scoped tools:sdk section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable Code Mode protocol when the deployment expects Code Mode to remain usable; no restoration pass overrides deliberate composition.

Codegen. jsonSchemaToTs() maps the defineTool JSON-Schema subset to TypeScript, carries schema descriptions into JSDoc, and degrades unsupported constructs to unknown. The SDK exposes tools as quoted object keys, supporting arbitrary names without aliases or collisions. Typing is advisory because the runtime strips types before execution.

The run_code tool and the dispatch bridge

Under 'code' and 'both' the registry owns run_code as a reserved presentation transport with one required parameter, { code: string }. It is represented by a normal ToolDefinition for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — tools/pre-execute → monotonic guards → tools/execute around dispatch → tools/post-execute → optional definition-owned finalizeContent → immutable tools/result notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its execute(args, exec):

  1. Build bindings. One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding snapshots lossless-JSON arguments, waits on the serialization queue, executes with a deterministic call id and the outer token as parent, defers returned contexts through the outer execution, and logs tool/code-dispatch. Success returns the tool's final canonical JSON value; failure becomes the program-visible ToolCallError. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline.
  2. Runs the program: ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal }). The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime.
  3. Settle after quiescence. When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured logs and the completion value as canonical output; the registry renders that value into durable tool/result.content, which the result card reads directly. A runtime failure becomes CodeRunFailedError; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after run_code settles.

Sub-call contexts are deferred through the parent. Injecting inside run_code would break parent call/result adjacency, so ToolRunContext.deferContext() collects every sub-result additionalContexts entry in dispatch order. The registry carries that array even when the program later throws, and the loop appends each entry only after the outer result and every sibling result in the step. An outer post-execute block discards tool-deferred entries and exposes only contexts explicitly attached by the blocking decision.

Concurrency is serialized. Each run owns a dispatch queue, so even Promise.all executes tool calls in submission order. Settlement abandons queued calls that have not started. Parallelism requires per-tool concurrency-safety metadata.

Presentation. run_code's render intent is decided here per the render-intent Agent Note: presentCall creates a generic card with kind: 'execute', the program text as its title, and the same program text as rawInput; run_code intentionally declares no presentResult, so ACP and TUI complete that card through their generic raw-content fallback using the final durable tool/result.content, including captured logs plus the returned value, failure, or post-policy spill preview. The program is the title because ACP execute cards reliably render that field while some clients omit body and raw-input content. This is not a terminal card: that card's semantics are "a shell command in a working directory", which a program is not. See the result-card completeness note.

Observability: tool/code-dispatch

Each sub-dispatch appends a log-only tool/code-dispatch event containing parent and child call ids, tool identity, normalized arguments, and result summary. It remains outside model history but available to persistence and UIs. Appends occur inside the open run_code turn. Direct executions without an agent still run but cannot log the event.

The code-runtime seam

packages/code-runtime/code-runtime/@deepseek-ai/dsh-code-runtime, depending only on cordis. An abstract CodeRuntime extends Service (super(ctx, 'codeRuntime')) plus the vocabulary:

  • CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }
  • CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<CodeJsonValue>>; errorClass?: { name: string; memberNameProperty: string } } — the runtime exposes each namespace as a global object of async functions inside the program; the optional descriptor asks the runtime to inject a real program-visible rejection class without teaching the seam consumer-specific names. CodeJsonValue is this dependency-light seam's structural lossless-JSON type, so binding arguments and resolutions cross the implementation's serialization boundary whole.
  • CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure } — program execution outcomes resolve as the error field. run() may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary.
  • CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string } — orthogonal outcomes reported independently per defensive patterns; a timed-out run is not an exception, an abort is not a timeout, a lossy completion is not an overflow, and a substrate exit is none of them.
  • Two readonly backend descriptors, informational not gating: language (what the program must be written in — 'typescript' for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and isolation ('worker-thread' for the shipped backend; 'process', 'container', … for future ones). dsh-tools requires language === 'typescript' in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as toolOrder violations (as when mode is non-native with no ctx.codeRuntime loaded at all).

Requests contain every runtime input; implementations own validated timeout and cap defaults. The registry looks up the optional runtime only when Code Mode is assembled, so native mode does not depend on one. Missing or language-incompatible runtimes fail loudly. Alternate substrates or languages can replace the implementation behind the same seam, paired with the appropriate SDK generator.

The worker-thread runtime

@deepseek-ai/dsh-code-runtime-worker, the second package of the packages/code-runtime/ group. Per run():

  1. Type-strip host-side with Node's built-in stripTypeScriptTypes (node:module; present across the repo's whole engines range, ^22.19.0 || >=24.0.0, and position-preserving, so runtime error line numbers match the model's source). Strip-only mode rejects non-erasable syntax (enum, namespaces) — that rejection returns as error.kind: 'exception' with Node's message, the SDK instructions say "erasable TypeScript only", and the model self-corrects like any other program error. A syntax-level failure never spawns a worker.
  2. Spawn one fresh Worker per run from the package's own bootstrap module: env: {} (truly empty — stronger than the scrubbed-env rule for spawned commands), resourceLimits from config, stdout/stderr captured into logs rather than inherited. No pooling and no cross-run state: a program's world dies with its worker, which keeps runs reconstructable from the log alone and makes state bleed unrepresentable.
  3. Execute in the bootstrap: the stripped program becomes the body of an AsyncFunction whose parameters are the binding globals, any consumer-declared rejection classes, and a capturing console shim, so top-level await and return work. Code Mode declares ToolCallError with member property toolName; the runtime materializes that real constructor without hardcoding tools. A lossless JSON completion crosses exactly; undefined remains absence, a lossy value is invalid-output, and an oversized outer result is output-limit rather than an inspected-string substitute.
  4. Bridge bindings over the message port: each binding function in the worker posts { id, global, name, args } and awaits the reply; the host validates the name against the request's bindings, invokes, and replies { id, ok, value } or { id, ok: false, message } (a host-side binding rejection becomes a program-side rejection). The worker-side namespace objects are built null-prototype via defineProperty, so a binding named __proto__, constructor, or toString is an ordinary own property, not a prototype collision. Unknown names, duplicate ids, and post-settlement messages are rejected or ignored — the port protocol assumes a hostile peer, because the peer runs model code.
  5. Enforce independent budgets. computeMs meters worker busy time, allowing slow awaited tools without excusing a hot loop. maxWallMs bounds total elapsed time, including unresolved waits. maxOutputBytes bounds only the combined serialized outer logs, completion, or diagnostic; intermediate binding values have no byte cap. Expiry, cancellation, and completion terminate the worker, and heap exits or outer overflow are explicit failures.
  6. Dispose to quiescence: the service's own disposal terminates in-flight workers and awaits their exits before resolving, per defensive patterns.

Trust posture

The worker runtime provides containment, not a security boundary: model code can reach Node APIs and has authority comparable to the bash tool. worker.terminate() stops the thread but not OS processes it spawned. Code Mode uses the same tools/pre-execute policy gate as bash and adds an empty environment, heap limits, a separate isolate, and hard termination of the program itself. Deployments that need a hard multi-tenant boundary need a container-class backend for both code and bash; the runtime's isolation descriptor lets them distinguish that backend.

What the model sees

The SDK instructs the model to write an async erasable-TypeScript body, call tools through await tools.name(args), catch rejected tool calls when needed, and return or log only the output that should re-enter context. Calls remain sequential even under Promise.all. The declaration prefix can be as large as native schemas, especially in 'both', but remains stable for provider caching.

Consequences

Deployments switching to 'code' must update any native-only toolOrder. Assembly listeners own the integrity of any rewritten protocol surface. Sub-dispatch remains serialized, while per-call contexts retain their source, envelope, and metadata through the outer result.

Testing

  • Worker runtime: Real-worker tests cover typed binding values and failures, every lossless JSON completion root, invalid and over-limit output, exact combined ledger boundaries, compute and wall budgets, hostile binding traffic, empty environment, and disposal to quiescence. A built-package test runs the worker entry under plain Node.
  • Registry integration: Tests cover code generation, all presentation modes, reserved-name and restriction rules, scoped visibility, authoritative assembly rewrites, toolOrder, runtime compatibility failures, full-pipeline sub-dispatch, parent-token correlation, serialization, cancellation and queue drain, JSON normalization, error propagation, log events, ordered context deferral across successful and failed programs, outer-block suppression, and HMR cleanup.
  • With-key e2e: A real model composes two bash calls in one program; another discovers nested workspace instructions through a Code Mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior.
  • Snapshot: The code-mode-turn, both-mode-turn, and code-mode-workspace-context fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards.

Alternatives considered

An add-on consumer plugin with zero core changes. Rejected because agent/request is call-config-only under reconstructable requests, while transforming an assembled tool list would have to undo toolOrder canonicalization without owning its config and would depend on listener order. Which tools the model is offered, and in which representation, is the registry's single concern: native schemas and the SDK are two projections of one visible store.

node:vm as the reference runtime, with hardening deferred. Rejected: node:vm is not isolation (prototype-chain escapes reach the host realm) and cannot interrupt a hot loop. A worker thread provides a separate isolate, empty environment, resourceLimits, and reliable terminate() at bash-equivalent trust, so the reference and production implementation are one package without an unsafe-acknowledgement ceremony.

Result elision / summarization over native tool-calling. Addresses only the context-bloat half of the problem: trimming old tool-results is cheap to add as a logged surface replacement under reconstructable requests, but still pays one model round-trip per call and cannot express loops, branches, or joins. Complementary, not competing; it can layer under Code Mode for residual native calls.

Parallel native dispatch in the loop. The other answer to round-trip cost; still valid future work (the open TODO), still blocked on concurrency-safety metadata, and still no composition — it parallelizes calls the model already decided on in one step. Code Mode's serialized-queue decision keeps the two compatible: when the metadata lands, both native parallel dispatch and per-tool binding parallelism unlock together.

Always-exclusive (Cloudflare-faithful, no mode). Rejected for this SDK's primary consumer: a coding agent's bread-and-butter single calls (bash, read, edit) are already ideal as native calls, and forcing every edit through a program taxes the common case. The mode config keeps the faithful form ('code') one line away without imposing it.

Per-tool visibility tiers (this tool native, that tool code-only). Deferred: it needs per-tool metadata and a presentation split that 'native' | 'code' | 'both' does not, and its design depends on evidence about how models split usage under 'both'.

Sanitized identifier aliases in the SDK (my-toolmy_tool, Cloudflare's approach). Rejected: quoted keys on a declare const make every name reachable with zero alias-collision logic; models handle tools["my-tool"](…) fine.

A REPL-style persistent kernel (state survives across run_code calls). Rejected for the MVP: cross-call state would be invisible to the session log, breaking the reconstructability guarantee that every request is a pure function of the log; fresh-per-run keeps it. A kernel-style backend remains expressible behind the seam later, with its own logging story.

Risks

The worker is not a hard security boundary. Deliberate and documented (§Trust posture): posture equals the existing bash tool, containment exceeds it, gating uses the same seams. Deployments needing more need a future isolation: 'container' backend — tracked as the seam's designed extension, not a TODO on this design.

stripTypeScriptTypes is marked experimental. It is the same engine (amaro/swc) behind Node's own native .ts execution, exposed as an API across this repo's whole engines range. Mitigations: the runtime's unit suite pins the behaviors relied on (position preservation, erasable-only rejection message shape loosely), the call sits behind one private function, and amaro/sucrase are drop-in replacements if the API shifts. The erasable-only subset is a model-facing contract line, and the error path is a working feedback loop, not a dead end.

Prompt cost of the SDK, especially under 'both'. The .d.ts can rival the native schemas it complements; 'both' carries two representations. Prefix stability + provider caching amortize per-session cost; the mode is per-deployment; the Agent Note makes no unconditional-savings claim. Measured guidance (when to prefer which mode) is explicitly post-ship learning.

Registry scope growth. dsh-tools absorbs codegen, a tool, a bridge, and an event. Contained by module boundaries inside the package (ts-types.ts, code-mode.ts beside schema.ts/json-schema.ts/presentation.ts) and by the seam: everything substrate-shaped lives behind ctx.codeRuntime.

Large lossless JSON values can exhaust memory. Tool bindings snapshot lossless JSON before dispatch and return canonical JSON resolutions whole. The runtime validates both sides of the worker port and applies no per-binding byte cap; structured-clone cost and process or worker memory are the practical bounds. The combined outer-output ledger for logs, the completion value, and a failure diagnostic is the only byte-capped boundary.

Serialized-only sub-dispatch. Promise.all gains no wall-clock parallelism yet, only fewer round-trips; models may over-expect. The instructions state it; lifting it is tied to the same concurrency-safety metadata the native parallel-dispatch TODO needs.

Budget metering reads the event loop, not a flag. Busy-time polling (eventLoopUtilization()) is coarser than an exact CPU meter — a budget expires up to one poll interval late — and its correctness claim ("a pending dispatch cannot pause it") is load-bearing against a hostile program. Both sides are unit-tested (hot loop with a pending decoy dispatch dies at computeMs; idle-on-slow-binding survives to maxWallMs), and the poll interval is an internal constant, not config — nothing a deployment could mis-tune into a bypass.