code-mode.ts 13 KB

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