1
/**2
* Tool registry, model presentation modes, and pre/guard/around/post/result3
* execution pipeline.4
* @module @deepseek-ai/dsh-tools5
*/7
import { Context, Service } from '@deepseek-ai/cordis'8
import z from '@deepseek-ai/schemastery'9
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'10
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'11
import type { ToolCallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'12
import { HarnessError } from '@deepseek-ai/dsh-llm'13
import type { Agent } from '@deepseek-ai/dsh-agent'14
import type { UserMessage } from '@deepseek-ai/dsh-session'15
import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'16
import type { PromptSection, ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'17
import type { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'18
import type {} from '@deepseek-ai/dsh-sandbox-policy'19
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService20
// augmentation. The seam stays optional at runtime — see `serviceAsk`.21
import type {} from '@deepseek-ai/dsh-user-approval'22
import type { ToolCallView, ToolResultView } from './presentation.ts'23
import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts'24
import type { JsonSchemaNode } from './json-schema.ts'25
import { createRunCodeTool, RUN_CODE_NAME } from './ptc.ts'26
import type { PtcSdkLanguage } from './ptc.ts'27
import { renderToolsSdk } from './ts-types.ts'28
import type { ToolSdkSchema } from './ts-types.ts'29
import { renderToolsSdkPy } from './py-types.ts'31
declare 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
}38
/**39
* Language → SDK-section renderer. The registry looks up the loaded40
* `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 key42
* fails the assembly loudly (same idiom as `toolOrder` violations). Adding a43
* new backend language is three parallel edits — a {@link PtcSdkLanguage}44
* member, an entry here, and a `RUN_CODE_FLAVORS` entry in `ptc.ts` for45
* its `run_code` schema strings — plus the renderer function this table points46
* at. The `satisfies` clause pins this table's key set to that union, which47
* the flavor table is checked against too, so any of the three left out is a48
* typecheck failure. What no check reaches is the prose that names the values49
* instead of deriving them: the seam's `dsh-ptc-runtime` README pair, its50
* `PtcRuntime.language` JSDoc, and `docs/subsystems/ptc-runtime.md`51
* with its zh pair, plus this package's own README pair and the52
* {@link Config.mode} JSDoc.53
*/54
/**55
* The model-facing statement of the `ptc` collapse. Names the consequence56
* (the call fails) and the route (inside the program), because a rule the57
* model can only discover by being denied is one it corrects too late.58
*/59
const PTC_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program.`61
const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = {62
typescript: renderToolsSdk,63
python: renderToolsSdkPy,64
} satisfies Record<PtcSdkLanguage, (schemas: ToolSdkSchema[]) => string>66
export {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'91
export {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'102
export type { PtcDispatchEventData, PtcDispatchStartEventData } from './types.ts'104
export { CodeRunFailedError, RUN_CODE_NAME } from './ptc.ts'105
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'106
export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts'107
export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts'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.112
export 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'137
declare module '@deepseek-ai/cordis' {138
interface Context {139
tools: ToolRuntime140
}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 missing146
* approval support turns `ask` into denial. Async gates must observe147
* `exec.signal`; the registry rechecks cancellation after they settle but148
* 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 waterfall152
*/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()` returns156
* a normalized result; wrappers may change only `exec.signal`, while call157
* identity remains immutable. The registry re-fuses the original caller158
* 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 waterfall163
*/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. Async168
* listeners must observe `exec.signal`; after they settle, caller169
* cancellation replaces only a successful accepted outcome with the code170
* 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 waterfall175
*/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 one179
* `run_code` sub-dispatch outcome before the bridge appends its180
* `tool/ptc-dispatch` event. `next()` keeps the181
* content unchanged; a listener may return replacement blocks (e.g. the182
* spill policy's preview + locator for an oversized text result). Only the183
* logged copy is affected — the program already received the complete184
* 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 waterfall189
*/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 emit197
*/198
'tools/result'(this: Scoped<ToolRuntime>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined199
/**200
* A tool was registered or unregistered, or a scoped restriction changed201
* (the available tool set changed — possibly for one scope only). An202
* UNFILTERED registry-subject notification, deliberately not scope-filtered203
* dispatch: a global change concerns every agent's next assembly, so a204
* scoped listener subscribing here sees every change, not just its own205
* scope's.206
* @mode emit207
*/208
'tools/change'(): void209
}210
}212
/** Tool-owned canonical output contract used after the body returns a JSON value. */213
export interface ToolOutputDefinition {214
/** Raw supported JSON Schema enforced against every successful canonical value. */215
readonly schema: JsonSchemaNode216
/** 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): JsonValue220
}222
/** A registered tool: its schema plus the execution function. */223
export interface ToolDefinition extends ToolSchema {224
/** Mandatory canonical output declaration. */225
readonly output: ToolOutputDefinition226
/**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 its229
* owned work reaches quiescence. The registry preserves caller cancellation230
* through around-dispatch signal replacement and does not abandon this231
* 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 a240
* normalized outcome entering post-execute. Policy replacements remain241
* 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[] | undefined247
/**248
* Synchronous last-mile transform for model-facing content. The registry249
* snapshots this callback when execution starts and invokes it exactly once250
* for every normalized outcome, including pipeline failures that bypass251
* `tools/post-execute`, immediately before lossless materialization.252
* Returning `undefined` preserves the content; every other result field253
* 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[] | undefined259
/**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); it262
* is NEVER sent to the model — `schemas()` whitelists only name/description/263
* parameters. Declaring it asserts this tool forwards `exec.signal` to a264
* cooperative implementation that can reach quiescence when the signal aborts.265
*/266
timeoutMs?: number267
/**268
* Pure synchronous classifier for overlap with sibling tool calls. Only269
* `true` opts in; omission, exceptions, non-`true` returns, and invalid270
* `defineTool` arguments are exclusive. This metadata is never model-visible.271
*272
* Opted-in executions must not mutate parent-owned state. Shared state must273
* tolerate concurrent dispatch; recorder races are permitted only when they274
* commute or fail closed. See the275
* [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): boolean281
/**282
* Optional: how to present the PENDING state of one call in a UI, derived from283
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows284
* 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 presentation286
* (title = tool name, raw args as input). Pure and side-effect-free: a UI may287
* call it during live streaming AND a session-log replay, so it must depend288
* only on `args`.289
*/290
presentCall?(args: unknown): ToolCallView | undefined291
/**292
* Optional: how to present the COMPLETED state, given the same `args` and the293
* durable result projection (`content`, failure state, and optional `meta`). Returns a294
* {@link ToolResultView}, or `undefined` (or omit the method) to keep the295
* pending title and render the raw result content. Pure and side-effect-free296
* for the same replay reason.297
*/298
presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined299
}301
/** The completed outcome handed to {@link ToolDefinition.presentResult}. */302
export 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: boolean307
/**308
* The tool-private presentation payload projected by its output declaration.309
* It is persisted verbatim on `tool/result` for Host presenters and Client310
* renderers to narrow independently. Absent when the tool declared no311
* projector or the call was nested under a composite transport.312
*/313
meta?: JsonValue314
}316
declare const toolExecutionTokenBrand: unique symbol318
/** Opaque call identity that permits correlation without exposing mutable execution state. */319
export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }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
*/326
export interface ToolExecutionInput {327
readonly callId: ToolCallId328
/**329
* Root model-requested call owning this execution tree. Callers omit it for330
* a root execution; nested dispatchers propagate the enclosing value.331
*/332
readonly rootCallId?: ToolCallId333
readonly name: string334
/** Binding-time tool schema for a PTC inner call; frozen by its producer and never logged. */335
readonly schema?: ToolSchema336
/** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */337
readonly arguments: unknown338
/** The agent on whose behalf the call runs (set by the agent loop). */339
readonly agent?: Agent340
/**341
* Opaque token of the enclosing transport execution, when one exists. PTC342
* mode sets this on SDK sub-dispatches so commit-style observers can wait for343
* 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 a345
* model-direct call: under `mode: 'ptc'`, only calls WITH a parent may346
* execute a native tool name — a model-direct call (no parent) is denied as347
* `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRuntime.execute}.348
*/349
readonly parent?: ToolExecutionToken350
/** Required caller-owned cancellation for this invocation. */351
readonly signal: AbortSignal352
}354
/**355
* Scheduling mode for one pending call. `parallel` may overlap with siblings;356
* `exclusive` runs alone and forms an ordering barrier.357
*/358
export type ToolExecutionMode =359
| { kind: 'parallel' }360
| { kind: 'exclusive' }362
/**363
* One settled `run_code` sub-dispatch about to be logged, as seen by the364
* `tools/ptc-dispatch-log` waterfall: the parent execution (session owner,365
* outer call identity), the sub-call identity, and the outcome whose durable366
* copy a listener may reshape. `content` is the RENDERED result projection367
* (what a native `tool/result` would carry) — the program itself received368
* the structured `value` (or just the error message on failure); only the369
* `tool/ptc-dispatch` event's copy changes.370
*/371
export interface PtcDispatchLog {372
/** The outer `run_code` execution. */373
readonly exec: ToolExecution374
/** The calling agent (the scope routing key and the spill owner), when the outer call has one. */375
readonly agent?: Agent376
/** Opaque sub-call id; new calls use `<parent>:ptc:<n>`. */377
readonly subCallId: ToolCallId378
/** The dispatched sub-tool name. */379
readonly name: string380
/** Whether the sub-call settled as an error. */381
readonly isError: boolean382
/** The sub-call's complete model-facing content (the settle event's default payload). */383
readonly content: ContentBlock[]384
}386
/**387
* One pending tool call inside the registry pipeline. Parsed arguments cross388
* one lossless-JSON materialization boundary before policy and are deep-frozen;389
* call identity, the caller signal, and the registry-assigned {@link token} are390
* readonly. The registry freezes the complete object before `tools/result`391
* observers run.392
*/393
export interface ToolExecution extends ToolExecutionInput {394
/** Root model-requested call, resolved for every root and nested execution. */395
readonly rootCallId: ToolCallId396
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */397
readonly token: ToolExecutionToken398
}400
/**401
* Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper402
* 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
*/405
export interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> {406
/** Cancellation signal visible to the next wrapper or tool body. */407
signal: AbortSignal408
}410
/**411
* Runtime context handed to a tool implementation after the registry has412
* accepted a {@link ToolExecution}. {@link deferContext} attaches context to413
* this execution's own result — a composite tool ferries nested-dispatch414
* context back to the outer result, and a leaf tool may mint a fresh415
* plugin-sourced instruction; the loop appends it only after the416
* `tool/result`.417
*/418
export interface ToolRunContext extends ToolExecution {419
/**420
* Defer one context — typically a nested-dispatch context ferried by a421
* composite tool, or a fresh plugin-sourced instruction — until this tool's422
* final result reaches the agent loop. Contexts retain their individual423
* source and metadata and are emitted in call order.424
*/425
deferContext(context: UserMessage): void426
/**427
* Mark a successful final result as terminal for the current agent turn.428
* The marker rides this execution's own result (`concludesTurn` exists only429
* on {@link ToolExecutionSuccess}); a composite that dispatches nested430
* calls forwards it from the nested result, exactly like431
* `additionalContexts`, so only an authoritative nested success can432
* conclude the enclosing run.433
*/434
concludeTurn(): void435
}437
/** Registry-owned live execution object; public pipeline views stay readonly. */438
type MutableToolRunContext = Omit<ToolRunContext, 'signal'> & { signal: AbortSignal }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
* @internal444
*/445
export type ScheduledToolPreparation =446
| { kind: 'dispatch'; exec: ToolRunContext }447
| { kind: 'post-result'; exec: ToolRunContext; result: ToolExecutionResult }448
| { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult }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
* @internal454
*/455
export type ScheduledToolDispatch =456
| { kind: 'post-result'; result: ToolExecutionResult }457
| { kind: 'final-result'; result: ToolExecutionResult }459
/**460
* Symbol-keyed scheduler view that keeps pre/post policy ordered while461
* overlapping dispatch. Ordinary callers use {@link ToolRuntime.execute};462
* this is not a plugin extension point.463
* @internal464
*/465
export 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): ToolExecutionResult474
}476
/**477
* Scheduler entry point omitted from the generated named service API.478
* @internal479
*/480
export const TOOL_RUNTIME_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')482
/** Canonical error code for cancellation after a tool body was invoked. */483
export const TOOL_ABORTED = 'ABORTED'485
/** Canonical error code for cancellation before a tool body was invoked. */486
export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'488
/** Structured error metadata for a failed tool call (alongside the model-facing text). */489
export interface ToolErrorInfo {490
name: string491
code: string492
/** Optional raw user-facing detail; durable projections preserve it but model-facing content does not include it. */493
reason?: string494
}496
/** Canonical failure detail; internal routing information remains optional. */497
export interface ToolFailure {498
/** Human-readable failure message without the Native `Error: ` envelope. */499
message: string500
/** Internal error class/code used by policy and durable diagnostics. */501
info?: ToolErrorInfo502
}504
/**505
* Thrown (internally) when the model requests a tool that isn't registered.506
* Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool507
* failure is as routable as a tool-thrown one — retry/sandbox/replay code can508
* distinguish it from a tool body's own error.509
*/510
export 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 the514
* 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 === undefined520
? `unknown tool "${toolName}"`521
: `unknown tool "${toolName}": ${reachableFrom}`,522
'UNKNOWN_TOOL',523
)524
this.name = 'ToolNotFoundError'525
}526
}528
/** Thrown when a tool body or post-policy value violates its declared output. */529
export class ToolOutputError extends HarnessError {530
/** Schema/value violations in validation order. */531
readonly violations: string[]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 = violations537
}538
}540
/** Convert one projector exception into the canonical invalid-output failure. */541
function projectionError(toolName: string, projector: 'render' | 'presentationMeta', error: unknown): ToolOutputError {542
return new ToolOutputError(toolName, [`output.${projector} failed: ${errorMessage(error)}`])543
}545
/** Snapshot one projector result before later durable-result materialization. */546
function 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 detached553
} catch (error: unknown) {554
if (error instanceof ToolOutputError) throw error555
throw projectionError(toolName, projector, error)556
}557
}559
/** Snapshot one body or policy value into the canonical invalid-output failure class. */560
function 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 JsonValue565
} catch (error: unknown) {566
if (error instanceof ToolOutputError) throw error567
throw new ToolOutputError(toolName, [`value snapshot failed: ${errorMessage(error)}`])568
}569
}571
/** Successful canonical tool execution, including its Native/model projection. */572
export interface ToolExecutionSuccess {573
readonly isError: false574
/** Execution-local canonical value; deliberately omitted from durable events. */575
readonly value: JsonValue576
readonly content: ContentBlock[]577
readonly error?: never578
readonly meta?: JsonValue579
readonly additionalContexts?: UserMessage[]580
/** The agent loop stops after committing this successful result batch. */581
readonly concludesTurn?: true582
}584
/** Failed canonical tool execution; failures never carry a successful value. */585
export interface ToolExecutionFailure {586
readonly isError: true587
readonly error: ToolFailure588
readonly value?: never589
readonly content: ContentBlock[]590
readonly meta?: JsonValue591
readonly additionalContexts?: UserMessage[]592
readonly concludesTurn?: never593
}595
/** The discriminated, execution-local outcome of one tool call. */596
export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure598
/**599
* Pre-dispatch decision. `allow` runs the call; `deny` materializes its600
* model-facing reason and optional structured error identity; `cancel` selects601
* the canonical cancellation result without presenting a policy denial; `ask`602
* runs only after an approval service returns `allowed-once` and otherwise603
* denies; its `reason` is the audited approval reason and its optional604
* `displayReason` is the localized prompt text. Input rewriting is excluded because arguments are already logged and605
* presented.606
*/607
export 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 } }613
/**614
* Post-dispatch decision: accept, replace one projection, attach context for the615
* next request, or block by turning corrective feedback into an error result.616
*/617
export 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[] }622
/**623
* Best-effort human-readable message from an arbitrary thrown value: Error624
* instances use `.message`; non-Error objects with a string `message`625
* property (e.g. `throw { message: 'denied' }`) use it too; everything else626
* is stringified.627
*/628
function errorMessage(error: unknown): string {629
try {630
if (error instanceof Error) return error.message631
if (typeof error === 'object' && error !== null632
&& 'message' in error && typeof error.message === 'string') {633
return error.message634
}635
return String(error)636
} catch {637
// A hostile thrown value can trap `instanceof`, property access, or string638
// coercion. Error normalization is the outermost safety boundary, so its639
// fallback must itself be total.640
return '<unprintable thrown value>'641
}642
}644
/** Derive one failure message from policy feedback without changing its rendered blocks. */645
function failureMessageFromContent(content: ContentBlock[]): string {646
const text = content647
.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
}652
/** Snapshot and freeze one durable tool-result projection or reject lossy data. */653
function 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
}661
/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */662
function errorInfo(error: unknown): ToolErrorInfo | undefined {663
try {664
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined665
} catch {666
return undefined667
}668
}670
/** How the registry presents its tools to the model (see {@link Config.mode}). */671
export type ToolPresentationMode = 'native' | 'ptc' | 'both'673
/** Plugin config: how the registered tools are presented to the model. */674
export 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 the678
* executor to the same surface (a model-direct call may only name679
* `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 prompt682
* assembly when it is absent or has no renderer. Under `ptc`, native names683
* in `toolOrder` are invalid.684
*/685
mode?: ToolPresentationMode686
/**687
* Concurrency cap for a `run_code` program's overlapping sub-calls688
* (default 10, the loop scheduler's own default). Sub-calls follow the689
* native scheduling contract — only calls whose tools classify690
* concurrency-safe overlap; exclusive calls form barriers — so `1`691
* restores strictly serial dispatch. Must be a positive integer.692
*/693
maxParallelSubCalls?: number694
}696
/**697
* Per-scope filter over global tools. Restrictions intersect and do not affect698
* scoped registrations or the reserved PTC mode transport.699
*/700
export 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
}707
/** One restriction compiled at registration for repeated live-global lookup. */708
interface CompiledToolRestriction {709
readonly allow?: ReadonlySet<string>710
readonly deny?: ReadonlySet<string>711
}713
/** One scope's complete registry view, derived in a single layer traversal. */714
interface 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
}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 allow727
* 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
*/731
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined733
/** One scope's complete tool-registry contribution. */734
class 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 the740
* deployment default. One cell rather than an entry table: two answers to741
* "which form does the model see" is a contradiction, not a merge.742
*/743
mode: ToolPresentationMode | undefined745
constructor(scope: ScopeKey | undefined) {746
this.tools = new NamedEntries(name => new Error(scope === undefined747
? `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
}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 === undefined755
}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 false762
}763
return true764
}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 reason771
}772
return undefined773
}774
}776
/** Approval decision plus whether the approval channel reported cancellation. */777
interface ToolAskResolution {778
readonly decision: Extract<PreToolDecision, { kind: 'allow' | 'deny' }>779
readonly approvalCancelled: boolean780
}782
/** Caller cancellation and dispatch state kept outside the around-wrapper view. */783
interface ToolCancellationState {784
readonly callerSignal: AbortSignal785
bodyInvoked: boolean786
}788
/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */789
interface FusedToolSignal {790
readonly signal: AbortSignal791
dispose(): void792
}794
/** Resolve the run_code overlap cap at the owning config boundary (direct construction bypasses the Loader schema). */795
function resolveMaxParallelSubCalls(value: number | undefined): number {796
const maxParallelSubCalls = value ?? 10797
if (!Number.isInteger(maxParallelSubCalls) || maxParallelSubCalls < 1) {798
throw new Error('maxParallelSubCalls must be a positive integer')799
}800
return maxParallelSubCalls801
}803
/**804
* Tool registry and execution pipeline. Scoped registrations shadow globals;805
* one visibility resolver feeds presentation, lookup, and dispatch.806
*/807
export class ToolRuntime extends Service {808
static inject = ['systemPrompt']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
})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
}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: ToolPresentationMode839
private readonly maxParallelSubCalls: number840
/**841
* Reserved presentation transport, kept outside the filterable registration842
* layers. Built on first need rather than at construction: which agents run843
* a PTC mode is no longer known when the service is constructed, and the844
* transport is stateless beyond its closures over `this`.845
*/846
private ptcTransport: ToolDefinition | undefined848
constructor(ctx: Context, config: Config = {}) {849
super(ctx, 'tools')850
// The schema already defaulted an omitted mode; the ?? narrows the851
// 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
}861
/**862
* The prompt statement of the `ptc` executor collapse, registered wherever863
* {@link sdkSection} is and rendering empty outside an effective `ptc`.864
*865
* Every tool contributes its own guidance section naming its tool, none of866
* 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 no868
* 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 concludes870
* the deployment is inconsistent. Its order places the rule before that871
* 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 state881
// a rule the registry does not enforce (see `collapses`).882
text: context => this.modeFor(context.scope) === 'ptc' ? PTC_ONLY_INSTRUCTION : '',883
}884
}886
/**887
* The generated-SDK prompt section, registered globally by a PTC mode888
* deployment and per scope by {@link presentAs}.889
*890
* The body regenerates from the CALLING scope, and renders empty for an891
* agent presenting natively — an agent that opted out under a PTC mode892
* deployment still sees the global registration, and an empty section is893
* 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` would907
// 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
}916
/**917
* The presentation one scope's agent sees: its own declaration, else the918
* 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 declaration924
// covers every agent parented under it, and an agent's own (were one ever925
// declared) would override its preset's. The mode decides what the model926
// 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]?.mode930
if (mode !== undefined) return mode931
}932
return this.defaultMode933
}935
/**936
* The reserved `run_code` transport, built on first need.937
*938
* It never enters the global layer: per-agent restrictions must not remove939
* it, and a scoped registration must not shadow it. The visibility resolver940
* 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 runtime954
// without demanding one, so a native-default process can still project955
// 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.ptcTransport961
}963
/**964
* Present the calling scope's tools in `mode` instead of the deployment965
* default. Nearest scope on the chain wins, so a preset's standing966
* declaration covers every agent joined under it.967
*968
* Scoped only, and one declaration per scope: this is how an agent preset969
* composes PTC mode agents beside native ones in the same process, and a970
* 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.ctx976
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 = mode987
return () => { layer.mode = undefined }988
},989
{ label: 'tools.presentAs()' },990
)991
// The SDK and collapse sections are per scope for the same reason the992
// mode is. Under a deployment that already defaults to PTC mode this993
// shadows the global registration with an identical body, which costs994
// 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 teardown1001
return dispose1002
}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 reads1016
// run_code's language-aware description/parameters getters, whose own1017
// flavor-table guard would otherwise surface first. This keeps the1018
// renderer-table rejection the canonical assembly-time error for a1019
// 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
}1031
/**1032
* Resolve the PTC runtime or throw the actionable misconfiguration error.1033
* Read at use time (assembly / run_code execution), NOT via static1034
* `inject`: an inject entry would hold `ctx.tools` — and every tool plugin1035
* 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 not1039
* bound to a request. Harmless while one published backend exists — both1040
* reads return the same flavor — but a reload that swapped in a second1041
* language between them would hand a program written against one SDK to the1042
* other. Binding it is deferred until a second backend ships (the first1043
* 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 runtime1055
}1057
/**1058
* Register globally or in the calling agent scope. Scoped tools shadow1059
* 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.name1065
const output = (definition as Partial<ToolDefinition>).output1066
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.timeoutMs1073
if (timeoutMs !== undefined1074
&& (!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 a1079
// 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
}1090
/**1091
* Restrict global tools for the calling agent scope. Empty filters, unknown1092
* names, scope-local names, and reserved transport names fail. Restrictions1093
* 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.allow1103
const deny = filter.deny1104
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).restrictableNames1115
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
}1126
/**1127
* Register a monotonic guard after the extensible `tools/pre-execute`1128
* waterfall. A plain-context guard applies globally; one registered through1129
* `agent.ctx` applies only to that agent. Any matching guard may deny by1130
* returning a reason, while no guard can force-allow a call another guard1131
* denied. The exact effect disposer is returned for ordered ownership and1132
* 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
}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 globalReason1148
if (exec.agent === undefined) return undefined1149
for (const layer of this.layers.chainLayers(exec.agent)) {1150
const reason = layer.guardReason(exec)1151
if (reason !== undefined) return reason1152
}1153
return undefined1154
}1156
/**1157
* Resolve every registry fact one scope needs in one layer traversal. The1158
* visible map applies restrictions to the INHERITED surface, then the1159
* scope's own registrations and the reserved presentation transport; the1160
* other sets retain the pre-restriction facts needed by restriction and1161
* prompt-order validation.1162
*1163
* A restriction filters what a scope inherits — the global layer and every1164
* 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 the1167
* child's own layer, and a filter naming the capabilities the child may use1168
* must not strip the machinery it answers through.1169
*1170
* Reading the exempt set as "the global layer" instead of "not mine" held1171
* only while every model-facing tool sat in the host composition. Once1172
* presets moved them onto the agent plane they became an ANCESTOR1173
* contribution, so a child's filter silently stopped constraining anything1174
* 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 the1182
// scope owns rather than inherits, and it is absent until the scope1183
// contributes something.1184
const own = this.layers.peek(scope)1185
// Inherited surface, nearest ancestor last: a nearer scope's same-name1186
// 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) continue1190
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 may1199
// 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 and1203
// 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 capability1211
// filtering. Registration rejects this reserved name, so the insertion is1212
// an invariant assertion as well as protection against future layer1213
// changes. Per scope: a native agent must not find `run_code` in its1214
// 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
}1221
/**1222
* Look up a tool as one scope sees it (scoped1223
* shadows global; a restricted-away global reads as absent). Presenters pass1224
* the calling agent so the rendered card matches the definition that1225
* 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
}1234
/**1235
* Resolve the definition that MAY EXECUTE for a call, applying the mode1236
* collapse at the operation boundary that owns it. The registry view1237
* (`get`) is presentation-agnostic; here a MODEL-DIRECT call under `ptc`1238
* may only name the reserved `run_code` transport, while a nested1239
* sub-dispatch (a `parent` token set — the `run_code` SDK calling a tool1240
* 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 undefined1250
if (this.collapses(name, scope, nested)) return undefined1251
return tool1252
}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
}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
}1281
/** Project one definition onto the model-facing schema fields. */1282
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {1283
const { name, description, parameters, deferLoading } = definition1284
const detached = detachParameters ? snapshotJsonValue(parameters) : parameters1285
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
}1296
/**1297
* Classify a pending call through the caller's visible tool definition. Only1298
* an exact `true` is parallel; unknown, hidden, undeclared, invalid, or1299
* 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
}1314
/**1315
* Run the `tools/ptc-dispatch-log` waterfall over one settled sub-dispatch1316
* and return the content the bridge should log on `tool/ptc-dispatch`.1317
* Contained: when a listener throws, the method logs the original settled1318
* 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, which1320
* receives it as a capability parameter (the `requireRuntime` idiom) — the1321
* 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.content1332
}1333
}1335
/**1336
* Whether the `ptc` mode collapse denies a model-direct call: only the1337
* reserved `run_code` transport may be named. Nested sub-dispatches (a1338
* `parent` token set) bypass the collapse. One home for the1339
* security-relevant predicate, shared by {@link resolveExecution} and1340
* {@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 composition1344
* `dsh-agent-tool-presentation` exists for, and reading the deployment default would1345
* leave exactly that agent uncollapsed — announcing one surface while1346
* 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_NAME1353
}1355
/**1356
* Execute through pre-policy, guards, around-dispatch, post-policy,1357
* definition-owned content finalization, and final notification. Tool and1358
* listener failures resolve as materialized error results; an invisible tool1359
* reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen1360
* snapshot final observers receive. Cancellation1361
* arriving after entry and before final result materialization skips a1362
* not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a1363
* successful started outcome with `ABORTED`; already-started work is still1364
* drained and may retain a tool-owned structured error.1365
* @param exec - the typed same-process call input. The registry assigns its1366
* 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
}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
}1391
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } {1392
const deferredContexts: UserMessage[] = []1393
const token = createExecutionToken()1394
const callId = exec.callId1395
const rootCallId = exec.rootCallId ?? callId1396
const name = exec.name1397
const agent = exec.agent1398
const parent = exec.parent1399
const signal = exec.signal1400
// Distinguish a mode-collapsed call (visible in the scope, denied only by1401
// the `ptc` collapse) from a genuinely unknown tool. A collapsed call is1402
// deterministically denied, so it terminates BEFORE the extensible policy1403
// pipeline: pre-execute listeners, approval `ask`, and guards must never1404
// observe — or worse, approve — a call that can only fail. An unknown tool1405
// keeps the historical dispatch-stage `UNKNOWN_TOOL` path so policy1406
// 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.concludingExecutions1410
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: the1427
// `finalizeContent` contract snapshots the callback when the call starts,1428
// and an arguments getter can replace or clear the registered callback1429
// during `snapshotJsonValue`. The collapse only decides whether the1430
// CAPTURED callback is retained: the pre-dispatch abort path keeps it1431
// (the cancellation contract routes aborted results through it — a getter1432
// that aborts mid-materialization before an invalid-args failure lands in1433
// the same retained path), while the `UNKNOWN_TOOL` denial and the1434
// invalid-args failure of a NON-ABORTED collapsed call drop it (the call1435
// 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 : capturedFinalizer1440
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 a1455
// pre-dispatch abort still keeps the established cancellation1456
// contract: `prepare`'s caller-cancellation check is skipped for1457
// final-results, so honor the abort here instead of surfacing1458
// `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 model1463
// must take instead. Without it the model reads a bare `unknown tool`1464
// for a tool the prompt just declared and concludes the deployment is1465
// 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
}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
* @internal1488
*/1489
private async prepareScheduledExecution(input: ToolExecutionInput): Promise<ScheduledToolPreparation> {1490
return this.prepareExecution(input, prepared => prepared)1491
}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.exec1500
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 } = askResolution1513
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.reason1520
const denialInfo = decision.kind === 'deny' ? decision.info : undefined1521
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
}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.aborted1547
}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.bodyInvoked1555
? toolAbortedResult(prior)1556
: toolAbortedBeforeDispatchResult(prior)1557
}1559
/**1560
* Dispatch the registered body with the original caller signal fused back1561
* 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.signal1569
const fused = fuseToolSignals(state.callerSignal, wrapperSignal)1570
const signal = fused.signal1572
if (isAborted(signal)) {1573
fused.dispose()1574
return toolAbortedBeforeDispatchResult()1575
}1576
exec.signal = signal1577
try {1578
const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)1579
if (!tool) throw new ToolNotFoundError(exec.name)1580
state.bodyInvoked = true1581
const returned = await tool.execute(exec.arguments, exec)1582
const result = this.createSuccessResult(exec, tool, returned)1583
return isAborted(signal)1584
? toolAbortedResult(result)1585
: result1586
} catch (error: unknown) {1587
return toolErrorResult(error)1588
} finally {1589
fused.dispose()1590
exec.signal = wrapperSignal1591
}1592
}1594
/**1595
* Run around-dispatch and the tool body. Tool and unknown-tool failures still1596
* receive post-execute; pipeline failures are already final.1597
* @param exec - the prepared execution.1598
* @returns whether the result still needs post-execute.1599
* @internal1600
*/1601
private async dispatchScheduledExecution(exec: ToolRunContext): Promise<ScheduledToolDispatch> {1602
try {1603
const mutableExec = exec as MutableToolRunContext1604
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 === 01614
? normalized1615
: 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.isError1625
? this.cancellationResult(exec, resultWithDeferredContexts)1626
: resultWithDeferredContexts,1627
}1628
} catch (error: unknown) {1629
return { kind: 'final-result', result: toolErrorResult(error) }1630
}1631
}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
* @internal1640
*/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 === undefined1647
? result1648
: 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.isError1653
? this.cancellationResult(exec, postResult)1654
: postResult,1655
)1656
} catch (error: unknown) {1657
return this.finishScheduledExecution(exec, toolErrorResult(error))1658
}1659
}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
* @internal1668
*/1669
private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {1670
let materializedResult: ToolExecutionResult1671
try {1672
materializedResult = this.materializeFinalResult(result)1673
} catch (error: unknown) {1674
materializedResult = this.materializeFinalResult(toolErrorResult(error))1675
}1676
let finalResult: ToolExecutionResult1677
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 finalResult1684
}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 result1690
const content = finalizeContent(exec, result)1691
return content === undefined ? result : { ...result, content }1692
}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 readonly1697
// WeakMap-keyable view.1698
Object.freeze(exec)1699
const { name: toolName, callId } = exec1700
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
}1716
/**1717
* Resolve an `ask` decision to allow/deny through the approval seam. The1718
* seam is consumed opportunistically with `ctx.get('approval')` — a1719
* deployment that composes no ApprovalService keeps the historical degrade1720
* 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 no1722
* session to audit to and no UI to route to. Otherwise the outcome maps1723
* one-to-one — `allowed-once` proceeds; the three non-grants deny with1724
* distinct reasons so the model can tell a human "no" from an absent1725
* 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
}1770
/**1771
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply1772
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing1773
* `content` when given), `block` turns it into an `isError` whose content is1774
* 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 is1777
* discarded when the outer call is blocked; a block exposes only context the1778
* 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
}1822
/** Registry-normalized results and the exact dispatch that validated each value. */1823
private readonly canonicalResults = new WeakMap<object, ToolExecutionToken>()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 result1829
}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 | undefined1845
if (exec.parent === undefined && tool.output.presentationMeta !== undefined) {1846
let projected: JsonValue1847
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
}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 result1867
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
}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
}1904
/** Mint a same-process correlation token whose identity is its value. */1905
function createExecutionToken(): ToolExecutionToken {1906
return Symbol('dsh.tool.execution') as ToolExecutionToken1907
}1909
function 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
}1919
/** Read live abort state across an await without treating it as synchronously immutable. */1920
function isAborted(signal: AbortSignal): boolean {1921
return signal.aborted1922
}1924
/**1925
* Fuse caller and wrapper cancellation without nesting `AbortSignal.any`.1926
* Keeping the relay dispatch-scoped also removes listeners when work settles.1927
*/1928
function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal {1929
if (caller === wrapper) return { signal: caller, dispose() {} }1931
const controller = new AbortController()1932
let listening = false1933
const dispose = (): void => {1934
if (!listening) return1935
listening = false1936
caller.removeEventListener('abort', abortFromCaller)1937
wrapper.removeEventListener('abort', abortFromWrapper)1938
}1939
const abortFrom = (source: AbortSignal): void => {1940
const reason: unknown = source.reason1941
controller.abort(reason)1942
dispose()1943
}1944
const abortFromCaller = (): void => { abortFrom(caller) }1945
const abortFromWrapper = (): void => { abortFrom(wrapper) }1947
if (wrapper.aborted) abortFromWrapper()1948
else if (caller.aborted) abortFromCaller()1949
else {1950
listening = true1951
caller.addEventListener('abort', abortFromCaller, { once: true })1952
wrapper.addEventListener('abort', abortFromWrapper, { once: true })1953
}1954
return { signal: controller.signal, dispose }1955
}1957
/** Canonical result when cancellation supersedes success after body invocation. */1958
function 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
}1971
/** Canonical result when cancellation prevents tool body invocation. */1972
function 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
}1985
export default ToolRuntime