code-mode.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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 { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
  8. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  9. import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
  10. import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
  11. import type { JsonValue } 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
  18. * `run_code` call id, the deterministic sub-call id
  19. * (`<parent>:code:<n>`), the tool `name` with its JSON-normalized
  20. * `arguments` — the exact value dispatched, normalized BEFORE dispatch,
  21. * so this append can never fail on payload shape — and the sub-call's
  22. * complete model-facing outcome in `tool/result`'s own vocabulary
  23. * (`content` + `isError`), so UIs render a sub-call through the exact
  24. * code path that renders a native call.
  25. * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
  26. * model context; persistence and UIs get every call. Appended inside the
  27. * parent `run_code`'s execution (the bridge drains its queue before
  28. * returning), so the turn-enclosure invariant holds by construction.
  29. */
  30. 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] }
  31. }
  32. }
  33. /** The model-facing name of the Code Mode tool. */
  34. export const RUN_CODE_NAME = 'run_code'
  35. /** The `tools:sdk` section order: inside the 100–199 tool-guidance band, after per-tool guidance sections. */
  36. export const SDK_SECTION_ORDER = 150
  37. /**
  38. * Thrown by `run_code` when the program run itself failed — a program
  39. * exception, a budget expiry, an abort, or substrate death. Extends
  40. * {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution
  41. * pipeline converts it into a structured `isError` result whose text carries
  42. * the failure kind plus the captured logs, so the model can self-correct.
  43. */
  44. export class CodeRunFailedError extends HarnessError {
  45. constructor(message: string) {
  46. super(message, 'CODE_RUN_FAILED')
  47. this.name = 'CodeRunFailedError'
  48. }
  49. }
  50. /**
  51. * Snapshot one binding call's argument as lossless JSON, then snapshot that
  52. * detached value again so dispatch and logging stay independent without
  53. * reintroducing structured-clone's platform-specific nesting limit.
  54. */
  55. function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
  56. let snapshot: JsonValue | undefined
  57. try {
  58. snapshot = snapshotJsonValue(value) as JsonValue | undefined
  59. } catch (error: unknown) {
  60. throw new Error(`tool arguments must be lossless JSON: ${error instanceof Error ? error.message : String(error)}`)
  61. }
  62. if (snapshot === undefined) {
  63. throw new Error('tool arguments must be lossless JSON (call the tool with an arguments object, e.g. `{}`)')
  64. }
  65. const logged = snapshotJsonValue(snapshot)
  66. /* v8 ignore next -- snapshot is already a detached lossless JSON value. */
  67. if (logged === undefined) {
  68. throw new Error('tool arguments could not be detached for durable logging')
  69. }
  70. return { dispatched: snapshot, logged }
  71. }
  72. /** Two-space JSON presentation, matching the existing shallow `run_code` text contract. */
  73. const JSON_INDENT = ' '
  74. /**
  75. * ECMAScript caps `JSON.stringify`'s `space` string at ten characters. The
  76. * renderer also caps TOTAL indentation there, compacting deeper subtrees, so
  77. * formatted output remains linear in the canonical JSON size.
  78. */
  79. const MAX_JSON_INDENT_CHARS = 10
  80. /** A pending fragment in the iterative JSON presentation traversal. */
  81. type JsonRenderTask =
  82. | { kind: 'text'; text: string }
  83. | { kind: 'value'; value: JsonValue; depth: number; compact: boolean }
  84. /** Render one non-string JSON root without recursive traversal or unbounded indentation growth. */
  85. function renderJsonValue(value: Exclude<JsonValue, string>): string {
  86. const chunks: string[] = []
  87. const tasks: JsonRenderTask[] = [{ kind: 'value', value, depth: 0, compact: false }]
  88. for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
  89. if (task.kind === 'text') {
  90. chunks.push(task.text)
  91. continue
  92. }
  93. const current = task.value
  94. if (current === null || typeof current === 'boolean' || typeof current === 'number') {
  95. chunks.push(String(current))
  96. continue
  97. }
  98. if (typeof current === 'string') {
  99. chunks.push(JSON.stringify(current))
  100. continue
  101. }
  102. const compact = task.compact || (task.depth + 1) * JSON_INDENT.length > MAX_JSON_INDENT_CHARS
  103. const childDepth = task.depth + 1
  104. if (Array.isArray(current)) {
  105. chunks.push('[')
  106. if (current.length === 0) {
  107. chunks.push(']')
  108. continue
  109. }
  110. tasks.push({ kind: 'text', text: compact ? ']' : `\n${JSON_INDENT.repeat(task.depth)}]` })
  111. for (let index = current.length - 1; index >= 0; index--) {
  112. const item = current[index]
  113. /* v8 ignore next -- canonical JsonValue arrays are dense. */
  114. if (item === undefined) throw new Error('cannot render a sparse JSON array')
  115. tasks.push({ kind: 'value', value: item, depth: childDepth, compact })
  116. tasks.push({
  117. kind: 'text',
  118. text: compact
  119. ? index === 0 ? '' : ','
  120. : `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}`,
  121. })
  122. }
  123. continue
  124. }
  125. const keys = Object.keys(current)
  126. chunks.push('{')
  127. if (keys.length === 0) {
  128. chunks.push('}')
  129. continue
  130. }
  131. tasks.push({ kind: 'text', text: compact ? '}' : `\n${JSON_INDENT.repeat(task.depth)}}` })
  132. for (let index = keys.length - 1; index >= 0; index--) {
  133. const key = keys[index]
  134. /* v8 ignore next -- the loop is bounded by the captured key count. */
  135. if (key === undefined) throw new Error('cannot render a missing JSON object key')
  136. const item = current[key]
  137. /* v8 ignore next -- canonical JsonValue records contain no undefined properties. */
  138. if (item === undefined) throw new Error('cannot render an undefined JSON object property')
  139. tasks.push({ kind: 'value', value: item, depth: childDepth, compact })
  140. tasks.push({
  141. kind: 'text',
  142. text: compact
  143. ? `${index === 0 ? '' : ','}${JSON.stringify(key)}:`
  144. : `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}${JSON.stringify(key)}: `,
  145. })
  146. }
  147. }
  148. return chunks.join('')
  149. }
  150. /** Render one present program completion value for the model-facing result text. */
  151. function renderValue(value: JsonValue): string {
  152. return typeof value === 'string' ? value : renderJsonValue(value)
  153. }
  154. /** Canonical value returned by the outer Code Mode transport. */
  155. type RunCodeOutput = { logs: string[]; result?: JsonValue }
  156. /**
  157. * Build the `run_code` {@link ToolDefinition}: required `code` and
  158. * `description` parameters, executed through the dispatch bridge described
  159. * above. The
  160. * registry reserves it as presentation infrastructure under non-native modes,
  161. * outside the filterable global/scoped capability layers.
  162. * @param registry - the owning registry (sub-calls go through its `execute`,
  163. * bindings cover its registered tools).
  164. * @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud
  165. * misconfiguration error (shared with the registry's assembly-time checks).
  166. * @returns the registry-ready definition.
  167. */
  168. export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime): ToolDefinition {
  169. return defineTool({
  170. name: RUN_CODE_NAME,
  171. description:
  172. 'Execute a TypeScript program against the available tools. Write the BODY of an '
  173. + 'async function (erasable syntax only; top-level `await` and `return` work) and '
  174. + 'call tools as `await tools.name(args)` per the declarations in the system prompt. '
  175. + 'Only what you print or return comes back — curate it.',
  176. parameters: {
  177. code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
  178. description: {
  179. type: 'string',
  180. required: true,
  181. description: 'Clear, concise description of what this program does in active voice, '
  182. + '5-10 words (shown in the UI). Examples: "Count TODO markers across packages"; '
  183. + '"Read failing test and its fixture"; "Rename config key in every cordis.yml".',
  184. },
  185. },
  186. output: {
  187. schema: {
  188. type: 'object',
  189. additionalProperties: false,
  190. properties: {
  191. logs: { type: 'array', required: true, items: { type: 'string' } },
  192. result: { type: 'json' },
  193. },
  194. },
  195. render: (_args, value) => {
  196. const rendered = value.result === undefined ? '' : renderValue(value.result)
  197. const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0)
  198. return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }]
  199. },
  200. },
  201. async execute(args, exec): Promise<RunCodeOutput> {
  202. if (args.description.trim().length === 0) {
  203. throw new Error('invalid description: expected a non-empty string')
  204. }
  205. const runtime = requireRuntime()
  206. // The run-scoped abort: follows the outer signal in, and fires when the
  207. // run settles for ANY reason, so an in-flight sub-dispatch is aborted
  208. // (its executor kills on this signal) instead of orphaned, and
  209. // queued-unstarted dispatches are abandoned.
  210. const runController = new AbortController()
  211. const onOuterAbort = (): void => { runController.abort(exec.signal.reason) }
  212. exec.signal.addEventListener('abort', onOuterAbort, { once: true })
  213. let dispatches = 0
  214. // The per-run serialization queue: every binding call chains onto the tail, so even
  215. // `Promise.all` executes the underlying tool calls one at a time in submission order (the
  216. // tool contract carries no concurrency-safety metadata yet).
  217. let queue: Promise<void> = Promise.resolve()
  218. const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
  219. const turn = queue.then(() => {
  220. if (runController.signal.aborted) {
  221. throw new Error(`run_code run is over (${String(runController.signal.reason)}); tool call abandoned`)
  222. }
  223. return task()
  224. })
  225. queue = turn.then(() => undefined, () => undefined)
  226. return turn
  227. }
  228. // Read through a call, not a bare property: the abort state genuinely
  229. // changes across awaits, and a direct `.aborted` re-check after one
  230. // would be narrowed away by control flow analysis.
  231. const runOver = (): boolean => runController.signal.aborted
  232. const binding = (name: string): CodeBindingFunction => async (rawArgs: unknown): Promise<JsonValue> => {
  233. if (runOver()) {
  234. throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} not dispatched`)
  235. }
  236. const normalized = jsonNormalizeArgs(rawArgs)
  237. const outcome = await enqueue(async () => {
  238. const n = ++dispatches
  239. const subCallId = CallId(`${String(exec.callId)}:code:${n}`)
  240. const result = await registry.execute({
  241. callId: subCallId,
  242. name,
  243. arguments: normalized.dispatched,
  244. ...exec.agent ? { agent: exec.agent } : {},
  245. parent: exec.token,
  246. signal: runController.signal,
  247. })
  248. for (const context of result.additionalContexts ?? []) {
  249. exec.deferContext(context)
  250. }
  251. exec.agent?.session.append('tool/code-dispatch', {
  252. parentCallId: exec.callId,
  253. subCallId,
  254. name,
  255. // The SIBLING parse of the dispatched value: byte-identical JSON,
  256. // but a separate object — a tool mutating its args cannot desync
  257. // this record from what it actually received.
  258. arguments: normalized.logged,
  259. isError: result.isError,
  260. // The registry deep-froze this projection at result finalization;
  261. // append snapshots it again, so the log copy stays detached.
  262. content: result.content,
  263. })
  264. return result.isError
  265. ? { isError: true as const, message: result.error.message }
  266. : { isError: false as const, value: result.value }
  267. })
  268. // A budget expiry or outer cancel that lands while this call was in
  269. // flight already aborted the dispatch; stop the program now rather
  270. // than hand it a result from a run that is over.
  271. if (runOver()) {
  272. throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} result discarded`)
  273. }
  274. // The worker turns a binding rejection into ToolCallError and adds
  275. // only the binding name. Native content and internal error metadata
  276. // stay outside the program-facing failure contract.
  277. if (outcome.isError) throw new Error(outcome.message)
  278. return outcome.value
  279. }
  280. // Null-prototype + defineProperty, mirroring the worker-side namespace
  281. // build: a registered tool named `__proto__` must become an ordinary
  282. // own key (a plain-object assignment would hit the prototype setter,
  283. // silently dropping the binding), and the runtime host resolves
  284. // binding names as own properties only.
  285. const functions: Record<string, CodeBindingFunction> = Object.create(null) as Record<string, CodeBindingFunction>
  286. // Enumerate the CALLING AGENT's visible set (scoped tools join,
  287. // restricted globals vanish) — the same view the SDK section declared,
  288. // so a program can bind exactly what its prompt promised; sub-dispatch
  289. // re-resolves per call through the same view (exec.agent threads down).
  290. for (const schema of registry.schemas(exec.agent)) {
  291. if (schema.name === RUN_CODE_NAME) continue
  292. Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(schema.name) })
  293. }
  294. try {
  295. let result: CodeRunResult
  296. try {
  297. result = await runtime.run({
  298. program: args.code,
  299. bindings: [{
  300. global: 'tools',
  301. functions,
  302. errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
  303. }],
  304. signal: runController.signal,
  305. })
  306. } finally {
  307. // Abort sub-dispatches and drain the folded queue before closing the turn.
  308. // Binding failures remain observable through their individual promises.
  309. runController.abort('run_code settled')
  310. await queue
  311. }
  312. if (result.error) {
  313. const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : ''
  314. throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
  315. }
  316. return {
  317. logs: result.logs,
  318. ...result.value !== undefined ? { result: result.value } : {},
  319. }
  320. } finally {
  321. exec.signal.removeEventListener('abort', onOuterAbort)
  322. }
  323. },
  324. // The model-authored description is the call's always-visible UI label
  325. // (the bash `description` precedent); the program itself rides rawInput.
  326. presentCall: args => ({
  327. card: 'generic',
  328. title: args.description,
  329. kind: 'execute',
  330. rawInput: args.code,
  331. }),
  332. // Deliberately no presentResult: the generic surface fallback keeps this
  333. // title and reads durable result content without duplicating a large raw
  334. // result into the host view payload.
  335. })
  336. }