index.ts 87 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955
  1. /**
  2. * Tool registry, model presentation modes, and pre/guard/around/post/result
  3. * execution pipeline.
  4. * @module @deepseek-ai/dsh-tools
  5. */
  6. import { Context, Service } from '@deepseek-ai/cordis'
  7. import z from '@deepseek-ai/schemastery'
  8. import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
  9. import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
  10. import type { ToolCallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
  11. import { HarnessError } from '@deepseek-ai/dsh-llm'
  12. import type { Agent } from '@deepseek-ai/dsh-agent'
  13. import type { UserMessage } from '@deepseek-ai/dsh-session'
  14. import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
  15. import type { PromptSection, ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
  16. import type { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'
  17. import type {} from '@deepseek-ai/dsh-sandbox-policy'
  18. // Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
  19. // augmentation. The seam stays optional at runtime — see `serviceAsk`.
  20. import type {} from '@deepseek-ai/dsh-user-approval'
  21. import type { ToolCallView, ToolResultView } from './presentation.ts'
  22. import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts'
  23. import type { JsonSchemaNode } from './json-schema.ts'
  24. import { createRunCodeTool, RUN_CODE_NAME } from './ptc.ts'
  25. import type { PtcSdkLanguage } from './ptc.ts'
  26. import { renderToolsSdk } from './ts-types.ts'
  27. import type { ToolSdkSchema } from './ts-types.ts'
  28. import { renderToolsSdkPy } from './py-types.ts'
  29. /**
  30. * Language → SDK-section renderer. The registry looks up the loaded
  31. * `ctx.ptcRuntime.language` in this table when assembling the `tools:sdk`
  32. * section under a non-native mode; a runtime whose language is not a key
  33. * fails the assembly loudly (same idiom as `toolOrder` violations). Adding a
  34. * new backend language is three parallel edits — a {@link PtcSdkLanguage}
  35. * member, an entry here, and a `RUN_CODE_FLAVORS` entry in `ptc.ts` for
  36. * its `run_code` schema strings — plus the renderer function this table points
  37. * at. The `satisfies` clause pins this table's key set to that union, which
  38. * the flavor table is checked against too, so any of the three left out is a
  39. * typecheck failure. What no check reaches is the prose that names the values
  40. * instead of deriving them: the seam's `dsh-ptc-runtime` README pair, its
  41. * `PtcRuntime.language` JSDoc, and `docs/subsystems/ptc-runtime.md`
  42. * with its zh pair, plus this package's own README pair and the
  43. * {@link Config.mode} JSDoc.
  44. */
  45. /**
  46. * The model-facing statement of the `ptc` collapse. Names the consequence
  47. * (the call fails) and the route (inside the program), because a rule the
  48. * model can only discover by being denied is one it corrects too late.
  49. */
  50. const PTC_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program.`
  51. const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = {
  52. typescript: renderToolsSdk,
  53. python: renderToolsSdkPy,
  54. } satisfies Record<PtcSdkLanguage, (schemas: ToolSdkSchema[]) => string>
  55. export {
  56. defineTool,
  57. valueSchemaSpecToJsonSchema,
  58. parameterSchemaSpecToJsonSchema,
  59. validateArgs,
  60. ToolArgsError,
  61. type ValueSchemaAnnotations,
  62. type StringValueSchemaSpec,
  63. type NumberValueSchemaSpec,
  64. type IntegerValueSchemaSpec,
  65. type BooleanValueSchemaSpec,
  66. type NullValueSchemaSpec,
  67. type ArrayValueSchemaSpec,
  68. type ObjectValueSchemaSpec,
  69. type JsonValueSchemaSpec,
  70. type OneOfValueSchemaSpec,
  71. type ValueSchemaSpec,
  72. type ParameterPropertySpec,
  73. type ParameterSchemaSpec,
  74. type ParameterJsonSchema,
  75. type InferValue,
  76. type InferArgs,
  77. type DefineToolOptions,
  78. } from './schema.ts'
  79. export {
  80. assertSupportedJsonSchema,
  81. assertObjectJsonSchema,
  82. validateJsonSchemaValue,
  83. JsonSchemaError,
  84. type JsonSchemaNode,
  85. type ObjectJsonSchema,
  86. type JsonSchemaType,
  87. type JsonSchemaScalar,
  88. } from './json-schema.ts'
  89. export type { PtcDispatchEventData, PtcDispatchStartEventData } from './types.ts'
  90. export { CodeRunFailedError, RUN_CODE_NAME } from './ptc.ts'
  91. export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
  92. export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts'
  93. export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts'
  94. // The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
  95. // lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
  96. // stays the single public API for tool producers and UI adapters.
  97. export type {
  98. ToolCallKind,
  99. FileLocation,
  100. FileDiff,
  101. ReadFileLine,
  102. ToolCallView,
  103. GenericCallView,
  104. TerminalCallView,
  105. DiffCallView,
  106. ToolResultView,
  107. GenericResultView,
  108. TerminalResultView,
  109. DiffResultView,
  110. SearchResultView,
  111. SearchMatchesResultView,
  112. SearchPathsResultView,
  113. SearchFileMatches,
  114. SearchLineMatch,
  115. ReadResultView,
  116. WebResultView,
  117. WebSearchResultView,
  118. WebFetchResultView,
  119. WebSource,
  120. } from './presentation.ts'
  121. declare module '@deepseek-ai/cordis' {
  122. interface Context {
  123. tools: ToolRuntime
  124. }
  125. interface Events {
  126. /**
  127. * Allow, deny, cancel, or ask before dispatch. `next()` delegates to allow;
  128. * `cancel` selects the canonical pre-dispatch cancellation result, and missing
  129. * approval support turns `ask` into denial. Async gates must observe
  130. * `exec.signal`; the registry rechecks cancellation after they settle but
  131. * never abandons their promise.
  132. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
  133. * @param exec - the pending call (name, parsed arguments, caller agent).
  134. * @mode waterfall
  135. */
  136. 'tools/pre-execute'(this: Scoped<ToolRuntime>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
  137. /**
  138. * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns
  139. * a normalized result; wrappers may change only `exec.signal`, while call
  140. * identity remains immutable. The registry re-fuses the original caller
  141. * signal before the body, so replacement cannot detach caller cancellation;
  142. * wrappers must still restore their signal and reach quiescence.
  143. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
  144. * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
  145. * @mode waterfall
  146. */
  147. 'tools/execute'(this: Scoped<ToolRuntime>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
  148. /**
  149. * Accept, replace, enrich, or block a normalized dispatch result. `next()`
  150. * accepts it unchanged; thrown tools still reach this waterfall as errors. Async
  151. * listeners must observe `exec.signal`; after they settle, caller
  152. * cancellation replaces only a successful accepted outcome with the code
  153. * selected by whether the tool body was invoked.
  154. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
  155. * @param exec - the call that just ran (name, parsed arguments, caller agent).
  156. * @param result - the dispatch outcome a listener may accept, replace, or block.
  157. * @mode waterfall
  158. */
  159. 'tools/post-execute'(this: Scoped<ToolRuntime>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
  160. /**
  161. * Allow a listener to replace content in the DURABLE LOG COPY of one
  162. * `run_code` sub-dispatch outcome before the bridge appends its
  163. * `tool/ptc-dispatch` event. `next()` keeps the
  164. * content unchanged; a listener may return replacement blocks (e.g. the
  165. * spill policy's preview + locator for an oversized text result). Only the
  166. * logged copy is affected — the program already received the complete
  167. * value, and the model sees neither. A throwing listener is contained:
  168. * the bridge falls back to logging the original settled content.
  169. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches.
  170. * @param dispatch - the parent execution, sub-call identity, and the settled content to log.
  171. * @mode waterfall
  172. */
  173. 'tools/ptc-dispatch-log'(this: Scoped<ToolRuntime>, dispatch: PtcDispatchLog, next: () => Promise<ContentBlock[]>): Promise<ContentBlock[]>
  174. /**
  175. * Observe the frozen, lossless-JSON final outcome. Listener failures are contained.
  176. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.
  177. * @param exec - the execution object that traversed the pipeline.
  178. * @param result - a deep-frozen snapshot of the final returned result.
  179. * @mode emit
  180. */
  181. 'tools/result'(this: Scoped<ToolRuntime>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined
  182. /**
  183. * A tool was registered or unregistered, or a scoped restriction changed
  184. * (the available tool set changed — possibly for one scope only). An
  185. * UNFILTERED registry-subject notification, deliberately not scope-filtered
  186. * dispatch: a global change concerns every agent's next assembly, so a
  187. * scoped listener subscribing here sees every change, not just its own
  188. * scope's.
  189. * @mode emit
  190. */
  191. 'tools/change'(): void
  192. }
  193. }
  194. /** Tool-owned canonical output contract used after the body returns a JSON value. */
  195. export interface ToolOutputDefinition {
  196. /** Raw supported JSON Schema enforced against every successful canonical value. */
  197. readonly schema: JsonSchemaNode
  198. /** Pure projection from validated arguments and value to Native/model content. */
  199. render(args: unknown, value: JsonValue): ContentBlock[]
  200. /** Pure replayable presentation projection, computed only for top-level calls. */
  201. presentationMeta?(args: unknown, value: JsonValue): JsonValue
  202. }
  203. /** A registered tool: its schema plus the execution function. */
  204. export interface ToolDefinition extends ToolSchema {
  205. /** Mandatory canonical output declaration. */
  206. readonly output: ToolOutputDefinition
  207. /**
  208. * Run one accepted call and return only its canonical lossless-JSON value.
  209. * Async work must observe or forward `exec.signal` and settle only after its
  210. * owned work reaches quiescence. The registry preserves caller cancellation
  211. * through around-dispatch signal replacement and does not abandon this
  212. * promise, but it cannot hard-kill same-process code.
  213. * @param args - losslessly snapshotted, frozen model arguments.
  214. * @param exec - execution identity, cancellation signal, and context deferral.
  215. * @returns the canonical value declared by `output.schema`.
  216. */
  217. execute(args: unknown, exec: ToolRunContext): Promise<unknown>
  218. /**
  219. * Synchronous last-mile transform for model-facing content. The registry
  220. * snapshots this callback when execution starts and invokes it exactly once
  221. * for every normalized outcome, including pipeline failures that bypass
  222. * `tools/post-execute`, immediately before lossless materialization.
  223. * Returning `undefined` preserves the content; every other result field
  224. * remains registry-owned. The callback must be total and must not throw.
  225. * @param exec - immutable execution identity and arguments.
  226. * @param result - complete normalized outcome before materialization.
  227. * @returns replacement content, or `undefined` to preserve it.
  228. */
  229. finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
  230. /**
  231. * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
  232. * Enforced by `@deepseek-ai/dsh-tool-call-timeout-policy` (a `tools/execute` wrapper); it
  233. * is NEVER sent to the model — `schemas()` whitelists only name/description/
  234. * parameters. Declaring it asserts this tool forwards `exec.signal` to a
  235. * cooperative implementation that can reach quiescence when the signal aborts.
  236. */
  237. timeoutMs?: number
  238. /**
  239. * Pure synchronous classifier for overlap with sibling tool calls. Only
  240. * `true` opts in; omission, exceptions, non-`true` returns, and invalid
  241. * `defineTool` arguments are exclusive. This metadata is never model-visible.
  242. *
  243. * Opted-in executions must not mutate parent-owned state. Shared state must
  244. * tolerate concurrent dispatch; recorder races are permitted only when they
  245. * commute or fail closed. See the
  246. * [parallel-tool-call Agent Note](../../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)
  247. * for the full contract.
  248. * @param args - parsed arguments; `defineTool` validates before calling.
  249. * @returns Whether this call may join a parallel group.
  250. */
  251. isConcurrencySafe?(args: unknown): boolean
  252. /**
  253. * Optional: how to present the PENDING state of one call in a UI, derived from
  254. * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
  255. * its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent),
  256. * or `undefined` (or omit the method) to fall back to a generic presentation
  257. * (title = tool name, raw args as input). Pure and side-effect-free: a UI may
  258. * call it during live streaming AND a session-log replay, so it must depend
  259. * only on `args`.
  260. */
  261. presentCall?(args: unknown): ToolCallView | undefined
  262. /**
  263. * Optional: how to present the COMPLETED state, given the same `args` and the
  264. * durable result projection (`content`, failure state, and optional `meta`). Returns a
  265. * {@link ToolResultView}, or `undefined` (or omit the method) to keep the
  266. * pending title and render the raw result content. Pure and side-effect-free
  267. * for the same replay reason.
  268. */
  269. presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined
  270. }
  271. /** The completed outcome handed to {@link ToolDefinition.presentResult}. */
  272. export interface ToolResult {
  273. /** The final model-facing content (or the rendered error text on failure). */
  274. content: ContentBlock[]
  275. /** Whether the call failed. */
  276. isError: boolean
  277. /**
  278. * The tool-private presentation payload projected by its output declaration.
  279. * It is persisted verbatim on `tool/result` for Host presenters and Client
  280. * renderers to narrow independently. Absent when the tool declared no
  281. * projector or the call was nested under a composite transport.
  282. */
  283. meta?: JsonValue
  284. }
  285. declare const toolExecutionTokenBrand: unique symbol
  286. /** Opaque call identity that permits correlation without exposing mutable execution state. */
  287. export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
  288. /**
  289. * Caller-supplied description of one tool call. {@link ToolRuntime.execute}
  290. * adds the registry-owned token to form a pipeline {@link ToolExecution};
  291. * callers do not choose that token.
  292. */
  293. export interface ToolExecutionInput {
  294. readonly callId: ToolCallId
  295. /**
  296. * Root model-requested call owning this execution tree. Callers omit it for
  297. * a root execution; nested dispatchers propagate the enclosing value.
  298. */
  299. readonly rootCallId?: ToolCallId
  300. readonly name: string
  301. /** Binding-time tool schema for a PTC inner call; frozen by its producer and never logged. */
  302. readonly schema?: ToolSchema
  303. /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */
  304. readonly arguments: unknown
  305. /** The agent on whose behalf the call runs (set by the agent loop). */
  306. readonly agent?: Agent
  307. /**
  308. * Opaque token of the enclosing transport execution, when one exists. PTC
  309. * mode sets this on SDK sub-dispatches so commit-style observers can wait for
  310. * the outer `run_code` outcome without receiving its live mutable execution.
  311. * The token also marks the call as a transport sub-dispatch rather than a
  312. * model-direct call: under `mode: 'ptc'`, only calls WITH a parent may
  313. * execute a native tool name — a model-direct call (no parent) is denied as
  314. * `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRuntime.execute}.
  315. */
  316. readonly parent?: ToolExecutionToken
  317. /** Required caller-owned cancellation for this invocation. */
  318. readonly signal: AbortSignal
  319. }
  320. /**
  321. * Scheduling mode for one pending call. `parallel` may overlap with siblings;
  322. * `exclusive` runs alone and forms an ordering barrier.
  323. */
  324. export type ToolExecutionMode =
  325. | { kind: 'parallel' }
  326. | { kind: 'exclusive' }
  327. /**
  328. * One settled `run_code` sub-dispatch about to be logged, as seen by the
  329. * `tools/ptc-dispatch-log` waterfall: the parent execution (session owner,
  330. * outer call identity), the sub-call identity, and the outcome whose durable
  331. * copy a listener may reshape. `content` is the RENDERED result projection
  332. * (what a native `tool/result` would carry) — the program itself received
  333. * the structured `value` (or just the error message on failure); only the
  334. * `tool/ptc-dispatch` event's copy changes.
  335. */
  336. export interface PtcDispatchLog {
  337. /** The outer `run_code` execution. */
  338. readonly exec: ToolExecution
  339. /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */
  340. readonly agent?: Agent
  341. /** Opaque sub-call id; new calls use `<parent>:ptc:<n>`. */
  342. readonly subCallId: ToolCallId
  343. /** The dispatched sub-tool name. */
  344. readonly name: string
  345. /** Whether the sub-call settled as an error. */
  346. readonly isError: boolean
  347. /** The sub-call's complete model-facing content (the settle event's default payload). */
  348. readonly content: ContentBlock[]
  349. }
  350. /**
  351. * One pending tool call inside the registry pipeline. Parsed arguments cross
  352. * one lossless-JSON materialization boundary before policy and are deep-frozen;
  353. * call identity, the caller signal, and the registry-assigned {@link token} are
  354. * readonly. The registry freezes the complete object before `tools/result`
  355. * observers run.
  356. */
  357. export interface ToolExecution extends ToolExecutionInput {
  358. /** Root model-requested call, resolved for every root and nested execution. */
  359. readonly rootCallId: ToolCallId
  360. /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
  361. readonly token: ToolExecutionToken
  362. }
  363. /**
  364. * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper
  365. * may replace the signal for its delegated lifetime, but it cannot remove it.
  366. * The registry fuses every replacement with the captured caller signal.
  367. */
  368. export interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> {
  369. /** Cancellation signal visible to the next wrapper or tool body. */
  370. signal: AbortSignal
  371. }
  372. /**
  373. * Runtime context handed to a tool implementation after the registry has
  374. * accepted a {@link ToolExecution}. {@link deferContext} attaches context to
  375. * this execution's own result — a composite tool ferries nested-dispatch
  376. * context back to the outer result, and a leaf tool may mint a fresh
  377. * plugin-sourced instruction; the loop appends it only after the
  378. * `tool/result`.
  379. */
  380. export interface ToolRunContext extends ToolExecution {
  381. /**
  382. * Defer one context — typically a nested-dispatch context ferried by a
  383. * composite tool, or a fresh plugin-sourced instruction — until this tool's
  384. * final result reaches the agent loop. Contexts retain their individual
  385. * source and metadata and are emitted in call order.
  386. */
  387. deferContext(context: UserMessage): void
  388. /**
  389. * Mark a successful final result as terminal for the current agent turn.
  390. * The marker rides this execution's own result (`concludesTurn` exists only
  391. * on {@link ToolExecutionSuccess}); a composite that dispatches nested
  392. * calls forwards it from the nested result, exactly like
  393. * `additionalContexts`, so only an authoritative nested success can
  394. * conclude the enclosing run.
  395. */
  396. concludeTurn(): void
  397. }
  398. /** Registry-owned live execution object; public pipeline views stay readonly. */
  399. type MutableToolRunContext = Omit<ToolRunContext, 'signal'> & { signal: AbortSignal }
  400. /**
  401. * Scheduler-only result after ordered pre-execute and guards. A `post-result`
  402. * still receives post-execute; a `final-result` bypasses it.
  403. * @internal
  404. */
  405. export type ScheduledToolPreparation =
  406. | { kind: 'dispatch'; exec: ToolRunContext }
  407. | { kind: 'post-result'; exec: ToolRunContext; result: ToolExecutionResult }
  408. | { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult }
  409. /**
  410. * Scheduler-only dispatch result. A `post-result` still receives post-execute;
  411. * a `final-result` already matches {@link ToolRuntime.execute} failure semantics.
  412. * @internal
  413. */
  414. export type ScheduledToolDispatch =
  415. | { kind: 'post-result'; result: ToolExecutionResult }
  416. | { kind: 'final-result'; result: ToolExecutionResult }
  417. /**
  418. * Symbol-keyed scheduler view that keeps pre/post policy ordered while
  419. * overlapping dispatch. Ordinary callers use {@link ToolRuntime.execute};
  420. * this is not a plugin extension point.
  421. * @internal
  422. */
  423. export interface ToolRuntimeScheduler {
  424. /** Materialize input, run the ordered pre-execute/guard gate, and decide what stage follows. */
  425. prepare(exec: ToolExecutionInput): Promise<ScheduledToolPreparation>
  426. /** Run only the around-dispatch/body stage. */
  427. dispatch(exec: ToolRunContext): Promise<ScheduledToolDispatch>
  428. /** Run post-execute and definition-owned content finalization, then materialize and notify. */
  429. finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult>
  430. /** Run definition-owned content finalization, then materialize and notify without post-execute. */
  431. finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult
  432. }
  433. /**
  434. * Scheduler entry point omitted from the generated named service API.
  435. * @internal
  436. */
  437. export const TOOL_RUNTIME_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')
  438. /** Canonical error code for cancellation after a tool body was invoked. */
  439. export const TOOL_ABORTED = 'ABORTED'
  440. /** Canonical error code for cancellation before a tool body was invoked. */
  441. export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'
  442. /** Structured error metadata for a failed tool call (alongside the model-facing text). */
  443. export interface ToolErrorInfo {
  444. name: string
  445. code: string
  446. /** Optional raw user-facing detail; durable projections preserve it but model-facing content does not include it. */
  447. reason?: string
  448. }
  449. /** Canonical failure detail; internal routing information remains optional. */
  450. export interface ToolFailure {
  451. /** Human-readable failure message without the Native `Error: ` envelope. */
  452. message: string
  453. /** Internal error class/code used by policy and durable diagnostics. */
  454. info?: ToolErrorInfo
  455. }
  456. /**
  457. * Thrown (internally) when the model requests a tool that isn't registered.
  458. * Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool
  459. * failure is as routable as a tool-thrown one — retry/sandbox/replay code can
  460. * distinguish it from a tool body's own error.
  461. */
  462. export class ToolNotFoundError extends HarnessError {
  463. /**
  464. * @param toolName - the name the caller asked for.
  465. * @param reachableFrom - how the model reaches this tool instead, when the
  466. * name IS visible and only the presentation denies calling it directly.
  467. * Omitted for a name that is registered nowhere.
  468. */
  469. constructor(toolName: string, reachableFrom?: string) {
  470. super(
  471. reachableFrom === undefined
  472. ? `unknown tool "${toolName}"`
  473. : `unknown tool "${toolName}": ${reachableFrom}`,
  474. 'UNKNOWN_TOOL',
  475. )
  476. this.name = 'ToolNotFoundError'
  477. }
  478. }
  479. /** Thrown when a tool body or post-policy value violates its declared output. */
  480. export class ToolOutputError extends HarnessError {
  481. /** Schema/value violations in validation order. */
  482. readonly violations: string[]
  483. constructor(toolName: string, violations: string[]) {
  484. super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT')
  485. this.name = 'ToolOutputError'
  486. this.violations = violations
  487. }
  488. }
  489. /** Convert one projector exception into the canonical invalid-output failure. */
  490. function projectionError(toolName: string, projector: 'render' | 'presentationMeta', error: unknown): ToolOutputError {
  491. return new ToolOutputError(toolName, [`output.${projector} failed: ${errorMessage(error)}`])
  492. }
  493. /** Snapshot one projector result before later durable-result materialization. */
  494. function snapshotProjection<T>(toolName: string, projector: 'render' | 'presentationMeta', candidate: T): T {
  495. try {
  496. const detached = snapshotJsonValue(candidate)
  497. if (detached === undefined) {
  498. throw new ToolOutputError(toolName, [`output.${projector} returned non-lossless JSON`])
  499. }
  500. return detached
  501. } catch (error: unknown) {
  502. if (error instanceof ToolOutputError) throw error
  503. throw projectionError(toolName, projector, error)
  504. }
  505. }
  506. /** Snapshot one body or policy value into the canonical invalid-output failure class. */
  507. function snapshotToolValue(toolName: string, candidate: unknown): JsonValue {
  508. try {
  509. const detached = snapshotJsonValue(candidate)
  510. if (detached === undefined) throw new ToolOutputError(toolName, ['value is not lossless JSON'])
  511. return detached as JsonValue
  512. } catch (error: unknown) {
  513. if (error instanceof ToolOutputError) throw error
  514. throw new ToolOutputError(toolName, [`value snapshot failed: ${errorMessage(error)}`])
  515. }
  516. }
  517. /** Successful canonical tool execution, including its Native/model projection. */
  518. export interface ToolExecutionSuccess {
  519. readonly isError: false
  520. /** Execution-local canonical value; deliberately omitted from durable events. */
  521. readonly value: JsonValue
  522. readonly content: ContentBlock[]
  523. readonly error?: never
  524. readonly meta?: JsonValue
  525. readonly additionalContexts?: UserMessage[]
  526. /** The agent loop stops after committing this successful result batch. */
  527. readonly concludesTurn?: true
  528. }
  529. /** Failed canonical tool execution; failures never carry a successful value. */
  530. export interface ToolExecutionFailure {
  531. readonly isError: true
  532. readonly error: ToolFailure
  533. readonly value?: never
  534. readonly content: ContentBlock[]
  535. readonly meta?: JsonValue
  536. readonly additionalContexts?: UserMessage[]
  537. readonly concludesTurn?: never
  538. }
  539. /** The discriminated, execution-local outcome of one tool call. */
  540. export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure
  541. /**
  542. * Pre-dispatch decision. `allow` runs the call; `deny` materializes its
  543. * model-facing reason and optional structured error identity; `cancel` selects
  544. * the canonical cancellation result without presenting a policy denial; `ask`
  545. * runs only after an approval service returns `allowed-once` and otherwise
  546. * denies. Input rewriting is excluded because arguments are already logged and
  547. * presented.
  548. */
  549. export type PreToolDecision =
  550. | { kind: 'allow' }
  551. | { kind: 'deny'; reason: string; info?: ToolErrorInfo }
  552. | { kind: 'cancel' }
  553. | { kind: 'ask'; reason?: string }
  554. /**
  555. * Post-dispatch decision: accept, replace one projection, attach context for the
  556. * next request, or block by turning corrective feedback into an error result.
  557. */
  558. export type PostToolDecision =
  559. | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] }
  560. | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] }
  561. | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] }
  562. /**
  563. * Best-effort human-readable message from an arbitrary thrown value: Error
  564. * instances use `.message`; non-Error objects with a string `message`
  565. * property (e.g. `throw { message: 'denied' }`) use it too; everything else
  566. * is stringified.
  567. */
  568. function errorMessage(error: unknown): string {
  569. try {
  570. if (error instanceof Error) return error.message
  571. if (typeof error === 'object' && error !== null
  572. && 'message' in error && typeof error.message === 'string') {
  573. return error.message
  574. }
  575. return String(error)
  576. } catch {
  577. // A hostile thrown value can trap `instanceof`, property access, or string
  578. // coercion. Error normalization is the outermost safety boundary, so its
  579. // fallback must itself be total.
  580. return '<unprintable thrown value>'
  581. }
  582. }
  583. /** Derive one failure message from policy feedback without changing its rendered blocks. */
  584. function failureMessageFromContent(content: ContentBlock[]): string {
  585. const text = content
  586. .map(block => block.type === 'text' ? block.text : `[${block.type} content]`)
  587. .join('\n')
  588. return text.length > 0 ? text : 'tool result blocked by post-execute policy'
  589. }
  590. /** Snapshot and freeze one durable tool-result projection or reject lossy data. */
  591. function materializePresentation<T>(candidate: T): T {
  592. const detached = snapshotJsonValue(candidate)
  593. if (detached === undefined) {
  594. throw new TypeError('tool result must be losslessly JSON-serializable')
  595. }
  596. return deepFreeze(detached)
  597. }
  598. /** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
  599. function errorInfo(error: unknown): ToolErrorInfo | undefined {
  600. try {
  601. return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
  602. } catch {
  603. return undefined
  604. }
  605. }
  606. /** How the registry presents its tools to the model (see {@link Config.mode}). */
  607. export type ToolPresentationMode = 'native' | 'ptc' | 'both'
  608. /** Plugin config: how the registered tools are presented to the model. */
  609. export interface Config {
  610. /**
  611. * Model presentation. `native` (default) sends every visible schema; `ptc`
  612. * sends only `run_code` plus a generated SDK prompt and collapses the
  613. * executor to the same surface (a model-direct call may only name
  614. * `run_code`; `run_code` SDK sub-dispatches keep every visible tool); `both`
  615. * sends both forms. PTC mode requires a `ctx.ptcRuntime` whose `language`
  616. * has a registered SDK renderer (TypeScript or Python) and fail prompt
  617. * assembly when it is absent or has no renderer. Under `ptc`, native names
  618. * in `toolOrder` are invalid.
  619. */
  620. mode?: ToolPresentationMode
  621. /**
  622. * Concurrency cap for a `run_code` program's overlapping sub-calls
  623. * (default 10, the loop scheduler's own default). Sub-calls follow the
  624. * native scheduling contract — only calls whose tools classify
  625. * concurrency-safe overlap; exclusive calls form barriers — so `1`
  626. * restores strictly serial dispatch. Must be a positive integer.
  627. */
  628. maxParallelSubCalls?: number
  629. }
  630. /**
  631. * Per-scope filter over global tools. Restrictions intersect and do not affect
  632. * scoped registrations or the reserved PTC mode transport.
  633. */
  634. export interface ToolRestriction {
  635. /** Global tool names that stay visible; everything else is removed. */
  636. readonly allow?: readonly string[]
  637. /** Global tool names removed from visibility. */
  638. readonly deny?: readonly string[]
  639. }
  640. /** One restriction compiled at registration for repeated live-global lookup. */
  641. interface CompiledToolRestriction {
  642. readonly allow?: ReadonlySet<string>
  643. readonly deny?: ReadonlySet<string>
  644. }
  645. /** One scope's complete registry view, derived in a single layer traversal. */
  646. interface ToolView {
  647. /** Visible definitions after restrictions, scoped shadowing, and transport insertion. */
  648. readonly visible: ReadonlyMap<string, ToolDefinition>
  649. /** Pre-restriction capability names used by prompt-order validation. */
  650. readonly knownNames: ReadonlySet<string>
  651. /** Current global names that a scoped restriction may name. */
  652. readonly restrictableNames: ReadonlySet<string>
  653. }
  654. /**
  655. * A monotonic execution guard evaluated after every `tools/pre-execute`
  656. * listener and before the tool body. Returning a reason denies the call;
  657. * returning `undefined` leaves it unchanged. Because guards have no allow
  658. * result, listener ordering cannot turn a denial back into permission.
  659. * @param execution - the identity-protected call after extensible pre-execute policy completed.
  660. * @returns a final denial reason, or `undefined` to leave the call allowed.
  661. */
  662. export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
  663. /** One scope's complete tool-registry contribution. */
  664. class ToolLayer implements ScopeLayer {
  665. readonly tools: NamedEntries<ToolDefinition>
  666. readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
  667. readonly guards = new AnonymousEntries<ToolGuard>()
  668. /**
  669. * Presentation this scope's agent declared for itself, shadowing the
  670. * deployment default. One cell rather than an entry table: two answers to
  671. * "which form does the model see" is a contradiction, not a merge.
  672. */
  673. mode: ToolPresentationMode | undefined
  674. constructor(scope: ScopeKey | undefined) {
  675. this.tools = new NamedEntries(name => new Error(scope === undefined
  676. ? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
  677. : `tool "${name}" is already registered in this scope`))
  678. }
  679. /** Whether every contribution table in this aggregate layer is empty. */
  680. isEmpty(): boolean {
  681. return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty()
  682. && this.mode === undefined
  683. }
  684. /** Whether every compiled restriction in this layer admits a global tool name. */
  685. admits(name: string): boolean {
  686. for (const filter of this.restrictions.values()) {
  687. if ((filter.allow !== undefined && !filter.allow.has(name))
  688. || (filter.deny !== undefined && filter.deny.has(name))) return false
  689. }
  690. return true
  691. }
  692. /** First monotonic denial from this layer's live guard registrations. */
  693. guardReason(exec: ToolExecution): string | undefined {
  694. for (const guard of this.guards.values()) {
  695. const reason = guard(exec)
  696. if (reason !== undefined) return reason
  697. }
  698. return undefined
  699. }
  700. }
  701. /** Approval decision plus whether the approval channel reported cancellation. */
  702. interface ToolAskResolution {
  703. readonly decision: Extract<PreToolDecision, { kind: 'allow' | 'deny' }>
  704. readonly approvalCancelled: boolean
  705. }
  706. /** Caller cancellation and dispatch state kept outside the around-wrapper view. */
  707. interface ToolCancellationState {
  708. readonly callerSignal: AbortSignal
  709. bodyInvoked: boolean
  710. }
  711. /** One dispatch-scoped fused signal plus listener cleanup after the body settles. */
  712. interface FusedToolSignal {
  713. readonly signal: AbortSignal
  714. dispose(): void
  715. }
  716. /** Resolve the run_code overlap cap at the owning config boundary (direct construction bypasses the Loader schema). */
  717. function resolveMaxParallelSubCalls(value: number | undefined): number {
  718. const maxParallelSubCalls = value ?? 10
  719. if (!Number.isInteger(maxParallelSubCalls) || maxParallelSubCalls < 1) {
  720. throw new Error('maxParallelSubCalls must be a positive integer')
  721. }
  722. return maxParallelSubCalls
  723. }
  724. /**
  725. * Tool registry and execution pipeline. Scoped registrations shadow globals;
  726. * one visibility resolver feeds presentation, lookup, and dispatch.
  727. */
  728. export class ToolRuntime extends Service {
  729. static inject = ['systemPrompt']
  730. static Config: z<Config> = z.object({
  731. mode: z.union(['native', 'ptc', 'both'] as const).default('native'),
  732. maxParallelSubCalls: z.natural().min(1).default(10),
  733. })
  734. /** Internal staged view consumed by `dsh-agent-loop`'s parallel scheduler. */
  735. readonly [TOOL_RUNTIME_SCHEDULER]: ToolRuntimeScheduler = {
  736. prepare: exec => this.prepareScheduledExecution(exec),
  737. dispatch: exec => this.dispatchScheduledExecution(exec),
  738. finalize: (exec, result) => this.finalizeScheduledExecution(exec, result),
  739. finish: (exec, result) => this.finishScheduledExecution(exec, result),
  740. }
  741. /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
  742. private deferredContexts = new WeakMap<ToolRunContext, UserMessage[]>()
  743. /** Executions whose tool body declared the current turn complete. */
  744. private concludingExecutions = new WeakSet<ToolExecution>()
  745. /** Original caller cancellation, kept outside the wrapper-mutable execution object. */
  746. private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
  747. /** Definition-owned final content transform snapshotted before policy begins. */
  748. private contentFinalizers = new WeakMap<ToolRunContext, ToolDefinition['finalizeContent']>()
  749. private readonly layers = new ScopedLayers(
  750. scope => new ToolLayer(scope),
  751. () => { this.ctx.emit('tools/change') },
  752. )
  753. /** Presentation for scopes that declare none; {@link presentAs} shadows it per scope. */
  754. private readonly defaultMode: ToolPresentationMode
  755. private readonly maxParallelSubCalls: number
  756. /**
  757. * Reserved presentation transport, kept outside the filterable registration
  758. * layers. Built on first need rather than at construction: which agents run
  759. * a PTC mode is no longer known when the service is constructed, and the
  760. * transport is stateless beyond its closures over `this`.
  761. */
  762. private ptcTransport: ToolDefinition | undefined
  763. constructor(ctx: Context, config: Config = {}) {
  764. super(ctx, 'tools')
  765. // The schema already defaulted an omitted mode; the ?? narrows the
  766. // optional-input type for direct (non-Loader) construction in tests.
  767. this.defaultMode = config.mode ?? 'native'
  768. this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls)
  769. ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
  770. if (this.defaultMode !== 'native') {
  771. ctx.systemPrompt.section(this.collapseSection())
  772. ctx.systemPrompt.section(this.sdkSection())
  773. }
  774. }
  775. /**
  776. * The prompt statement of the `ptc` executor collapse, registered wherever
  777. * {@link sdkSection} is and rendering empty outside an effective `ptc`.
  778. *
  779. * Every tool contributes its own guidance section naming its tool, none of
  780. * them qualify how that tool is reached, and they all render before the SDK.
  781. * Without this the model reads a catalog of tools it is told to use and no
  782. * statement that only `run_code` may be called, so it emits a native call,
  783. * receives `UNKNOWN_TOOL` for a tool the prompt just declared, and concludes
  784. * the deployment is inconsistent. Its order places the rule before that
  785. * guidance rather than after it.
  786. *
  787. * `both` renders empty: native calls do execute there, so the rule is false.
  788. * @returns the section registration.
  789. */
  790. private collapseSection(): PromptSection {
  791. return {
  792. name: 'tools:ptc-only',
  793. order: this.ctx.systemPrompt.getSectionOrder('PTC_ONLY'),
  794. // The SAME predicate the executor denies by, so the prompt cannot state
  795. // a rule the registry does not enforce (see `collapses`).
  796. text: context => this.modeFor(context.scope) === 'ptc' ? PTC_ONLY_INSTRUCTION : '',
  797. }
  798. }
  799. /**
  800. * The generated-SDK prompt section, registered globally by a PTC mode
  801. * deployment and per scope by {@link presentAs}.
  802. *
  803. * The body regenerates from the CALLING scope, and renders empty for an
  804. * agent presenting natively — an agent that opted out under a PTC mode
  805. * deployment still sees the global registration, and an empty section is
  806. * dropped from the rendered prompt.
  807. * @returns the section registration.
  808. */
  809. private sdkSection(): PromptSection {
  810. return {
  811. name: 'tools:sdk',
  812. order: this.ctx.systemPrompt.getSectionOrder('TOOLS_SDK'),
  813. interpolate: false,
  814. // Regenerate from the calling scope's visible tools in stable order.
  815. text: (context) => {
  816. const mode = this.modeFor(context.scope)
  817. if (mode === 'native') return ''
  818. const runtime = this.requirePtcRuntime(mode)
  819. // Own-property read: a language like `toString`/`constructor` would
  820. // otherwise resolve an inherited Object.prototype member as a renderer.
  821. const render = SDK_RENDERERS[runtime.language]
  822. /* v8 ignore next -- requirePtcRuntime rejects an unknown language before this runs. */
  823. if (render === undefined) throw new Error(`dsh-tools: no SDK renderer for ${runtime.language}`)
  824. return render(this.sdkSchemas(context.scope))
  825. },
  826. }
  827. }
  828. /**
  829. * The presentation one scope's agent sees: its own declaration, else the
  830. * deployment default.
  831. * @param scope - the calling agent, or undefined for the global view.
  832. * @returns the resolved presentation mode.
  833. */
  834. private modeFor(scope?: ScopeKey): ToolPresentationMode {
  835. // Nearest scope wins along the chain: a preset's standing declaration
  836. // covers every agent parented under it, and an agent's own (were one ever
  837. // declared) would override its preset's. The mode decides what the model
  838. // SEES, which is exactly the class of fact the chain inherits.
  839. const layers = this.layers.chainLayers(scope)
  840. for (let index = layers.length - 1; index >= 0; index -= 1) {
  841. const mode = layers[index]?.mode
  842. if (mode !== undefined) return mode
  843. }
  844. return this.defaultMode
  845. }
  846. /**
  847. * The reserved `run_code` transport, built on first need.
  848. *
  849. * It never enters the global layer: per-agent restrictions must not remove
  850. * it, and a scoped registration must not shadow it. The visibility resolver
  851. * appends it after resolving the filterable global/scoped capability layers,
  852. * and only for scopes whose mode actually presents it.
  853. * @returns the shared transport definition.
  854. */
  855. private requirePtcTransport(): ToolDefinition {
  856. this.ptcTransport ??= createRunCodeTool(this, {
  857. requireRuntime: () => this.requirePtcRuntime(this.defaultMode),
  858. peekApprover: () => this.ctx.get('approval'),
  859. resolveSandboxPolicy: (exec) => {
  860. const policy = this.ctx.get('sandboxPolicy')
  861. if (policy === undefined) throw new Error('dsh-tools: confined PTC runtime requires sandboxPolicy')
  862. return policy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
  863. },
  864. // The language-aware description/parameters getters read the runtime
  865. // without demanding one, so a native-default process can still project
  866. // the transport for an agent that chose code.
  867. peekRuntime: () => this.ctx.get('ptcRuntime'),
  868. maxParallel: this.maxParallelSubCalls,
  869. shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
  870. })
  871. return this.ptcTransport
  872. }
  873. /**
  874. * Present the calling scope's tools in `mode` instead of the deployment
  875. * default. Nearest scope on the chain wins, so a preset's standing
  876. * declaration covers every agent joined under it.
  877. *
  878. * Scoped only, and one declaration per scope: this is how an agent preset
  879. * composes PTC mode agents beside native ones in the same process, and a
  880. * process-global override would be the `mode` config field instead.
  881. * @param mode - the presentation the covered agents' models see.
  882. * @returns the exact disposer that restores the deployment default.
  883. */
  884. presentAs(mode: ToolPresentationMode): () => void {
  885. const ctx = this.ctx
  886. if (scopeOf(ctx) === undefined) {
  887. throw new Error('tools.presentAs() requires a scoped context (agent.ctx): a context-global presentation is the `mode` config field on the tools row')
  888. }
  889. const dispose = ctx.effect(function* (this: ToolRuntime) {
  890. yield this.layers.effect(
  891. ctx,
  892. (layer) => {
  893. if (layer.mode !== undefined) {
  894. throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this scope; one composition selects one presentation`)
  895. }
  896. layer.mode = mode
  897. return () => { layer.mode = undefined }
  898. },
  899. { label: 'tools.presentAs()' },
  900. )
  901. // The SDK and collapse sections are per scope for the same reason the
  902. // mode is. Under a deployment that already defaults to PTC mode this
  903. // shadows the global registration with an identical body, which costs
  904. // nothing and keeps one rule instead of a case analysis.
  905. if (mode !== 'native') {
  906. yield ctx.systemPrompt.section(this.collapseSection())
  907. yield ctx.systemPrompt.section(this.sdkSection())
  908. }
  909. }.bind(this), 'tools.presentAs()')
  910. // oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown
  911. return dispose
  912. }
  913. /**
  914. * Build one scope's wire schemas and names for prompt-order validation.
  915. * Restrictions do not make known tools invalid, but a mode collapse does.
  916. */
  917. private wireSchemas(scope?: ScopeKey): ToolProviderResult {
  918. const view = this.view(scope)
  919. const mode = this.modeFor(scope)
  920. if (mode === 'native') {
  921. const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
  922. return { schemas, knownNames: [...view.knownNames] }
  923. }
  924. // Validate the runtime language BEFORE projecting schemas: schemaOf reads
  925. // run_code's language-aware description/parameters getters, whose own
  926. // flavor-table guard would otherwise surface first. This keeps the
  927. // renderer-table rejection the canonical assembly-time error for a
  928. // language with no SDK renderer.
  929. this.requirePtcRuntime(mode)
  930. const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
  931. if (mode === 'ptc') {
  932. return {
  933. schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
  934. knownNames: [RUN_CODE_NAME],
  935. }
  936. }
  937. return { schemas, knownNames: [...view.knownNames, RUN_CODE_NAME] }
  938. }
  939. /**
  940. * Resolve the PTC runtime or throw the actionable misconfiguration error.
  941. * Read at use time (assembly / run_code execution), NOT via static
  942. * `inject`: an inject entry would hold `ctx.tools` — and every tool plugin
  943. * behind it — hostage to a PTC runtime existing even under `mode:
  944. * 'native'`.
  945. *
  946. * Assembly and `run_code` execution read separately, so the language is not
  947. * bound to a request. Harmless while one published backend exists — both
  948. * reads return the same flavor — but a reload that swapped in a second
  949. * language between them would hand a program written against one SDK to the
  950. * other. Binding it is deferred until a second backend ships (the first
  951. * point it is testable).
  952. */
  953. private requirePtcRuntime(mode: ToolPresentationMode): PtcRuntime {
  954. const runtime = this.ctx.get('ptcRuntime')
  955. if (!runtime) {
  956. throw new Error(`dsh-tools: mode "${mode}" requires a PTC runtime — load a ctx.ptcRuntime implementation (e.g. @deepseek-ai/dsh-ptc-runtime-node) or set tools mode to "native"`)
  957. }
  958. if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) {
  959. const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')
  960. throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`)
  961. }
  962. return runtime
  963. }
  964. /**
  965. * Register globally or in the calling agent scope. Scoped tools shadow
  966. * globals; duplicates within one layer and the reserved `run_code` name fail.
  967. * @param definition - tool schema, execution, and optional finalization/presentation callbacks.
  968. * @returns the exact disposer that unregisters the tool.
  969. */
  970. register(definition: ToolDefinition): () => void {
  971. const name = definition.name
  972. const output = (definition as Partial<ToolDefinition>).output
  973. if (output === undefined || typeof output !== 'object'
  974. || typeof output.render !== 'function'
  975. || (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function')) {
  976. throw new TypeError(`tool "${name}" must declare output { schema, render, presentationMeta? }`)
  977. }
  978. assertSupportedJsonSchema(output.schema)
  979. const timeoutMs = definition.timeoutMs
  980. if (timeoutMs !== undefined
  981. && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
  982. throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`)
  983. }
  984. // Reserved unconditionally: any agent may select a code mode for itself,
  985. // so a name free to take under the deployment default would become a
  986. // collision the moment a preset mounted.
  987. if (name === RUN_CODE_NAME) {
  988. throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the PTC mode presentation transport and cannot be registered or shadowed`)
  989. }
  990. return this.layers.effect(
  991. this.ctx,
  992. layer => layer.tools.insert(name, definition),
  993. { label: 'tools.register()' },
  994. )
  995. }
  996. /**
  997. * Restrict global tools for the calling agent scope. Empty filters, unknown
  998. * names, scope-local names, and reserved transport names fail. Restrictions
  999. * intersect; scoped registrations remain visible.
  1000. * @param filter - global-tool mask: `allow` (keep only) and/or `deny` (remove).
  1001. * @returns the exact disposer that lifts this restriction.
  1002. */
  1003. restrict(filter: ToolRestriction): () => void {
  1004. const scope = scopeOf(this.ctx)
  1005. if (scope === undefined) {
  1006. throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead')
  1007. }
  1008. const allow = filter.allow
  1009. const deny = filter.deny
  1010. if (allow === undefined && deny === undefined) {
  1011. throw new Error('tools.restrict({}) is a no-op: pass `allow` and/or `deny` (an empty filter is almost always a materialized-empty-config bug)')
  1012. }
  1013. const compiled: CompiledToolRestriction = {
  1014. ...allow !== undefined ? { allow: new Set(allow) } : {},
  1015. ...deny !== undefined ? { deny: new Set(deny) } : {},
  1016. }
  1017. if ([...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {
  1018. throw new Error(`tools.restrict() cannot name reserved PTC mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`)
  1019. }
  1020. const known = this.view(scope).restrictableNames
  1021. const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))
  1022. if (unknown.length > 0) {
  1023. throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
  1024. }
  1025. return this.layers.effect(
  1026. this.ctx,
  1027. layer => layer.restrictions.append(compiled),
  1028. { label: 'tools.restrict()' },
  1029. )
  1030. }
  1031. /**
  1032. * Register a monotonic guard after the extensible `tools/pre-execute`
  1033. * waterfall. A plain-context guard applies globally; one registered through
  1034. * `agent.ctx` applies only to that agent. Any matching guard may deny by
  1035. * returning a reason, while no guard can force-allow a call another guard
  1036. * denied. The exact effect disposer is returned for ordered ownership and
  1037. * HMR cleanup.
  1038. * @param guard - synchronous check; a returned string denies the execution.
  1039. * @returns the exact disposer that unregisters the guard.
  1040. */
  1041. guard(guard: ToolGuard): () => void {
  1042. return this.layers.effect(
  1043. this.ctx,
  1044. layer => layer.guards.append(guard),
  1045. { label: 'tools.guard()', notify: false },
  1046. )
  1047. }
  1048. /** First monotonic denial from the global then the scope chain's guard layers, farthest first. */
  1049. private guardReason(exec: ToolExecution): string | undefined {
  1050. const globalReason = this.layers.global.guardReason(exec)
  1051. if (globalReason !== undefined) return globalReason
  1052. if (exec.agent === undefined) return undefined
  1053. for (const layer of this.layers.chainLayers(exec.agent)) {
  1054. const reason = layer.guardReason(exec)
  1055. if (reason !== undefined) return reason
  1056. }
  1057. return undefined
  1058. }
  1059. /**
  1060. * Resolve every registry fact one scope needs in one layer traversal. The
  1061. * visible map applies restrictions to the INHERITED surface, then the
  1062. * scope's own registrations and the reserved presentation transport; the
  1063. * other sets retain the pre-restriction facts needed by restriction and
  1064. * prompt-order validation.
  1065. *
  1066. * A restriction filters what a scope inherits — the global layer and every
  1067. * ancestor layer on its chain — and never what its OWN layer registers.
  1068. * That exemption is what a per-child capability filter has to keep intact:
  1069. * the delegation runtime registers a child's structured-output tool into the
  1070. * child's own layer, and a filter naming the capabilities the child may use
  1071. * must not strip the machinery it answers through.
  1072. *
  1073. * Reading the exempt set as "the global layer" instead of "not mine" held
  1074. * only while every model-facing tool sat in the host composition. Once
  1075. * presets moved them onto the agent plane they became an ANCESTOR
  1076. * contribution, so a child's filter silently stopped constraining anything
  1077. * it was given.
  1078. * @param scope - the viewing scope (the agent), or undefined for the global view.
  1079. * @returns the complete derived view for that scope.
  1080. */
  1081. private view(scope?: ScopeKey): ToolView {
  1082. // Scope-chain layers, farthest ancestor first, the exact scope last.
  1083. const layers = this.layers.chainLayers(scope)
  1084. // Chain-blind on purpose: this is the ONE layer whose registrations the
  1085. // scope owns rather than inherits, and it is absent until the scope
  1086. // contributes something.
  1087. const own = this.layers.peek(scope)
  1088. // Inherited surface, nearest ancestor last: a nearer scope's same-name
  1089. // entry shadows a farther one, and the global layer is the farthest.
  1090. const inherited = new Map<string, ToolDefinition>(this.layers.global.tools.entries())
  1091. for (const layer of layers) {
  1092. if (layer === own) continue
  1093. for (const [name, definition] of layer.tools.entries()) inherited.set(name, definition)
  1094. }
  1095. const visible = new Map<string, ToolDefinition>()
  1096. const knownNames = new Set<string>()
  1097. const restrictableNames = new Set<string>()
  1098. for (const [name, definition] of inherited) {
  1099. knownNames.add(name)
  1100. restrictableNames.add(name)
  1101. // Restrictions intersect across the whole chain: any scope on it may
  1102. // mask an inherited name for everything nested inside it.
  1103. if (layers.every(layer => layer.admits(name))) visible.set(name, definition)
  1104. }
  1105. // The scope's own registrations last, shadowing an inherited name and
  1106. // outside the filter above.
  1107. if (own !== undefined) {
  1108. for (const [name, definition] of own.tools.entries()) {
  1109. knownNames.add(name)
  1110. visible.set(name, definition)
  1111. }
  1112. }
  1113. // Presentation infrastructure is resolved last and outside capability
  1114. // filtering. Registration rejects this reserved name, so the insertion is
  1115. // an invariant assertion as well as protection against future layer
  1116. // changes. Per scope: a native agent must not find `run_code` in its
  1117. // dispatch table because some other agent in the process presents it.
  1118. if (this.modeFor(scope) !== 'native') {
  1119. visible.set(RUN_CODE_NAME, this.requirePtcTransport())
  1120. }
  1121. return { visible, knownNames, restrictableNames }
  1122. }
  1123. /**
  1124. * Look up a tool as one scope sees it (scoped
  1125. * shadows global; a restricted-away global reads as absent). Presenters pass
  1126. * the calling agent so the rendered card matches the definition that
  1127. * actually executed.
  1128. * @param name - the tool name as registered.
  1129. * @param scope - the viewing scope (the agent); omitted = the global view.
  1130. * @returns the definition the scope resolves, or undefined when none is visible.
  1131. */
  1132. get(name: string, scope?: ScopeKey): ToolDefinition | undefined {
  1133. return this.view(scope).visible.get(name)
  1134. }
  1135. /**
  1136. * Resolve the definition that MAY EXECUTE for a call, applying the mode
  1137. * collapse at the operation boundary that owns it. The registry view
  1138. * (`get`) is presentation-agnostic; here a MODEL-DIRECT call under `ptc`
  1139. * may only name the reserved `run_code` transport, while a nested
  1140. * sub-dispatch (a `parent` token set — the `run_code` SDK calling a tool
  1141. * it bound) may call any visible tool. Denial surfaces as `UNKNOWN_TOOL`
  1142. * through the executor, matching an absent definition.
  1143. * @param name - the tool name as registered.
  1144. * @param scope - the viewing scope (the agent); omitted = the global view.
  1145. * @param nested - whether the call is a transport sub-dispatch, not a model-direct call.
  1146. * @returns the definition that may run, or undefined when the call must be rejected.
  1147. */
  1148. private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined {
  1149. const tool = this.get(name, scope)
  1150. if (tool === undefined) return undefined
  1151. if (this.collapses(name, scope, nested)) return undefined
  1152. return tool
  1153. }
  1154. /**
  1155. * Project visible definitions onto the allowlisted model-facing schema fields,
  1156. * excluding execution and presentation callbacks.
  1157. * @param scope - the viewing scope (the agent); omitted = the global view.
  1158. * @returns one deep-cloned schema per visible tool.
  1159. */
  1160. schemas(scope?: ScopeKey): ToolSchema[] {
  1161. return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true))
  1162. }
  1163. /** Project visible callable tools onto the generated PTC mode SDK contract. */
  1164. private sdkSchemas(scope?: ScopeKey): ToolSdkSchema[] {
  1165. return [...this.view(scope).visible.values()]
  1166. .filter(definition => definition.name !== RUN_CODE_NAME)
  1167. .map((definition): ToolSdkSchema => {
  1168. const output = snapshotJsonValue(definition.output.schema)
  1169. /* v8 ignore next -- registration already validated and retained this schema as lossless JSON. */
  1170. if (output === undefined) {
  1171. throw new Error(`tool "${definition.name}" output schema must be lossless JSON before SDK projection`)
  1172. }
  1173. return {
  1174. ...this.schemaOf(definition, true),
  1175. output,
  1176. }
  1177. })
  1178. }
  1179. /** Project one definition onto the model-facing schema fields. */
  1180. private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
  1181. const { name, description, parameters } = definition
  1182. const detached = detachParameters ? snapshotJsonValue(parameters) : parameters
  1183. if (detached === undefined) {
  1184. throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`)
  1185. }
  1186. return {
  1187. name,
  1188. description,
  1189. parameters: detached,
  1190. }
  1191. }
  1192. /**
  1193. * Classify a pending call through the caller's visible tool definition. Only
  1194. * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or
  1195. * throwing classifiers are exclusive.
  1196. * @param exec - call name, parsed arguments, and optional agent scope.
  1197. * @returns the fail-closed scheduling mode.
  1198. */
  1199. executionMode(exec: ToolExecutionInput): ToolExecutionMode {
  1200. const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
  1201. if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
  1202. try {
  1203. const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments)
  1204. return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' }
  1205. } catch {
  1206. return { kind: 'exclusive' }
  1207. }
  1208. }
  1209. /**
  1210. * Run the `tools/ptc-dispatch-log` waterfall over one settled sub-dispatch
  1211. * and return the content the bridge should log on `tool/ptc-dispatch`.
  1212. * Contained: when a listener throws, the method logs the original settled
  1213. * content; that failure must not fail the dispatch or omit the settle event. Private:
  1214. * the ONE consumer is the `run_code` bridge this registry constructs, which
  1215. * receives it as a capability parameter (the `requireRuntime` idiom) — the
  1216. * waterfall, not this invoker, is the public extension point.
  1217. */
  1218. private async shapeDispatchLog(dispatch: PtcDispatchLog): Promise<ContentBlock[]> {
  1219. try {
  1220. return await this.ctx.waterfall(
  1221. scopeTarget(this, dispatch.agent), 'tools/ptc-dispatch-log', dispatch,
  1222. () => Promise.resolve(dispatch.content),
  1223. )
  1224. } catch (error: unknown) {
  1225. this.ctx.logger.warn(`tools: ptc-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the original settled content`)
  1226. return dispatch.content
  1227. }
  1228. }
  1229. /**
  1230. * Whether the `ptc` mode collapse denies a model-direct call: only the
  1231. * reserved `run_code` transport may be named. Nested sub-dispatches (a
  1232. * `parent` token set) bypass the collapse. One home for the
  1233. * security-relevant predicate, shared by {@link resolveExecution} and
  1234. * {@link createExecution} so the two can never drift apart.
  1235. *
  1236. * Resolved through {@link modeFor}, NOT `defaultMode`: an agent given `ptc`
  1237. * by an agent preset under a native deployment is the composition
  1238. * `dsh-agent-tool-presentation` exists for, and reading the deployment default would
  1239. * leave exactly that agent uncollapsed — announcing one surface while
  1240. * executing another, which is the bypass this collapse closes.
  1241. * @param name - the tool name as registered.
  1242. * @param scope - the viewing scope whose effective presentation mode applies.
  1243. * @param nested - whether the call is a transport sub-dispatch, not a model-direct call.
  1244. */
  1245. private collapses(name: string, scope: ScopeKey | undefined, nested: boolean): boolean {
  1246. return !nested && this.modeFor(scope) === 'ptc' && name !== RUN_CODE_NAME
  1247. }
  1248. /**
  1249. * Execute through pre-policy, guards, around-dispatch, post-policy,
  1250. * definition-owned content finalization, and final notification. Tool and
  1251. * listener failures resolve as materialized error results; an invisible tool
  1252. * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen
  1253. * snapshot final observers receive. Cancellation
  1254. * arriving after entry and before final result materialization skips a
  1255. * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a
  1256. * successful started outcome with `ABORTED`; already-started work is still
  1257. * drained and may retain a tool-owned structured error.
  1258. * @param exec - the typed same-process call input. The registry assigns its
  1259. * correlation token before policy begins.
  1260. * @returns the materialized final result.
  1261. */
  1262. async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
  1263. return this.prepareExecution(exec, prepared => this.completeScheduledExecution(prepared))
  1264. }
  1265. private async completeScheduledExecution(prepared: ScheduledToolPreparation): Promise<ToolExecutionResult> {
  1266. switch (prepared.kind) {
  1267. case 'dispatch': {
  1268. const dispatched = await this.dispatchScheduledExecution(prepared.exec)
  1269. return dispatched.kind === 'post-result'
  1270. ? await this.finalizeScheduledExecution(prepared.exec, dispatched.result)
  1271. : this.finishScheduledExecution(prepared.exec, dispatched.result)
  1272. }
  1273. case 'post-result':
  1274. return await this.finalizeScheduledExecution(prepared.exec, prepared.result)
  1275. case 'final-result':
  1276. return this.finishScheduledExecution(prepared.exec, prepared.result)
  1277. /* v8 ignore next -- closed-union exhaustiveness guard */
  1278. default:
  1279. return assertNever(prepared, 'scheduled tool preparation')
  1280. }
  1281. }
  1282. private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } {
  1283. const deferredContexts: UserMessage[] = []
  1284. const token = createExecutionToken()
  1285. const callId = exec.callId
  1286. const rootCallId = exec.rootCallId ?? callId
  1287. const name = exec.name
  1288. const agent = exec.agent
  1289. const parent = exec.parent
  1290. const signal = exec.signal
  1291. // Distinguish a mode-collapsed call (visible in the scope, denied only by
  1292. // the `ptc` collapse) from a genuinely unknown tool. A collapsed call is
  1293. // deterministically denied, so it terminates BEFORE the extensible policy
  1294. // pipeline: pre-execute listeners, approval `ask`, and guards must never
  1295. // observe — or worse, approve — a call that can only fail. An unknown tool
  1296. // keeps the historical dispatch-stage `UNKNOWN_TOOL` path so policy
  1297. // listeners still see every name that reaches the registry.
  1298. const visible = this.get(name, agent)
  1299. const collapsed = visible !== undefined && this.collapses(name, agent, parent !== undefined)
  1300. const concludingExecutions = this.concludingExecutions
  1301. const base = {
  1302. token,
  1303. callId,
  1304. rootCallId,
  1305. name,
  1306. signal,
  1307. ...agent !== undefined ? { agent } : {},
  1308. ...parent !== undefined ? { parent } : {},
  1309. ...exec.schema !== undefined ? { schema: exec.schema } : {},
  1310. deferContext(context: UserMessage): void {
  1311. deferredContexts.push(context)
  1312. },
  1313. concludeTurn(): void {
  1314. concludingExecutions.add(this as unknown as ToolExecution)
  1315. },
  1316. }
  1317. // Capture the finalizer BEFORE argument materialization: the
  1318. // `finalizeContent` contract snapshots the callback when the call starts,
  1319. // and an arguments getter can replace or clear the registered callback
  1320. // during `snapshotJsonValue`. The collapse only decides whether the
  1321. // CAPTURED callback is retained: the pre-dispatch abort path keeps it
  1322. // (the cancellation contract routes aborted results through it — a getter
  1323. // that aborts mid-materialization before an invalid-args failure lands in
  1324. // the same retained path), while the `UNKNOWN_TOOL` denial and the
  1325. // invalid-args failure of a NON-ABORTED collapsed call drop it (the call
  1326. // could never execute).
  1327. const capturedFinalizer = visible?.finalizeContent?.bind(visible)
  1328. const finalizerFor = (): ToolDefinition['finalizeContent'] | undefined =>
  1329. collapsed && !signal.aborted ? undefined : capturedFinalizer
  1330. try {
  1331. const detached = snapshotJsonValue(exec.arguments)
  1332. if (detached === undefined) {
  1333. throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
  1334. }
  1335. const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) }
  1336. this.deferredContexts.set(execution, deferredContexts)
  1337. this.contentFinalizers.set(execution, finalizerFor())
  1338. this.cancellationStates.set(execution, {
  1339. callerSignal: signal,
  1340. bodyInvoked: false,
  1341. })
  1342. if (collapsed) {
  1343. // The collapse denies the call before the policy pipeline, but a
  1344. // pre-dispatch abort still keeps the established cancellation
  1345. // contract: `prepare`'s caller-cancellation check is skipped for
  1346. // final-results, so honor the abort here instead of surfacing
  1347. // `UNKNOWN_TOOL` on an already-cancelled call.
  1348. if (signal.aborted) {
  1349. return { kind: 'final-result', exec: execution, result: toolAbortedBeforeDispatchResult() }
  1350. }
  1351. // The name IS visible here, so the denial carries the route the model
  1352. // must take instead. Without it the model reads a bare `unknown tool`
  1353. // for a tool the prompt just declared and concludes the deployment is
  1354. // broken rather than correcting itself.
  1355. return {
  1356. kind: 'final-result',
  1357. exec: execution,
  1358. result: toolErrorResult(new ToolNotFoundError(
  1359. name,
  1360. `only \`${RUN_CODE_NAME}\` is callable directly — call \`${name}\` from inside a \`${RUN_CODE_NAME}\` program instead`,
  1361. )),
  1362. }
  1363. }
  1364. return { kind: 'ready', exec: execution }
  1365. } catch (error: unknown) {
  1366. const execution: MutableToolRunContext = { ...base, arguments: undefined }
  1367. this.contentFinalizers.set(execution, finalizerFor())
  1368. return { kind: 'final-result', exec: execution, result: toolErrorResult(error) }
  1369. }
  1370. }
  1371. /**
  1372. * Run the ordered pre-execute and monotonic guard stages for the scheduler.
  1373. * @param input - the caller-supplied execution input.
  1374. * @returns the prepared execution plus the next scheduler stage.
  1375. * @internal
  1376. */
  1377. private async prepareScheduledExecution(input: ToolExecutionInput): Promise<ScheduledToolPreparation> {
  1378. return this.prepareExecution(input, prepared => prepared)
  1379. }
  1380. private async prepareExecution<T>(
  1381. input: ToolExecutionInput,
  1382. next: (prepared: ScheduledToolPreparation) => T | PromiseLike<T>,
  1383. ): Promise<T> {
  1384. const created = this.createExecution(input)
  1385. if (created.kind !== 'ready') return next(created)
  1386. const exec = created.exec
  1387. if (this.callerCancelled(exec)) {
  1388. return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() })
  1389. }
  1390. try {
  1391. const carrier = scopeTarget(this, exec.agent)
  1392. const gate = await this.ctx.waterfall(
  1393. carrier, 'tools/pre-execute', exec,
  1394. () => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
  1395. )
  1396. const askResolution = gate.kind === 'ask'
  1397. ? await this.serviceAsk(exec, gate)
  1398. : { decision: gate, approvalCancelled: false }
  1399. const { decision } = askResolution
  1400. if (this.callerCancelled(exec) && askResolution.approvalCancelled) {
  1401. return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
  1402. }
  1403. if (decision.kind === 'cancel') {
  1404. return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
  1405. }
  1406. const denialReason = decision.kind === 'allow' ? this.guardReason(exec) : decision.reason
  1407. const denialInfo = decision.kind === 'deny' ? decision.info : undefined
  1408. if (denialReason !== undefined) {
  1409. return await next({
  1410. kind: 'post-result',
  1411. exec,
  1412. result: this.materializeFinalResult({
  1413. content: [{ type: 'text', text: `Error: ${denialReason}` }],
  1414. isError: true,
  1415. error: { message: denialReason, ...denialInfo === undefined ? {} : { info: denialInfo } },
  1416. }),
  1417. })
  1418. }
  1419. if (this.callerCancelled(exec)) {
  1420. return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
  1421. }
  1422. return await next({ kind: 'dispatch', exec })
  1423. } catch (error: unknown) {
  1424. return next({ kind: 'final-result', exec, result: toolErrorResult(error) })
  1425. }
  1426. }
  1427. /** Whether the original caller signal is currently aborted. */
  1428. private callerCancelled(exec: ToolRunContext): boolean {
  1429. const state = this.cancellationStates.get(exec)
  1430. /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
  1431. if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
  1432. return state.callerSignal.aborted
  1433. }
  1434. /** Canonical cancellation outcome selected by whether the tool body started. */
  1435. private cancellationResult(exec: ToolRunContext, prior?: ToolExecutionResult): ToolExecutionResult {
  1436. const state = this.cancellationStates.get(exec)
  1437. /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
  1438. if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
  1439. return state.bodyInvoked
  1440. ? toolAbortedResult(prior)
  1441. : toolAbortedBeforeDispatchResult(prior)
  1442. }
  1443. /**
  1444. * Dispatch the registered body with the original caller signal fused back
  1445. * into any around-wrapper replacement. Cancellation never abandons the body:
  1446. * a started promise reaches quiescence before its outcome becomes `ABORTED`.
  1447. */
  1448. private async dispatchToolBody(exec: MutableToolRunContext): Promise<ToolExecutionResult> {
  1449. const state = this.cancellationStates.get(exec)
  1450. /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
  1451. if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
  1452. const wrapperSignal = exec.signal
  1453. const fused = fuseToolSignals(state.callerSignal, wrapperSignal)
  1454. const signal = fused.signal
  1455. if (isAborted(signal)) {
  1456. fused.dispose()
  1457. return toolAbortedBeforeDispatchResult()
  1458. }
  1459. exec.signal = signal
  1460. try {
  1461. const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
  1462. if (!tool) throw new ToolNotFoundError(exec.name)
  1463. state.bodyInvoked = true
  1464. const returned = await tool.execute(exec.arguments, exec)
  1465. const result = this.createSuccessResult(exec, tool, returned)
  1466. return isAborted(signal)
  1467. ? toolAbortedResult(result)
  1468. : result
  1469. } catch (error: unknown) {
  1470. return toolErrorResult(error)
  1471. } finally {
  1472. fused.dispose()
  1473. exec.signal = wrapperSignal
  1474. }
  1475. }
  1476. /**
  1477. * Run around-dispatch and the tool body. Tool and unknown-tool failures still
  1478. * receive post-execute; pipeline failures are already final.
  1479. * @param exec - the prepared execution.
  1480. * @returns whether the result still needs post-execute.
  1481. * @internal
  1482. */
  1483. private async dispatchScheduledExecution(exec: ToolRunContext): Promise<ScheduledToolDispatch> {
  1484. try {
  1485. const mutableExec = exec as MutableToolRunContext
  1486. const carrier = scopeTarget(this, exec.agent)
  1487. const result = await this.ctx.waterfall(
  1488. carrier, 'tools/execute', mutableExec,
  1489. () => this.dispatchToolBody(mutableExec),
  1490. )
  1491. const normalized = this.normalizeDispatchResult(exec, result)
  1492. const deferredContexts = this.deferredContexts.get(exec)
  1493. /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
  1494. if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution')
  1495. const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
  1496. ? normalized
  1497. : this.markCanonical(exec, {
  1498. ...normalized,
  1499. additionalContexts: [
  1500. ...deferredContexts,
  1501. ...normalized.additionalContexts ?? [],
  1502. ],
  1503. })
  1504. return {
  1505. kind: 'post-result',
  1506. result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError
  1507. ? this.cancellationResult(exec, resultWithDeferredContexts)
  1508. : resultWithDeferredContexts,
  1509. }
  1510. } catch (error: unknown) {
  1511. return { kind: 'final-result', result: toolErrorResult(error) }
  1512. }
  1513. }
  1514. /**
  1515. * Run ordered post-execute, then apply definition-owned content finalization,
  1516. * materialize, and notify the final outcome.
  1517. * @param exec - the prepared execution.
  1518. * @param result - dispatch/pre result that still needs post-execute.
  1519. * @returns the materialized final result.
  1520. * @internal
  1521. */
  1522. private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult> {
  1523. try {
  1524. const postResult = await this.postExecute(exec, result)
  1525. return this.finishScheduledExecution(
  1526. exec,
  1527. this.callerCancelled(exec) && !postResult.isError
  1528. ? this.cancellationResult(exec, postResult)
  1529. : postResult,
  1530. )
  1531. } catch (error: unknown) {
  1532. return this.finishScheduledExecution(exec, toolErrorResult(error))
  1533. }
  1534. }
  1535. /**
  1536. * Materialize the candidate, apply definition-owned content finalization,
  1537. * then materialize and notify the authoritative result.
  1538. * @param exec - the prepared execution.
  1539. * @param result - final result.
  1540. * @returns the materialized final result.
  1541. * @internal
  1542. */
  1543. private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
  1544. let materializedResult: ToolExecutionResult
  1545. try {
  1546. materializedResult = this.materializeFinalResult(result)
  1547. } catch (error: unknown) {
  1548. materializedResult = this.materializeFinalResult(toolErrorResult(error))
  1549. }
  1550. let finalResult: ToolExecutionResult
  1551. try {
  1552. finalResult = this.materializeFinalResult(this.applyFinalContent(exec, materializedResult))
  1553. } catch (error: unknown) {
  1554. finalResult = this.materializeFinalResult(toolErrorResult(error))
  1555. }
  1556. this.notifyResult(exec, finalResult)
  1557. return finalResult
  1558. }
  1559. /** Apply the snapshotted tool-owned content transform without exposing other result fields. */
  1560. private applyFinalContent(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
  1561. const finalizeContent = this.contentFinalizers.get(exec)
  1562. if (finalizeContent === undefined) return result
  1563. const content = finalizeContent(exec, result)
  1564. return content === undefined ? result : { ...result, content }
  1565. }
  1566. /** Notify observers without exposing a mutation or error channel into the outcome. */
  1567. private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
  1568. // Freeze the registry's live object before observers receive its readonly
  1569. // WeakMap-keyable view.
  1570. Object.freeze(exec)
  1571. const { name: toolName, callId } = exec
  1572. const reportFailure = (error: unknown): void => {
  1573. this.ctx.logger.warn(`tool "${toolName}" (${callId}): tools/result observer failed: ${errorMessage(error)}`)
  1574. }
  1575. const callbacks = this.ctx.events.dispatch('emit', [
  1576. scopeTarget(this, exec.agent), 'tools/result', exec, result,
  1577. ])
  1578. for (const callback of callbacks) {
  1579. try {
  1580. const returned: unknown = callback(exec, result)
  1581. void Promise.resolve(returned).catch(reportFailure)
  1582. } catch (error: unknown) {
  1583. reportFailure(error)
  1584. }
  1585. }
  1586. }
  1587. /**
  1588. * Resolve an `ask` decision to allow/deny through the approval seam. The
  1589. * seam is consumed opportunistically with `ctx.get('approval')` — a
  1590. * deployment that composes no ApprovalService keeps the historical degrade
  1591. * to deny, and an unmount mid-session degrades the same way on the next ask.
  1592. * An agent-less execution also degrades: without an agent there is no
  1593. * session to audit to and no UI to route to. Otherwise the outcome maps
  1594. * one-to-one — `allowed-once` proceeds; the three non-grants deny with
  1595. * distinct reasons so the model can tell a human "no" from an absent
  1596. * approval channel.
  1597. */
  1598. private async serviceAsk(
  1599. exec: ToolExecution,
  1600. ask: Extract<PreToolDecision, { kind: 'ask' }>,
  1601. ): Promise<ToolAskResolution> {
  1602. const approval = this.ctx.get('approval')
  1603. if (approval === undefined) {
  1604. return {
  1605. decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` },
  1606. approvalCancelled: false,
  1607. }
  1608. }
  1609. if (exec.agent === undefined) {
  1610. return {
  1611. decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` },
  1612. approvalCancelled: false,
  1613. }
  1614. }
  1615. const outcome = await approval.request({
  1616. agent: exec.agent,
  1617. toolName: exec.name,
  1618. callId: exec.callId,
  1619. ...ask.reason !== undefined ? { reason: ask.reason } : {},
  1620. signal: exec.signal,
  1621. })
  1622. switch (outcome) {
  1623. case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false }
  1624. case 'rejected': return {
  1625. decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` },
  1626. approvalCancelled: false,
  1627. }
  1628. case 'cancelled': return {
  1629. decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` },
  1630. approvalCancelled: true,
  1631. }
  1632. case 'unavailable': return {
  1633. decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` },
  1634. approvalCancelled: false,
  1635. }
  1636. default: return assertNever(outcome, 'ApprovalOutcome')
  1637. }
  1638. }
  1639. /**
  1640. * Run the `tools/post-execute` waterfall over a dispatched `result` and apply
  1641. * its {@link PostToolDecision}: `accept` keeps the call successful (replacing
  1642. * `content` when given), `block` turns it into an `isError` whose content is
  1643. * the corrective `feedback`. Either decision may attach `additionalContexts`,
  1644. * which are ferried on the returned result for the loop's active-batch FIFO.
  1645. * Context deferred by the tool body survives an accepted result but is
  1646. * discarded when the outer call is blocked; a block exposes only context the
  1647. * blocking decision explicitly supplied.
  1648. * Runs inside `execute`'s outer try/catch (a throwing listener → isError).
  1649. */
  1650. private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
  1651. const decision = await this.ctx.waterfall(
  1652. scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
  1653. () => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
  1654. )
  1655. const decisionContexts = decision.additionalContexts ?? []
  1656. if (decision.kind === 'block') {
  1657. const message = failureMessageFromContent(decision.feedback)
  1658. return this.markCanonical(exec, {
  1659. content: decision.feedback,
  1660. isError: true,
  1661. error: { message },
  1662. ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},
  1663. })
  1664. }
  1665. if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) {
  1666. throw new TypeError('tools/post-execute accept decision cannot replace both value and content')
  1667. }
  1668. const additionalContexts = [
  1669. ...result.additionalContexts ?? [],
  1670. ...decisionContexts,
  1671. ]
  1672. if (Object.hasOwn(decision, 'value')) {
  1673. if (result.isError) {
  1674. throw new TypeError('tools/post-execute cannot replace the value of a failed result')
  1675. }
  1676. const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
  1677. if (tool === undefined) throw new ToolNotFoundError(exec.name)
  1678. const replaced = this.createSuccessResult(exec, tool, decision.value)
  1679. return this.markCanonical(exec, {
  1680. ...replaced,
  1681. ...additionalContexts.length > 0 ? { additionalContexts } : {},
  1682. })
  1683. }
  1684. return this.markCanonical(exec, {
  1685. ...result,
  1686. ...decision.content !== undefined ? { content: decision.content } : {},
  1687. ...additionalContexts.length > 0 ? { additionalContexts } : {},
  1688. })
  1689. }
  1690. /** Registry-normalized results and the exact dispatch that validated each value. */
  1691. private readonly canonicalResults = new WeakMap<object, ToolExecutionToken>()
  1692. /** Mark one registry-normalized result as canonical only for its owning dispatch. */
  1693. private markCanonical<T extends ToolExecutionResult>(exec: ToolExecution, result: T): T {
  1694. this.canonicalResults.set(result, exec.token)
  1695. return result
  1696. }
  1697. /** Snapshot, validate, render, and optionally project one successful body value. */
  1698. private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess {
  1699. const detached = snapshotToolValue(tool.name, candidate)
  1700. const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
  1701. if (violations.length > 0) throw new ToolOutputError(tool.name, violations)
  1702. const value = deepFreeze(detached)
  1703. let rendered: ContentBlock[]
  1704. try {
  1705. rendered = tool.output.render(exec.arguments, value)
  1706. } catch (error: unknown) {
  1707. throw projectionError(tool.name, 'render', error)
  1708. }
  1709. const content = snapshotProjection(tool.name, 'render', rendered)
  1710. let meta: JsonValue | undefined
  1711. if (exec.parent === undefined && tool.output.presentationMeta !== undefined) {
  1712. let projected: JsonValue
  1713. try {
  1714. projected = tool.output.presentationMeta(exec.arguments, value)
  1715. } catch (error: unknown) {
  1716. throw projectionError(tool.name, 'presentationMeta', error)
  1717. }
  1718. meta = snapshotProjection(tool.name, 'presentationMeta', projected)
  1719. }
  1720. const concludesTurn = this.concludingExecutions.has(exec)
  1721. return this.markCanonical(exec, this.materializeFinalResult({
  1722. isError: false,
  1723. value,
  1724. content,
  1725. ...meta !== undefined ? { meta } : {},
  1726. ...concludesTurn ? { concludesTurn: true as const } : {},
  1727. }) as ToolExecutionSuccess)
  1728. }
  1729. /** Normalize an around-dispatch wrapper's authored result through the owning output contract. */
  1730. private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult {
  1731. if (this.canonicalResults.get(result) === exec.token) return result
  1732. if (result.isError) {
  1733. return this.markCanonical(exec, {
  1734. isError: true,
  1735. error: result.error,
  1736. content: result.content,
  1737. ...result.meta !== undefined ? { meta: result.meta } : {},
  1738. ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
  1739. })
  1740. }
  1741. const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
  1742. if (tool === undefined) throw new ToolNotFoundError(exec.name)
  1743. const normalized = this.createSuccessResult(exec, tool, result.value)
  1744. return this.markCanonical(exec, {
  1745. ...normalized,
  1746. ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
  1747. })
  1748. }
  1749. /** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
  1750. private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult {
  1751. const presentation = {
  1752. content: result.content,
  1753. ...result.meta !== undefined ? { meta: result.meta } : {},
  1754. ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
  1755. }
  1756. if (result.isError) {
  1757. return materializePresentation({ isError: true as const, error: result.error, ...presentation })
  1758. }
  1759. const detached = materializePresentation({
  1760. isError: false as const,
  1761. ...presentation,
  1762. ...result.concludesTurn === true ? { concludesTurn: true as const } : {},
  1763. })
  1764. return deepFreeze({ ...detached, value: result.value })
  1765. }
  1766. }
  1767. /** Mint a same-process correlation token whose identity is its value. */
  1768. function createExecutionToken(): ToolExecutionToken {
  1769. return Symbol('dsh.tool.execution') as ToolExecutionToken
  1770. }
  1771. function toolErrorResult(error: unknown): ToolExecutionResult {
  1772. const info = errorInfo(error)
  1773. const message = errorMessage(error)
  1774. return {
  1775. content: [{ type: 'text', text: `Error: ${message}` }],
  1776. isError: true,
  1777. error: { message, ...info ? { info } : {} },
  1778. }
  1779. }
  1780. /** Read live abort state across an await without treating it as synchronously immutable. */
  1781. function isAborted(signal: AbortSignal): boolean {
  1782. return signal.aborted
  1783. }
  1784. /**
  1785. * Fuse caller and wrapper cancellation without nesting `AbortSignal.any`.
  1786. * Keeping the relay dispatch-scoped also removes listeners when work settles.
  1787. */
  1788. function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal {
  1789. if (caller === wrapper) return { signal: caller, dispose() {} }
  1790. const controller = new AbortController()
  1791. let listening = false
  1792. const dispose = (): void => {
  1793. if (!listening) return
  1794. listening = false
  1795. caller.removeEventListener('abort', abortFromCaller)
  1796. wrapper.removeEventListener('abort', abortFromWrapper)
  1797. }
  1798. const abortFrom = (source: AbortSignal): void => {
  1799. const reason: unknown = source.reason
  1800. controller.abort(reason)
  1801. dispose()
  1802. }
  1803. const abortFromCaller = (): void => { abortFrom(caller) }
  1804. const abortFromWrapper = (): void => { abortFrom(wrapper) }
  1805. if (wrapper.aborted) abortFromWrapper()
  1806. else if (caller.aborted) abortFromCaller()
  1807. else {
  1808. listening = true
  1809. caller.addEventListener('abort', abortFromCaller, { once: true })
  1810. wrapper.addEventListener('abort', abortFromWrapper, { once: true })
  1811. }
  1812. return { signal: controller.signal, dispose }
  1813. }
  1814. /** Canonical result when cancellation supersedes success after body invocation. */
  1815. function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult {
  1816. const additionalContexts = prior?.additionalContexts ?? []
  1817. return {
  1818. content: [{ type: 'text', text: 'Error: tool call aborted' }],
  1819. isError: true,
  1820. error: {
  1821. message: 'tool call aborted',
  1822. info: { name: 'AbortError', code: TOOL_ABORTED },
  1823. },
  1824. ...additionalContexts.length > 0 ? { additionalContexts } : {},
  1825. }
  1826. }
  1827. /** Canonical result when cancellation prevents tool body invocation. */
  1828. function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecutionResult {
  1829. const additionalContexts = prior?.additionalContexts ?? []
  1830. return {
  1831. content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
  1832. isError: true,
  1833. error: {
  1834. message: 'tool call aborted before dispatch',
  1835. info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
  1836. },
  1837. ...additionalContexts.length > 0 ? { additionalContexts } : {},
  1838. }
  1839. }
  1840. export default ToolRuntime