index.ts 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072
  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 { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
  9. import type { ScopeKey, 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 { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
  15. import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
  16. // Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
  17. // augmentation. The seam stays optional at runtime — see `serviceAsk`.
  18. import type {} from '@deepseek-ai/dsh-user-approval'
  19. import type { ToolCallView, ToolResultView } from './presentation.ts'
  20. import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
  21. import { renderToolsSdk } from './ts-types.ts'
  22. export {
  23. defineTool,
  24. schemaSpecToJsonSchema,
  25. validateArgs,
  26. ToolArgsError,
  27. type SchemaSpec,
  28. type SchemaProp,
  29. type SchemaType,
  30. type InferArgs,
  31. type DefineToolOptions,
  32. type JsonSchemaObject,
  33. } from './schema.ts'
  34. export {
  35. assertSupportedOutputSchema,
  36. validateStructuredValue,
  37. OutputSchemaError,
  38. type StructuredOutputSchema,
  39. type StructuredSchemaNode,
  40. type StructuredSchemaType,
  41. type StructuredScalar,
  42. } from './json-schema.ts'
  43. export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
  44. export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
  45. // The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
  46. // lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
  47. // stays the single public surface for consumers (producers + the ACP bridge).
  48. export type {
  49. ToolCallKind,
  50. FileLocation,
  51. FileDiff,
  52. ToolCallView,
  53. GenericCallView,
  54. TerminalCallView,
  55. DiffCallView,
  56. ToolResultView,
  57. GenericResultView,
  58. TerminalResultView,
  59. DiffResultView,
  60. } from './presentation.ts'
  61. declare module 'cordis' {
  62. interface Context {
  63. tools: ToolRegistry
  64. }
  65. interface Events {
  66. /**
  67. * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing
  68. * approval support turns `ask` into denial.
  69. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
  70. * @param exec - the pending call (name, parsed arguments, caller agent).
  71. * @mode waterfall
  72. */
  73. 'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
  74. /**
  75. * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns
  76. * a normalized result; wrappers may change only `exec.signal`, while call
  77. * identity remains immutable.
  78. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
  79. * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
  80. * @mode waterfall
  81. */
  82. 'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
  83. /**
  84. * Accept, replace, enrich, or block a normalized dispatch result. `next()`
  85. * accepts it unchanged; thrown tools still reach this seam as errors.
  86. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
  87. * @param exec - the call that just ran (name, parsed arguments, caller agent).
  88. * @param result - the dispatch outcome a listener may accept, replace, or block.
  89. * @mode waterfall
  90. */
  91. 'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
  92. /**
  93. * Observe the frozen, lossless-JSON final outcome. Listener failures are contained.
  94. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.
  95. * @param exec - the execution object that traversed the pipeline.
  96. * @param result - a deep-frozen snapshot of the final returned result.
  97. * @mode emit
  98. */
  99. 'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined
  100. /**
  101. * A tool was registered or unregistered, or a scoped restriction changed
  102. * (the available tool set changed — possibly for one scope only). An
  103. * UNFILTERED registry-subject notification, deliberately not scope-filtered
  104. * dispatch: a global change concerns every agent's next assembly, so a
  105. * scoped listener subscribing here sees every change, not just its own
  106. * scope's.
  107. * @mode emit
  108. */
  109. 'tools/change'(): void
  110. }
  111. }
  112. /** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */
  113. export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
  114. /** A registered tool: its schema plus the execution function. */
  115. export interface ToolDefinition extends ToolSchema {
  116. execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
  117. /**
  118. * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
  119. * Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
  120. * is NEVER sent to the model — `schemas()` whitelists only name/description/
  121. * parameters. Declaring it asserts this tool forwards `exec.signal` to a
  122. * cooperative implementation that can reach quiescence when the signal aborts.
  123. */
  124. timeoutMs?: number
  125. /**
  126. * Pure synchronous classifier for overlap with sibling tool calls. Only
  127. * `true` opts in; omission, exceptions, non-`true` returns, and invalid
  128. * `defineTool` arguments are exclusive. This metadata is never model-visible.
  129. *
  130. * Opted-in executions must not mutate parent-owned state. Shared state must
  131. * tolerate concurrent dispatch; recorder races are permitted only when they
  132. * commute or fail closed. See the
  133. * [parallel-tool-call Agent Note](../../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)
  134. * for the full contract.
  135. * @param args - parsed arguments; `defineTool` validates before calling.
  136. * @returns Whether this call may join a parallel group.
  137. */
  138. isConcurrencySafe?(args: unknown): boolean
  139. /**
  140. * Optional: how to present the PENDING state of one call in a UI, derived from
  141. * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
  142. * its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent),
  143. * or `undefined` (or omit the method) to fall back to a generic presentation
  144. * (title = tool name, raw args as input). Pure and side-effect-free: a UI may
  145. * call it during live streaming AND a session-log replay, so it must depend
  146. * only on `args`.
  147. */
  148. presentCall?(args: unknown): ToolCallView | undefined
  149. /**
  150. * Optional: how to present the COMPLETED state, given the same `args` and the
  151. * `result` (`execute`'s content + whether it errored). Returns a
  152. * {@link ToolResultView}, or `undefined` (or omit the method) to keep the
  153. * pending title and render the raw result content. Pure and side-effect-free
  154. * for the same replay reason.
  155. */
  156. presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined
  157. }
  158. /** The completed outcome handed to {@link ToolDefinition.presentResult}. */
  159. export interface ToolResult {
  160. /** The model-facing content `execute` returned (or the error text on failure). */
  161. content: ContentBlock[]
  162. /** Whether the call failed. */
  163. isError: boolean
  164. /**
  165. * The tool-private presentation payload the tool attached from `execute` (via
  166. * the object return form), threaded verbatim from the `tool/result` event.
  167. * Opaque (`unknown`); the tool narrows it back to its own shape. Absent when
  168. * the tool attached none.
  169. */
  170. meta?: unknown
  171. }
  172. declare const toolExecutionTokenBrand: unique symbol
  173. /** Opaque call identity that permits correlation without exposing mutable execution state. */
  174. export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
  175. /**
  176. * Caller-supplied description of one tool call. {@link ToolRegistry.execute}
  177. * adds the registry-owned token to form a pipeline {@link ToolExecution};
  178. * callers do not choose that token.
  179. */
  180. export interface ToolExecutionInput {
  181. readonly callId: CallId
  182. readonly name: string
  183. /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */
  184. readonly arguments: unknown
  185. /** The agent on whose behalf the call runs (set by the agent loop). */
  186. readonly agent?: Agent
  187. /**
  188. * Opaque token of the enclosing transport execution, when one exists. Code
  189. * Mode sets this on SDK sub-dispatches so commit-style observers can wait for
  190. * the outer `run_code` outcome without receiving its live mutable execution.
  191. */
  192. readonly parent?: ToolExecutionToken
  193. signal?: AbortSignal
  194. }
  195. /**
  196. * Scheduling mode for one pending call. `parallel` may overlap with siblings;
  197. * `exclusive` runs alone and forms an ordering barrier.
  198. */
  199. export type ToolExecutionMode =
  200. | { kind: 'parallel' }
  201. | { kind: 'exclusive' }
  202. /**
  203. * One pending tool call inside the registry pipeline. Parsed arguments cross
  204. * one lossless-JSON materialization boundary before policy and are deep-frozen;
  205. * call identity and the registry-assigned {@link token} are readonly. An
  206. * around-dispatch wrapper may set, replace, or remove `signal`. The registry
  207. * freezes the complete object before `tools/result` observers run.
  208. */
  209. export interface ToolExecution extends ToolExecutionInput {
  210. /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
  211. readonly token: ToolExecutionToken
  212. }
  213. /**
  214. * Runtime context handed to a tool implementation after the registry has
  215. * accepted a {@link ToolExecution}. A composite tool uses
  216. * {@link deferContext} to ferry context produced by nested dispatches back to
  217. * the outer result; the loop appends it only after the outer `tool/result`.
  218. */
  219. export interface ToolRunContext extends ToolExecution {
  220. /**
  221. * Defer one nested-dispatch context until this tool's final result reaches
  222. * the agent loop. Contexts retain their individual source, envelope, and
  223. * metadata and are emitted in call order.
  224. */
  225. deferContext(context: HookContext): void
  226. }
  227. /**
  228. * Scheduler-only result after ordered pre-execute and guards. A `post-result`
  229. * still receives post-execute; a `final-result` bypasses it.
  230. * @internal
  231. */
  232. export type ScheduledToolPreparation =
  233. | { kind: 'dispatch'; exec: ToolRunContext }
  234. | { kind: 'post-result'; exec: ToolRunContext; result: ToolExecutionResult }
  235. | { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult }
  236. /**
  237. * Scheduler-only dispatch result. A `post-result` still receives post-execute;
  238. * a `final-result` already matches {@link ToolRegistry.execute} failure semantics.
  239. * @internal
  240. */
  241. export type ScheduledToolDispatch =
  242. | { kind: 'post-result'; result: ToolExecutionResult }
  243. | { kind: 'final-result'; result: ToolExecutionResult }
  244. /**
  245. * Symbol-keyed scheduler view that keeps pre/post policy ordered while
  246. * overlapping dispatch. Ordinary callers use {@link ToolRegistry.execute};
  247. * this is not a plugin seam.
  248. * @internal
  249. */
  250. export interface ToolRegistryScheduler {
  251. /** Materialize input, run the ordered pre-execute/guard gate, and decide what stage follows. */
  252. prepare(exec: ToolExecutionInput): Promise<ScheduledToolPreparation>
  253. /** Run only the around-dispatch/body stage. */
  254. dispatch(exec: ToolRunContext): Promise<ScheduledToolDispatch>
  255. /** Run ordered post-execute finalization, then materialize and notify the final outcome. */
  256. finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult>
  257. /** Materialize and notify a final outcome that must bypass post-execute. */
  258. finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult
  259. }
  260. /**
  261. * Scheduler entry point omitted from the generated named service API.
  262. * @internal
  263. */
  264. export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')
  265. /** Structured error metadata for a failed tool call (alongside the model-facing text). */
  266. export interface ToolErrorInfo {
  267. name: string
  268. code: string
  269. }
  270. /**
  271. * Thrown (internally) when the model requests a tool that isn't registered.
  272. * Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool
  273. * failure is as routable as a tool-thrown one — retry/sandbox/replay code can
  274. * distinguish it from a tool body's own error.
  275. */
  276. export class ToolNotFoundError extends HarnessError {
  277. constructor(toolName: string) {
  278. super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL')
  279. this.name = 'ToolNotFoundError'
  280. }
  281. }
  282. /** The outcome of one tool call. */
  283. export interface ToolExecutionResult {
  284. content: ContentBlock[]
  285. isError: boolean
  286. /**
  287. * Set when the call failed with a {@link HarnessError}: machine-routable
  288. * `{ name, code }` for retry/sandbox plugins and replay. The model-facing
  289. * text in `content` is always present; this is extra structure for code.
  290. */
  291. error?: ToolErrorInfo
  292. /**
  293. * Model-facing context for the next request, separate from this tool result. The loop
  294. * accepts it into the active-batch FIFO, then appends after recorded results even if interrupted.
  295. */
  296. additionalContexts?: HookContext[]
  297. /**
  298. * The tool-private presentation payload from a successful `execute` (the object
  299. * return form). Threaded onto the `tool/result` session event and back into
  300. * {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the
  301. * tool attached none or the call failed.
  302. */
  303. meta?: unknown
  304. }
  305. /**
  306. * Pre-dispatch decision. `allow` runs the call; `deny` materializes an error;
  307. * `ask` runs only after an approval service returns `allowed-once` and otherwise
  308. * denies. Input rewriting is excluded because arguments are already logged and
  309. * presented.
  310. */
  311. export type PreToolDecision =
  312. | { kind: 'allow' }
  313. | { kind: 'deny'; reason: string }
  314. | { kind: 'ask'; reason?: string }
  315. /**
  316. * Post-dispatch decision: accept or replace content, attach context for the next
  317. * request, or block by turning corrective feedback into an error result.
  318. */
  319. export type PostToolDecision =
  320. | { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
  321. | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] }
  322. /**
  323. * Best-effort human-readable message from an arbitrary thrown value: Error
  324. * instances use `.message`; non-Error objects with a string `message`
  325. * property (e.g. `throw { message: 'denied' }`) use it too; everything else
  326. * is stringified.
  327. */
  328. function errorMessage(error: unknown): string {
  329. try {
  330. if (error instanceof Error) return error.message
  331. if (typeof error === 'object' && error !== null
  332. && 'message' in error && typeof error.message === 'string') {
  333. return error.message
  334. }
  335. return String(error)
  336. } catch {
  337. // A hostile thrown value can trap `instanceof`, property access, or string
  338. // coercion. Error normalization is the outermost safety boundary, so its
  339. // fallback must itself be total.
  340. return '<unprintable thrown value>'
  341. }
  342. }
  343. /** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
  344. function errorInfo(error: unknown): ToolErrorInfo | undefined {
  345. try {
  346. return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
  347. } catch {
  348. return undefined
  349. }
  350. }
  351. /** How the registry presents its tools to the model (see {@link Config.mode}). */
  352. export type ToolPresentationMode = 'native' | 'code' | 'both'
  353. /** Plugin config: how the registered tools are presented to the model. */
  354. export interface Config {
  355. /**
  356. * Model presentation. `native` (default) sends every visible schema; `code`
  357. * sends only `run_code` plus a generated SDK prompt; `both` sends both forms.
  358. * Code modes require a TypeScript runtime and fail prompt assembly when it is
  359. * absent or mismatched. Under `code`, native names in `toolOrder` are invalid.
  360. */
  361. mode?: ToolPresentationMode
  362. }
  363. /**
  364. * Per-scope filter over global tools. Restrictions intersect and do not affect
  365. * scoped registrations or the reserved Code Mode transport.
  366. */
  367. export interface ToolRestriction {
  368. /** Global tool names that stay visible; everything else is removed. */
  369. readonly allow?: readonly string[]
  370. /** Global tool names removed from visibility. */
  371. readonly deny?: readonly string[]
  372. }
  373. /** One restriction compiled at registration for repeated live-global lookup. */
  374. interface CompiledToolRestriction {
  375. readonly allow?: ReadonlySet<string>
  376. readonly deny?: ReadonlySet<string>
  377. }
  378. /** One scope's complete registry view, derived in a single layer traversal. */
  379. interface ToolView {
  380. /** Visible definitions after restrictions, scoped shadowing, and transport insertion. */
  381. readonly visible: ReadonlyMap<string, ToolDefinition>
  382. /** Pre-restriction capability names used by prompt-order validation. */
  383. readonly knownNames: ReadonlySet<string>
  384. /** Current global names that a scoped restriction may name. */
  385. readonly restrictableNames: ReadonlySet<string>
  386. }
  387. /**
  388. * A monotonic execution guard evaluated after every `tools/pre-execute`
  389. * listener and before the tool body. Returning a reason denies the call;
  390. * returning `undefined` leaves it unchanged. Because guards have no allow
  391. * result, listener ordering cannot turn a denial back into permission.
  392. * @param execution - the identity-protected call after extensible pre-execute policy completed.
  393. * @returns a final denial reason, or `undefined` to leave the call allowed.
  394. */
  395. export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
  396. /** One guard registration; the wrapper preserves independent duplicate registrations. */
  397. interface ToolGuardRegistration {
  398. guard: ToolGuard
  399. }
  400. /**
  401. * Tool registry and execution pipeline. Scoped registrations shadow globals;
  402. * one visibility resolver feeds presentation, lookup, and dispatch.
  403. */
  404. export class ToolRegistry extends Service {
  405. static inject = ['systemPrompt']
  406. static Config: z<Config> = z.object({
  407. mode: z.union(['native', 'code', 'both'] as const).default('native'),
  408. })
  409. /** Internal staged view consumed by `dsh-agent-loop`'s parallel scheduler. */
  410. readonly [TOOL_REGISTRY_SCHEDULER]: ToolRegistryScheduler = {
  411. prepare: exec => this.prepareScheduledExecution(exec),
  412. dispatch: exec => this.dispatchScheduledExecution(exec),
  413. finalize: (exec, result) => this.finalizeScheduledExecution(exec, result),
  414. finish: (exec, result) => this.finishScheduledExecution(exec, result),
  415. }
  416. /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
  417. private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
  418. private global = new Map<string, ToolDefinition>()
  419. private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
  420. /** Compiled restriction filters, per scope (see {@link restrict}). */
  421. private restrictions = new Map<ScopeKey, CompiledToolRestriction[]>()
  422. /** Monotonic post-policy guards, split into global and per-agent layers. */
  423. private globalGuards = new Set<ToolGuardRegistration>()
  424. private scopedGuards = new Map<ScopeKey, Set<ToolGuardRegistration>>()
  425. private readonly mode: ToolPresentationMode
  426. /** Reserved presentation transport, kept outside the filterable registration layers. */
  427. private readonly codeTransport: ToolDefinition | undefined
  428. constructor(ctx: Context, config: Config = {}) {
  429. super(ctx, 'tools')
  430. // The schema already defaulted an omitted mode; the ?? narrows the
  431. // optional-input type for direct (non-Loader) construction in tests.
  432. this.mode = config.mode ?? 'native'
  433. // `run_code` is presentation infrastructure, not an end capability. It
  434. // therefore does not enter the global layer: per-agent restrictions must
  435. // not remove it, and a scoped registration must not shadow it. The
  436. // visibility resolver appends this reserved definition after resolving
  437. // the filterable global/scoped capability layers.
  438. this.codeTransport = this.mode === 'native'
  439. ? undefined
  440. : createRunCodeTool(this, () => this.requireCodeRuntime())
  441. ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
  442. if (this.mode !== 'native') {
  443. ctx.systemPrompt.section({
  444. name: 'tools:sdk',
  445. order: SDK_SECTION_ORDER,
  446. // Regenerate from the calling scope's visible tools in stable order.
  447. text: (context) => {
  448. this.requireCodeRuntime()
  449. return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME))
  450. },
  451. })
  452. }
  453. }
  454. /**
  455. * Build one scope's wire schemas and names for prompt-order validation.
  456. * Restrictions do not make known tools invalid, but a mode collapse does.
  457. */
  458. private wireSchemas(scope?: ScopeKey): ToolProviderResult {
  459. const view = this.view(scope)
  460. const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
  461. if (this.mode === 'native') {
  462. return { schemas, knownNames: [...view.knownNames] }
  463. }
  464. this.requireCodeRuntime()
  465. if (this.mode === 'code') {
  466. return {
  467. schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
  468. knownNames: [RUN_CODE_NAME],
  469. }
  470. }
  471. return { schemas, knownNames: [...view.knownNames, RUN_CODE_NAME] }
  472. }
  473. /**
  474. * Resolve the code runtime or throw the actionable misconfiguration error.
  475. * Read at use time (assembly / run_code execution), NOT via static
  476. * `inject`: an inject entry would hold `ctx.tools` — and every tool plugin
  477. * behind it — hostage to a code runtime existing even under `mode:
  478. * 'native'` (the loop's optional-backend idiom, same as
  479. * `sessionPersistence`).
  480. */
  481. private requireCodeRuntime(): CodeRuntime {
  482. const runtime = this.ctx.get('codeRuntime')
  483. if (!runtime) {
  484. 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"`)
  485. }
  486. if (runtime.language !== 'typescript') {
  487. throw new Error(`dsh-tools: mode "${this.mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`)
  488. }
  489. return runtime
  490. }
  491. /**
  492. * Register globally or in the calling agent scope. Scoped tools shadow
  493. * globals; duplicates within one layer and the reserved `run_code` name fail.
  494. * @param definition - the tool schema, execution, and optional presentation functions.
  495. * @returns the exact disposer that unregisters the tool.
  496. */
  497. register(definition: ToolDefinition): () => void {
  498. const scope = scopeOf(this.ctx)
  499. const name = definition.name
  500. const timeoutMs = definition.timeoutMs
  501. if (timeoutMs !== undefined
  502. && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
  503. throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`)
  504. }
  505. if (this.codeTransport !== undefined && name === RUN_CODE_NAME) {
  506. throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
  507. }
  508. const dispose = this.ctx.effect(function* (this: ToolRegistry) {
  509. const layer = scope === undefined ? this.global : this.layerFor(scope)
  510. if (layer.has(name)) {
  511. throw new Error(scope === undefined
  512. ? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
  513. : `tool "${name}" is already registered in this scope`)
  514. }
  515. layer.set(name, definition)
  516. // Install rollback before notifying listeners.
  517. yield () => {
  518. layer.delete(name)
  519. // Drop empty scope layers.
  520. if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
  521. this.ctx.emit('tools/change')
  522. }
  523. this.ctx.emit('tools/change')
  524. }.bind(this), 'tools.register()')
  525. // Return the exact disposer so composite effects preserve teardown order.
  526. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
  527. return dispose
  528. }
  529. /**
  530. * Restrict global tools for the calling agent scope. Empty filters, unknown
  531. * names, scope-local names, and reserved transport names fail. Restrictions
  532. * intersect; scoped registrations remain visible.
  533. * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).
  534. * @returns the exact disposer that lifts this restriction.
  535. */
  536. restrict(filter: ToolRestriction): () => void {
  537. const scope = scopeOf(this.ctx)
  538. if (scope === undefined) {
  539. 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')
  540. }
  541. const allow = filter.allow
  542. const deny = filter.deny
  543. if (allow === undefined && deny === undefined) {
  544. 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)')
  545. }
  546. const compiled: CompiledToolRestriction = {
  547. ...allow !== undefined ? { allow: new Set(allow) } : {},
  548. ...deny !== undefined ? { deny: new Set(deny) } : {},
  549. }
  550. if (this.codeTransport !== undefined
  551. && [...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {
  552. throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`)
  553. }
  554. const known = this.view(scope).restrictableNames
  555. const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))
  556. if (unknown.length > 0) {
  557. 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)'}`)
  558. }
  559. const dispose = this.ctx.effect(function* (this: ToolRegistry) {
  560. const list = this.restrictions.get(scope) ?? []
  561. this.restrictions.set(scope, list)
  562. list.push(compiled)
  563. yield () => {
  564. const index = list.indexOf(compiled)
  565. /* v8 ignore next 3 -- defensive: the compiled restriction was pushed, so indexOf is guaranteed >= 0 */
  566. if (index >= 0) list.splice(index, 1)
  567. if (list.length === 0) this.restrictions.delete(scope)
  568. this.ctx.emit('tools/change')
  569. }
  570. this.ctx.emit('tools/change')
  571. }.bind(this), 'tools.restrict()')
  572. // Return the exact disposer so composite effects preserve teardown order.
  573. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
  574. return dispose
  575. }
  576. /**
  577. * Register a monotonic guard after the extensible `tools/pre-execute`
  578. * waterfall. A plain-context guard applies globally; one registered through
  579. * `agent.ctx` applies only to that agent. Any matching guard may deny by
  580. * returning a reason, while no guard can force-allow a call another guard
  581. * denied. The exact effect disposer is returned for ordered ownership and
  582. * HMR cleanup.
  583. * @param guard - synchronous check; a returned string denies the execution.
  584. * @returns the exact disposer that unregisters the guard.
  585. */
  586. guard(guard: ToolGuard): () => void {
  587. const scope = scopeOf(this.ctx)
  588. const registration = { guard }
  589. const dispose = this.ctx.effect(function* (this: ToolRegistry) {
  590. const layer = scope === undefined ? this.globalGuards : this.guardLayerFor(scope)
  591. layer.add(registration)
  592. yield () => {
  593. layer.delete(registration)
  594. if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope)
  595. }
  596. }.bind(this), 'tools.guard()')
  597. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
  598. return dispose
  599. }
  600. /** The (created-on-demand) scoped layer for `scope`. */
  601. private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
  602. let layer = this.scoped.get(scope)
  603. if (!layer) {
  604. layer = new Map()
  605. this.scoped.set(scope, layer)
  606. }
  607. return layer
  608. }
  609. /** Get or create the guard layer for one agent scope. */
  610. private guardLayerFor(scope: ScopeKey): Set<ToolGuardRegistration> {
  611. let layer = this.scopedGuards.get(scope)
  612. if (layer === undefined) {
  613. layer = new Set()
  614. this.scopedGuards.set(scope, layer)
  615. }
  616. return layer
  617. }
  618. /** First monotonic denial from the global then matching scoped guard layers. */
  619. private guardReason(exec: ToolExecution): string | undefined {
  620. for (const { guard } of this.globalGuards) {
  621. const reason = guard(exec)
  622. if (reason !== undefined) return reason
  623. }
  624. if (exec.agent !== undefined) {
  625. for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) {
  626. const reason = guard(exec)
  627. if (reason !== undefined) return reason
  628. }
  629. }
  630. return undefined
  631. }
  632. /** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */
  633. private admits(scope: ScopeKey | undefined, name: string): boolean {
  634. if (scope === undefined) return true
  635. const filters = this.restrictions.get(scope)
  636. if (!filters) return true
  637. return filters.every(filter =>
  638. (filter.allow === undefined || filter.allow.has(name))
  639. && (filter.deny === undefined || !filter.deny.has(name)))
  640. }
  641. /**
  642. * Resolve every registry fact one scope needs in one layer traversal. The
  643. * visible map applies global restrictions, scoped shadowing, and the reserved
  644. * presentation transport; the other sets retain the pre-restriction facts
  645. * needed by restriction and prompt-order validation.
  646. * @param scope - the viewing scope (the agent), or undefined for the global view.
  647. * @returns the complete derived view for that scope.
  648. */
  649. private view(scope?: ScopeKey): ToolView {
  650. const layer = scope === undefined ? undefined : this.scoped.get(scope)
  651. const visible = new Map<string, ToolDefinition>()
  652. const knownNames = new Set<string>()
  653. const restrictableNames = new Set<string>()
  654. for (const [name, definition] of this.global) {
  655. knownNames.add(name)
  656. restrictableNames.add(name)
  657. if (this.admits(scope, name)) visible.set(name, definition)
  658. }
  659. // Scoped layer second: same-name entries REPLACE (shadow) the global ones,
  660. // and scope-local registrations are never part of the global filter above.
  661. for (const [name, definition] of layer ?? []) {
  662. knownNames.add(name)
  663. visible.set(name, definition)
  664. }
  665. // Presentation infrastructure is resolved last and outside capability
  666. // filtering. Registration rejects this reserved name, so the insertion is
  667. // an invariant assertion as well as protection against future layer changes.
  668. if (this.codeTransport !== undefined) {
  669. visible.set(RUN_CODE_NAME, this.codeTransport)
  670. }
  671. return { visible, knownNames, restrictableNames }
  672. }
  673. /**
  674. * Look up a tool as one scope sees it (scoped
  675. * shadows global; a restricted-away global reads as absent). Presenters pass
  676. * the calling agent so the rendered card matches the definition that
  677. * actually executed.
  678. * @param name - the tool name as registered.
  679. * @param scope - the viewing scope (the agent); omitted = the global view.
  680. * @returns the definition the scope resolves, or undefined when none is visible.
  681. */
  682. get(name: string, scope?: ScopeKey): ToolDefinition | undefined {
  683. return this.view(scope).visible.get(name)
  684. }
  685. /**
  686. * Project visible definitions onto the allowlisted model-facing schema fields,
  687. * excluding execution and presentation callbacks.
  688. * @param scope - the viewing scope (the agent); omitted = the global view.
  689. * @returns one deep-cloned schema per visible tool.
  690. */
  691. schemas(scope?: ScopeKey): ToolSchema[] {
  692. return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true))
  693. }
  694. /** Project one definition onto the model-facing schema fields. */
  695. private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
  696. const { name, description, parameters } = definition
  697. return {
  698. name,
  699. description,
  700. parameters: detachParameters ? structuredClone(parameters) : parameters,
  701. }
  702. }
  703. /**
  704. * Classify a pending call through the caller's visible tool definition. Only
  705. * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or
  706. * throwing classifiers are exclusive.
  707. * @param exec - call name, parsed arguments, and optional agent scope.
  708. * @returns the fail-closed scheduling mode.
  709. */
  710. executionMode(exec: ToolExecutionInput): ToolExecutionMode {
  711. const tool = this.get(exec.name, exec.agent)
  712. if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
  713. try {
  714. const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments)
  715. return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' }
  716. } catch {
  717. return { kind: 'exclusive' }
  718. }
  719. }
  720. /**
  721. * Execute through pre-policy, guards, around-dispatch, post-policy, and final
  722. * notification. Tool and listener failures resolve as materialized error
  723. * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
  724. * the same lossless, frozen snapshot final observers receive.
  725. * @param exec - the typed same-process call input. The registry assigns its
  726. * correlation token before policy begins.
  727. * @returns the materialized final result.
  728. */
  729. async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
  730. return this.prepareExecution(exec, prepared => this.completeScheduledExecution(prepared))
  731. }
  732. private async completeScheduledExecution(prepared: ScheduledToolPreparation): Promise<ToolExecutionResult> {
  733. switch (prepared.kind) {
  734. case 'dispatch': {
  735. const dispatched = await this.dispatchScheduledExecution(prepared.exec)
  736. return dispatched.kind === 'post-result'
  737. ? await this.finalizeScheduledExecution(prepared.exec, dispatched.result)
  738. : this.finishScheduledExecution(prepared.exec, dispatched.result)
  739. }
  740. case 'post-result':
  741. return await this.finalizeScheduledExecution(prepared.exec, prepared.result)
  742. case 'final-result':
  743. return this.finishScheduledExecution(prepared.exec, prepared.result)
  744. /* v8 ignore next -- closed-union exhaustiveness guard */
  745. default:
  746. return assertNever(prepared, 'scheduled tool preparation')
  747. }
  748. }
  749. private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: ToolRunContext } {
  750. const deferredContexts: HookContext[] = []
  751. const token = createExecutionToken()
  752. const callId = exec.callId
  753. const name = exec.name
  754. const agent = exec.agent
  755. const parent = exec.parent
  756. const signal = exec.signal
  757. const base = {
  758. token,
  759. callId,
  760. name,
  761. ...agent !== undefined ? { agent } : {},
  762. ...parent !== undefined ? { parent } : {},
  763. ...signal !== undefined ? { signal } : {},
  764. deferContext(context: HookContext): void {
  765. deferredContexts.push(context)
  766. },
  767. }
  768. try {
  769. const detached = snapshotJsonValue(exec.arguments)
  770. if (detached === undefined) {
  771. throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
  772. }
  773. const execution: ToolRunContext = { ...base, arguments: deepFreeze(detached) }
  774. this.deferredContexts.set(execution, deferredContexts)
  775. return { kind: 'ready', exec: execution }
  776. } catch (error: unknown) {
  777. const execution: ToolRunContext = { ...base, arguments: undefined }
  778. return { kind: 'final-result', exec: execution, result: toolErrorResult(error) }
  779. }
  780. }
  781. /**
  782. * Run the ordered pre-execute and monotonic guard stages for the scheduler.
  783. * @param input - the caller-supplied execution input.
  784. * @returns the prepared execution plus the next scheduler stage.
  785. * @internal
  786. */
  787. private async prepareScheduledExecution(input: ToolExecutionInput): Promise<ScheduledToolPreparation> {
  788. return this.prepareExecution(input, prepared => prepared)
  789. }
  790. private async prepareExecution<T>(
  791. input: ToolExecutionInput,
  792. next: (prepared: ScheduledToolPreparation) => T | PromiseLike<T>,
  793. ): Promise<T> {
  794. const created = this.createExecution(input)
  795. if (created.kind !== 'ready') return next(created)
  796. const exec = created.exec
  797. try {
  798. const carrier = scopeTarget(this, exec.agent)
  799. const gate = await this.ctx.waterfall(
  800. carrier, 'tools/pre-execute', exec,
  801. () => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
  802. )
  803. const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
  804. const denialReason = decision.kind === 'allow'
  805. ? this.guardReason(exec)
  806. : decision.reason
  807. if (denialReason !== undefined) {
  808. return await next({
  809. kind: 'post-result',
  810. exec,
  811. result: {
  812. content: [{ type: 'text', text: `Error: ${denialReason}` }],
  813. isError: true,
  814. },
  815. })
  816. }
  817. return await next({ kind: 'dispatch', exec })
  818. } catch (error: unknown) {
  819. return next({ kind: 'final-result', exec, result: toolErrorResult(error) })
  820. }
  821. }
  822. /**
  823. * Run around-dispatch and the tool body. Tool and unknown-tool failures still
  824. * receive post-execute; pipeline failures are already final.
  825. * @param exec - the prepared execution.
  826. * @returns whether the result still needs post-execute.
  827. * @internal
  828. */
  829. private async dispatchScheduledExecution(exec: ToolRunContext): Promise<ScheduledToolDispatch> {
  830. try {
  831. const carrier = scopeTarget(this, exec.agent)
  832. const result = await this.ctx.waterfall(
  833. carrier, 'tools/execute', exec,
  834. async (): Promise<ToolExecutionResult> => {
  835. try {
  836. const tool = this.get(exec.name, exec.agent)
  837. if (!tool) throw new ToolNotFoundError(exec.name)
  838. const returned = await tool.execute(exec.arguments, exec)
  839. const content = Array.isArray(returned) ? returned : returned.content
  840. const meta = Array.isArray(returned) ? undefined : returned.meta
  841. return { content, isError: false, ...meta !== undefined ? { meta } : {} }
  842. } catch (error: unknown) {
  843. return toolErrorResult(error)
  844. }
  845. },
  846. )
  847. const deferredContexts = this.deferredContexts.get(exec)
  848. /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
  849. if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution')
  850. const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
  851. ? result
  852. : {
  853. ...result,
  854. additionalContexts: [
  855. ...deferredContexts,
  856. ...result.additionalContexts ?? [],
  857. ],
  858. }
  859. return { kind: 'post-result', result: resultWithDeferredContexts }
  860. } catch (error: unknown) {
  861. return { kind: 'final-result', result: toolErrorResult(error) }
  862. }
  863. }
  864. /**
  865. * Run ordered post-execute, then materialize and notify the final outcome.
  866. * @param exec - the prepared execution.
  867. * @param result - dispatch/pre result that still needs post-execute.
  868. * @returns the materialized final result.
  869. * @internal
  870. */
  871. private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult> {
  872. try {
  873. return this.finishScheduledExecution(exec, await this.postExecute(exec, result))
  874. } catch (error: unknown) {
  875. return this.finishScheduledExecution(exec, toolErrorResult(error))
  876. }
  877. }
  878. /**
  879. * Materialize and notify a final result that must bypass post-execute.
  880. * @param exec - the prepared execution.
  881. * @param result - final result.
  882. * @returns the materialized final result.
  883. * @internal
  884. */
  885. private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
  886. let finalResult: ToolExecutionResult
  887. try {
  888. finalResult = this.materializeFinalResult(result)
  889. } catch (error: unknown) {
  890. finalResult = this.materializeFinalResult(toolErrorResult(error))
  891. }
  892. this.notifyResult(exec, finalResult)
  893. return finalResult
  894. }
  895. /** Notify observers without exposing a mutation or error channel into the outcome. */
  896. private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
  897. // Freeze the remaining mutable signal slot before observers receive the
  898. // shared WeakMap-keyable execution object.
  899. Object.freeze(exec)
  900. const callbacks = this.ctx.events.dispatch('emit', [
  901. scopeTarget(this, exec.agent), 'tools/result', exec, result,
  902. ])
  903. for (const callback of callbacks) {
  904. try {
  905. callback(exec, result)
  906. } catch (error: unknown) {
  907. this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`)
  908. }
  909. }
  910. }
  911. /**
  912. * Resolve an `ask` decision to allow/deny through the approval seam. The
  913. * seam is consumed opportunistically with `ctx.get('approval')` — a
  914. * deployment that composes no ApprovalService keeps the historical degrade
  915. * to deny, and an unmount mid-session degrades the same way on the next ask.
  916. * An agent-less execution also degrades: without an agent there is no
  917. * session to audit to and no UI to route to. Otherwise the outcome maps
  918. * one-to-one — `allowed-once` proceeds; the three non-grants deny with
  919. * distinct reasons so the model can tell a human "no" from an absent
  920. * approval channel.
  921. */
  922. private async serviceAsk(
  923. exec: ToolExecution,
  924. ask: Extract<PreToolDecision, { kind: 'ask' }>,
  925. ): Promise<Extract<PreToolDecision, { kind: 'allow' | 'deny' }>> {
  926. const approval = this.ctx.get('approval')
  927. if (approval === undefined) {
  928. return { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }
  929. }
  930. if (exec.agent === undefined) {
  931. return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }
  932. }
  933. const outcome = await approval.request({
  934. agent: exec.agent,
  935. toolName: exec.name,
  936. callId: exec.callId,
  937. ...ask.reason !== undefined ? { reason: ask.reason } : {},
  938. ...exec.signal !== undefined ? { signal: exec.signal } : {},
  939. })
  940. switch (outcome) {
  941. case 'allowed-once': return { kind: 'allow' }
  942. case 'rejected': return { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }
  943. case 'cancelled': return { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }
  944. case 'unavailable': return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }
  945. default: return assertNever(outcome, 'ApprovalOutcome')
  946. }
  947. }
  948. /**
  949. * Run the `tools/post-execute` waterfall over a dispatched `result` and apply
  950. * its {@link PostToolDecision}: `accept` keeps the call successful (replacing
  951. * `content` when given), `block` turns it into an `isError` whose content is
  952. * the corrective `feedback`. Either decision may attach `additionalContexts`,
  953. * which are ferried on the returned result for the loop's active-batch FIFO.
  954. * Context deferred by the tool body survives an accepted result but is
  955. * discarded when the outer call is blocked; a block exposes only context the
  956. * blocking decision explicitly supplied.
  957. * Runs inside `execute`'s outer try/catch (a throwing listener → isError).
  958. */
  959. private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
  960. const decision = await this.ctx.waterfall(
  961. scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
  962. () => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
  963. )
  964. const decisionContexts = decision.additionalContexts ?? []
  965. if (decision.kind === 'block') {
  966. return {
  967. content: decision.feedback,
  968. isError: true,
  969. ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},
  970. }
  971. }
  972. // Accept: replace content if supplied, preserve the dispatched outcome, and
  973. // append decision contexts after contexts deferred by the tool body.
  974. const additionalContexts = [
  975. ...result.additionalContexts ?? [],
  976. ...decisionContexts,
  977. ]
  978. return {
  979. ...result,
  980. ...decision.content ? { content: decision.content } : {},
  981. ...additionalContexts.length > 0 ? { additionalContexts } : {},
  982. }
  983. }
  984. /** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
  985. private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult {
  986. const detached = snapshotJsonValue(result)
  987. if (detached === undefined) {
  988. throw new TypeError('tool result must be losslessly JSON-serializable')
  989. }
  990. return deepFreeze(detached)
  991. }
  992. }
  993. /** Mint a same-process correlation token whose identity is its value. */
  994. function createExecutionToken(): ToolExecutionToken {
  995. return Symbol('dsh.tool.execution') as ToolExecutionToken
  996. }
  997. function toolErrorResult(error: unknown): ToolExecutionResult {
  998. const info = errorInfo(error)
  999. return {
  1000. content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
  1001. isError: true,
  1002. ...info ? { error: info } : {},
  1003. }
  1004. }
  1005. export default ToolRegistry