code-mode.ts 16 KB

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