index.ts 67 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570
  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 'cordis'
  7. import z from '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 { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
  11. import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
  12. import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
  13. import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
  14. import type { JsonValue } from '@deepseek-ai/dsh-session'
  15. import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
  16. import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
  17. // Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
  18. // augmentation. The seam stays optional at runtime — see `serviceAsk`.
  19. import type {} from '@deepseek-ai/dsh-user-approval'
  20. import type { ToolCallView, ToolResultView } from './presentation.ts'
  21. import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts'
  22. import type { JsonSchemaNode } from './json-schema.ts'
  23. import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
  24. import { renderToolsSdk } from './ts-types.ts'
  25. import type { ToolSdkSchema } from './ts-types.ts'
  26. export {
  27. defineTool,
  28. valueSchemaSpecToJsonSchema,
  29. parameterSchemaSpecToJsonSchema,
  30. validateArgs,
  31. ToolArgsError,
  32. type ValueSchemaAnnotations,
  33. type StringValueSchemaSpec,
  34. type NumberValueSchemaSpec,
  35. type IntegerValueSchemaSpec,
  36. type BooleanValueSchemaSpec,
  37. type NullValueSchemaSpec,
  38. type ArrayValueSchemaSpec,
  39. type ObjectValueSchemaSpec,
  40. type JsonValueSchemaSpec,
  41. type OneOfValueSchemaSpec,
  42. type ValueSchemaSpec,
  43. type ParameterPropertySpec,
  44. type ParameterSchemaSpec,
  45. type ParameterJsonSchema,
  46. type InferValue,
  47. type InferArgs,
  48. type DefineToolOptions,
  49. } from './schema.ts'
  50. export {
  51. assertSupportedJsonSchema,
  52. assertObjectJsonSchema,
  53. validateJsonSchemaValue,
  54. JsonSchemaError,
  55. type JsonSchemaNode,
  56. type ObjectJsonSchema,
  57. type JsonSchemaType,
  58. type JsonSchemaScalar,
  59. } from './json-schema.ts'
  60. export type { JsonValue } from '@deepseek-ai/dsh-session'
  61. export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
  62. export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
  63. export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts'
  64. // The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
  65. // lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
  66. // stays the single public surface for tool producers and UI adapters.
  67. export type {
  68. ToolCallKind,
  69. FileLocation,
  70. FileDiff,
  71. ToolCallView,
  72. GenericCallView,
  73. TerminalCallView,
  74. DiffCallView,
  75. ToolResultView,
  76. GenericResultView,
  77. TerminalResultView,
  78. DiffResultView,
  79. } from './presentation.ts'
  80. declare module 'cordis' {
  81. interface Context {
  82. tools: ToolRegistry
  83. }
  84. interface Events {
  85. /**
  86. * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing
  87. * approval support turns `ask` into denial. Async gates must observe
  88. * `exec.signal`; the registry rechecks cancellation after they settle but
  89. * never abandons their promise.
  90. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
  91. * @param exec - the pending call (name, parsed arguments, caller agent).
  92. * @mode waterfall
  93. */
  94. 'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
  95. /**
  96. * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns
  97. * a normalized result; wrappers may change only `exec.signal`, while call
  98. * identity remains immutable. The registry re-fuses the original caller
  99. * signal before the body, so replacement cannot detach caller cancellation;
  100. * wrappers must still restore their signal and reach quiescence.
  101. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
  102. * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
  103. * @mode waterfall
  104. */
  105. 'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
  106. /**
  107. * Accept, replace, enrich, or block a normalized dispatch result. `next()`
  108. * accepts it unchanged; thrown tools still reach this seam as errors. Async
  109. * listeners must observe `exec.signal`; after they settle, caller
  110. * cancellation replaces only a successful accepted outcome with the code
  111. * selected by whether the tool body was invoked.
  112. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
  113. * @param exec - the call that just ran (name, parsed arguments, caller agent).
  114. * @param result - the dispatch outcome a listener may accept, replace, or block.
  115. * @mode waterfall
  116. */
  117. 'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
  118. /**
  119. * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before
  120. * the bridge appends its `tool/code-dispatch` event. `next()` keeps the
  121. * content unchanged; a listener may return replacement blocks (e.g. the
  122. * spill policy's preview + locator for an oversized text result). Only the
  123. * logged copy is affected — the program already received the complete
  124. * value, and the model sees neither. A throwing listener is contained:
  125. * the bridge falls back to logging the unshaped content.
  126. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches.
  127. * @param dispatch - the parent execution, sub-call identity, and the settled content to log.
  128. * @mode waterfall
  129. */
  130. 'tools/code-dispatch-log'(this: Scoped<ToolRegistry>, dispatch: CodeDispatchLog, next: () => Promise<ContentBlock[]>): Promise<ContentBlock[]>
  131. /**
  132. * Observe the frozen, lossless-JSON final outcome. Listener failures are contained.
  133. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.
  134. * @param exec - the execution object that traversed the pipeline.
  135. * @param result - a deep-frozen snapshot of the final returned result.
  136. * @mode emit
  137. */
  138. 'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined
  139. /**
  140. * A tool was registered or unregistered, or a scoped restriction changed
  141. * (the available tool set changed — possibly for one scope only). An
  142. * UNFILTERED registry-subject notification, deliberately not scope-filtered
  143. * dispatch: a global change concerns every agent's next assembly, so a
  144. * scoped listener subscribing here sees every change, not just its own
  145. * scope's.
  146. * @mode emit
  147. */
  148. 'tools/change'(): void
  149. }
  150. }
  151. /** Tool-owned canonical output contract used after the body returns a JSON value. */
  152. export interface ToolOutputDefinition {
  153. /** Raw supported JSON Schema enforced against every successful canonical value. */
  154. readonly schema: JsonSchemaNode
  155. /** Pure projection from validated arguments and value to Native/model content. */
  156. render(args: unknown, value: JsonValue): ContentBlock[]
  157. /** Pure replayable presentation projection, computed only for surface calls. */
  158. presentationMeta?(args: unknown, value: JsonValue): JsonValue
  159. }
  160. /** A registered tool: its schema plus the execution function. */
  161. export interface ToolDefinition extends ToolSchema {
  162. /** Mandatory canonical output declaration. */
  163. readonly output: ToolOutputDefinition
  164. /**
  165. * Run one accepted call and return only its canonical lossless-JSON value.
  166. * Async work must observe or forward `exec.signal` and settle only after its
  167. * owned work reaches quiescence. The registry preserves caller cancellation
  168. * through around-dispatch signal replacement and does not abandon this
  169. * promise, but it cannot hard-kill same-process code.
  170. * @param args - losslessly snapshotted, frozen model arguments.
  171. * @param exec - execution identity, cancellation signal, and context deferral.
  172. * @returns the canonical value declared by `output.schema`.
  173. */
  174. execute(args: unknown, exec: ToolRunContext): Promise<unknown>
  175. /**
  176. * Synchronous last-mile transform for model-facing content. The registry
  177. * snapshots this callback when execution starts and invokes it exactly once
  178. * for every normalized outcome, including pipeline failures that bypass
  179. * `tools/post-execute`, immediately before lossless materialization.
  180. * Returning `undefined` preserves the content; every other result field
  181. * remains registry-owned. The callback must be total and must not throw.
  182. * @param exec - immutable execution identity and arguments.
  183. * @param result - complete normalized outcome before materialization.
  184. * @returns replacement content, or `undefined` to preserve it.
  185. */
  186. finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
  187. /**
  188. * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
  189. * Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
  190. * is NEVER sent to the model — `schemas()` whitelists only name/description/
  191. * parameters. Declaring it asserts this tool forwards `exec.signal` to a
  192. * cooperative implementation that can reach quiescence when the signal aborts.
  193. */
  194. timeoutMs?: number
  195. /**
  196. * Pure synchronous classifier for overlap with sibling tool calls. Only
  197. * `true` opts in; omission, exceptions, non-`true` returns, and invalid
  198. * `defineTool` arguments are exclusive. This metadata is never model-visible.
  199. *
  200. * Opted-in executions must not mutate parent-owned state. Shared state must
  201. * tolerate concurrent dispatch; recorder races are permitted only when they
  202. * commute or fail closed. See the
  203. * [parallel-tool-call Agent Note](../../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)
  204. * for the full contract.
  205. * @param args - parsed arguments; `defineTool` validates before calling.
  206. * @returns Whether this call may join a parallel group.
  207. */
  208. isConcurrencySafe?(args: unknown): boolean
  209. /**
  210. * Optional: how to present the PENDING state of one call in a UI, derived from
  211. * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
  212. * its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent),
  213. * or `undefined` (or omit the method) to fall back to a generic presentation
  214. * (title = tool name, raw args as input). Pure and side-effect-free: a UI may
  215. * call it during live streaming AND a session-log replay, so it must depend
  216. * only on `args`.
  217. */
  218. presentCall?(args: unknown): ToolCallView | undefined
  219. /**
  220. * Optional: how to present the COMPLETED state, given the same `args` and the
  221. * durable result projection (`content`, failure state, and optional `meta`). Returns a
  222. * {@link ToolResultView}, or `undefined` (or omit the method) to keep the
  223. * pending title and render the raw result content. Pure and side-effect-free
  224. * for the same replay reason.
  225. */
  226. presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined
  227. }
  228. /** The completed outcome handed to {@link ToolDefinition.presentResult}. */
  229. export interface ToolResult {
  230. /** The final model-facing content (or the rendered error text on failure). */
  231. content: ContentBlock[]
  232. /** Whether the call failed. */
  233. isError: boolean
  234. /**
  235. * The tool-private presentation payload projected by its output declaration
  236. * and threaded verbatim from the `tool/result` event. Absent when the tool
  237. * declared no projector or the call was nested under a composite transport.
  238. */
  239. meta?: JsonValue
  240. }
  241. declare const toolExecutionTokenBrand: unique symbol
  242. /** Opaque call identity that permits correlation without exposing mutable execution state. */
  243. export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
  244. /**
  245. * Caller-supplied description of one tool call. {@link ToolRegistry.execute}
  246. * adds the registry-owned token to form a pipeline {@link ToolExecution};
  247. * callers do not choose that token.
  248. */
  249. export interface ToolExecutionInput {
  250. readonly callId: CallId
  251. readonly name: string
  252. /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */
  253. readonly arguments: unknown
  254. /** The agent on whose behalf the call runs (set by the agent loop). */
  255. readonly agent?: Agent
  256. /**
  257. * Opaque token of the enclosing transport execution, when one exists. Code
  258. * Mode sets this on SDK sub-dispatches so commit-style observers can wait for
  259. * the outer `run_code` outcome without receiving its live mutable execution.
  260. */
  261. readonly parent?: ToolExecutionToken
  262. /** Required caller-owned cancellation for this invocation. */
  263. readonly signal: AbortSignal
  264. }
  265. /**
  266. * Scheduling mode for one pending call. `parallel` may overlap with siblings;
  267. * `exclusive` runs alone and forms an ordering barrier.
  268. */
  269. export type ToolExecutionMode =
  270. | { kind: 'parallel' }
  271. | { kind: 'exclusive' }
  272. /**
  273. * One settled `run_code` sub-dispatch about to be logged, as seen by the
  274. * `tools/code-dispatch-log` waterfall: the parent execution (session owner,
  275. * outer call identity), the sub-call identity, and the outcome whose durable
  276. * copy a listener may reshape. `content` is the RENDERED result projection
  277. * (what a native `tool/result` would carry) — the program itself received
  278. * the structured `value` (or just the error message on failure); only the
  279. * `tool/code-dispatch` event's copy changes.
  280. */
  281. export interface CodeDispatchLog {
  282. /** The outer `run_code` execution. */
  283. readonly exec: ToolExecution
  284. /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */
  285. readonly agent?: Agent
  286. /** Deterministic sub-call id (`<parent>:code:<n>`). */
  287. readonly subCallId: CallId
  288. /** The dispatched sub-tool name. */
  289. readonly name: string
  290. /** Whether the sub-call settled as an error. */
  291. readonly isError: boolean
  292. /** The sub-call's complete model-facing content (the settle event's default payload). */
  293. readonly content: ContentBlock[]
  294. }
  295. /**
  296. * One pending tool call inside the registry pipeline. Parsed arguments cross
  297. * one lossless-JSON materialization boundary before policy and are deep-frozen;
  298. * call identity, the caller signal, and the registry-assigned {@link token} are
  299. * readonly. The registry freezes the complete object before `tools/result`
  300. * observers run.
  301. */
  302. export interface ToolExecution extends ToolExecutionInput {
  303. /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
  304. readonly token: ToolExecutionToken
  305. }
  306. /**
  307. * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper
  308. * may replace the signal for its delegated lifetime, but it cannot remove it.
  309. * The registry fuses every replacement with the captured caller signal.
  310. */
  311. export interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> {
  312. /** Cancellation signal visible to the next wrapper or tool body. */
  313. signal: AbortSignal
  314. }
  315. /**
  316. * Runtime context handed to a tool implementation after the registry has
  317. * accepted a {@link ToolExecution}. A composite tool uses
  318. * {@link deferContext} to ferry context produced by nested dispatches back to
  319. * the outer result; the loop appends it only after the outer `tool/result`.
  320. */
  321. export interface ToolRunContext extends ToolExecution {
  322. /**
  323. * Defer one nested-dispatch context until this tool's final result reaches
  324. * the agent loop. Contexts retain their individual source and metadata and
  325. * are emitted in call order.
  326. */
  327. deferContext(context: HookContext): void
  328. }
  329. /** Registry-owned live execution object; public pipeline views stay readonly. */
  330. type MutableToolRunContext = Omit<ToolRunContext, 'signal'> & { signal: AbortSignal }
  331. /**
  332. * Scheduler-only result after ordered pre-execute and guards. A `post-result`
  333. * still receives post-execute; a `final-result` bypasses it.
  334. * @internal
  335. */
  336. export type ScheduledToolPreparation =
  337. | { kind: 'dispatch'; exec: ToolRunContext }
  338. | { kind: 'post-result'; exec: ToolRunContext; result: ToolExecutionResult }
  339. | { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult }
  340. /**
  341. * Scheduler-only dispatch result. A `post-result` still receives post-execute;
  342. * a `final-result` already matches {@link ToolRegistry.execute} failure semantics.
  343. * @internal
  344. */
  345. export type ScheduledToolDispatch =
  346. | { kind: 'post-result'; result: ToolExecutionResult }
  347. | { kind: 'final-result'; result: ToolExecutionResult }
  348. /**
  349. * Symbol-keyed scheduler view that keeps pre/post policy ordered while
  350. * overlapping dispatch. Ordinary callers use {@link ToolRegistry.execute};
  351. * this is not a plugin seam.
  352. * @internal
  353. */
  354. export interface ToolRegistryScheduler {
  355. /** Materialize input, run the ordered pre-execute/guard gate, and decide what stage follows. */
  356. prepare(exec: ToolExecutionInput): Promise<ScheduledToolPreparation>
  357. /** Run only the around-dispatch/body stage. */
  358. dispatch(exec: ToolRunContext): Promise<ScheduledToolDispatch>
  359. /** Run post-execute and definition-owned content finalization, then materialize and notify. */
  360. finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult>
  361. /** Run definition-owned content finalization, then materialize and notify without post-execute. */
  362. finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult
  363. }
  364. /**
  365. * Scheduler entry point omitted from the generated named service API.
  366. * @internal
  367. */
  368. export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')
  369. /** Canonical error code for cancellation after a tool body was invoked. */
  370. export const TOOL_ABORTED = 'ABORTED'
  371. /** Canonical error code for cancellation before a tool body was invoked. */
  372. export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'
  373. /** Structured error metadata for a failed tool call (alongside the model-facing text). */
  374. export interface ToolErrorInfo {
  375. name: string
  376. code: string
  377. }
  378. /** Canonical failure detail; internal routing information remains optional. */
  379. export interface ToolFailure {
  380. /** Human-readable failure message without the Native `Error: ` envelope. */
  381. message: string
  382. /** Internal error class/code used by policy and durable diagnostics. */
  383. info?: ToolErrorInfo
  384. }
  385. /**
  386. * Thrown (internally) when the model requests a tool that isn't registered.
  387. * Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool
  388. * failure is as routable as a tool-thrown one — retry/sandbox/replay code can
  389. * distinguish it from a tool body's own error.
  390. */
  391. export class ToolNotFoundError extends HarnessError {
  392. constructor(toolName: string) {
  393. super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL')
  394. this.name = 'ToolNotFoundError'
  395. }
  396. }
  397. /** Thrown when a tool body or post-policy value violates its declared output. */
  398. export class ToolOutputError extends HarnessError {
  399. /** Schema/value violations in validation order. */
  400. readonly violations: string[]
  401. constructor(toolName: string, violations: string[]) {
  402. super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT')
  403. this.name = 'ToolOutputError'
  404. this.violations = violations
  405. }
  406. }
  407. /** Convert one projector exception into the canonical invalid-output failure. */
  408. function projectionError(toolName: string, projector: 'render' | 'presentationMeta', error: unknown): ToolOutputError {
  409. return new ToolOutputError(toolName, [`output.${projector} failed: ${errorMessage(error)}`])
  410. }
  411. /** Snapshot one projector result before later durable-result materialization. */
  412. function snapshotProjection<T>(toolName: string, projector: 'render' | 'presentationMeta', candidate: T): T {
  413. try {
  414. const detached = snapshotJsonValue(candidate)
  415. if (detached === undefined) {
  416. throw new ToolOutputError(toolName, [`output.${projector} returned non-lossless JSON`])
  417. }
  418. return detached
  419. } catch (error: unknown) {
  420. if (error instanceof ToolOutputError) throw error
  421. throw projectionError(toolName, projector, error)
  422. }
  423. }
  424. /** Snapshot one body or policy value into the canonical invalid-output failure class. */
  425. function snapshotToolValue(toolName: string, candidate: unknown): JsonValue {
  426. try {
  427. const detached = snapshotJsonValue(candidate)
  428. if (detached === undefined) throw new ToolOutputError(toolName, ['value is not lossless JSON'])
  429. return detached as JsonValue
  430. } catch (error: unknown) {
  431. if (error instanceof ToolOutputError) throw error
  432. throw new ToolOutputError(toolName, [`value snapshot failed: ${errorMessage(error)}`])
  433. }
  434. }
  435. /** Successful canonical tool execution, including its Native/model projection. */
  436. export interface ToolExecutionSuccess {
  437. readonly isError: false
  438. /** Execution-local canonical value; deliberately omitted from durable events. */
  439. readonly value: JsonValue
  440. readonly content: ContentBlock[]
  441. readonly error?: never
  442. readonly meta?: JsonValue
  443. readonly additionalContexts?: HookContext[]
  444. }
  445. /** Failed canonical tool execution; failures never carry a successful value. */
  446. export interface ToolExecutionFailure {
  447. readonly isError: true
  448. readonly error: ToolFailure
  449. readonly value?: never
  450. readonly content: ContentBlock[]
  451. readonly meta?: JsonValue
  452. readonly additionalContexts?: HookContext[]
  453. }
  454. /** The discriminated, execution-local outcome of one tool call. */
  455. export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure
  456. /**
  457. * Pre-dispatch decision. `allow` runs the call; `deny` materializes an error;
  458. * `ask` runs only after an approval service returns `allowed-once` and otherwise
  459. * denies. Input rewriting is excluded because arguments are already logged and
  460. * presented.
  461. */
  462. export type PreToolDecision =
  463. | { kind: 'allow' }
  464. | { kind: 'deny'; reason: string }
  465. | { kind: 'ask'; reason?: string }
  466. /**
  467. * Post-dispatch decision: accept, replace one projection, attach context for the
  468. * next request, or block by turning corrective feedback into an error result.
  469. */
  470. export type PostToolDecision =
  471. | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: HookContext[] }
  472. | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: HookContext[] }
  473. | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] }
  474. /**
  475. * Best-effort human-readable message from an arbitrary thrown value: Error
  476. * instances use `.message`; non-Error objects with a string `message`
  477. * property (e.g. `throw { message: 'denied' }`) use it too; everything else
  478. * is stringified.
  479. */
  480. function errorMessage(error: unknown): string {
  481. try {
  482. if (error instanceof Error) return error.message
  483. if (typeof error === 'object' && error !== null
  484. && 'message' in error && typeof error.message === 'string') {
  485. return error.message
  486. }
  487. return String(error)
  488. } catch {
  489. // A hostile thrown value can trap `instanceof`, property access, or string
  490. // coercion. Error normalization is the outermost safety boundary, so its
  491. // fallback must itself be total.
  492. return '<unprintable thrown value>'
  493. }
  494. }
  495. /** Derive one failure message from policy feedback without changing its rendered blocks. */
  496. function failureMessageFromContent(content: ContentBlock[]): string {
  497. const text = content
  498. .map(block => block.type === 'text' ? block.text : `[${block.type} content]`)
  499. .join('\n')
  500. return text.length > 0 ? text : 'tool result blocked by post-execute policy'
  501. }
  502. /** Snapshot and freeze one durable tool-result projection or reject lossy data. */
  503. function materializePresentation<T>(candidate: T): T {
  504. const detached = snapshotJsonValue(candidate)
  505. if (detached === undefined) {
  506. throw new TypeError('tool result must be losslessly JSON-serializable')
  507. }
  508. return deepFreeze(detached)
  509. }
  510. /** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
  511. function errorInfo(error: unknown): ToolErrorInfo | undefined {
  512. try {
  513. return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
  514. } catch {
  515. return undefined
  516. }
  517. }
  518. /** How the registry presents its tools to the model (see {@link Config.mode}). */
  519. export type ToolPresentationMode = 'native' | 'code' | 'both'
  520. /** Plugin config: how the registered tools are presented to the model. */
  521. export interface Config {
  522. /**
  523. * Model presentation. `native` (default) sends every visible schema; `code`
  524. * sends only `run_code` plus a generated SDK prompt; `both` sends both forms.
  525. * Code modes require a TypeScript runtime and fail prompt assembly when it is
  526. * absent or mismatched. Under `code`, native names in `toolOrder` are invalid.
  527. */
  528. mode?: ToolPresentationMode
  529. /**
  530. * Concurrency cap for a `run_code` program's overlapping sub-calls
  531. * (default 10, the loop scheduler's own default). Sub-calls follow the
  532. * native scheduling contract — only calls whose tools classify
  533. * concurrency-safe overlap; exclusive calls form barriers — so `1`
  534. * restores strictly serial dispatch. Must be a positive integer.
  535. */
  536. maxParallelSubCalls?: number
  537. }
  538. /**
  539. * Per-scope filter over global tools. Restrictions intersect and do not affect
  540. * scoped registrations or the reserved Code Mode transport.
  541. */
  542. export interface ToolRestriction {
  543. /** Global tool names that stay visible; everything else is removed. */
  544. readonly allow?: readonly string[]
  545. /** Global tool names removed from visibility. */
  546. readonly deny?: readonly string[]
  547. }
  548. /** One restriction compiled at registration for repeated live-global lookup. */
  549. interface CompiledToolRestriction {
  550. readonly allow?: ReadonlySet<string>
  551. readonly deny?: ReadonlySet<string>
  552. }
  553. /** One scope's complete registry view, derived in a single layer traversal. */
  554. interface ToolView {
  555. /** Visible definitions after restrictions, scoped shadowing, and transport insertion. */
  556. readonly visible: ReadonlyMap<string, ToolDefinition>
  557. /** Pre-restriction capability names used by prompt-order validation. */
  558. readonly knownNames: ReadonlySet<string>
  559. /** Current global names that a scoped restriction may name. */
  560. readonly restrictableNames: ReadonlySet<string>
  561. }
  562. /**
  563. * A monotonic execution guard evaluated after every `tools/pre-execute`
  564. * listener and before the tool body. Returning a reason denies the call;
  565. * returning `undefined` leaves it unchanged. Because guards have no allow
  566. * result, listener ordering cannot turn a denial back into permission.
  567. * @param execution - the identity-protected call after extensible pre-execute policy completed.
  568. * @returns a final denial reason, or `undefined` to leave the call allowed.
  569. */
  570. export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
  571. /** One scope's complete tool-registry contribution. */
  572. class ToolLayer implements ScopeLayer {
  573. readonly tools: NamedEntries<ToolDefinition>
  574. readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
  575. readonly guards = new AnonymousEntries<ToolGuard>()
  576. constructor(scope: ScopeKey | undefined) {
  577. this.tools = new NamedEntries(name => new Error(scope === undefined
  578. ? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
  579. : `tool "${name}" is already registered in this scope`))
  580. }
  581. /** Whether every contribution table in this aggregate layer is empty. */
  582. isEmpty(): boolean {
  583. return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty()
  584. }
  585. /** Whether every compiled restriction in this layer admits a global tool name. */
  586. admits(name: string): boolean {
  587. for (const filter of this.restrictions.values()) {
  588. if ((filter.allow !== undefined && !filter.allow.has(name))
  589. || (filter.deny !== undefined && filter.deny.has(name))) return false
  590. }
  591. return true
  592. }
  593. /** First monotonic denial from this layer's live guard registrations. */
  594. guardReason(exec: ToolExecution): string | undefined {
  595. for (const guard of this.guards.values()) {
  596. const reason = guard(exec)
  597. if (reason !== undefined) return reason
  598. }
  599. return undefined
  600. }
  601. }
  602. /** Approval decision plus whether the approval channel reported cancellation. */
  603. interface ToolAskResolution {
  604. readonly decision: Extract<PreToolDecision, { kind: 'allow' | 'deny' }>
  605. readonly approvalCancelled: boolean
  606. }
  607. /** Caller cancellation and dispatch state kept outside the around-wrapper view. */
  608. interface ToolCancellationState {
  609. readonly callerSignal: AbortSignal
  610. bodyInvoked: boolean
  611. }
  612. /** One dispatch-scoped fused signal plus listener cleanup after the body settles. */
  613. interface FusedToolSignal {
  614. readonly signal: AbortSignal
  615. dispose(): void
  616. }
  617. /** Resolve the run_code overlap cap at the owning config boundary (direct construction bypasses the Loader schema). */
  618. function resolveMaxParallelSubCalls(value: number | undefined): number {
  619. const maxParallelSubCalls = value ?? 10
  620. if (!Number.isInteger(maxParallelSubCalls) || maxParallelSubCalls < 1) {
  621. throw new Error('maxParallelSubCalls must be a positive integer')
  622. }
  623. return maxParallelSubCalls
  624. }
  625. /**
  626. * Tool registry and execution pipeline. Scoped registrations shadow globals;
  627. * one visibility resolver feeds presentation, lookup, and dispatch.
  628. */
  629. export class ToolRegistry extends Service {
  630. static inject = ['systemPrompt']
  631. static Config: z<Config> = z.object({
  632. mode: z.union(['native', 'code', 'both'] as const).default('native'),
  633. maxParallelSubCalls: z.natural().min(1).default(10),
  634. })
  635. /** Internal staged view consumed by `dsh-agent-loop`'s parallel scheduler. */
  636. readonly [TOOL_REGISTRY_SCHEDULER]: ToolRegistryScheduler = {
  637. prepare: exec => this.prepareScheduledExecution(exec),
  638. dispatch: exec => this.dispatchScheduledExecution(exec),
  639. finalize: (exec, result) => this.finalizeScheduledExecution(exec, result),
  640. finish: (exec, result) => this.finishScheduledExecution(exec, result),
  641. }
  642. /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
  643. private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
  644. /** Original caller cancellation, kept outside the wrapper-mutable execution object. */
  645. private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
  646. /** Definition-owned final content transform snapshotted before policy begins. */
  647. private contentFinalizers = new WeakMap<ToolRunContext, ToolDefinition['finalizeContent']>()
  648. private readonly layers = new ScopedLayers(
  649. scope => new ToolLayer(scope),
  650. () => { this.ctx.emit('tools/change') },
  651. )
  652. private readonly mode: ToolPresentationMode
  653. /** Reserved presentation transport, kept outside the filterable registration layers. */
  654. private readonly codeTransport: ToolDefinition | undefined
  655. constructor(ctx: Context, config: Config = {}) {
  656. super(ctx, 'tools')
  657. // The schema already defaulted an omitted mode; the ?? narrows the
  658. // optional-input type for direct (non-Loader) construction in tests.
  659. this.mode = config.mode ?? 'native'
  660. // `run_code` is presentation infrastructure, not an end capability. It
  661. // therefore does not enter the global layer: per-agent restrictions must
  662. // not remove it, and a scoped registration must not shadow it. The
  663. // visibility resolver appends this reserved definition after resolving
  664. // the filterable global/scoped capability layers.
  665. this.codeTransport = this.mode === 'native'
  666. ? undefined
  667. : createRunCodeTool(this, {
  668. requireRuntime: () => this.requireCodeRuntime(),
  669. maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls),
  670. shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
  671. })
  672. ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
  673. if (this.mode !== 'native') {
  674. ctx.systemPrompt.section({
  675. name: 'tools:sdk',
  676. order: SDK_SECTION_ORDER,
  677. // Regenerate from the calling scope's visible tools in stable order.
  678. text: (context) => {
  679. this.requireCodeRuntime()
  680. return renderToolsSdk(this.sdkSchemas(context.scope))
  681. },
  682. })
  683. }
  684. }
  685. /**
  686. * Build one scope's wire schemas and names for prompt-order validation.
  687. * Restrictions do not make known tools invalid, but a mode collapse does.
  688. */
  689. private wireSchemas(scope?: ScopeKey): ToolProviderResult {
  690. const view = this.view(scope)
  691. const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
  692. if (this.mode === 'native') {
  693. return { schemas, knownNames: [...view.knownNames] }
  694. }
  695. this.requireCodeRuntime()
  696. if (this.mode === 'code') {
  697. return {
  698. schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
  699. knownNames: [RUN_CODE_NAME],
  700. }
  701. }
  702. return { schemas, knownNames: [...view.knownNames, RUN_CODE_NAME] }
  703. }
  704. /**
  705. * Resolve the code runtime or throw the actionable misconfiguration error.
  706. * Read at use time (assembly / run_code execution), NOT via static
  707. * `inject`: an inject entry would hold `ctx.tools` — and every tool plugin
  708. * behind it — hostage to a code runtime existing even under `mode:
  709. * 'native'` (the loop's optional-backend idiom, same as
  710. * `sessionPersistence`).
  711. */
  712. private requireCodeRuntime(): CodeRuntime {
  713. const runtime = this.ctx.get('codeRuntime')
  714. if (!runtime) {
  715. throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`)
  716. }
  717. if (runtime.language !== 'typescript') {
  718. throw new Error(`dsh-tools: mode "${this.mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`)
  719. }
  720. return runtime
  721. }
  722. /**
  723. * Register globally or in the calling agent scope. Scoped tools shadow
  724. * globals; duplicates within one layer and the reserved `run_code` name fail.
  725. * @param definition - tool schema, execution, and optional finalization/presentation callbacks.
  726. * @returns the exact disposer that unregisters the tool.
  727. */
  728. register(definition: ToolDefinition): () => void {
  729. const name = definition.name
  730. const output = (definition as Partial<ToolDefinition>).output
  731. if (output === undefined || typeof output !== 'object'
  732. || typeof output.render !== 'function'
  733. || (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function')) {
  734. throw new TypeError(`tool "${name}" must declare output { schema, render, presentationMeta? }`)
  735. }
  736. assertSupportedJsonSchema(output.schema)
  737. const timeoutMs = definition.timeoutMs
  738. if (timeoutMs !== undefined
  739. && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
  740. throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`)
  741. }
  742. if (this.codeTransport !== undefined && name === RUN_CODE_NAME) {
  743. throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
  744. }
  745. return this.layers.effect(
  746. this.ctx,
  747. layer => layer.tools.insert(name, definition),
  748. { label: 'tools.register()' },
  749. )
  750. }
  751. /**
  752. * Restrict global tools for the calling agent scope. Empty filters, unknown
  753. * names, scope-local names, and reserved transport names fail. Restrictions
  754. * intersect; scoped registrations remain visible.
  755. * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).
  756. * @returns the exact disposer that lifts this restriction.
  757. */
  758. restrict(filter: ToolRestriction): () => void {
  759. const scope = scopeOf(this.ctx)
  760. if (scope === undefined) {
  761. 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')
  762. }
  763. const allow = filter.allow
  764. const deny = filter.deny
  765. if (allow === undefined && deny === undefined) {
  766. 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)')
  767. }
  768. const compiled: CompiledToolRestriction = {
  769. ...allow !== undefined ? { allow: new Set(allow) } : {},
  770. ...deny !== undefined ? { deny: new Set(deny) } : {},
  771. }
  772. if (this.codeTransport !== undefined
  773. && [...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {
  774. throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`)
  775. }
  776. const known = this.view(scope).restrictableNames
  777. const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))
  778. if (unknown.length > 0) {
  779. 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)'}`)
  780. }
  781. return this.layers.effect(
  782. this.ctx,
  783. layer => layer.restrictions.append(compiled),
  784. { label: 'tools.restrict()' },
  785. )
  786. }
  787. /**
  788. * Register a monotonic guard after the extensible `tools/pre-execute`
  789. * waterfall. A plain-context guard applies globally; one registered through
  790. * `agent.ctx` applies only to that agent. Any matching guard may deny by
  791. * returning a reason, while no guard can force-allow a call another guard
  792. * denied. The exact effect disposer is returned for ordered ownership and
  793. * HMR cleanup.
  794. * @param guard - synchronous check; a returned string denies the execution.
  795. * @returns the exact disposer that unregisters the guard.
  796. */
  797. guard(guard: ToolGuard): () => void {
  798. return this.layers.effect(
  799. this.ctx,
  800. layer => layer.guards.append(guard),
  801. { label: 'tools.guard()', notify: false },
  802. )
  803. }
  804. /** First monotonic denial from the global then matching scoped guard layers. */
  805. private guardReason(exec: ToolExecution): string | undefined {
  806. const globalReason = this.layers.global.guardReason(exec)
  807. if (globalReason !== undefined) return globalReason
  808. return exec.agent === undefined ? undefined : this.layers.peek(exec.agent)?.guardReason(exec)
  809. }
  810. /**
  811. * Resolve every registry fact one scope needs in one layer traversal. The
  812. * visible map applies global restrictions, scoped shadowing, and the reserved
  813. * presentation transport; the other sets retain the pre-restriction facts
  814. * needed by restriction and prompt-order validation.
  815. * @param scope - the viewing scope (the agent), or undefined for the global view.
  816. * @returns the complete derived view for that scope.
  817. */
  818. private view(scope?: ScopeKey): ToolView {
  819. const layer = this.layers.peek(scope)
  820. const visible = new Map<string, ToolDefinition>()
  821. const knownNames = new Set<string>()
  822. const restrictableNames = new Set<string>()
  823. for (const [name, definition] of this.layers.global.tools.entries()) {
  824. knownNames.add(name)
  825. restrictableNames.add(name)
  826. if (layer?.admits(name) ?? true) visible.set(name, definition)
  827. }
  828. // Scoped layer second: same-name entries REPLACE (shadow) the global ones,
  829. // and scope-local registrations are never part of the global filter above.
  830. for (const [name, definition] of layer?.tools.entries() ?? []) {
  831. knownNames.add(name)
  832. visible.set(name, definition)
  833. }
  834. // Presentation infrastructure is resolved last and outside capability
  835. // filtering. Registration rejects this reserved name, so the insertion is
  836. // an invariant assertion as well as protection against future layer changes.
  837. if (this.codeTransport !== undefined) {
  838. visible.set(RUN_CODE_NAME, this.codeTransport)
  839. }
  840. return { visible, knownNames, restrictableNames }
  841. }
  842. /**
  843. * Look up a tool as one scope sees it (scoped
  844. * shadows global; a restricted-away global reads as absent). Presenters pass
  845. * the calling agent so the rendered card matches the definition that
  846. * actually executed.
  847. * @param name - the tool name as registered.
  848. * @param scope - the viewing scope (the agent); omitted = the global view.
  849. * @returns the definition the scope resolves, or undefined when none is visible.
  850. */
  851. get(name: string, scope?: ScopeKey): ToolDefinition | undefined {
  852. return this.view(scope).visible.get(name)
  853. }
  854. /**
  855. * Project visible definitions onto the allowlisted model-facing schema fields,
  856. * excluding execution and presentation callbacks.
  857. * @param scope - the viewing scope (the agent); omitted = the global view.
  858. * @returns one deep-cloned schema per visible tool.
  859. */
  860. schemas(scope?: ScopeKey): ToolSchema[] {
  861. return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true))
  862. }
  863. /** Project visible callable tools onto the generated Code Mode SDK contract. */
  864. private sdkSchemas(scope?: ScopeKey): ToolSdkSchema[] {
  865. return [...this.view(scope).visible.values()]
  866. .filter(definition => definition.name !== RUN_CODE_NAME)
  867. .map((definition): ToolSdkSchema => {
  868. const output = snapshotJsonValue(definition.output.schema)
  869. /* v8 ignore next -- registration already validated and retained this schema as lossless JSON. */
  870. if (output === undefined) {
  871. throw new Error(`tool "${definition.name}" output schema must be lossless JSON before SDK projection`)
  872. }
  873. return {
  874. ...this.schemaOf(definition, true),
  875. output,
  876. }
  877. })
  878. }
  879. /** Project one definition onto the model-facing schema fields. */
  880. private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
  881. const { name, description, parameters } = definition
  882. const detached = detachParameters ? snapshotJsonValue(parameters) : parameters
  883. if (detached === undefined) {
  884. throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`)
  885. }
  886. return {
  887. name,
  888. description,
  889. parameters: detached,
  890. }
  891. }
  892. /**
  893. * Classify a pending call through the caller's visible tool definition. Only
  894. * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or
  895. * throwing classifiers are exclusive.
  896. * @param exec - call name, parsed arguments, and optional agent scope.
  897. * @returns the fail-closed scheduling mode.
  898. */
  899. executionMode(exec: ToolExecutionInput): ToolExecutionMode {
  900. const tool = this.get(exec.name, exec.agent)
  901. if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
  902. try {
  903. const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments)
  904. return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' }
  905. } catch {
  906. return { kind: 'exclusive' }
  907. }
  908. }
  909. /**
  910. * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch
  911. * and return the content the bridge should log on `tool/code-dispatch`.
  912. * Contained: a throwing listener falls back to the unshaped content — log
  913. * shaping must never fail the dispatch or lose the settle event. Private:
  914. * the ONE consumer is the `run_code` bridge this registry constructs, which
  915. * receives it as a capability parameter (the `requireRuntime` idiom) — the
  916. * waterfall, not this invoker, is the public extension seam.
  917. */
  918. private async shapeDispatchLog(dispatch: CodeDispatchLog): Promise<ContentBlock[]> {
  919. try {
  920. return await this.ctx.waterfall(
  921. scopeTarget(this, dispatch.agent), 'tools/code-dispatch-log', dispatch,
  922. () => Promise.resolve(dispatch.content),
  923. )
  924. } catch (error: unknown) {
  925. this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the unshaped content`)
  926. return dispatch.content
  927. }
  928. }
  929. /**
  930. * Execute through pre-policy, guards, around-dispatch, post-policy,
  931. * definition-owned content finalization, and final notification. Tool and
  932. * listener failures resolve as materialized error results; an invisible tool
  933. * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen
  934. * snapshot final observers receive. Cancellation
  935. * arriving after entry and before final result materialization skips a
  936. * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a
  937. * successful started outcome with `ABORTED`; already-started work is still
  938. * drained and may retain a tool-owned structured error.
  939. * @param exec - the typed same-process call input. The registry assigns its
  940. * correlation token before policy begins.
  941. * @returns the materialized final result.
  942. */
  943. async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
  944. return this.prepareExecution(exec, prepared => this.completeScheduledExecution(prepared))
  945. }
  946. private async completeScheduledExecution(prepared: ScheduledToolPreparation): Promise<ToolExecutionResult> {
  947. switch (prepared.kind) {
  948. case 'dispatch': {
  949. const dispatched = await this.dispatchScheduledExecution(prepared.exec)
  950. return dispatched.kind === 'post-result'
  951. ? await this.finalizeScheduledExecution(prepared.exec, dispatched.result)
  952. : this.finishScheduledExecution(prepared.exec, dispatched.result)
  953. }
  954. case 'post-result':
  955. return await this.finalizeScheduledExecution(prepared.exec, prepared.result)
  956. case 'final-result':
  957. return this.finishScheduledExecution(prepared.exec, prepared.result)
  958. /* v8 ignore next -- closed-union exhaustiveness guard */
  959. default:
  960. return assertNever(prepared, 'scheduled tool preparation')
  961. }
  962. }
  963. private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } {
  964. const deferredContexts: HookContext[] = []
  965. const token = createExecutionToken()
  966. const callId = exec.callId
  967. const name = exec.name
  968. const agent = exec.agent
  969. const parent = exec.parent
  970. const signal = exec.signal
  971. const definition = this.get(name, agent)
  972. const finalizeContent = definition?.finalizeContent?.bind(definition)
  973. const base = {
  974. token,
  975. callId,
  976. name,
  977. signal,
  978. ...agent !== undefined ? { agent } : {},
  979. ...parent !== undefined ? { parent } : {},
  980. deferContext(context: HookContext): void {
  981. deferredContexts.push(context)
  982. },
  983. }
  984. try {
  985. const detached = snapshotJsonValue(exec.arguments)
  986. if (detached === undefined) {
  987. throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
  988. }
  989. const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) }
  990. this.deferredContexts.set(execution, deferredContexts)
  991. this.contentFinalizers.set(execution, finalizeContent)
  992. this.cancellationStates.set(execution, {
  993. callerSignal: signal,
  994. bodyInvoked: false,
  995. })
  996. return { kind: 'ready', exec: execution }
  997. } catch (error: unknown) {
  998. const execution: MutableToolRunContext = { ...base, arguments: undefined }
  999. this.contentFinalizers.set(execution, finalizeContent)
  1000. return { kind: 'final-result', exec: execution, result: toolErrorResult(error) }
  1001. }
  1002. }
  1003. /**
  1004. * Run the ordered pre-execute and monotonic guard stages for the scheduler.
  1005. * @param input - the caller-supplied execution input.
  1006. * @returns the prepared execution plus the next scheduler stage.
  1007. * @internal
  1008. */
  1009. private async prepareScheduledExecution(input: ToolExecutionInput): Promise<ScheduledToolPreparation> {
  1010. return this.prepareExecution(input, prepared => prepared)
  1011. }
  1012. private async prepareExecution<T>(
  1013. input: ToolExecutionInput,
  1014. next: (prepared: ScheduledToolPreparation) => T | PromiseLike<T>,
  1015. ): Promise<T> {
  1016. const created = this.createExecution(input)
  1017. if (created.kind !== 'ready') return next(created)
  1018. const exec = created.exec
  1019. if (this.callerCancelled(exec)) {
  1020. return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() })
  1021. }
  1022. try {
  1023. const carrier = scopeTarget(this, exec.agent)
  1024. const gate = await this.ctx.waterfall(
  1025. carrier, 'tools/pre-execute', exec,
  1026. () => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
  1027. )
  1028. const askResolution: ToolAskResolution = gate.kind === 'ask'
  1029. ? await this.serviceAsk(exec, gate)
  1030. : { decision: gate, approvalCancelled: false }
  1031. const { decision } = askResolution
  1032. if (this.callerCancelled(exec) && askResolution.approvalCancelled) {
  1033. return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
  1034. }
  1035. const denialReason = decision.kind === 'allow'
  1036. ? this.guardReason(exec)
  1037. : decision.reason
  1038. if (denialReason !== undefined) {
  1039. return await next({
  1040. kind: 'post-result',
  1041. exec,
  1042. result: this.materializeFinalResult({
  1043. content: [{ type: 'text', text: `Error: ${denialReason}` }],
  1044. isError: true,
  1045. error: { message: denialReason },
  1046. }),
  1047. })
  1048. }
  1049. if (this.callerCancelled(exec)) {
  1050. return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
  1051. }
  1052. return await next({ kind: 'dispatch', exec })
  1053. } catch (error: unknown) {
  1054. return next({ kind: 'final-result', exec, result: toolErrorResult(error) })
  1055. }
  1056. }
  1057. /** Whether the original caller signal is currently aborted. */
  1058. private callerCancelled(exec: ToolRunContext): boolean {
  1059. const state = this.cancellationStates.get(exec)
  1060. /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
  1061. if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
  1062. return state.callerSignal.aborted
  1063. }
  1064. /** Canonical cancellation outcome selected by whether the tool body started. */
  1065. private cancellationResult(exec: ToolRunContext, prior?: ToolExecutionResult): ToolExecutionResult {
  1066. const state = this.cancellationStates.get(exec)
  1067. /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
  1068. if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
  1069. return state.bodyInvoked
  1070. ? toolAbortedResult(prior)
  1071. : toolAbortedBeforeDispatchResult(prior)
  1072. }
  1073. /**
  1074. * Dispatch the registered body with the original caller signal fused back
  1075. * into any around-wrapper replacement. Cancellation never abandons the body:
  1076. * a started promise reaches quiescence before its outcome becomes `ABORTED`.
  1077. */
  1078. private async dispatchToolBody(exec: MutableToolRunContext): Promise<ToolExecutionResult> {
  1079. const state = this.cancellationStates.get(exec)
  1080. /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
  1081. if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
  1082. const wrapperSignal = exec.signal
  1083. const fused = fuseToolSignals(state.callerSignal, wrapperSignal)
  1084. const signal = fused.signal
  1085. if (isAborted(signal)) {
  1086. fused.dispose()
  1087. return toolAbortedBeforeDispatchResult()
  1088. }
  1089. exec.signal = signal
  1090. try {
  1091. const tool = this.get(exec.name, exec.agent)
  1092. if (!tool) throw new ToolNotFoundError(exec.name)
  1093. state.bodyInvoked = true
  1094. const returned = await tool.execute(exec.arguments, exec)
  1095. const result = this.createSuccessResult(exec, tool, returned)
  1096. return isAborted(signal)
  1097. ? toolAbortedResult(result)
  1098. : result
  1099. } catch (error: unknown) {
  1100. return toolErrorResult(error)
  1101. } finally {
  1102. fused.dispose()
  1103. exec.signal = wrapperSignal
  1104. }
  1105. }
  1106. /**
  1107. * Run around-dispatch and the tool body. Tool and unknown-tool failures still
  1108. * receive post-execute; pipeline failures are already final.
  1109. * @param exec - the prepared execution.
  1110. * @returns whether the result still needs post-execute.
  1111. * @internal
  1112. */
  1113. private async dispatchScheduledExecution(exec: ToolRunContext): Promise<ScheduledToolDispatch> {
  1114. try {
  1115. const mutableExec = exec as MutableToolRunContext
  1116. const carrier = scopeTarget(this, exec.agent)
  1117. const result = await this.ctx.waterfall(
  1118. carrier, 'tools/execute', mutableExec,
  1119. () => this.dispatchToolBody(mutableExec),
  1120. )
  1121. const normalized = this.normalizeDispatchResult(exec, result)
  1122. const deferredContexts = this.deferredContexts.get(exec)
  1123. /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
  1124. if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution')
  1125. const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
  1126. ? normalized
  1127. : this.markCanonical(exec, {
  1128. ...normalized,
  1129. additionalContexts: [
  1130. ...deferredContexts,
  1131. ...normalized.additionalContexts ?? [],
  1132. ],
  1133. })
  1134. return {
  1135. kind: 'post-result',
  1136. result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError
  1137. ? this.cancellationResult(exec, resultWithDeferredContexts)
  1138. : resultWithDeferredContexts,
  1139. }
  1140. } catch (error: unknown) {
  1141. return { kind: 'final-result', result: toolErrorResult(error) }
  1142. }
  1143. }
  1144. /**
  1145. * Run ordered post-execute, then apply definition-owned content finalization,
  1146. * materialize, and notify the final outcome.
  1147. * @param exec - the prepared execution.
  1148. * @param result - dispatch/pre result that still needs post-execute.
  1149. * @returns the materialized final result.
  1150. * @internal
  1151. */
  1152. private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult> {
  1153. try {
  1154. const postResult = await this.postExecute(exec, result)
  1155. return this.finishScheduledExecution(
  1156. exec,
  1157. this.callerCancelled(exec) && !postResult.isError
  1158. ? this.cancellationResult(exec, postResult)
  1159. : postResult,
  1160. )
  1161. } catch (error: unknown) {
  1162. return this.finishScheduledExecution(exec, toolErrorResult(error))
  1163. }
  1164. }
  1165. /**
  1166. * Materialize the candidate, apply definition-owned content finalization,
  1167. * then materialize and notify the authoritative result.
  1168. * @param exec - the prepared execution.
  1169. * @param result - final result.
  1170. * @returns the materialized final result.
  1171. * @internal
  1172. */
  1173. private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
  1174. let materializedResult: ToolExecutionResult
  1175. try {
  1176. materializedResult = this.materializeFinalResult(result)
  1177. } catch (error: unknown) {
  1178. materializedResult = this.materializeFinalResult(toolErrorResult(error))
  1179. }
  1180. let finalResult: ToolExecutionResult
  1181. try {
  1182. finalResult = this.materializeFinalResult(this.applyFinalContent(exec, materializedResult))
  1183. } catch (error: unknown) {
  1184. finalResult = this.materializeFinalResult(toolErrorResult(error))
  1185. }
  1186. this.notifyResult(exec, finalResult)
  1187. return finalResult
  1188. }
  1189. /** Apply the snapshotted tool-owned content transform without exposing other result fields. */
  1190. private applyFinalContent(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
  1191. const finalizeContent = this.contentFinalizers.get(exec)
  1192. if (finalizeContent === undefined) return result
  1193. const content = finalizeContent(exec, result)
  1194. return content === undefined ? result : { ...result, content }
  1195. }
  1196. /** Notify observers without exposing a mutation or error channel into the outcome. */
  1197. private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
  1198. // Freeze the registry's live object before observers receive its readonly
  1199. // WeakMap-keyable view.
  1200. Object.freeze(exec)
  1201. const { name: toolName, callId } = exec
  1202. const reportFailure = (error: unknown): void => {
  1203. this.ctx.logger.warn(`tool "${toolName}" (${callId}): tools/result observer failed: ${errorMessage(error)}`)
  1204. }
  1205. const callbacks = this.ctx.events.dispatch('emit', [
  1206. scopeTarget(this, exec.agent), 'tools/result', exec, result,
  1207. ])
  1208. for (const callback of callbacks) {
  1209. try {
  1210. const returned: unknown = callback(exec, result)
  1211. void Promise.resolve(returned).catch(reportFailure)
  1212. } catch (error: unknown) {
  1213. reportFailure(error)
  1214. }
  1215. }
  1216. }
  1217. /**
  1218. * Resolve an `ask` decision to allow/deny through the approval seam. The
  1219. * seam is consumed opportunistically with `ctx.get('approval')` — a
  1220. * deployment that composes no ApprovalService keeps the historical degrade
  1221. * to deny, and an unmount mid-session degrades the same way on the next ask.
  1222. * An agent-less execution also degrades: without an agent there is no
  1223. * session to audit to and no UI to route to. Otherwise the outcome maps
  1224. * one-to-one — `allowed-once` proceeds; the three non-grants deny with
  1225. * distinct reasons so the model can tell a human "no" from an absent
  1226. * approval channel.
  1227. */
  1228. private async serviceAsk(
  1229. exec: ToolExecution,
  1230. ask: Extract<PreToolDecision, { kind: 'ask' }>,
  1231. ): Promise<ToolAskResolution> {
  1232. const approval = this.ctx.get('approval')
  1233. if (approval === undefined) {
  1234. return {
  1235. decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` },
  1236. approvalCancelled: false,
  1237. }
  1238. }
  1239. if (exec.agent === undefined) {
  1240. return {
  1241. decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` },
  1242. approvalCancelled: false,
  1243. }
  1244. }
  1245. const outcome = await approval.request({
  1246. agent: exec.agent,
  1247. toolName: exec.name,
  1248. callId: exec.callId,
  1249. ...ask.reason !== undefined ? { reason: ask.reason } : {},
  1250. signal: exec.signal,
  1251. })
  1252. switch (outcome) {
  1253. case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false }
  1254. case 'rejected': return {
  1255. decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` },
  1256. approvalCancelled: false,
  1257. }
  1258. case 'cancelled': return {
  1259. decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` },
  1260. approvalCancelled: true,
  1261. }
  1262. case 'unavailable': return {
  1263. decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` },
  1264. approvalCancelled: false,
  1265. }
  1266. default: return assertNever(outcome, 'ApprovalOutcome')
  1267. }
  1268. }
  1269. /**
  1270. * Run the `tools/post-execute` waterfall over a dispatched `result` and apply
  1271. * its {@link PostToolDecision}: `accept` keeps the call successful (replacing
  1272. * `content` when given), `block` turns it into an `isError` whose content is
  1273. * the corrective `feedback`. Either decision may attach `additionalContexts`,
  1274. * which are ferried on the returned result for the loop's active-batch FIFO.
  1275. * Context deferred by the tool body survives an accepted result but is
  1276. * discarded when the outer call is blocked; a block exposes only context the
  1277. * blocking decision explicitly supplied.
  1278. * Runs inside `execute`'s outer try/catch (a throwing listener → isError).
  1279. */
  1280. private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
  1281. const decision = await this.ctx.waterfall(
  1282. scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
  1283. () => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
  1284. )
  1285. const decisionContexts = decision.additionalContexts ?? []
  1286. if (decision.kind === 'block') {
  1287. const message = failureMessageFromContent(decision.feedback)
  1288. return this.markCanonical(exec, {
  1289. content: decision.feedback,
  1290. isError: true,
  1291. error: { message },
  1292. ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},
  1293. })
  1294. }
  1295. if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) {
  1296. throw new TypeError('tools/post-execute accept decision cannot replace both value and content')
  1297. }
  1298. const additionalContexts = [
  1299. ...result.additionalContexts ?? [],
  1300. ...decisionContexts,
  1301. ]
  1302. if (Object.hasOwn(decision, 'value')) {
  1303. if (result.isError) {
  1304. throw new TypeError('tools/post-execute cannot replace the value of a failed result')
  1305. }
  1306. const tool = this.get(exec.name, exec.agent)
  1307. if (tool === undefined) throw new ToolNotFoundError(exec.name)
  1308. const replaced = this.createSuccessResult(exec, tool, decision.value)
  1309. return this.markCanonical(exec, {
  1310. ...replaced,
  1311. ...additionalContexts.length > 0 ? { additionalContexts } : {},
  1312. })
  1313. }
  1314. return this.markCanonical(exec, {
  1315. ...result,
  1316. ...decision.content !== undefined ? { content: decision.content } : {},
  1317. ...additionalContexts.length > 0 ? { additionalContexts } : {},
  1318. })
  1319. }
  1320. /** Registry-normalized results and the exact dispatch that validated each value. */
  1321. private readonly canonicalResults = new WeakMap<object, ToolExecutionToken>()
  1322. /** Mark one registry-normalized result as canonical only for its owning dispatch. */
  1323. private markCanonical<T extends ToolExecutionResult>(exec: ToolExecution, result: T): T {
  1324. this.canonicalResults.set(result, exec.token)
  1325. return result
  1326. }
  1327. /** Snapshot, validate, render, and optionally project one successful body value. */
  1328. private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess {
  1329. const detached = snapshotToolValue(tool.name, candidate)
  1330. const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
  1331. if (violations.length > 0) throw new ToolOutputError(tool.name, violations)
  1332. const value = deepFreeze(detached)
  1333. let rendered: ContentBlock[]
  1334. try {
  1335. rendered = tool.output.render(exec.arguments, value)
  1336. } catch (error: unknown) {
  1337. throw projectionError(tool.name, 'render', error)
  1338. }
  1339. const content = snapshotProjection(tool.name, 'render', rendered)
  1340. let meta: JsonValue | undefined
  1341. if (exec.parent === undefined && tool.output.presentationMeta !== undefined) {
  1342. let projected: JsonValue
  1343. try {
  1344. projected = tool.output.presentationMeta(exec.arguments, value)
  1345. } catch (error: unknown) {
  1346. throw projectionError(tool.name, 'presentationMeta', error)
  1347. }
  1348. meta = snapshotProjection(tool.name, 'presentationMeta', projected)
  1349. }
  1350. return this.markCanonical(exec, this.materializeFinalResult({
  1351. isError: false,
  1352. value,
  1353. content,
  1354. ...meta !== undefined ? { meta } : {},
  1355. }) as ToolExecutionSuccess)
  1356. }
  1357. /** Normalize an around-dispatch wrapper's authored result through the owning output contract. */
  1358. private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult {
  1359. if (this.canonicalResults.get(result) === exec.token) return result
  1360. if (result.isError) {
  1361. return this.markCanonical(exec, {
  1362. isError: true,
  1363. error: result.error,
  1364. content: result.content,
  1365. ...result.meta !== undefined ? { meta: result.meta } : {},
  1366. ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
  1367. })
  1368. }
  1369. const tool = this.get(exec.name, exec.agent)
  1370. if (tool === undefined) throw new ToolNotFoundError(exec.name)
  1371. const normalized = this.createSuccessResult(exec, tool, result.value)
  1372. return this.markCanonical(exec, {
  1373. ...normalized,
  1374. ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
  1375. })
  1376. }
  1377. /** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
  1378. private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult {
  1379. const presentation = {
  1380. content: result.content,
  1381. ...result.meta !== undefined ? { meta: result.meta } : {},
  1382. ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
  1383. }
  1384. if (result.isError) {
  1385. return materializePresentation({ isError: true as const, error: result.error, ...presentation })
  1386. }
  1387. const detached = materializePresentation({ isError: false as const, ...presentation })
  1388. return deepFreeze({ ...detached, value: result.value })
  1389. }
  1390. }
  1391. /** Mint a same-process correlation token whose identity is its value. */
  1392. function createExecutionToken(): ToolExecutionToken {
  1393. return Symbol('dsh.tool.execution') as ToolExecutionToken
  1394. }
  1395. function toolErrorResult(error: unknown): ToolExecutionResult {
  1396. const info = errorInfo(error)
  1397. const message = errorMessage(error)
  1398. return {
  1399. content: [{ type: 'text', text: `Error: ${message}` }],
  1400. isError: true,
  1401. error: { message, ...info ? { info } : {} },
  1402. }
  1403. }
  1404. /** Read live abort state across an await without treating it as synchronously immutable. */
  1405. function isAborted(signal: AbortSignal): boolean {
  1406. return signal.aborted
  1407. }
  1408. /**
  1409. * Fuse caller and wrapper cancellation without nesting `AbortSignal.any`.
  1410. * Keeping the relay dispatch-scoped also removes listeners when work settles.
  1411. */
  1412. function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal {
  1413. if (caller === wrapper) return { signal: caller, dispose() {} }
  1414. const controller = new AbortController()
  1415. let listening = false
  1416. const dispose = (): void => {
  1417. if (!listening) return
  1418. listening = false
  1419. caller.removeEventListener('abort', abortFromCaller)
  1420. wrapper.removeEventListener('abort', abortFromWrapper)
  1421. }
  1422. const abortFrom = (source: AbortSignal): void => {
  1423. const reason: unknown = source.reason
  1424. controller.abort(reason)
  1425. dispose()
  1426. }
  1427. const abortFromCaller = (): void => { abortFrom(caller) }
  1428. const abortFromWrapper = (): void => { abortFrom(wrapper) }
  1429. if (wrapper.aborted) abortFromWrapper()
  1430. else if (caller.aborted) abortFromCaller()
  1431. else {
  1432. listening = true
  1433. caller.addEventListener('abort', abortFromCaller, { once: true })
  1434. wrapper.addEventListener('abort', abortFromWrapper, { once: true })
  1435. }
  1436. return { signal: controller.signal, dispose }
  1437. }
  1438. /** Canonical result when cancellation supersedes success after body invocation. */
  1439. function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult {
  1440. const additionalContexts = prior?.additionalContexts ?? []
  1441. return {
  1442. content: [{ type: 'text', text: 'Error: tool call aborted' }],
  1443. isError: true,
  1444. error: {
  1445. message: 'tool call aborted',
  1446. info: { name: 'AbortError', code: TOOL_ABORTED },
  1447. },
  1448. ...additionalContexts.length > 0 ? { additionalContexts } : {},
  1449. }
  1450. }
  1451. /** Canonical result when cancellation prevents tool body invocation. */
  1452. function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecutionResult {
  1453. const additionalContexts = prior?.additionalContexts ?? []
  1454. return {
  1455. content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
  1456. isError: true,
  1457. error: {
  1458. message: 'tool call aborted before dispatch',
  1459. info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
  1460. },
  1461. ...additionalContexts.length > 0 ? { additionalContexts } : {},
  1462. }
  1463. }
  1464. export default ToolRegistry