code-mode.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. /**
  2. * Code Mode: the `run_code` tool and its dispatch bridge. The model writes a
  3. * TypeScript program; the bridge hands it to `ctx.codeRuntime` with one
  4. * async binding per registered tool, serializes every binding call through a
  5. * per-run queue onto `ToolRegistry.execute()` (so `tools/pre-execute` /
  6. * `tools/post-execute` gate sub-calls exactly like native ones), logs each
  7. * sub-dispatch as a `tool/code-dispatch` session event, and returns only the
  8. * program's curated output. The registry itself decides WHEN this tool
  9. * exists (its `mode` config); this module owns only the tool and the bridge.
  10. *
  11. * @module @deepseek-ai/dsh-tools/src/code-mode
  12. */
  13. import { inspect } from 'node:util'
  14. import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
  15. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  16. import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
  17. import type {} from '@deepseek-ai/dsh-session'
  18. import { defineTool } from './schema.ts'
  19. import type { ToolDefinition, ToolRegistry } from './index.ts'
  20. declare module '@deepseek-ai/dsh-session' {
  21. interface SessionEventMap {
  22. /**
  23. * One bridged sub-dispatch from a `run_code` program: the parent
  24. * `run_code` call id, the deterministic sub-call id
  25. * (`<parent>:code:<n>`), the tool `name` with its JSON-normalized
  26. * `arguments` — the exact value dispatched, normalized BEFORE dispatch,
  27. * so this append can never fail on payload shape — whether the sub-call
  28. * errored, and a bounded `resultSummary` of its model-facing text.
  29. * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
  30. * model context; persistence and UIs get every call. Appended inside the
  31. * parent `run_code`'s execution (the bridge drains its queue before
  32. * returning), so the turn-enclosure invariant holds by construction.
  33. */
  34. 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
  35. }
  36. }
  37. /** The model-facing name of the Code Mode tool. */
  38. export const RUN_CODE_NAME = 'run_code'
  39. /** The `tools:sdk` section order: inside the 100–199 tool-guidance band, after per-tool guidance sections. */
  40. export const SDK_SECTION_ORDER = 150
  41. /**
  42. * Thrown by `run_code` when the program run itself failed — a program
  43. * exception, a budget expiry, an abort, or substrate death. Extends
  44. * {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution
  45. * pipeline converts it into a structured `isError` result whose text carries
  46. * the failure kind plus the captured logs, so the model can self-correct.
  47. */
  48. export class CodeRunFailedError extends HarnessError {
  49. constructor(message: string) {
  50. super(message, 'CODE_RUN_FAILED')
  51. this.name = 'CodeRunFailedError'
  52. }
  53. }
  54. /**
  55. * Cap for a `tool/code-dispatch` event's `resultSummary`. A log-ergonomics
  56. * constant, not config: the full result already flows to the program; the
  57. * summary exists so log readers see what a sub-call returned at a glance.
  58. */
  59. const SUMMARY_MAX_CHARS = 200
  60. /** Bounded inspect for rendering a program's completion value into the model-facing text. */
  61. const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
  62. /** Join a result's text blocks; a non-text block becomes a placeholder (an MVP limitation, stated in the SDK instructions). */
  63. function textOf(content: ContentBlock[]): string {
  64. return content
  65. .map((block) => {
  66. switch (block.type) {
  67. case 'text': return block.text
  68. // ContentBlockMap is merge-extensible — future block kinds land here
  69. // deliberately (no assertNever on merge-extensible unions).
  70. default: return `[${block.type} content]`
  71. }
  72. })
  73. .join('\n')
  74. }
  75. /** Bound a sub-call's model-facing text for the log event's `resultSummary`. */
  76. function summarize(text: string): string {
  77. return text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, SUMMARY_MAX_CHARS)}…` : text
  78. }
  79. /**
  80. * JSON-normalize one binding call's argument into TWO independent parses of
  81. * the same canonical text: `dispatched` goes to the tool, `logged` to the
  82. * `tool/code-dispatch` event — identical by construction (the runtime's
  83. * structured-clone boundary is wider than JSON; the session log accepts only
  84. * JSON), and separate objects, so a tool mutating its args can neither
  85. * desync the log from what was dispatched nor re-poison the append. A value
  86. * that does not survive the round-trip (`undefined` — the log rejects it as
  87. * event data — `BigInt`, a circular structure, a bare function) rejects that
  88. * one call BEFORE dispatch with a model-correctable error: nothing ever
  89. * executes unlogged.
  90. */
  91. function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
  92. if (value === undefined) {
  93. throw new Error('tool arguments must be JSON-serializable (call the tool with an arguments object, e.g. `{}`)')
  94. }
  95. let text: string | undefined
  96. try {
  97. text = JSON.stringify(value)
  98. } catch (error: unknown) {
  99. throw new Error(`tool arguments must be JSON-serializable: ${error instanceof Error ? error.message : String(error)}`)
  100. }
  101. // JSON.stringify's lib type claims `string`, but a bare function or symbol
  102. // root really yields `undefined` at runtime — the guard is live.
  103. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  104. if (text === undefined) throw new Error('tool arguments must be JSON-serializable (got a value JSON cannot represent)')
  105. return { dispatched: JSON.parse(text) as unknown, logged: JSON.parse(text) as unknown }
  106. }
  107. /** Render the program's completion value for the model-facing result text (`''` when the program returned nothing). */
  108. function renderValue(value: unknown): string {
  109. if (value === undefined) return ''
  110. return typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
  111. }
  112. /** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */
  113. interface RunCodeMeta {
  114. logs: CodeRunResult['logs']
  115. dispatches: number
  116. }
  117. /** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */
  118. function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
  119. if (typeof meta !== 'object' || meta === null) return undefined
  120. const m = meta as Record<string, unknown>
  121. if (!Array.isArray(m.logs) || typeof m.dispatches !== 'number') return undefined
  122. return m as unknown as RunCodeMeta
  123. }
  124. /**
  125. * Build the `run_code` {@link ToolDefinition}: one required `code` parameter,
  126. * executed through the dispatch bridge described in the module doc. The
  127. * registry registers it under non-native modes.
  128. * @param registry - the owning registry (sub-calls go through its `execute`,
  129. * bindings cover its registered tools).
  130. * @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud
  131. * misconfiguration error (shared with the registry's assembly-time checks).
  132. * @returns the registry-ready definition.
  133. */
  134. export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime): ToolDefinition {
  135. return defineTool({
  136. name: RUN_CODE_NAME,
  137. description:
  138. 'Execute a TypeScript program against the available tools. Write the BODY of an '
  139. + 'async function (erasable syntax only; top-level `await` and `return` work) and '
  140. + 'call tools as `await tools.name(args)` per the declarations in the system prompt. '
  141. + 'Only what you print or return comes back — curate it.',
  142. parameters: {
  143. code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
  144. },
  145. async execute(args, exec) {
  146. const runtime = requireRuntime()
  147. // The run-scoped abort: follows the outer signal in, and fires when the
  148. // run settles for ANY reason, so an in-flight sub-dispatch is aborted
  149. // (its executor kills on this signal) instead of orphaned, and
  150. // queued-unstarted dispatches are abandoned.
  151. const runController = new AbortController()
  152. const onOuterAbort = (): void => { runController.abort(exec.signal?.reason) }
  153. if (exec.signal?.aborted) onOuterAbort()
  154. exec.signal?.addEventListener('abort', onOuterAbort, { once: true })
  155. let dispatches = 0
  156. // The per-run serialization queue: every binding call chains onto the
  157. // tail, so even `Promise.all` executes the underlying tool calls one at
  158. // a time in submission order (the tool contract carries no
  159. // concurrency-safety metadata yet). The fold keeps the tail non-rejecting
  160. // so one failed dispatch never poisons the chain.
  161. let queue: Promise<void> = Promise.resolve()
  162. const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
  163. const turn = queue.then(() => {
  164. if (runController.signal.aborted) {
  165. throw new Error(`run_code run is over (${String(runController.signal.reason)}); tool call abandoned`)
  166. }
  167. return task()
  168. })
  169. queue = turn.then(() => undefined, () => undefined)
  170. return turn
  171. }
  172. // Read through a call, not a bare property: the abort state genuinely
  173. // changes across awaits, and a direct `.aborted` re-check after one
  174. // would be narrowed away by control flow analysis.
  175. const runOver = (): boolean => runController.signal.aborted
  176. const binding = (name: string): CodeBindingFunction => async (rawArgs: unknown): Promise<unknown> => {
  177. if (runOver()) {
  178. throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} not dispatched`)
  179. }
  180. const normalized = jsonNormalizeArgs(rawArgs)
  181. const outcome = await enqueue(async () => {
  182. const n = ++dispatches
  183. const subCallId = CallId(`${String(exec.callId)}:code:${n}`)
  184. const result = await registry.execute({
  185. callId: subCallId,
  186. name,
  187. arguments: normalized.dispatched,
  188. ...exec.agent ? { agent: exec.agent } : {},
  189. signal: runController.signal,
  190. })
  191. const text = textOf(result.content)
  192. // Sub-call `additionalContext` is deliberately DROPPED here: the
  193. // loop's buffering (append after the step's tool/results) has no
  194. // safe analogue from inside a running run_code — injecting now
  195. // would break tool-call/result adjacency. Deferred until a real
  196. // hook needs it through Code Mode.
  197. exec.agent?.session.append('tool/code-dispatch', {
  198. parentCallId: exec.callId,
  199. subCallId,
  200. name,
  201. // The SIBLING parse of the dispatched value: byte-identical JSON,
  202. // but a separate object — a tool mutating its args cannot desync
  203. // this record from what it actually received.
  204. arguments: normalized.logged,
  205. isError: result.isError,
  206. resultSummary: summarize(text),
  207. })
  208. return { text, isError: result.isError }
  209. })
  210. // A budget expiry or outer cancel that lands while this call was in
  211. // flight already aborted the dispatch; stop the program now rather
  212. // than hand it a result from a run that is over.
  213. if (runOver()) {
  214. throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} result discarded`)
  215. }
  216. // A failed tool call REJECTS — real code signals failure by throwing,
  217. // so try/catch and Promise.all short-circuiting behave as models
  218. // expect (the error text is the tool's model-facing result text).
  219. if (outcome.isError) throw new Error(outcome.text)
  220. return outcome.text
  221. }
  222. // Null-prototype + defineProperty, mirroring the worker-side namespace
  223. // build: a registered tool named `__proto__` must become an ordinary
  224. // own key (a plain-object assignment would hit the prototype setter,
  225. // silently dropping the binding), and the runtime host resolves
  226. // binding names as own properties only.
  227. const functions: Record<string, CodeBindingFunction> = Object.create(null) as Record<string, CodeBindingFunction>
  228. for (const schema of registry.schemas()) {
  229. if (schema.name === RUN_CODE_NAME) continue
  230. Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(schema.name) })
  231. }
  232. try {
  233. let result: CodeRunResult
  234. try {
  235. result = await runtime.run({
  236. program: args.code,
  237. bindings: [{ global: 'tools', functions }],
  238. signal: runController.signal,
  239. })
  240. } finally {
  241. // Quiescence before returning, whether the runtime fulfilled or
  242. // REJECTED (a backend that starts a binding call and then throws
  243. // must not leak a live sub-dispatch past this settlement): fire
  244. // the run-scoped abort (cancelling an in-flight sub-dispatch,
  245. // abandoning queued ones), then await the queue's drain — an
  246. // aborted sub-call still settles and logs its event INSIDE the
  247. // open turn; nothing can append after we return. `queue` is the
  248. // FOLDED tail (every link swallows its rejection into undefined),
  249. // so this await cannot itself reject — an abandoned queued call
  250. // can never mask the runtime's own failure, returned or thrown;
  251. // rejections surface only on the per-call promises the program
  252. // holds.
  253. runController.abort('run_code settled')
  254. await queue
  255. }
  256. if (result.error) {
  257. const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.map(entry => entry.text).join('\n')}` : ''
  258. throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
  259. }
  260. const rendered = renderValue(result.value)
  261. const parts = [result.logs.map(entry => entry.text).join('\n'), rendered].filter(part => part.length > 0)
  262. const meta: RunCodeMeta = { logs: result.logs, dispatches }
  263. return {
  264. content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }],
  265. meta,
  266. }
  267. } finally {
  268. exec.signal?.removeEventListener('abort', onOuterAbort)
  269. }
  270. },
  271. // The program IS the title, the way command tools title their cards with
  272. // the command: an execute-card's title is the one slot an ACP client
  273. // always shows (Zed's execute cards render no body content and no raw
  274. // input without a real terminal attached), so anywhere else the code
  275. // would be invisible. Multi-line titles are the execute-card idiom —
  276. // capable clients render them whole; others truncate to the first line
  277. // and still hold the full program in rawInput.
  278. presentCall: args => ({
  279. card: 'generic',
  280. title: args.code,
  281. kind: 'execute',
  282. rawInput: args.code,
  283. }),
  284. // Title omitted on the result: an update replaces only the fields it
  285. // carries, so the pending card's program title persists through
  286. // completion; the captured output rides as body content.
  287. presentResult: (_args, result) => {
  288. const meta = asRunCodeMeta(result.meta)
  289. if (!meta) return undefined
  290. const output = meta.logs.map(entry => entry.text).join('\n')
  291. return {
  292. card: 'generic',
  293. ...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {},
  294. }
  295. },
  296. })
  297. }