feat: Code Mode — the registry's mode config, the SDK codegen, and the run_code bridge
The dsh-tools half of the Code Mode RFC (its fourth, final change): the registry gains its first config — mode: native | code | both — and OWNS how its tools reach the model. 'code' contributes exactly one wire tool, run_code, plus a lazy tools:sdk prompt section declaring every other tool as a generated TypeScript API (jsonSchemaToTs: total over the defineTool subset, unknown degradation, lexicographic byte-identical rendering); 'both' ships both representations; 'native' is byte-for-byte the old behavior. Non-native modes fail every assembly loudly without a typescript-language ctx.codeRuntime. run_code's dispatch bridge: JSON-normalizes each binding argument before dispatch (what dispatches is what the tool/code-dispatch event logs — the append can never fail on payload shape; BigInt/circulars reject that one call), serializes all program tool calls through a per-run queue (even Promise.all — no concurrency-safety metadata yet), routes every sub-call through tools/pre-execute → tools/post-execute (a deny rejects the program-side promise), drops sub-call additionalContext (no safe outlet mid-run; pinned), owns a run-scoped abort that follows the outer signal in and fires on settlement (in-flight sub-dispatch aborted, queued abandoned, queue drained before returning), and converts a failed run into CodeRunFailedError → a structured isError carrying kind + captured logs. tool/code-dispatch joins SessionEventMap by declaration merging (log-only; deriveMessages ignores it). The composed surface: the tools config forwards through agent-core and both app packages; examples/code-agent + demo:code run the worker runtime under mode code (keyless boot smoke + a with-key e2e proving the collapsed [run_code] header, the dispatch events, and the file the program wrote); two new snapshot scenarios (code-mode-turn, both-mode-turn) record the SDK section, collapsed header, dispatch events, and result card — each its own header-pinning class (the harness gains per-scenario config overlays and per-class pins). Catalogs, graphs, cookbook, hooks-bridge notes, and the RFC (moved to implemented/, restructured to decision-era headings) updated in the same change.
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 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: a `JSON.parse(JSON.stringify(…))`
|
||||
* round-trip, so the value dispatched to the tool and the value logged on the
|
||||
* `tool/code-dispatch` event are the same JSON value by construction (the
|
||||
* runtime's structured-clone boundary is wider than JSON; the session log
|
||||
* accepts only JSON). A value that does not survive (`BigInt`, a circular
|
||||
* structure, a bare function) rejects that one call with a model-correctable
|
||||
* error. `undefined` passes through — the tool's own schema validation
|
||||
* rejects it with its usual "must be an object" feedback.
|
||||
*/
|
||||
function jsonNormalizeArgs(value: unknown): unknown {
|
||||
if (value === undefined) return undefined
|
||||
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 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,
|
||||
...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,
|
||||
arguments: normalized,
|
||||
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
|
||||
}
|
||||
|
||||
const functions: Record<string, CodeBindingFunction> = {}
|
||||
for (const schema of registry.schemas()) {
|
||||
if (schema.name === RUN_CODE_NAME) continue
|
||||
functions[schema.name] = binding(schema.name)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runtime.run({
|
||||
program: args.code,
|
||||
bindings: [{ global: 'tools', functions }],
|
||||
signal: runController.signal,
|
||||
})
|
||||
// Quiescence before returning: 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.
|
||||
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)
|
||||
}
|
||||
},
|
||||
presentCall: args => ({ card: 'generic', title: 'Run code', kind: 'execute', rawInput: args.code }),
|
||||
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',
|
||||
title: `Run code (${meta.dispatches} tool call${meta.dispatches === 1 ? '' : 's'})`,
|
||||
...output.length > 0 ? { content: [{ type: 'text', text: output }] } : {},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user