ptc.ts 38 KB

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