Merge origin/master: Code Mode composed with agent scopes

Master advanced 11 commits mid-merge (the Code Mode registry integration:
mode config, run_code, the tools:sdk section, the ACP run_code cards and
unified demos). The fusion makes Code Mode scope-aware end to end:

- wireSchemas(scope): the mode-aware wire contribution is computed from the
  CALLING SCOPE's visible set (scoped tools join, shadowing and restrictions
  apply) and feeds the {schemas, knownNames} provider protocol.
- knownNames under the mode collapse: a per-scope RESTRICTION is runtime
  state, so the universe stays pre-restriction (a restricted-away tool in
  toolOrder is a normal absence) — but the MODE collapse is deployment
  config, so under mode 'code' the universe is [run_code] and a toolOrder
  naming a native tool fails every assembly loud (master's tested decision,
  kept).
- The tools:sdk section renders per assembly CONTEXT: the SDK declares
  exactly the calling agent's callable set, using the section-text provider
  signature this branch already had.
- run_code bindings enumerate schemas(exec.agent) — a program can bind
  exactly what its prompt promised; sub-dispatches already threaded
  exec.agent through registry.execute, so scoped resolution and carriers
  flow unchanged.
- dsh-tools declares both sides' new deps (dsh-scope + dsh-session);
  lockfile and all generated catalogs/graphs/api-catalog regenerated.

Gates green on the merged tree: typecheck, lint, per-file 100% coverage
(2710 tests), snapshots (41), doc-sync, module graph, build, hygiene, demo
smoke.
This commit is contained in:
Tianyi Cui
2026-07-09 23:57:50 +08:00
60 changed files with 2645 additions and 135 deletions

View File

@@ -0,0 +1,322 @@
/**
* Code Mode: the `run_code` tool and its dispatch bridge. The model writes a
* TypeScript program; the bridge hands it to `ctx.codeRuntime` with one
* async binding per registered tool, serializes every binding call through a
* per-run queue onto `ToolRegistry.execute()` (so `tools/pre-execute` /
* `tools/post-execute` gate sub-calls exactly like native ones), logs each
* sub-dispatch as a `tool/code-dispatch` session event, and returns only the
* program's curated output. The registry itself decides WHEN this tool
* exists (its `mode` config); this module owns only the tool and the bridge.
*
* @module @deepseek-ai/dsh-tools/src/code-mode
*/
import { inspect } from 'node:util'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type {} from '@deepseek-ai/dsh-session'
import { defineTool } from './schema.ts'
import type { ToolDefinition, ToolRegistry } from './index.ts'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* One bridged sub-dispatch from a `run_code` program: the parent
* `run_code` call id, the deterministic sub-call id
* (`<parent>:code:<n>`), the tool `name` with its JSON-normalized
* `arguments` — the exact value dispatched, normalized BEFORE dispatch,
* so this append can never fail on payload shape — whether the sub-call
* errored, and a bounded `resultSummary` of its model-facing text.
* Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
* model context; persistence and UIs get every call. Appended inside the
* parent `run_code`'s execution (the bridge drains its queue before
* returning), so the turn-enclosure invariant holds by construction.
*/
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
}
}
/** The model-facing name of the Code Mode tool. */
export const RUN_CODE_NAME = 'run_code'
/** The `tools:sdk` section order: inside the 100–199 tool-guidance band, after per-tool guidance sections. */
export const SDK_SECTION_ORDER = 150
/**
* Thrown by `run_code` when the program run itself failed — a program
* exception, a budget expiry, an abort, or substrate death. Extends
* {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution
* pipeline converts it into a structured `isError` result whose text carries
* the failure kind plus the captured logs, so the model can self-correct.
*/
export class CodeRunFailedError extends HarnessError {
constructor(message: string) {
super(message, 'CODE_RUN_FAILED')
this.name = 'CodeRunFailedError'
}
}
/**
* Cap for a `tool/code-dispatch` event's `resultSummary`. A log-ergonomics
* constant, not config: the full result already flows to the program; the
* summary exists so log readers see what a sub-call returned at a glance.
*/
const SUMMARY_MAX_CHARS = 200
/** Bounded inspect for rendering a program's completion value into the model-facing text. */
const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
/** Join a result's text blocks; a non-text block becomes a placeholder (an MVP limitation, stated in the SDK instructions). */
function textOf(content: ContentBlock[]): string {
return content
.map((block) => {
switch (block.type) {
case 'text': return block.text
// ContentBlockMap is merge-extensible — future block kinds land here
// deliberately (no assertNever on merge-extensible unions).
default: return `[${block.type} content]`
}
})
.join('\n')
}
/** Bound a sub-call's model-facing text for the log event's `resultSummary`. */
function summarize(text: string): string {
return text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, SUMMARY_MAX_CHARS)}…` : text
}
/**
* JSON-normalize one binding call's argument into TWO independent parses of
* the same canonical text: `dispatched` goes to the tool, `logged` to the
* `tool/code-dispatch` event — identical by construction (the runtime's
* structured-clone boundary is wider than JSON; the session log accepts only
* JSON), and separate objects, so a tool mutating its args can neither
* desync the log from what was dispatched nor re-poison the append. A value
* that does not survive the round-trip (`undefined` — the log rejects it as
* event data — `BigInt`, a circular structure, a bare function) rejects that
* one call BEFORE dispatch with a model-correctable error: nothing ever
* executes unlogged.
*/
function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
if (value === undefined) {
throw new Error('tool arguments must be JSON-serializable (call the tool with an arguments object, e.g. `{}`)')
}
let text: string | undefined
try {
text = JSON.stringify(value)
} catch (error: unknown) {
throw new Error(`tool arguments must be JSON-serializable: ${error instanceof Error ? error.message : String(error)}`)
}
// JSON.stringify's lib type claims `string`, but a bare function or symbol
// root really yields `undefined` at runtime — the guard is live.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (text === undefined) throw new Error('tool arguments must be JSON-serializable (got a value JSON cannot represent)')
return { dispatched: JSON.parse(text) as unknown, logged: JSON.parse(text) as unknown }
}
/** Render the program's completion value for the model-facing result text (`''` when the program returned nothing). */
function renderValue(value: unknown): string {
if (value === undefined) return ''
return typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
}
/** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */
interface RunCodeMeta {
logs: CodeRunResult['logs']
dispatches: number
}
/** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */
function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
if (typeof meta !== 'object' || meta === null) return undefined
const m = meta as Record<string, unknown>
if (!Array.isArray(m.logs) || typeof m.dispatches !== 'number') return undefined
return m as unknown as RunCodeMeta
}
/**
* Build the `run_code` {@link ToolDefinition}: one required `code` parameter,
* executed through the dispatch bridge described in the module doc. The
* registry registers it under non-native modes.
* @param registry - the owning registry (sub-calls go through its `execute`,
* bindings cover its registered tools).
* @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud
* misconfiguration error (shared with the registry's assembly-time checks).
* @returns the registry-ready definition.
*/
export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime): ToolDefinition {
return defineTool({
name: RUN_CODE_NAME,
description:
'Execute a TypeScript program against the available tools. Write the BODY of an '
+ 'async function (erasable syntax only; top-level `await` and `return` work) and '
+ 'call tools as `await tools.name(args)` per the declarations in the system prompt. '
+ 'Only what you print or return comes back — curate it.',
parameters: {
code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
},
async execute(args, exec) {
const runtime = requireRuntime()
// The run-scoped abort: follows the outer signal in, and fires when the
// run settles for ANY reason, so an in-flight sub-dispatch is aborted
// (its executor kills on this signal) instead of orphaned, and
// queued-unstarted dispatches are abandoned.
const runController = new AbortController()
const onOuterAbort = (): void => { runController.abort(exec.signal?.reason) }
if (exec.signal?.aborted) onOuterAbort()
exec.signal?.addEventListener('abort', onOuterAbort, { once: true })
let dispatches = 0
// The per-run serialization queue: every binding call chains onto the
// tail, so even `Promise.all` executes the underlying tool calls one at
// a time in submission order (the tool contract carries no
// concurrency-safety metadata yet). The fold keeps the tail non-rejecting
// so one failed dispatch never poisons the chain.
let queue: Promise<void> = Promise.resolve()
const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
const turn = queue.then(() => {
if (runController.signal.aborted) {
throw new Error(`run_code run is over (${String(runController.signal.reason)}); tool call abandoned`)
}
return task()
})
queue = turn.then(() => undefined, () => undefined)
return turn
}
// Read through a call, not a bare property: the abort state genuinely
// changes across awaits, and a direct `.aborted` re-check after one
// would be narrowed away by control flow analysis.
const runOver = (): boolean => runController.signal.aborted
const binding = (name: string): CodeBindingFunction => async (rawArgs: unknown): Promise<unknown> => {
if (runOver()) {
throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} not dispatched`)
}
const normalized = jsonNormalizeArgs(rawArgs)
const outcome = await enqueue(async () => {
const n = ++dispatches
const subCallId = CallId(`${String(exec.callId)}:code:${n}`)
const result = await registry.execute({
callId: subCallId,
name,
arguments: normalized.dispatched,
...exec.agent ? { agent: exec.agent } : {},
signal: runController.signal,
})
const text = textOf(result.content)
// Sub-call `additionalContext` is deliberately DROPPED here: the
// loop's buffering (append after the step's tool/results) has no
// safe analogue from inside a running run_code — injecting now
// would break tool-call/result adjacency. Deferred until a real
// hook needs it through Code Mode.
exec.agent?.session.append('tool/code-dispatch', {
parentCallId: exec.callId,
subCallId,
name,
// The SIBLING parse of the dispatched value: byte-identical JSON,
// but a separate object — a tool mutating its args cannot desync
// this record from what it actually received.
arguments: normalized.logged,
isError: result.isError,
resultSummary: summarize(text),
})
return { text, isError: result.isError }
})
// A budget expiry or outer cancel that lands while this call was in
// flight already aborted the dispatch; stop the program now rather
// than hand it a result from a run that is over.
if (runOver()) {
throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} result discarded`)
}
// A failed tool call REJECTS — real code signals failure by throwing,
// so try/catch and Promise.all short-circuiting behave as models
// expect (the error text is the tool's model-facing result text).
if (outcome.isError) throw new Error(outcome.text)
return outcome.text
}
// Null-prototype + defineProperty, mirroring the worker-side namespace
// build: a registered tool named `__proto__` must become an ordinary
// own key (a plain-object assignment would hit the prototype setter,
// silently dropping the binding), and the runtime host resolves
// binding names as own properties only.
const functions: Record<string, CodeBindingFunction> = Object.create(null) as Record<string, CodeBindingFunction>
// Enumerate the CALLING AGENT's visible set (scoped tools join,
// restricted globals vanish) — the same view the SDK section declared,
// so a program can bind exactly what its prompt promised; sub-dispatch
// re-resolves per call through the same view (exec.agent threads down).
for (const schema of registry.schemas(exec.agent)) {
if (schema.name === RUN_CODE_NAME) continue
Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(schema.name) })
}
try {
let result: CodeRunResult
try {
result = await runtime.run({
program: args.code,
bindings: [{ global: 'tools', functions }],
signal: runController.signal,
})
} finally {
// Quiescence before returning, whether the runtime fulfilled or
// REJECTED (a backend that starts a binding call and then throws
// must not leak a live sub-dispatch past this settlement): fire
// the run-scoped abort (cancelling an in-flight sub-dispatch,
// abandoning queued ones), then await the queue's drain — an
// aborted sub-call still settles and logs its event INSIDE the
// open turn; nothing can append after we return. `queue` is the
// FOLDED tail (every link swallows its rejection into undefined),
// so this await cannot itself reject — an abandoned queued call
// can never mask the runtime's own failure, returned or thrown;
// rejections surface only on the per-call promises the program
// holds.
runController.abort('run_code settled')
await queue
}
if (result.error) {
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.map(entry => entry.text).join('\n')}` : ''
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
}
const rendered = renderValue(result.value)
const parts = [result.logs.map(entry => entry.text).join('\n'), rendered].filter(part => part.length > 0)
const meta: RunCodeMeta = { logs: result.logs, dispatches }
return {
content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }],
meta,
}
} finally {
exec.signal?.removeEventListener('abort', onOuterAbort)
}
},
// The program IS the title, the way command tools title their cards with
// the command: an execute-card's title is the one slot an ACP client
// always shows (Zed's execute cards render no body content and no raw
// input without a real terminal attached), so anywhere else the code
// would be invisible. Multi-line titles are the execute-card idiom —
// capable clients render them whole; others truncate to the first line
// and still hold the full program in rawInput.
presentCall: args => ({
card: 'generic',
title: args.code,
kind: 'execute',
rawInput: args.code,
}),
// Title omitted on the result: an update replaces only the fields it
// carries, so the pending card's program title persists through
// completion; the captured output rides as body content.
presentResult: (_args, result) => {
const meta = asRunCodeMeta(result.meta)
if (!meta) return undefined
const output = meta.logs.map(entry => entry.text).join('\n')
return {
card: 'generic',
...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {},
}
},
})
}

View File

@@ -6,17 +6,28 @@
* (inspect/replace the result, attach context) for sandbox, permission, and hook
* plugins to gate or transform a call.
*
* The registry also owns HOW its tools are presented to the model — its
* `mode` config: `'native'` (every tool as a wire function definition,
* today's behavior and the default), `'code'` (the wire carries exactly one
* tool, `run_code`, plus a generated TypeScript SDK prompt section), or
* `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and
* `ts-types.ts` (the SDK codegen); design in the Code Mode RFC.
*
* @module @deepseek-ai/dsh-tools
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { ToolCallView, ToolResultView } from './presentation.ts'
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
import { renderToolsSdk } from './ts-types.ts'
export {
defineTool,
@@ -41,6 +52,9 @@ export {
type StructuredScalar,
} from './json-schema.ts'
export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
// stays the single public surface for consumers (producers + the ACP bridge).
@@ -317,6 +331,28 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined {
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
}
/** How the registry presents its tools to the model (see {@link Config.mode}). */
export type ToolPresentationMode = 'native' | 'code' | 'both'
/** Plugin config: how the registered tools are presented to the model. */
export interface Config {
/**
* The presentation mode. `'native'` (the default) contributes every
* registered tool as a wire function definition — byte-for-byte today's
* behavior. `'code'` contributes exactly ONE wire tool, `run_code`, plus
* the generated `tools:sdk` prompt section declaring every other tool as a
* TypeScript API the program calls. `'both'` contributes every native
* definition AND `run_code` + the SDK section. Non-native modes require a
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
* or mismatched runtime rejects every prompt assembly with an actionable
* error (misconfiguration fails loud, before any model request). A
* configured `systemPrompt.toolOrder` naming native tools likewise rejects
* every assembly under `'code'` (those names are no longer contributed) —
* a deployment switching modes updates its order config or drops it.
*/
mode?: ToolPresentationMode
}
/**
* A per-scope restriction over the GLOBAL tool surface, registered via
* {@link ToolRegistry.restrict}. `allow` keeps only the listed global tools;
@@ -337,7 +373,9 @@ export interface ToolRestriction {
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
* loop executes calls through the `tools/pre-execute` → `tools/execute` →
* `tools/post-execute` pipeline. The registry contributes its schemas into the
* system-prompt assembly.
* system-prompt assembly — WHICH schemas is governed by its `mode` config
* (see {@link Config.mode}); under a non-native mode it also registers the
* `run_code` tool and the `tools:sdk` prompt section itself.
*
* Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a
* plain plugin context is GLOBAL (visible to every agent); one through a
@@ -346,23 +384,93 @@ export interface ToolRestriction {
* the same name for that agent (most-specific-wins; within one layer a
* duplicate name still throws). {@link restrict} masks the global layer per
* scope. One visibility function ({@link visible}) feeds prompt assembly,
* {@link get}, and {@link execute}, so what the model is shown, what a
* presenter renders, and what dispatches can never disagree.
* {@link get}, and {@link execute} — and, under a non-native mode, the SDK
* section and `run_code`'s bindings — so what the model is shown, what a
* presenter renders, what a program can call, and what dispatches can never
* disagree.
*/
export class ToolRegistry extends Service {
static inject = ['systemPrompt']
static Config: z<Config> = z.object({
mode: z.union(['native', 'code', 'both'] as const).default('native'),
})
private global = new Map<string, ToolDefinition>()
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
/** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */
private restrictions = new Map<ScopeKey, ToolRestriction[]>()
private readonly mode: ToolPresentationMode
constructor(ctx: Context) {
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'tools')
ctx.systemPrompt.tools(context => ({
schemas: this.schemas(context.scope),
knownNames: this.knownNames(context.scope),
}))
// The schema already defaulted an omitted mode; the ?? narrows the
// optional-input type for direct (non-Loader) construction in tests.
this.mode = config.mode ?? 'native'
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
if (this.mode !== 'native') {
this.register(createRunCodeTool(this, () => this.requireCodeRuntime()))
ctx.systemPrompt.section({
name: 'tools:sdk',
order: SDK_SECTION_ORDER,
// A lazy thunk over the live registry, per assembly CONTEXT:
// regenerated at each assembly over the CALLING SCOPE's visible set
// (scoped tools join, restricted globals vanish — the SDK declares
// exactly what that agent's programs can call), in lexicographic
// tool order, so an unchanged tool set renders byte-identical text
// (prefix-cache-friendly) and a mid-session registration surfaces
// exactly like a native-mode tool change.
text: (context) => {
this.requireCodeRuntime()
return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME))
},
})
}
}
/**
* The registry's contribution to the wire tool list, per {@link Config.mode},
* as ONE SCOPE sees it (scoped layer joins, shadowing and restrictions
* applied — {@link schemas}). Because `PromptAssembly.tools` is what the
* loop's request header snapshots, the mode's collapse is logged and
* reconstructable for free. Under a non-native mode this is also the loud
* misconfiguration gate: no usable code runtime → every assembly rejects
* before any model request.
*
* The `knownNames` universe distinguishes the two ways a tool can be off
* the wire: a per-scope RESTRICTION is runtime state, so `knownNames` stays
* pre-restriction and a restricted-away tool in `toolOrder` is a normal
* absence — while the MODE collapse is deployment config, so under
* `mode: 'code'` the universe is `[run_code]` and a `toolOrder` naming a
* native tool is dead configuration that fails every assembly loud.
*/
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
if (this.mode === 'native') return { schemas: this.schemas(scope), knownNames: this.knownNames(scope) }
this.requireCodeRuntime()
const all = this.schemas(scope)
if (this.mode === 'code') {
return { schemas: all.filter(schema => schema.name === RUN_CODE_NAME), knownNames: [RUN_CODE_NAME] }
}
return { schemas: all, knownNames: this.knownNames(scope) }
}
/**
* Resolve the code runtime or throw the actionable misconfiguration error.
* Read at use time (assembly / run_code execution), NOT via static
* `inject`: an inject entry would hold `ctx.tools` — and every tool plugin
* behind it — hostage to a code runtime existing even under `mode:
* 'native'` (the loop's optional-backend idiom, same as
* `sessionPersistence`).
*/
private requireCodeRuntime(): CodeRuntime {
const runtime = this.ctx.get('codeRuntime')
if (!runtime) {
throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`)
}
if (runtime.language !== 'typescript') {
throw new Error(`dsh-tools: mode "${this.mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`)
}
return runtime
}
/**

View File

@@ -0,0 +1,121 @@
/**
* Code Mode codegen: the pure projection from registered tool schemas to the
* TypeScript SDK text the model programs against (the `tools:sdk` prompt
* section). Sibling of `json-schema.ts` — `schemas()` (native function
* calling) and this module (the generated `declare const tools` surface) are
* two projections of the same store.
*
* TOTAL by design: {@link jsonSchemaToTs} maps the JSON-Schema subset the
* `defineTool` DSL emits and degrades every construct outside it (`$ref`,
* `oneOf`, `integer`, future MCP shapes, …) to `unknown` without ever
* throwing — codegen must never be the thing that fails an assembly.
* Deterministic: a fixed tool set renders byte-identical text (tools in
* lexicographic name order), so the section is prefix-cache-friendly.
*
* @module @deepseek-ai/dsh-tools/src/ts-types
*/
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
/** Property names that are valid bare TS identifiers; anything else is quoted. */
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
/** Render an object key: bare when it is a valid identifier, quoted otherwise (every name stays reachable, no aliasing). */
function renderKey(name: string): string {
return IDENTIFIER.test(name) ? name : JSON.stringify(name)
}
/** One `indent`-deep line prefix (two spaces per level). */
function pad(indent: number): string {
return ' '.repeat(indent)
}
/** A one-line JSDoc block for a schema `description`, or no lines when there is none. */
function docLines(description: unknown, indent: number): string[] {
if (typeof description !== 'string' || description.length === 0) return []
// Keep the doc a single-line comment per property: descriptions are prose
// (possibly with newlines); collapse whitespace so the rendered SDK stays
// stable and compact. A comment-closer inside the description is escaped so
// it cannot terminate the generated JSDoc early.
const collapsed = description.replace(/\s+/g, ' ').trim()
return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`]
}
/**
* Map one JSON-Schema node to a TypeScript type literal. Handles exactly the
* subset the `defineTool` DSL emits — `object` (`properties` + `required`),
* `string` (with `enum` → a literal union), `number`, `boolean`, `array`
* (`items`) — and returns `unknown` for anything else, without throwing.
* @param schema - the JSON-Schema node (any shape; hostile inputs degrade).
* @param indent - the indentation level for nested object members.
* @returns the TS type text (multi-line for objects with properties).
*/
export function jsonSchemaToTs(schema: unknown, indent = 0): string {
if (typeof schema !== 'object' || schema === null) return 'unknown'
const node = schema as Record<string, unknown>
switch (node.type) {
case 'string': {
if (Array.isArray(node.enum) && node.enum.length > 0 && node.enum.every(value => typeof value === 'string')) {
return node.enum.map(value => JSON.stringify(value)).join(' | ')
}
return 'string'
}
case 'number': return 'number'
case 'boolean': return 'boolean'
case 'array': {
const item = jsonSchemaToTs(node.items, indent)
// Parenthesize a union item type so `('a' | 'b')[]` parses as intended.
return item.includes('|') ? `(${item})[]` : `${item}[]`
}
case 'object': {
const properties = node.properties
if (typeof properties !== 'object' || properties === null) return 'Record<string, unknown>'
const entries = Object.entries(properties as Record<string, unknown>)
if (entries.length === 0) return 'Record<string, unknown>'
const required = new Set(Array.isArray(node.required) ? node.required.filter(name => typeof name === 'string') : [])
const lines: string[] = ['{']
for (const [name, prop] of entries) {
const description = typeof prop === 'object' && prop !== null ? (prop as Record<string, unknown>).description : undefined
lines.push(...docLines(description, indent + 1))
lines.push(`${pad(indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: ${jsonSchemaToTs(prop, indent + 1)};`)
}
lines.push(`${pad(indent)}}`)
return lines.join('\n')
}
default: return 'unknown'
}
}
/** The fixed model-facing usage contract rendered above the declarations (see the Code Mode RFC's "What the model sees"). */
const SDK_INSTRUCTIONS = `## Writing code for run_code
Pass \`run_code\` the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:
- Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.
- A FAILED tool call rejects with an \`Error\` carrying the tool's error text — \`try/catch\` it to handle and continue.
- Calls execute sequentially, even under \`Promise.all\`.
- Emit results with \`return\` and/or \`console.log(...)\`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
The available tools:`
/**
* Render the full `tools:sdk` prompt section: the fixed usage instructions
* plus one `declare const tools` interface covering every given tool.
* Deterministic — tools are emitted in lexicographic name order, so an
* unchanged tool set produces byte-identical text across assemblies.
* @param schemas - the tool schemas to declare (the caller excludes
* `run_code` itself).
* @returns the complete section text.
*/
export function renderToolsSdk(schemas: ToolSchema[]): string {
const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
const members: string[] = []
for (const schema of sorted) {
members.push(...docLines(schema.description, 1))
members.push(`${pad(1)}${renderKey(schema.name)}(args: ${jsonSchemaToTs(schema.parameters, 1)}): Promise<string>;`)
}
const declaration = members.length > 0
? `declare const tools: {\n${members.join('\n')}\n}`
: 'declare const tools: {}'
return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${declaration}\n\`\`\``
}