code-mode.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  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 { parse } from 'node:path'
  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 { snapshotJsonValue } from '@deepseek-ai/dsh-session'
  12. import type { JsonValue } from '@deepseek-ai/dsh-session'
  13. import { defineTool } from './schema.ts'
  14. import type { ToolDefinition, ToolRegistry } from './index.ts'
  15. declare module '@deepseek-ai/dsh-session' {
  16. interface SessionEventMap {
  17. /**
  18. * One bridged sub-dispatch from a `run_code` program: the parent
  19. * `run_code` call id, the deterministic sub-call id
  20. * (`<parent>:code:<n>`), the tool `name` with its JSON-normalized
  21. * `arguments` — the exact value dispatched, normalized BEFORE dispatch,
  22. * so this append can never fail on payload shape — whether the sub-call
  23. * errored, and a bounded `resultSummary` of its model-facing text. Before
  24. * bounding, occurrences of a non-root session workspace path are
  25. * normalized to `.` so host-specific absolute path lengths cannot change
  26. * the summary.
  27. * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
  28. * model context; persistence and UIs get every call. Appended inside the
  29. * parent `run_code`'s execution (the bridge drains its queue before
  30. * returning), so the turn-enclosure invariant holds by construction.
  31. */
  32. 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
  33. }
  34. }
  35. /** The model-facing name of the Code Mode tool. */
  36. export const RUN_CODE_NAME = 'run_code'
  37. /** The `tools:sdk` section order: inside the 100–199 tool-guidance band, after per-tool guidance sections. */
  38. export const SDK_SECTION_ORDER = 150
  39. /**
  40. * Thrown by `run_code` when the program run itself failed — a program
  41. * exception, a budget expiry, an abort, or substrate death. Extends
  42. * {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution
  43. * pipeline converts it into a structured `isError` result whose text carries
  44. * the failure kind plus the captured logs, so the model can self-correct.
  45. */
  46. export class CodeRunFailedError extends HarnessError {
  47. constructor(message: string) {
  48. super(message, 'CODE_RUN_FAILED')
  49. this.name = 'CodeRunFailedError'
  50. }
  51. }
  52. /**
  53. * Cap for a `tool/code-dispatch` event's `resultSummary`. A log-ergonomics
  54. * constant, not config: the full result already flows to the program; the
  55. * summary exists so log readers see what a sub-call returned at a glance.
  56. */
  57. const SUMMARY_MAX_CHARS = 200
  58. /** Join Native content for the bounded durable sub-dispatch summary; non-text blocks become diagnostic placeholders. */
  59. function textOf(content: ContentBlock[]): string {
  60. return content
  61. .map((block) => {
  62. switch (block.type) {
  63. case 'text': return block.text
  64. // ContentBlockMap is merge-extensible — future block kinds land here
  65. // deliberately (no assertNever on merge-extensible unions).
  66. default: return `[${block.type} content]`
  67. }
  68. })
  69. .join('\n')
  70. }
  71. /** Normalize workspace paths, then bound a sub-call's model-facing text for its durable log summary. */
  72. function summarize(text: string, cwd: string | undefined): string {
  73. const stableText = cwd === undefined || cwd === parse(cwd).root
  74. ? text
  75. : text.replaceAll(cwd, '.')
  76. return stableText.length > SUMMARY_MAX_CHARS ? `${stableText.slice(0, SUMMARY_MAX_CHARS)}…` : stableText
  77. }
  78. /**
  79. * Snapshot one binding call's argument as lossless JSON, then clone it into
  80. * independent dispatch/log values so a tool mutation cannot desynchronize the
  81. * durable event from what was called.
  82. */
  83. function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
  84. let snapshot: JsonValue | undefined
  85. try {
  86. snapshot = snapshotJsonValue(value) as JsonValue | undefined
  87. } catch (error: unknown) {
  88. throw new Error(`tool arguments must be lossless JSON: ${error instanceof Error ? error.message : String(error)}`)
  89. }
  90. if (snapshot === undefined) {
  91. throw new Error('tool arguments must be lossless JSON (call the tool with an arguments object, e.g. `{}`)')
  92. }
  93. return { dispatched: structuredClone(snapshot), logged: structuredClone(snapshot) }
  94. }
  95. /** Render one present program completion value for the model-facing result text. */
  96. function renderValue(value: JsonValue): string {
  97. return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
  98. }
  99. /** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */
  100. interface RunCodeMeta {
  101. logs: CodeRunResult['logs']
  102. }
  103. /** Canonical value returned by the outer Code Mode transport. */
  104. type RunCodeOutput = { logs: string[]; result?: JsonValue }
  105. /** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */
  106. function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
  107. if (typeof meta !== 'object' || meta === null) return undefined
  108. const m = meta as Record<string, unknown>
  109. if (!Array.isArray(m.logs) || !m.logs.every(log => typeof log === 'string')) return undefined
  110. return m as unknown as RunCodeMeta
  111. }
  112. /**
  113. * Build the `run_code` {@link ToolDefinition}: one required `code` parameter,
  114. * executed through the dispatch bridge described above. The
  115. * registry reserves it as presentation infrastructure under non-native modes,
  116. * outside the filterable global/scoped capability layers.
  117. * @param registry - the owning registry (sub-calls go through its `execute`,
  118. * bindings cover its registered tools).
  119. * @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud
  120. * misconfiguration error (shared with the registry's assembly-time checks).
  121. * @returns the registry-ready definition.
  122. */
  123. export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime): ToolDefinition {
  124. return defineTool({
  125. name: RUN_CODE_NAME,
  126. description:
  127. 'Execute a TypeScript program against the available tools. Write the BODY of an '
  128. + 'async function (erasable syntax only; top-level `await` and `return` work) and '
  129. + 'call tools as `await tools.name(args)` per the declarations in the system prompt. '
  130. + 'Only what you print or return comes back — curate it.',
  131. parameters: {
  132. code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
  133. },
  134. output: {
  135. schema: {
  136. type: 'object',
  137. additionalProperties: false,
  138. properties: {
  139. logs: { type: 'array', required: true, items: { type: 'string' } },
  140. result: { type: 'json' },
  141. },
  142. },
  143. render: (_args, value) => {
  144. const rendered = value.result === undefined ? '' : renderValue(value.result)
  145. const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0)
  146. return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }]
  147. },
  148. presentationMeta: (_args, value) => ({ logs: value.logs }),
  149. },
  150. async execute(args, exec): Promise<RunCodeOutput> {
  151. const runtime = requireRuntime()
  152. // The run-scoped abort: follows the outer signal in, and fires when the
  153. // run settles for ANY reason, so an in-flight sub-dispatch is aborted
  154. // (its executor kills on this signal) instead of orphaned, and
  155. // queued-unstarted dispatches are abandoned.
  156. const runController = new AbortController()
  157. const onOuterAbort = (): void => { runController.abort(exec.signal?.reason) }
  158. if (exec.signal?.aborted) onOuterAbort()
  159. exec.signal?.addEventListener('abort', onOuterAbort, { once: true })
  160. let dispatches = 0
  161. // The per-run serialization queue: every binding call chains onto the tail, so even
  162. // `Promise.all` executes the underlying tool calls one at a time in submission order (the
  163. // tool contract carries no concurrency-safety metadata yet).
  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<JsonValue> => {
  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. for (const context of result.additionalContexts ?? []) {
  196. exec.deferContext(context)
  197. }
  198. const text = textOf(result.content)
  199. exec.agent?.session.append('tool/code-dispatch', {
  200. parentCallId: exec.callId,
  201. subCallId,
  202. name,
  203. // The SIBLING parse of the dispatched value: byte-identical JSON,
  204. // but a separate object — a tool mutating its args cannot desync
  205. // this record from what it actually received.
  206. arguments: normalized.logged,
  207. isError: result.isError,
  208. resultSummary: summarize(text, exec.agent.session.header.cwd),
  209. })
  210. return result.isError
  211. ? { isError: true as const, message: result.error.message }
  212. : { isError: false as const, value: result.value }
  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. // The worker turns a binding rejection into ToolCallError and adds
  221. // only the binding name. Native content and internal error metadata
  222. // stay outside the program-facing failure contract.
  223. if (outcome.isError) throw new Error(outcome.message)
  224. return outcome.value
  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. // Abort sub-dispatches and drain the folded queue before closing the turn.
  250. // Binding failures remain observable through their individual promises.
  251. runController.abort('run_code settled')
  252. await queue
  253. }
  254. if (result.error) {
  255. const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : ''
  256. throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
  257. }
  258. return {
  259. logs: result.logs,
  260. ...result.value !== undefined ? { result: result.value } : {},
  261. }
  262. } finally {
  263. exec.signal?.removeEventListener('abort', onOuterAbort)
  264. }
  265. },
  266. // ACP execute cards use the program as their visible title.
  267. presentCall: args => ({
  268. card: 'generic',
  269. title: args.code,
  270. kind: 'execute',
  271. rawInput: args.code,
  272. }),
  273. // Title omitted on the result: an update replaces only the fields it
  274. // carries, so the pending card's program title persists through
  275. // completion; the captured output rides as body content.
  276. presentResult: (_args, result) => {
  277. const meta = asRunCodeMeta(result.meta)
  278. if (!meta) return undefined
  279. const output = meta.logs.join('\n')
  280. return {
  281. card: 'generic',
  282. ...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {},
  283. }
  284. },
  285. })
  286. }