返回源码地图

packages/core/tools/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 08 / 08 / 08 / 08 / 08 / 08 / 08 / 08 / 08 / 08 / 08 / 08 / 08 / 08 / 21;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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