code-mode.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  1. /**
  2. * Code Mode `run_code` transport. Programs call the registry's agent-visible
  3. * tools through nested executions scheduled under the native concurrency
  4. * contract; each sub-dispatch is logged for reconstruction, while only the
  5. * outer curated result enters model history.
  6. * @module @deepseek-ai/dsh-tools/src/code-mode
  7. */
  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, parameterSchemaSpecToJsonSchema } from './schema.ts'
  14. import { TOOL_REGISTRY_SCHEDULER } from './index.ts'
  15. import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts'
  16. declare module '@deepseek-ai/dsh-session' {
  17. interface SessionEventMap {
  18. /**
  19. * One sub-dispatch STARTING inside a `run_code` program: the parent
  20. * `run_code` call id, the deterministic sub-call id (`<parent>:code:<n>`,
  21. * numbered in submission order), and the tool `name` with its
  22. * JSON-normalized `arguments` — the exact value dispatched, normalized
  23. * BEFORE dispatch, so this append can never fail on payload shape.
  24. * Appended when the scheduler actually starts the call (not at
  25. * submission), so a start means the tool body pipeline was entered; a
  26. * call abandoned in the queue logs nothing. Log-only: `deriveMessages()`
  27. * ignores it; UIs use it for live per-sub-call running state and pair it
  28. * with `tool/code-dispatch` by `subCallId` (timing = the two events'
  29. * `time` fields).
  30. */
  31. 'tool/code-dispatch-start': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown }
  32. /**
  33. * One bridged sub-dispatch SETTLING: the pairing ids (matching the
  34. * `tool/code-dispatch-start` with the same `subCallId`), the tool `name`
  35. * with the same JSON-normalized `arguments`, and the sub-call's complete
  36. * model-facing outcome in `tool/result`'s own vocabulary
  37. * (`content` + `isError`), so UIs render a sub-call through the exact
  38. * code path that renders a native call. Every started sub-call settles
  39. * with exactly one of these (abort included: the aborted pipeline result
  40. * is an `isError` outcome).
  41. * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
  42. * model context; persistence and UIs get every call. Appended inside the
  43. * parent `run_code`'s execution (the bridge drains in-flight dispatches
  44. * before returning), so its execution-enclosure relation holds by
  45. * construction.
  46. */
  47. 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] }
  48. }
  49. }
  50. /** The model-facing name of the Code Mode tool. */
  51. export const RUN_CODE_NAME = 'run_code'
  52. /** The `tools:sdk` section order: inside the 100–199 tool-guidance band, after per-tool guidance sections. */
  53. export const SDK_SECTION_ORDER = 150
  54. /**
  55. * The language-specific `run_code` schema text: the tool `description` and its
  56. * `code` parameter description, kept together so a language's two model-facing
  57. * strings share one source of truth. Keyed by `CodeRuntime.language`, mirroring
  58. * `SDK_RENDERERS` in {@link ./index.ts}. The emitted flavor MUST match the
  59. * semantics the same language's SDK instructions promise, so the model never
  60. * receives a TypeScript-shaped schema beside a Python SDK (or vice versa).
  61. */
  62. interface RunCodeFlavor {
  63. /** The tool `description` the model sees for this language. */
  64. readonly description: string
  65. /** The `code` parameter's description for this language. */
  66. readonly codeDescription: string
  67. }
  68. /**
  69. * The TypeScript flavor: the historical default, and the fallback for a schema
  70. * read with no runtime mounted ({@link resolveFlavor} owns which readers reach
  71. * that). A real assembly always resolves a runtime first, so the model never
  72. * sees this fallback outside its own language.
  73. */
  74. const TYPESCRIPT_FLAVOR: RunCodeFlavor = {
  75. description:
  76. 'Execute a TypeScript program against the available tools. Write the BODY of an '
  77. + 'async function (erasable syntax only; top-level `await` and `return` work) and '
  78. + 'call tools as `await tools.name(args)` per the declarations in the system prompt. '
  79. + 'Only what you print or return comes back — curate it.',
  80. codeDescription: 'The program: the body of an async TypeScript function.',
  81. }
  82. /**
  83. * The Python flavor: the body of an async function, top-level `await` and
  84. * `return`, answer via `print` and/or the returned value, matching
  85. * {@link ./py-types.ts}'s SDK instructions.
  86. */
  87. const PYTHON_FLAVOR: RunCodeFlavor = {
  88. description:
  89. 'Execute a Python program against the available tools. Write the BODY of an '
  90. + 'async function (top-level `await` and `return` work) and call tools as '
  91. + '`await tools.name(args)` per the declarations in the system prompt. Answer '
  92. + 'with `print(...)` and/or `return <value>` — only that comes back, so curate it.',
  93. codeDescription: 'The program: the body of an async Python function.',
  94. }
  95. /**
  96. * The languages Code Mode ships a presentation for. Both per-language tables —
  97. * {@link RUN_CODE_FLAVORS} here and `SDK_RENDERERS` in {@link ./index.ts} — are
  98. * checked against this union with `satisfies`, so a language added to one and
  99. * not the other fails `typecheck` instead of waiting for a runtime that reports
  100. * it. The tables stay declared `Record<string, …>` because `CodeRuntime.language`
  101. * is an unconstrained `string`: this union pins what the harness ships, while the
  102. * `Object.hasOwn` guards reject what a mounted runtime may report.
  103. */
  104. export type CodeSdkLanguage = 'typescript' | 'python'
  105. /** Per-language `run_code` schema flavors (see {@link RunCodeFlavor}); one entry per {@link CodeSdkLanguage}. */
  106. const RUN_CODE_FLAVORS: Record<string, RunCodeFlavor> = {
  107. typescript: TYPESCRIPT_FLAVOR,
  108. python: PYTHON_FLAVOR,
  109. } satisfies Record<CodeSdkLanguage, RunCodeFlavor>
  110. /**
  111. * The `description` parameter's model-facing description: language-independent
  112. * (the UI label contract is the same for every runtime), shared between the
  113. * static spec and the language-aware `parameters` getter so the two emissions
  114. * can never drift.
  115. */
  116. const RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION
  117. = 'Clear, concise description of what this program does in active voice, '
  118. + '5-10 words (shown in the UI). Examples: "Count TODO markers across packages"; '
  119. + '"Read failing test and its fixture"; "Rename config key in every cordis.yml".'
  120. /**
  121. * Resolve the {@link RunCodeFlavor} for the loaded runtime's language, read at
  122. * schema-emission time so the model-visible `run_code` schema always matches
  123. * the SDK section's language. `peekRuntime` returns `undefined` only when no
  124. * runtime is mounted, which reaches this function through definition readers
  125. * and `schemas()` — the doc-catalog harvest is the only shipped one, and none
  126. * of them feeds a model, because `wireSchemas` calls `requireCodeRuntime`
  127. * before projecting — so that path degrades to {@link TYPESCRIPT_FLAVOR}. A
  128. * mounted runtime whose language has no flavor entry fails loud, exactly as
  129. * `requireCodeRuntime` rejects it at assembly. Keeping this table in step with
  130. * `SDK_RENDERERS` is the compiler's job ({@link CodeSdkLanguage}); what this
  131. * guard owns is the runtime-supplied language neither table knows, which never
  132. * yields a wrong-language schema for a real runtime.
  133. */
  134. function resolveFlavor(peekRuntime: () => CodeRuntime | undefined): RunCodeFlavor {
  135. const runtime = peekRuntime()
  136. if (runtime === undefined) {
  137. // No runtime mounted: reached by definition readers and `schemas()`, of
  138. // which the doc-catalog harvest is the only shipped one. None feeds a
  139. // model — `wireSchemas` calls `requireCodeRuntime` before projecting, so
  140. // the assembly path never arrives here. Degrade to the TS default.
  141. return TYPESCRIPT_FLAVOR
  142. }
  143. // Own-property read: a language like `toString`/`constructor` would otherwise
  144. // resolve an inherited Object.prototype member as a flavor.
  145. const flavor = RUN_CODE_FLAVORS[runtime.language]
  146. if (!Object.hasOwn(RUN_CODE_FLAVORS, runtime.language) || flavor === undefined) {
  147. const known = Object.keys(RUN_CODE_FLAVORS).map(name => JSON.stringify(name)).join(', ')
  148. throw new Error(`dsh-tools: no run_code schema flavor registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`)
  149. }
  150. return flavor
  151. }
  152. /**
  153. * Thrown by `run_code` when the program run itself failed — a program
  154. * exception, a budget expiry, an abort, or substrate death. Extends
  155. * {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution
  156. * pipeline converts it into a structured `isError` result whose text carries
  157. * the failure kind plus the captured logs, so the model can self-correct.
  158. */
  159. export class CodeRunFailedError extends HarnessError {
  160. constructor(message: string) {
  161. super(message, 'CODE_RUN_FAILED')
  162. this.name = 'CodeRunFailedError'
  163. }
  164. }
  165. /**
  166. * Snapshot one binding call's argument as lossless JSON, then snapshot that
  167. * detached value again so dispatch and logging stay independent without
  168. * reintroducing structured-clone's platform-specific nesting limit.
  169. */
  170. function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
  171. let snapshot: JsonValue | undefined
  172. try {
  173. snapshot = snapshotJsonValue(value) as JsonValue | undefined
  174. } catch (error: unknown) {
  175. throw new Error(`tool arguments must be lossless JSON: ${error instanceof Error ? error.message : String(error)}`)
  176. }
  177. if (snapshot === undefined) {
  178. throw new Error('tool arguments must be lossless JSON (call the tool with an arguments object, e.g. `{}`)')
  179. }
  180. const logged = snapshotJsonValue(snapshot)
  181. /* v8 ignore next -- snapshot is already a detached lossless JSON value. */
  182. if (logged === undefined) {
  183. throw new Error('tool arguments could not be detached for durable logging')
  184. }
  185. return { dispatched: snapshot, logged }
  186. }
  187. /** Two-space JSON presentation, matching the existing shallow `run_code` text contract. */
  188. const JSON_INDENT = ' '
  189. /**
  190. * ECMAScript caps `JSON.stringify`'s `space` string at ten characters. The
  191. * renderer also caps TOTAL indentation there, compacting deeper subtrees, so
  192. * formatted output remains linear in the canonical JSON size.
  193. */
  194. const MAX_JSON_INDENT_CHARS = 10
  195. /** A pending fragment in the iterative JSON presentation traversal. */
  196. type JsonRenderTask =
  197. | { kind: 'text'; text: string }
  198. | { kind: 'value'; value: JsonValue; depth: number; compact: boolean }
  199. /** Render one non-string JSON root without recursive traversal or unbounded indentation growth. */
  200. function renderJsonValue(value: Exclude<JsonValue, string>): string {
  201. const chunks: string[] = []
  202. const tasks: JsonRenderTask[] = [{ kind: 'value', value, depth: 0, compact: false }]
  203. for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
  204. if (task.kind === 'text') {
  205. chunks.push(task.text)
  206. continue
  207. }
  208. const current = task.value
  209. if (current === null || typeof current === 'boolean' || typeof current === 'number') {
  210. chunks.push(String(current))
  211. continue
  212. }
  213. if (typeof current === 'string') {
  214. chunks.push(JSON.stringify(current))
  215. continue
  216. }
  217. const compact = task.compact || (task.depth + 1) * JSON_INDENT.length > MAX_JSON_INDENT_CHARS
  218. const childDepth = task.depth + 1
  219. if (Array.isArray(current)) {
  220. chunks.push('[')
  221. if (current.length === 0) {
  222. chunks.push(']')
  223. continue
  224. }
  225. tasks.push({ kind: 'text', text: compact ? ']' : `\n${JSON_INDENT.repeat(task.depth)}]` })
  226. for (let index = current.length - 1; index >= 0; index--) {
  227. const item = current[index]
  228. /* v8 ignore next -- canonical JsonValue arrays are dense. */
  229. if (item === undefined) throw new Error('cannot render a sparse JSON array')
  230. tasks.push({ kind: 'value', value: item, depth: childDepth, compact })
  231. tasks.push({
  232. kind: 'text',
  233. text: compact
  234. ? index === 0 ? '' : ','
  235. : `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}`,
  236. })
  237. }
  238. continue
  239. }
  240. const keys = Object.keys(current)
  241. chunks.push('{')
  242. if (keys.length === 0) {
  243. chunks.push('}')
  244. continue
  245. }
  246. tasks.push({ kind: 'text', text: compact ? '}' : `\n${JSON_INDENT.repeat(task.depth)}}` })
  247. for (let index = keys.length - 1; index >= 0; index--) {
  248. const key = keys[index]
  249. /* v8 ignore next -- the loop is bounded by the captured key count. */
  250. if (key === undefined) throw new Error('cannot render a missing JSON object key')
  251. const item = current[key]
  252. /* v8 ignore next -- canonical JsonValue records contain no undefined properties. */
  253. if (item === undefined) throw new Error('cannot render an undefined JSON object property')
  254. tasks.push({ kind: 'value', value: item, depth: childDepth, compact })
  255. tasks.push({
  256. kind: 'text',
  257. text: compact
  258. ? `${index === 0 ? '' : ','}${JSON.stringify(key)}:`
  259. : `${index === 0 ? '\n' : ',\n'}${JSON_INDENT.repeat(childDepth)}${JSON.stringify(key)}: `,
  260. })
  261. }
  262. }
  263. return chunks.join('')
  264. }
  265. /** Render one present program completion value for the model-facing result text. */
  266. function renderValue(value: JsonValue): string {
  267. return typeof value === 'string' ? value : renderJsonValue(value)
  268. }
  269. /** Canonical value returned by the outer Code Mode transport. */
  270. type RunCodeOutput = { logs: string[]; result?: JsonValue }
  271. /**
  272. * Registry-private capabilities the bridge receives at construction — the
  273. * `requireRuntime` idiom: operations only the owning registry can mint stay
  274. * off its public service surface and flow here as closures instead.
  275. */
  276. export interface RunCodeBridgeOptions {
  277. /** Resolves `ctx.codeRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */
  278. requireRuntime: () => CodeRuntime
  279. /**
  280. * Reads `ctx.codeRuntime` without throwing: `undefined` when none is mounted.
  281. * Lets schema emission tell "no runtime" (degrade to TS; the readers that
  282. * reach it are {@link resolveFlavor}'s) apart from "unknown language" (fail
  283. * loud).
  284. */
  285. peekRuntime: () => CodeRuntime | undefined
  286. /** The run's overlap cap for parallel-classified sub-calls (the registry passes its validated `maxParallelSubCalls`). */
  287. maxParallel: number
  288. /** Runs the contained `tools/code-dispatch-log` waterfall over one settled sub-dispatch (the registry's private invoker). */
  289. shapeDispatchLog: (dispatch: CodeDispatchLog) => Promise<ContentBlock[]>
  290. }
  291. /**
  292. * Build the `run_code` {@link ToolDefinition}: required `code` and
  293. * `description` parameters, executed through the dispatch bridge described
  294. * above. The
  295. * registry reserves it as presentation infrastructure under non-native modes,
  296. * outside the filterable global/scoped capability layers.
  297. * @param registry - the owning registry (sub-calls go through its `execute`,
  298. * bindings cover its registered tools).
  299. * @param options - the registry-private capabilities described above.
  300. * @returns the registry-ready definition.
  301. */
  302. export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridgeOptions): ToolDefinition {
  303. const { requireRuntime, peekRuntime, maxParallel, shapeDispatchLog } = options
  304. const definition = defineTool({
  305. name: RUN_CODE_NAME,
  306. // The description and `code` parameter description are placeholders here:
  307. // the language-aware getters installed below replace both, resolving the
  308. // loaded runtime's flavor at schema-emission time so the schema the MODEL
  309. // sees matches the SDK section's language. Argument VALIDATION still keys
  310. // off this static spec (defineTool closes over it), which is language-
  311. // independent (one required string `code`).
  312. description: TYPESCRIPT_FLAVOR.description,
  313. parameters: {
  314. code: { type: 'string', required: true, description: TYPESCRIPT_FLAVOR.codeDescription },
  315. description: {
  316. type: 'string',
  317. required: true,
  318. description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION,
  319. },
  320. },
  321. output: {
  322. schema: {
  323. type: 'object',
  324. additionalProperties: false,
  325. properties: {
  326. logs: { type: 'array', required: true, items: { type: 'string' } },
  327. result: { type: 'json' },
  328. },
  329. },
  330. render: (_args, value) => {
  331. const rendered = value.result === undefined ? '' : renderValue(value.result)
  332. const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0)
  333. return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }]
  334. },
  335. },
  336. async execute(args, exec): Promise<RunCodeOutput> {
  337. if (args.description.trim().length === 0) {
  338. throw new Error('invalid description: expected a non-empty string')
  339. }
  340. const runtime = requireRuntime()
  341. // The run-scoped abort: follows the outer signal in, and fires when the
  342. // run settles for ANY reason, so an in-flight sub-dispatch is aborted
  343. // (its executor kills on this signal) instead of orphaned, and
  344. // queued-unstarted dispatches are abandoned.
  345. const runController = new AbortController()
  346. const onOuterAbort = (): void => { runController.abort(exec.signal.reason) }
  347. exec.signal.addEventListener('abort', onOuterAbort, { once: true })
  348. let dispatches = 0
  349. // The per-run scheduler, reusing the NATIVE concurrency contract through
  350. // the registry's staged view (the loop scheduler's own seam) — and the
  351. // native loop's SEQUENCING: every ordered stage (the dispatch-start
  352. // append, prepare = pre-execute/guards, finalize/finish = post-execute,
  353. // context deferral, the settle append) runs inside ONE driver lane, so
  354. // ordered policy stages never overlap each other and only the
  355. // around-dispatch/body stage runs concurrently. Starts are strictly
  356. // submission-ordered; results commit in submission order through the
  357. // head-of-line cursor. Consecutive parallel-classified calls overlap up
  358. // to maxParallel; an exclusive call waits for the pool to drain, runs
  359. // alone, and holds its barrier until its COMMIT (post-execute included)
  360. // completes, exactly like a native exclusive group. Classification is
  361. // re-read via executionMode() immediately before each start (a registry
  362. // mutation while queued can flip a call exclusive), matching the native
  363. // scheduler's lazy reclassification.
  364. interface PendingDispatch {
  365. /** Ordered stage: append the start event, await prepare (pre-execute/guards), launch the body into `flight`. */
  366. start(): Promise<void>
  367. classify(): 'parallel' | 'exclusive'
  368. abandon(): void
  369. /** Ordered stage: post-execute + context deferral + settle event, in submission order. */
  370. commit(): Promise<void>
  371. /** The launched around-dispatch/body stage; resolved until start() replaces it. */
  372. flight: Promise<void>
  373. /** True once the dispatch stage parked its outcome; the commit cursor waits on it. */
  374. settled: boolean
  375. /** The classification this entry started under; an exclusive holds its barrier through commit(). */
  376. mode?: 'parallel' | 'exclusive'
  377. }
  378. const pendingQueue: PendingDispatch[] = []
  379. const inFlight = new Set<Promise<void>>()
  380. /** Tracked settle-event side work (log shaping + append), drained at run settlement. */
  381. const logWork = new Set<Promise<void>>()
  382. const commitQueue: PendingDispatch[] = []
  383. let exclusiveActive = false
  384. let driving = false
  385. let driverRun: Promise<void> = Promise.resolve()
  386. let wake: (() => void) | undefined
  387. const wakeup = (): void => {
  388. const release = wake
  389. wake = undefined
  390. release?.()
  391. }
  392. /**
  393. * The single ordered lane. Each pass commits the head-of-line settled
  394. * dispatch (ordered post-execute), then starts the next queued entry if
  395. * its slot is free (ordered pre-execute), and otherwise sleeps until a
  396. * body settles or a new submission arrives. One run reaching the
  397. * empty-queues/empty-pool state is quiescence.
  398. */
  399. const drive = (): Promise<void> => {
  400. if (driving) return driverRun
  401. driving = true
  402. driverRun = (async () => {
  403. try {
  404. for (;;) {
  405. // Arm before inspecting state so a settle or submission landing
  406. // between the checks and the await below cannot be lost.
  407. const signal = new Promise<void>((resolve) => { wake = resolve })
  408. const commitHead = commitQueue[0]
  409. if (commitHead !== undefined && commitHead.settled) {
  410. commitQueue.shift()
  411. await commitHead.commit()
  412. // The barrier covers post-execute: later starts wait for the
  413. // exclusive call's full pipeline, as under the native loop.
  414. if (commitHead.mode === 'exclusive') exclusiveActive = false
  415. continue
  416. }
  417. const head = pendingQueue[0]
  418. if (head !== undefined) {
  419. if (runController.signal.aborted) {
  420. pendingQueue.shift()
  421. head.abandon()
  422. continue
  423. }
  424. // Reclassify at start time (fail-closed on registry changes).
  425. const mode = head.classify()
  426. const capacity = !exclusiveActive
  427. && (mode === 'exclusive' ? inFlight.size === 0 : inFlight.size < maxParallel)
  428. if (capacity) {
  429. if (mode === 'exclusive') exclusiveActive = true
  430. head.mode = mode
  431. pendingQueue.shift()
  432. // Joined before start() so the commit cursor sees submission
  433. // order; nothing commits it until `settled` flips.
  434. commitQueue.push(head)
  435. await head.start()
  436. const flight: Promise<void> = head.flight.finally(() => {
  437. inFlight.delete(flight)
  438. wakeup()
  439. })
  440. inFlight.add(flight)
  441. continue
  442. }
  443. }
  444. if (pendingQueue.length === 0 && commitQueue.length === 0 && inFlight.size === 0) return
  445. await signal
  446. }
  447. } finally {
  448. driving = false
  449. wake = undefined
  450. }
  451. })()
  452. return driverRun
  453. }
  454. /** Every dispatch settled AND committed; nothing can start (the run is aborted at call time). */
  455. const drainDispatches = async (): Promise<void> => {
  456. // The abort already fired: the driver abandons queued-unstarted
  457. // entries, awaits the live pool, and drains the ordered commit lane —
  458. // including a commit already in progress when the program returned.
  459. await drive()
  460. // Every settle's shaped append lands inside the open run_code turn
  461. // (tasks self-remove on settlement).
  462. while (logWork.size > 0) await Promise.allSettled([...logWork])
  463. }
  464. // Read through a call, not a bare property: the abort state genuinely
  465. // changes across awaits, and a direct `.aborted` re-check after one
  466. // would be narrowed away by control flow analysis.
  467. const runOver = (): boolean => runController.signal.aborted
  468. const binding = (name: string): CodeBindingFunction => async (rawArgs: unknown): Promise<JsonValue> => {
  469. if (runOver()) {
  470. throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} not dispatched`)
  471. }
  472. const normalized = jsonNormalizeArgs(rawArgs)
  473. const n = ++dispatches
  474. const subCallId = CallId(`${String(exec.callId)}:code:${n}`)
  475. const input = {
  476. callId: subCallId,
  477. name,
  478. arguments: normalized.dispatched,
  479. ...exec.agent ? { agent: exec.agent } : {},
  480. parent: exec.token,
  481. signal: runController.signal,
  482. }
  483. type DispatchOutcome = { isError: true; message: string } | { isError: false; value: JsonValue }
  484. const scheduler = registry[TOOL_REGISTRY_SCHEDULER]
  485. const outcome = await new Promise<DispatchOutcome>((resolve, reject) => {
  486. // Set by the dispatch stage (or start() for a pre-settled result): what commit() finalizes in submission order.
  487. let parked:
  488. | { kind: 'post-result' | 'final-result'; exec: ToolRunContext; result: ToolExecutionResult }
  489. | undefined
  490. const settle = (result: ToolExecutionResult): void => {
  491. // The program gets its value NOW: log shaping (e.g. a spill
  492. // backend) must never delay the binding or occupy a dispatch
  493. // slot. The shaped append is tracked side work; the run's
  494. // settlement drains logWork so every settle event still lands
  495. // inside the open turn (shapeDispatchLog is contained, so this
  496. // chain cannot reject).
  497. resolve(result.isError
  498. ? { isError: true, message: result.error.message }
  499. : { isError: false, value: result.value })
  500. const agent = exec.agent
  501. if (agent === undefined) return
  502. const task: Promise<void> = (async () => {
  503. // The durable copy may be reshaped (e.g. spilled to a preview +
  504. // locator) by the log-shaping waterfall; the program's value
  505. // and the model contract are untouched.
  506. const logged = await shapeDispatchLog({
  507. exec, agent, subCallId, name, isError: result.isError,
  508. // The registry deep-froze this projection at result
  509. // finalization; append snapshots the final copy again, so
  510. // the log stays detached.
  511. content: result.content,
  512. })
  513. agent.session.append('tool/code-dispatch', {
  514. parentCallId: exec.callId,
  515. subCallId,
  516. name,
  517. // The SIBLING parse of the dispatched value: byte-identical JSON,
  518. // but a separate object — a tool mutating its args cannot desync
  519. // this record from what it actually received.
  520. arguments: normalized.logged,
  521. isError: result.isError,
  522. content: logged,
  523. })
  524. })().finally(() => { logWork.delete(task) })
  525. logWork.add(task)
  526. }
  527. pendingQueue.push({
  528. flight: Promise.resolve(),
  529. settled: false,
  530. // Re-read per driver pass against the same agent view the SDK
  531. // declared; fail-closed exclusive when undeclared/invalid.
  532. classify: () => registry.executionMode(input).kind,
  533. abandon: () => {
  534. reject(new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} tool call abandoned`))
  535. },
  536. async start(): Promise<void> {
  537. exec.agent?.session.append('tool/code-dispatch-start', {
  538. parentCallId: exec.callId,
  539. subCallId,
  540. name,
  541. arguments: normalized.logged,
  542. })
  543. // Ordered prepare runs INSIDE the driver lane: the next entry's
  544. // pre-execute waits for this resolution, as under the native
  545. // scheduler. Only the launched body below overlaps.
  546. const prepared = await scheduler.prepare(input)
  547. if (prepared.kind === 'dispatch') {
  548. this.flight = scheduler.dispatch(prepared.exec).then((dispatchOutcome) => {
  549. parked = { kind: dispatchOutcome.kind, exec: prepared.exec, result: dispatchOutcome.result }
  550. this.settled = true
  551. })
  552. return
  553. }
  554. parked = { kind: prepared.kind, exec: prepared.exec, result: prepared.result }
  555. this.settled = true
  556. },
  557. async commit(): Promise<void> {
  558. /* v8 ignore next -- commit() runs only after `settled` flipped, which set parked. */
  559. if (parked === undefined) return
  560. const result = parked.kind === 'post-result'
  561. ? await scheduler.finalize(parked.exec, parked.result)
  562. : scheduler.finish(parked.exec, parked.result)
  563. for (const context of result.additionalContexts ?? []) {
  564. exec.deferContext(context)
  565. }
  566. // Like the context forwarding above, cross-boundary facts travel
  567. // on the nested result and the composite forwards them: only a
  568. // successful nested result can carry the terminal marker
  569. // (ToolExecutionFailure types it never), so a policy-converted
  570. // failure cannot stop the turn through a recovering program.
  571. if (result.concludesTurn) exec.concludeTurn()
  572. settle(result)
  573. // Backpressure on the shaped-append side channel: pending log
  574. // tasks (each retaining a full result while a slow backend
  575. // stores it) are bounded by the pool cap — beyond it the
  576. // ordered lane waits, so later sub-calls cannot start and
  577. // pending I/O/memory cannot grow without bound.
  578. while (logWork.size > maxParallel) await Promise.race(logWork)
  579. },
  580. })
  581. wakeup()
  582. void drive()
  583. })
  584. // A budget expiry or outer cancel that lands while this call was in
  585. // flight already aborted the dispatch; stop the program now rather
  586. // than hand it a result from a run that is over.
  587. if (runOver()) {
  588. throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} result discarded`)
  589. }
  590. // The worker turns a binding rejection into ToolCallError and adds
  591. // only the binding name. Native content and internal error metadata
  592. // stay outside the program-facing failure contract.
  593. if (outcome.isError) throw new Error(outcome.message)
  594. return outcome.value
  595. }
  596. // Null-prototype + defineProperty, mirroring the worker-side namespace
  597. // build: a registered tool named `__proto__` must become an ordinary
  598. // own key (a plain-object assignment would hit the prototype setter,
  599. // silently dropping the binding), and the runtime host resolves
  600. // binding names as own properties only.
  601. const functions: Record<string, CodeBindingFunction> = Object.create(null) as Record<string, CodeBindingFunction>
  602. // Enumerate the CALLING AGENT's visible set (scoped tools join,
  603. // restricted globals vanish) — the same view the SDK section declared,
  604. // so a program can bind exactly what its prompt promised; sub-dispatch
  605. // re-resolves per call through the same view (exec.agent threads down).
  606. for (const schema of registry.schemas(exec.agent)) {
  607. if (schema.name === RUN_CODE_NAME) continue
  608. Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(schema.name) })
  609. }
  610. try {
  611. let result: CodeRunResult
  612. try {
  613. result = await runtime.run({
  614. program: args.code,
  615. bindings: [{
  616. global: 'tools',
  617. functions,
  618. errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
  619. }],
  620. signal: runController.signal,
  621. })
  622. } finally {
  623. // Abort sub-dispatches and drain every in-flight dispatch before
  624. // closing the turn (queued-unstarted ones are abandoned unlogged).
  625. // Binding failures remain observable through their individual promises.
  626. runController.abort('run_code settled')
  627. await drainDispatches()
  628. }
  629. if (result.error) {
  630. const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : ''
  631. throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
  632. }
  633. return {
  634. logs: result.logs,
  635. ...result.value !== undefined ? { result: result.value } : {},
  636. }
  637. } finally {
  638. exec.signal.removeEventListener('abort', onOuterAbort)
  639. }
  640. },
  641. // The model-authored description is the call's always-visible UI label
  642. // (the bash `description` precedent); the program itself rides rawInput.
  643. presentCall: args => ({
  644. card: 'generic',
  645. title: args.description,
  646. kind: 'execute',
  647. rawInput: args.code,
  648. }),
  649. // Deliberately no presentResult: the generic surface fallback keeps this
  650. // title and reads durable result content without duplicating a large raw
  651. // result into the host view payload.
  652. })
  653. // Resolve the language flavor lazily, at the moment the registry projects the
  654. // schema (`schemaOf` destructures `description`/`parameters`). The definition
  655. // is minted once at registration, before a runtime is known; deferring here
  656. // is the least invasive point that still emits the loaded runtime's language.
  657. Object.defineProperty(definition, 'description', {
  658. enumerable: true,
  659. get: () => resolveFlavor(peekRuntime).description,
  660. })
  661. Object.defineProperty(definition, 'parameters', {
  662. enumerable: true,
  663. // Recompile through the same spec→schema projection defineTool used, so
  664. // the emitted shape can never drift from the validated one.
  665. get: () => parameterSchemaSpecToJsonSchema({
  666. code: { type: 'string', required: true, description: resolveFlavor(peekRuntime).codeDescription },
  667. description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION },
  668. }) as unknown as Record<string, unknown>,
  669. })
  670. return definition
  671. }