mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
# Conflicts: # apps/cli/tests/web-agent-presets.e2e.ts # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-primitives/tests/icons.spec.tsx # packages/client/ui-settings/src/client/contract/slots.ts # packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
1781 lines
77 KiB
TypeScript
1781 lines
77 KiB
TypeScript
/**
|
|
* Tool registry, model presentation modes, and pre/guard/around/post/result
|
|
* execution pipeline.
|
|
* @module @deepseek-ai/dsh-tools
|
|
*/
|
|
|
|
import { Context, Service } from 'cordis'
|
|
import z from 'schemastery'
|
|
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
|
import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope'
|
|
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
|
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
|
import type { JsonValue, UserMessage } from '@deepseek-ai/dsh-session'
|
|
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
|
|
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
|
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
|
|
// augmentation. The seam stays optional at runtime — see `serviceAsk`.
|
|
import type {} from '@deepseek-ai/dsh-user-approval'
|
|
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
|
import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts'
|
|
import type { JsonSchemaNode } from './json-schema.ts'
|
|
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
|
|
import type { CodeSdkLanguage } from './code-mode.ts'
|
|
import { renderToolsSdk } from './ts-types.ts'
|
|
import type { ToolSdkSchema } from './ts-types.ts'
|
|
import { renderToolsSdkPy } from './py-types.ts'
|
|
|
|
/**
|
|
* Language → SDK-section renderer. The registry looks up the loaded
|
|
* `ctx.codeRuntime.language` in this table when assembling the `tools:sdk`
|
|
* section under a non-native mode; a runtime whose language is not a key
|
|
* fails the assembly loudly (same idiom as `toolOrder` violations). Adding a
|
|
* new backend language is three parallel edits — a {@link CodeSdkLanguage}
|
|
* member, an entry here, and a `RUN_CODE_FLAVORS` entry in `code-mode.ts` for
|
|
* its `run_code` schema strings — plus the renderer function this table points
|
|
* at. The `satisfies` clause pins this table's key set to that union, which
|
|
* the flavor table is checked against too, so any of the three left out is a
|
|
* typecheck failure. What no check reaches is the prose that names the values
|
|
* instead of deriving them: the seam's `dsh-code-runtime` README pair, its
|
|
* `CodeRuntime.language` JSDoc, and `docs/subsystems/code-runtime.md`
|
|
* with its zh pair, plus this package's own README pair and the
|
|
* {@link Config.mode} JSDoc.
|
|
*/
|
|
const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = {
|
|
typescript: renderToolsSdk,
|
|
python: renderToolsSdkPy,
|
|
} satisfies Record<CodeSdkLanguage, (schemas: ToolSdkSchema[]) => string>
|
|
|
|
export {
|
|
defineTool,
|
|
valueSchemaSpecToJsonSchema,
|
|
parameterSchemaSpecToJsonSchema,
|
|
validateArgs,
|
|
ToolArgsError,
|
|
type ValueSchemaAnnotations,
|
|
type StringValueSchemaSpec,
|
|
type NumberValueSchemaSpec,
|
|
type IntegerValueSchemaSpec,
|
|
type BooleanValueSchemaSpec,
|
|
type NullValueSchemaSpec,
|
|
type ArrayValueSchemaSpec,
|
|
type ObjectValueSchemaSpec,
|
|
type JsonValueSchemaSpec,
|
|
type OneOfValueSchemaSpec,
|
|
type ValueSchemaSpec,
|
|
type ParameterPropertySpec,
|
|
type ParameterSchemaSpec,
|
|
type ParameterJsonSchema,
|
|
type InferValue,
|
|
type InferArgs,
|
|
type DefineToolOptions,
|
|
} from './schema.ts'
|
|
|
|
export {
|
|
assertSupportedJsonSchema,
|
|
assertObjectJsonSchema,
|
|
validateJsonSchemaValue,
|
|
JsonSchemaError,
|
|
type JsonSchemaNode,
|
|
type ObjectJsonSchema,
|
|
type JsonSchemaType,
|
|
type JsonSchemaScalar,
|
|
} from './json-schema.ts'
|
|
|
|
export type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
export type { CodeDispatchEventData, CodeDispatchStartEventData } from './types.ts'
|
|
|
|
export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
|
|
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
|
|
export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts'
|
|
export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts'
|
|
|
|
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
|
|
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
|
|
// stays the single public surface for tool producers and UI adapters.
|
|
export type {
|
|
ToolCallKind,
|
|
FileLocation,
|
|
FileDiff,
|
|
ReadFileLine,
|
|
ToolCallView,
|
|
GenericCallView,
|
|
TerminalCallView,
|
|
DiffCallView,
|
|
ToolResultView,
|
|
GenericResultView,
|
|
TerminalResultView,
|
|
DiffResultView,
|
|
SearchResultView,
|
|
SearchMatchesResultView,
|
|
SearchPathsResultView,
|
|
SearchFileMatches,
|
|
SearchLineMatch,
|
|
ReadResultView,
|
|
WebResultView,
|
|
WebSearchResultView,
|
|
WebFetchResultView,
|
|
WebSource,
|
|
} from './presentation.ts'
|
|
|
|
declare module 'cordis' {
|
|
interface Context {
|
|
tools: ToolRegistry
|
|
}
|
|
|
|
interface Events {
|
|
/**
|
|
* Allow, deny, or ask before dispatch. `next()` delegates to allow; missing
|
|
* approval support turns `ask` into denial. Async gates must observe
|
|
* `exec.signal`; the registry rechecks cancellation after they settle but
|
|
* never abandons their promise.
|
|
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
|
* @param exec - the pending call (name, parsed arguments, caller agent).
|
|
* @mode waterfall
|
|
*/
|
|
'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
|
|
/**
|
|
* Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns
|
|
* a normalized result; wrappers may change only `exec.signal`, while call
|
|
* identity remains immutable. The registry re-fuses the original caller
|
|
* signal before the body, so replacement cannot detach caller cancellation;
|
|
* wrappers must still restore their signal and reach quiescence.
|
|
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
|
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
|
|
* @mode waterfall
|
|
*/
|
|
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
|
/**
|
|
* Accept, replace, enrich, or block a normalized dispatch result. `next()`
|
|
* accepts it unchanged; thrown tools still reach this waterfall as errors. Async
|
|
* listeners must observe `exec.signal`; after they settle, caller
|
|
* cancellation replaces only a successful accepted outcome with the code
|
|
* selected by whether the tool body was invoked.
|
|
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
|
* @param exec - the call that just ran (name, parsed arguments, caller agent).
|
|
* @param result - the dispatch outcome a listener may accept, replace, or block.
|
|
* @mode waterfall
|
|
*/
|
|
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
|
/**
|
|
* Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before
|
|
* the bridge appends its `tool/code-dispatch` event. `next()` keeps the
|
|
* content unchanged; a listener may return replacement blocks (e.g. the
|
|
* spill policy's preview + locator for an oversized text result). Only the
|
|
* logged copy is affected — the program already received the complete
|
|
* value, and the model sees neither. A throwing listener is contained:
|
|
* the bridge falls back to logging the unshaped content.
|
|
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches.
|
|
* @param dispatch - the parent execution, sub-call identity, and the settled content to log.
|
|
* @mode waterfall
|
|
*/
|
|
'tools/code-dispatch-log'(this: Scoped<ToolRegistry>, dispatch: CodeDispatchLog, next: () => Promise<ContentBlock[]>): Promise<ContentBlock[]>
|
|
/**
|
|
* Observe the frozen, lossless-JSON final outcome. Listener failures are contained.
|
|
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.
|
|
* @param exec - the execution object that traversed the pipeline.
|
|
* @param result - a deep-frozen snapshot of the final returned result.
|
|
* @mode emit
|
|
*/
|
|
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined
|
|
/**
|
|
* A tool was registered or unregistered, or a scoped restriction changed
|
|
* (the available tool set changed — possibly for one scope only). An
|
|
* UNFILTERED registry-subject notification, deliberately not scope-filtered
|
|
* dispatch: a global change concerns every agent's next assembly, so a
|
|
* scoped listener subscribing here sees every change, not just its own
|
|
* scope's.
|
|
* @mode emit
|
|
*/
|
|
'tools/change'(): void
|
|
}
|
|
}
|
|
|
|
/** Tool-owned canonical output contract used after the body returns a JSON value. */
|
|
export interface ToolOutputDefinition {
|
|
/** Raw supported JSON Schema enforced against every successful canonical value. */
|
|
readonly schema: JsonSchemaNode
|
|
/** Pure projection from validated arguments and value to Native/model content. */
|
|
render(args: unknown, value: JsonValue): ContentBlock[]
|
|
/** Pure replayable presentation projection, computed only for surface calls. */
|
|
presentationMeta?(args: unknown, value: JsonValue): JsonValue
|
|
}
|
|
|
|
/** A registered tool: its schema plus the execution function. */
|
|
export interface ToolDefinition extends ToolSchema {
|
|
/** Mandatory canonical output declaration. */
|
|
readonly output: ToolOutputDefinition
|
|
/**
|
|
* Run one accepted call and return only its canonical lossless-JSON value.
|
|
* Async work must observe or forward `exec.signal` and settle only after its
|
|
* owned work reaches quiescence. The registry preserves caller cancellation
|
|
* through around-dispatch signal replacement and does not abandon this
|
|
* promise, but it cannot hard-kill same-process code.
|
|
* @param args - losslessly snapshotted, frozen model arguments.
|
|
* @param exec - execution identity, cancellation signal, and context deferral.
|
|
* @returns the canonical value declared by `output.schema`.
|
|
*/
|
|
execute(args: unknown, exec: ToolRunContext): Promise<unknown>
|
|
/**
|
|
* Synchronous last-mile transform for model-facing content. The registry
|
|
* snapshots this callback when execution starts and invokes it exactly once
|
|
* for every normalized outcome, including pipeline failures that bypass
|
|
* `tools/post-execute`, immediately before lossless materialization.
|
|
* Returning `undefined` preserves the content; every other result field
|
|
* remains registry-owned. The callback must be total and must not throw.
|
|
* @param exec - immutable execution identity and arguments.
|
|
* @param result - complete normalized outcome before materialization.
|
|
* @returns replacement content, or `undefined` to preserve it.
|
|
*/
|
|
finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
|
|
/**
|
|
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
|
|
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
|
|
* is NEVER sent to the model — `schemas()` whitelists only name/description/
|
|
* parameters. Declaring it asserts this tool forwards `exec.signal` to a
|
|
* cooperative implementation that can reach quiescence when the signal aborts.
|
|
*/
|
|
timeoutMs?: number
|
|
/**
|
|
* Pure synchronous classifier for overlap with sibling tool calls. Only
|
|
* `true` opts in; omission, exceptions, non-`true` returns, and invalid
|
|
* `defineTool` arguments are exclusive. This metadata is never model-visible.
|
|
*
|
|
* Opted-in executions must not mutate parent-owned state. Shared state must
|
|
* tolerate concurrent dispatch; recorder races are permitted only when they
|
|
* commute or fail closed. See the
|
|
* [parallel-tool-call Agent Note](../../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)
|
|
* for the full contract.
|
|
* @param args - parsed arguments; `defineTool` validates before calling.
|
|
* @returns Whether this call may join a parallel group.
|
|
*/
|
|
isConcurrencySafe?(args: unknown): boolean
|
|
/**
|
|
* Optional: how to present the PENDING state of one call in a UI, derived from
|
|
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
|
|
* its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent),
|
|
* or `undefined` (or omit the method) to fall back to a generic presentation
|
|
* (title = tool name, raw args as input). Pure and side-effect-free: a UI may
|
|
* call it during live streaming AND a session-log replay, so it must depend
|
|
* only on `args`.
|
|
*/
|
|
presentCall?(args: unknown): ToolCallView | undefined
|
|
/**
|
|
* Optional: how to present the COMPLETED state, given the same `args` and the
|
|
* durable result projection (`content`, failure state, and optional `meta`). Returns a
|
|
* {@link ToolResultView}, or `undefined` (or omit the method) to keep the
|
|
* pending title and render the raw result content. Pure and side-effect-free
|
|
* for the same replay reason.
|
|
*/
|
|
presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined
|
|
}
|
|
|
|
/** The completed outcome handed to {@link ToolDefinition.presentResult}. */
|
|
export interface ToolResult {
|
|
/** The final model-facing content (or the rendered error text on failure). */
|
|
content: ContentBlock[]
|
|
/** Whether the call failed. */
|
|
isError: boolean
|
|
/**
|
|
* The tool-private presentation payload projected by its output declaration
|
|
* and threaded verbatim from the `tool/result` event. Absent when the tool
|
|
* declared no projector or the call was nested under a composite transport.
|
|
*/
|
|
meta?: JsonValue
|
|
}
|
|
|
|
declare const toolExecutionTokenBrand: unique symbol
|
|
|
|
/** Opaque call identity that permits correlation without exposing mutable execution state. */
|
|
export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
|
|
|
|
/**
|
|
* Caller-supplied description of one tool call. {@link ToolRegistry.execute}
|
|
* adds the registry-owned token to form a pipeline {@link ToolExecution};
|
|
* callers do not choose that token.
|
|
*/
|
|
export interface ToolExecutionInput {
|
|
readonly callId: CallId
|
|
/**
|
|
* Root model-requested call owning this execution tree. Callers omit it for
|
|
* a root execution; nested dispatchers propagate the enclosing value.
|
|
*/
|
|
readonly rootCallId?: CallId
|
|
readonly name: string
|
|
/** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */
|
|
readonly arguments: unknown
|
|
/** The agent on whose behalf the call runs (set by the agent loop). */
|
|
readonly agent?: Agent
|
|
/**
|
|
* Opaque token of the enclosing transport execution, when one exists. Code
|
|
* Mode sets this on SDK sub-dispatches so commit-style observers can wait for
|
|
* the outer `run_code` outcome without receiving its live mutable execution.
|
|
*/
|
|
readonly parent?: ToolExecutionToken
|
|
/** Required caller-owned cancellation for this invocation. */
|
|
readonly signal: AbortSignal
|
|
}
|
|
|
|
/**
|
|
* Scheduling mode for one pending call. `parallel` may overlap with siblings;
|
|
* `exclusive` runs alone and forms an ordering barrier.
|
|
*/
|
|
export type ToolExecutionMode =
|
|
| { kind: 'parallel' }
|
|
| { kind: 'exclusive' }
|
|
|
|
/**
|
|
* One settled `run_code` sub-dispatch about to be logged, as seen by the
|
|
* `tools/code-dispatch-log` waterfall: the parent execution (session owner,
|
|
* outer call identity), the sub-call identity, and the outcome whose durable
|
|
* copy a listener may reshape. `content` is the RENDERED result projection
|
|
* (what a native `tool/result` would carry) — the program itself received
|
|
* the structured `value` (or just the error message on failure); only the
|
|
* `tool/code-dispatch` event's copy changes.
|
|
*/
|
|
export interface CodeDispatchLog {
|
|
/** The outer `run_code` execution. */
|
|
readonly exec: ToolExecution
|
|
/** The calling agent (the scope routing key and the spill owner), when the outer call has one. */
|
|
readonly agent?: Agent
|
|
/** Deterministic sub-call id (`<parent>:code:<n>`). */
|
|
readonly subCallId: CallId
|
|
/** The dispatched sub-tool name. */
|
|
readonly name: string
|
|
/** Whether the sub-call settled as an error. */
|
|
readonly isError: boolean
|
|
/** The sub-call's complete model-facing content (the settle event's default payload). */
|
|
readonly content: ContentBlock[]
|
|
}
|
|
|
|
/**
|
|
* One pending tool call inside the registry pipeline. Parsed arguments cross
|
|
* one lossless-JSON materialization boundary before policy and are deep-frozen;
|
|
* call identity, the caller signal, and the registry-assigned {@link token} are
|
|
* readonly. The registry freezes the complete object before `tools/result`
|
|
* observers run.
|
|
*/
|
|
export interface ToolExecution extends ToolExecutionInput {
|
|
/** Root model-requested call, resolved for every root and nested execution. */
|
|
readonly rootCallId: CallId
|
|
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
|
|
readonly token: ToolExecutionToken
|
|
}
|
|
|
|
/**
|
|
* Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper
|
|
* may replace the signal for its delegated lifetime, but it cannot remove it.
|
|
* The registry fuses every replacement with the captured caller signal.
|
|
*/
|
|
export interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> {
|
|
/** Cancellation signal visible to the next wrapper or tool body. */
|
|
signal: AbortSignal
|
|
}
|
|
|
|
/**
|
|
* Runtime context handed to a tool implementation after the registry has
|
|
* accepted a {@link ToolExecution}. {@link deferContext} attaches context to
|
|
* this execution's own result — a composite tool ferries nested-dispatch
|
|
* context back to the outer result, and a leaf tool may mint a fresh
|
|
* plugin-sourced instruction; the loop appends it only after the
|
|
* `tool/result`.
|
|
*/
|
|
export interface ToolRunContext extends ToolExecution {
|
|
/**
|
|
* Defer one context — typically a nested-dispatch context ferried by a
|
|
* composite tool, or a fresh plugin-sourced instruction — until this tool's
|
|
* final result reaches the agent loop. Contexts retain their individual
|
|
* source and metadata and are emitted in call order.
|
|
*/
|
|
deferContext(context: UserMessage): void
|
|
/**
|
|
* Mark a successful final result as terminal for the current agent turn.
|
|
* The marker rides this execution's own result (`concludesTurn` exists only
|
|
* on {@link ToolExecutionSuccess}); a composite that dispatches nested
|
|
* calls forwards it from the nested result, exactly like
|
|
* `additionalContexts`, so only an authoritative nested success can
|
|
* conclude the enclosing run.
|
|
*/
|
|
concludeTurn(): void
|
|
}
|
|
|
|
/** Registry-owned live execution object; public pipeline views stay readonly. */
|
|
type MutableToolRunContext = Omit<ToolRunContext, 'signal'> & { signal: AbortSignal }
|
|
|
|
/**
|
|
* Scheduler-only result after ordered pre-execute and guards. A `post-result`
|
|
* still receives post-execute; a `final-result` bypasses it.
|
|
* @internal
|
|
*/
|
|
export type ScheduledToolPreparation =
|
|
| { kind: 'dispatch'; exec: ToolRunContext }
|
|
| { kind: 'post-result'; exec: ToolRunContext; result: ToolExecutionResult }
|
|
| { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult }
|
|
|
|
/**
|
|
* Scheduler-only dispatch result. A `post-result` still receives post-execute;
|
|
* a `final-result` already matches {@link ToolRegistry.execute} failure semantics.
|
|
* @internal
|
|
*/
|
|
export type ScheduledToolDispatch =
|
|
| { kind: 'post-result'; result: ToolExecutionResult }
|
|
| { kind: 'final-result'; result: ToolExecutionResult }
|
|
|
|
/**
|
|
* Symbol-keyed scheduler view that keeps pre/post policy ordered while
|
|
* overlapping dispatch. Ordinary callers use {@link ToolRegistry.execute};
|
|
* this is not a plugin extension point.
|
|
* @internal
|
|
*/
|
|
export interface ToolRegistryScheduler {
|
|
/** Materialize input, run the ordered pre-execute/guard gate, and decide what stage follows. */
|
|
prepare(exec: ToolExecutionInput): Promise<ScheduledToolPreparation>
|
|
/** Run only the around-dispatch/body stage. */
|
|
dispatch(exec: ToolRunContext): Promise<ScheduledToolDispatch>
|
|
/** Run post-execute and definition-owned content finalization, then materialize and notify. */
|
|
finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult>
|
|
/** Run definition-owned content finalization, then materialize and notify without post-execute. */
|
|
finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult
|
|
}
|
|
|
|
/**
|
|
* Scheduler entry point omitted from the generated named service API.
|
|
* @internal
|
|
*/
|
|
export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')
|
|
|
|
/** Canonical error code for cancellation after a tool body was invoked. */
|
|
export const TOOL_ABORTED = 'ABORTED'
|
|
|
|
/** Canonical error code for cancellation before a tool body was invoked. */
|
|
export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'
|
|
|
|
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
|
|
export interface ToolErrorInfo {
|
|
name: string
|
|
code: string
|
|
}
|
|
|
|
/** Canonical failure detail; internal routing information remains optional. */
|
|
export interface ToolFailure {
|
|
/** Human-readable failure message without the Native `Error: ` envelope. */
|
|
message: string
|
|
/** Internal error class/code used by policy and durable diagnostics. */
|
|
info?: ToolErrorInfo
|
|
}
|
|
|
|
/**
|
|
* Thrown (internally) when the model requests a tool that isn't registered.
|
|
* Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool
|
|
* failure is as routable as a tool-thrown one — retry/sandbox/replay code can
|
|
* distinguish it from a tool body's own error.
|
|
*/
|
|
export class ToolNotFoundError extends HarnessError {
|
|
constructor(toolName: string) {
|
|
super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL')
|
|
this.name = 'ToolNotFoundError'
|
|
}
|
|
}
|
|
|
|
/** Thrown when a tool body or post-policy value violates its declared output. */
|
|
export class ToolOutputError extends HarnessError {
|
|
/** Schema/value violations in validation order. */
|
|
readonly violations: string[]
|
|
|
|
constructor(toolName: string, violations: string[]) {
|
|
super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT')
|
|
this.name = 'ToolOutputError'
|
|
this.violations = violations
|
|
}
|
|
}
|
|
|
|
/** Convert one projector exception into the canonical invalid-output failure. */
|
|
function projectionError(toolName: string, projector: 'render' | 'presentationMeta', error: unknown): ToolOutputError {
|
|
return new ToolOutputError(toolName, [`output.${projector} failed: ${errorMessage(error)}`])
|
|
}
|
|
|
|
/** Snapshot one projector result before later durable-result materialization. */
|
|
function snapshotProjection<T>(toolName: string, projector: 'render' | 'presentationMeta', candidate: T): T {
|
|
try {
|
|
const detached = snapshotJsonValue(candidate)
|
|
if (detached === undefined) {
|
|
throw new ToolOutputError(toolName, [`output.${projector} returned non-lossless JSON`])
|
|
}
|
|
return detached
|
|
} catch (error: unknown) {
|
|
if (error instanceof ToolOutputError) throw error
|
|
throw projectionError(toolName, projector, error)
|
|
}
|
|
}
|
|
|
|
/** Snapshot one body or policy value into the canonical invalid-output failure class. */
|
|
function snapshotToolValue(toolName: string, candidate: unknown): JsonValue {
|
|
try {
|
|
const detached = snapshotJsonValue(candidate)
|
|
if (detached === undefined) throw new ToolOutputError(toolName, ['value is not lossless JSON'])
|
|
return detached as JsonValue
|
|
} catch (error: unknown) {
|
|
if (error instanceof ToolOutputError) throw error
|
|
throw new ToolOutputError(toolName, [`value snapshot failed: ${errorMessage(error)}`])
|
|
}
|
|
}
|
|
|
|
/** Successful canonical tool execution, including its Native/model projection. */
|
|
export interface ToolExecutionSuccess {
|
|
readonly isError: false
|
|
/** Execution-local canonical value; deliberately omitted from durable events. */
|
|
readonly value: JsonValue
|
|
readonly content: ContentBlock[]
|
|
readonly error?: never
|
|
readonly meta?: JsonValue
|
|
readonly additionalContexts?: UserMessage[]
|
|
/** The agent loop stops after committing this successful result batch. */
|
|
readonly concludesTurn?: true
|
|
}
|
|
|
|
/** Failed canonical tool execution; failures never carry a successful value. */
|
|
export interface ToolExecutionFailure {
|
|
readonly isError: true
|
|
readonly error: ToolFailure
|
|
readonly value?: never
|
|
readonly content: ContentBlock[]
|
|
readonly meta?: JsonValue
|
|
readonly additionalContexts?: UserMessage[]
|
|
readonly concludesTurn?: never
|
|
}
|
|
|
|
/** The discriminated, execution-local outcome of one tool call. */
|
|
export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure
|
|
|
|
/**
|
|
* Pre-dispatch decision. `allow` runs the call; `deny` materializes an error;
|
|
* `ask` runs only after an approval service returns `allowed-once` and otherwise
|
|
* denies. Input rewriting is excluded because arguments are already logged and
|
|
* presented.
|
|
*/
|
|
export type PreToolDecision =
|
|
| { kind: 'allow' }
|
|
| { kind: 'deny'; reason: string }
|
|
| { kind: 'ask'; reason?: string }
|
|
|
|
/**
|
|
* Post-dispatch decision: accept, replace one projection, attach context for the
|
|
* next request, or block by turning corrective feedback into an error result.
|
|
*/
|
|
export type PostToolDecision =
|
|
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] }
|
|
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] }
|
|
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] }
|
|
|
|
/**
|
|
* Best-effort human-readable message from an arbitrary thrown value: Error
|
|
* instances use `.message`; non-Error objects with a string `message`
|
|
* property (e.g. `throw { message: 'denied' }`) use it too; everything else
|
|
* is stringified.
|
|
*/
|
|
function errorMessage(error: unknown): string {
|
|
try {
|
|
if (error instanceof Error) return error.message
|
|
if (typeof error === 'object' && error !== null
|
|
&& 'message' in error && typeof error.message === 'string') {
|
|
return error.message
|
|
}
|
|
return String(error)
|
|
} catch {
|
|
// A hostile thrown value can trap `instanceof`, property access, or string
|
|
// coercion. Error normalization is the outermost safety boundary, so its
|
|
// fallback must itself be total.
|
|
return '<unprintable thrown value>'
|
|
}
|
|
}
|
|
|
|
/** Derive one failure message from policy feedback without changing its rendered blocks. */
|
|
function failureMessageFromContent(content: ContentBlock[]): string {
|
|
const text = content
|
|
.map(block => block.type === 'text' ? block.text : `[${block.type} content]`)
|
|
.join('\n')
|
|
return text.length > 0 ? text : 'tool result blocked by post-execute policy'
|
|
}
|
|
|
|
/** Snapshot and freeze one durable tool-result projection or reject lossy data. */
|
|
function materializePresentation<T>(candidate: T): T {
|
|
const detached = snapshotJsonValue(candidate)
|
|
if (detached === undefined) {
|
|
throw new TypeError('tool result must be losslessly JSON-serializable')
|
|
}
|
|
return deepFreeze(detached)
|
|
}
|
|
|
|
/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
|
|
function errorInfo(error: unknown): ToolErrorInfo | undefined {
|
|
try {
|
|
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/** How the registry presents its tools to the model (see {@link Config.mode}). */
|
|
export type ToolPresentationMode = 'native' | 'code' | 'both'
|
|
|
|
/** Plugin config: how the registered tools are presented to the model. */
|
|
export interface Config {
|
|
/**
|
|
* Model presentation for agents that declare none of their own. `native`
|
|
* (default) sends every visible schema; `code` sends only `run_code` plus a
|
|
* generated SDK prompt; `both` sends both forms. Code modes require a
|
|
* `ctx.codeRuntime` whose `language` has a registered SDK renderer
|
|
* (TypeScript or Python) and fail prompt assembly when it is absent or has
|
|
* no renderer. Under `code`, native names in `toolOrder` are invalid.
|
|
*
|
|
* One agent overrides this for itself with {@link ToolRegistry.presentAs},
|
|
* which is how an agent preset composes a Code Mode agent beside native
|
|
* ones in the same process.
|
|
*/
|
|
mode?: ToolPresentationMode
|
|
/**
|
|
* Concurrency cap for a `run_code` program's overlapping sub-calls
|
|
* (default 10, the loop scheduler's own default). Sub-calls follow the
|
|
* native scheduling contract — only calls whose tools classify
|
|
* concurrency-safe overlap; exclusive calls form barriers — so `1`
|
|
* restores strictly serial dispatch. Must be a positive integer.
|
|
*/
|
|
maxParallelSubCalls?: number
|
|
}
|
|
|
|
/**
|
|
* Per-scope filter over global tools. Restrictions intersect and do not affect
|
|
* scoped registrations or the reserved Code Mode transport.
|
|
*/
|
|
export interface ToolRestriction {
|
|
/** Global tool names that stay visible; everything else is removed. */
|
|
readonly allow?: readonly string[]
|
|
/** Global tool names removed from visibility. */
|
|
readonly deny?: readonly string[]
|
|
}
|
|
|
|
/** One restriction compiled at registration for repeated live-global lookup. */
|
|
interface CompiledToolRestriction {
|
|
readonly allow?: ReadonlySet<string>
|
|
readonly deny?: ReadonlySet<string>
|
|
}
|
|
|
|
/** One scope's complete registry view, derived in a single layer traversal. */
|
|
interface ToolView {
|
|
/** Visible definitions after restrictions, scoped shadowing, and transport insertion. */
|
|
readonly visible: ReadonlyMap<string, ToolDefinition>
|
|
/** Pre-restriction capability names used by prompt-order validation. */
|
|
readonly knownNames: ReadonlySet<string>
|
|
/** Current global names that a scoped restriction may name. */
|
|
readonly restrictableNames: ReadonlySet<string>
|
|
}
|
|
|
|
/**
|
|
* A monotonic execution guard evaluated after every `tools/pre-execute`
|
|
* listener and before the tool body. Returning a reason denies the call;
|
|
* returning `undefined` leaves it unchanged. Because guards have no allow
|
|
* result, listener ordering cannot turn a denial back into permission.
|
|
* @param execution - the identity-protected call after extensible pre-execute policy completed.
|
|
* @returns a final denial reason, or `undefined` to leave the call allowed.
|
|
*/
|
|
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
|
|
|
|
/** One scope's complete tool-registry contribution. */
|
|
class ToolLayer implements ScopeLayer {
|
|
readonly tools: NamedEntries<ToolDefinition>
|
|
readonly restrictions = new AnonymousEntries<CompiledToolRestriction>()
|
|
readonly guards = new AnonymousEntries<ToolGuard>()
|
|
/**
|
|
* Presentation this scope's agent declared for itself, shadowing the
|
|
* deployment default. One cell rather than an entry table: two answers to
|
|
* "which form does the model see" is a contradiction, not a merge.
|
|
*/
|
|
mode: ToolPresentationMode | undefined
|
|
|
|
constructor(scope: ScopeKey | undefined) {
|
|
this.tools = new NamedEntries(name => new Error(scope === undefined
|
|
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
|
|
: `tool "${name}" is already registered in this scope`))
|
|
}
|
|
|
|
/** Whether every contribution table in this aggregate layer is empty. */
|
|
isEmpty(): boolean {
|
|
return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty()
|
|
&& this.mode === undefined
|
|
}
|
|
|
|
/** Whether every compiled restriction in this layer admits a global tool name. */
|
|
admits(name: string): boolean {
|
|
for (const filter of this.restrictions.values()) {
|
|
if ((filter.allow !== undefined && !filter.allow.has(name))
|
|
|| (filter.deny !== undefined && filter.deny.has(name))) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/** First monotonic denial from this layer's live guard registrations. */
|
|
guardReason(exec: ToolExecution): string | undefined {
|
|
for (const guard of this.guards.values()) {
|
|
const reason = guard(exec)
|
|
if (reason !== undefined) return reason
|
|
}
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/** Approval decision plus whether the approval channel reported cancellation. */
|
|
interface ToolAskResolution {
|
|
readonly decision: Extract<PreToolDecision, { kind: 'allow' | 'deny' }>
|
|
readonly approvalCancelled: boolean
|
|
}
|
|
|
|
/** Caller cancellation and dispatch state kept outside the around-wrapper view. */
|
|
interface ToolCancellationState {
|
|
readonly callerSignal: AbortSignal
|
|
bodyInvoked: boolean
|
|
}
|
|
|
|
/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */
|
|
interface FusedToolSignal {
|
|
readonly signal: AbortSignal
|
|
dispose(): void
|
|
}
|
|
|
|
/** Resolve the run_code overlap cap at the owning config boundary (direct construction bypasses the Loader schema). */
|
|
function resolveMaxParallelSubCalls(value: number | undefined): number {
|
|
const maxParallelSubCalls = value ?? 10
|
|
if (!Number.isInteger(maxParallelSubCalls) || maxParallelSubCalls < 1) {
|
|
throw new Error('maxParallelSubCalls must be a positive integer')
|
|
}
|
|
return maxParallelSubCalls
|
|
}
|
|
|
|
/**
|
|
* Tool registry and execution pipeline. Scoped registrations shadow globals;
|
|
* one visibility resolver feeds presentation, lookup, and dispatch.
|
|
*/
|
|
export class ToolRegistry extends Service {
|
|
static inject = ['systemPrompt']
|
|
|
|
static Config: z<Config> = z.object({
|
|
mode: z.union(['native', 'code', 'both'] as const).default('native'),
|
|
maxParallelSubCalls: z.natural().min(1).default(10),
|
|
})
|
|
|
|
/** Internal staged view consumed by `dsh-agent-loop`'s parallel scheduler. */
|
|
readonly [TOOL_REGISTRY_SCHEDULER]: ToolRegistryScheduler = {
|
|
prepare: exec => this.prepareScheduledExecution(exec),
|
|
dispatch: exec => this.dispatchScheduledExecution(exec),
|
|
finalize: (exec, result) => this.finalizeScheduledExecution(exec, result),
|
|
finish: (exec, result) => this.finishScheduledExecution(exec, result),
|
|
}
|
|
|
|
/** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
|
|
private deferredContexts = new WeakMap<ToolRunContext, UserMessage[]>()
|
|
/** Executions whose tool body declared the current turn complete. */
|
|
private concludingExecutions = new WeakSet<ToolExecution>()
|
|
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
|
|
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
|
|
/** Definition-owned final content transform snapshotted before policy begins. */
|
|
private contentFinalizers = new WeakMap<ToolRunContext, ToolDefinition['finalizeContent']>()
|
|
private readonly layers = new ScopedLayers(
|
|
scope => new ToolLayer(scope),
|
|
() => { this.ctx.emit('tools/change') },
|
|
)
|
|
/** Presentation for agents that declare none; {@link presentAs} shadows it per agent. */
|
|
private readonly defaultMode: ToolPresentationMode
|
|
private readonly maxParallelSubCalls: number
|
|
/**
|
|
* Reserved presentation transport, kept outside the filterable registration
|
|
* layers. Built on first need rather than at construction: which agents run
|
|
* a code mode is no longer known when the service is constructed, and the
|
|
* transport is stateless beyond its closures over `this`.
|
|
*/
|
|
private codeTransport: ToolDefinition | undefined
|
|
|
|
constructor(ctx: Context, config: Config = {}) {
|
|
super(ctx, 'tools')
|
|
// The schema already defaulted an omitted mode; the ?? narrows the
|
|
// optional-input type for direct (non-Loader) construction in tests.
|
|
this.defaultMode = config.mode ?? 'native'
|
|
this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls)
|
|
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
|
|
if (this.defaultMode !== 'native') {
|
|
ctx.systemPrompt.section(this.sdkSection())
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The generated-SDK prompt section, registered globally by a code-mode
|
|
* deployment and per agent by {@link presentAs}.
|
|
*
|
|
* The body regenerates from the CALLING scope, and renders empty for an
|
|
* agent presenting natively — an agent that opted out under a code-mode
|
|
* deployment still sees the global registration, and an empty section is
|
|
* dropped from the rendered prompt.
|
|
* @returns the section registration.
|
|
*/
|
|
private sdkSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } {
|
|
return {
|
|
name: 'tools:sdk',
|
|
order: SDK_SECTION_ORDER,
|
|
// Regenerate from the calling scope's visible tools in stable order.
|
|
text: (context) => {
|
|
const mode = this.modeFor(context.scope)
|
|
if (mode === 'native') return ''
|
|
const runtime = this.requireCodeRuntime(mode)
|
|
// Own-property read: a language like `toString`/`constructor` would
|
|
// otherwise resolve an inherited Object.prototype member as a renderer.
|
|
const render = SDK_RENDERERS[runtime.language]
|
|
/* v8 ignore next -- requireCodeRuntime rejects an unknown language before this runs. */
|
|
if (render === undefined) throw new Error(`dsh-tools: no SDK renderer for ${runtime.language}`)
|
|
return render(this.sdkSchemas(context.scope))
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The presentation one scope's agent sees: its own declaration, else the
|
|
* deployment default.
|
|
* @param scope - the calling agent, or undefined for the global view.
|
|
* @returns the resolved presentation mode.
|
|
*/
|
|
private modeFor(scope?: ScopeKey): ToolPresentationMode {
|
|
// Nearest scope wins along the chain: a preset's standing declaration
|
|
// covers every agent parented under it, and an agent's own (were one ever
|
|
// declared) would override its preset's. The mode decides what the model
|
|
// SEES, which is exactly the class of fact the chain inherits.
|
|
const layers = this.layers.chainLayers(scope)
|
|
for (let index = layers.length - 1; index >= 0; index -= 1) {
|
|
const mode = layers[index]?.mode
|
|
if (mode !== undefined) return mode
|
|
}
|
|
return this.defaultMode
|
|
}
|
|
|
|
/**
|
|
* The reserved `run_code` transport, built on first need.
|
|
*
|
|
* It never enters the global layer: per-agent restrictions must not remove
|
|
* it, and a scoped registration must not shadow it. The visibility resolver
|
|
* appends it after resolving the filterable global/scoped capability layers,
|
|
* and only for scopes whose mode actually presents it.
|
|
* @returns the shared transport definition.
|
|
*/
|
|
private requireCodeTransport(): ToolDefinition {
|
|
this.codeTransport ??= createRunCodeTool(this, {
|
|
requireRuntime: () => this.requireCodeRuntime(this.defaultMode),
|
|
// The language-aware description/parameters getters read the runtime
|
|
// without demanding one, so a native-default process can still project
|
|
// the transport for an agent that chose code.
|
|
peekRuntime: () => this.ctx.get('codeRuntime'),
|
|
maxParallel: this.maxParallelSubCalls,
|
|
shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
|
|
})
|
|
return this.codeTransport
|
|
}
|
|
|
|
/**
|
|
* Present this agent's tools in `mode` instead of the deployment default.
|
|
*
|
|
* Scoped only, and one declaration per agent: this is how an agent preset
|
|
* composes a Code Mode agent beside native ones in the same process, and a
|
|
* process-global override would be the `mode` config field instead.
|
|
* @param mode - the presentation this agent's model sees.
|
|
* @returns the exact disposer that restores the deployment default.
|
|
*/
|
|
presentAs(mode: ToolPresentationMode): () => void {
|
|
const ctx = this.ctx
|
|
if (scopeOf(ctx) === undefined) {
|
|
throw new Error('tools.presentAs() requires a scoped context (agent.ctx): a context-global presentation is the `mode` config field on the tools row')
|
|
}
|
|
const dispose = ctx.effect(function* (this: ToolRegistry) {
|
|
yield this.layers.effect(
|
|
ctx,
|
|
(layer) => {
|
|
if (layer.mode !== undefined) {
|
|
throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this agent; one composition selects one presentation`)
|
|
}
|
|
layer.mode = mode
|
|
return () => { layer.mode = undefined }
|
|
},
|
|
{ label: 'tools.presentAs()' },
|
|
)
|
|
// The SDK section is per agent for the same reason the mode is. Under a
|
|
// deployment that already defaults to a code mode this shadows the
|
|
// global registration with an identical body, which costs nothing and
|
|
// keeps one rule instead of a case analysis.
|
|
if (mode !== 'native') yield ctx.systemPrompt.section(this.sdkSection())
|
|
}.bind(this), 'tools.presentAs()')
|
|
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown; direct return preserves disposer identity
|
|
return dispose
|
|
}
|
|
|
|
/**
|
|
* Build one scope's wire schemas and names for prompt-order validation.
|
|
* Restrictions do not make known tools invalid, but a mode collapse does.
|
|
*/
|
|
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
|
|
const view = this.view(scope)
|
|
const mode = this.modeFor(scope)
|
|
if (mode === 'native') {
|
|
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
|
|
return { schemas, knownNames: [...view.knownNames] }
|
|
}
|
|
// Validate the runtime language BEFORE projecting schemas: schemaOf reads
|
|
// run_code's language-aware description/parameters getters, whose own
|
|
// flavor-table guard would otherwise surface first. This keeps the
|
|
// renderer-table rejection the canonical assembly-time error for a
|
|
// language with no SDK renderer.
|
|
this.requireCodeRuntime(mode)
|
|
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
|
|
if (mode === 'code') {
|
|
return {
|
|
schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
|
|
knownNames: [RUN_CODE_NAME],
|
|
}
|
|
}
|
|
return { schemas, knownNames: [...view.knownNames, RUN_CODE_NAME] }
|
|
}
|
|
|
|
/**
|
|
* Resolve the code runtime or throw the actionable misconfiguration error.
|
|
* Read at use time (assembly / run_code execution), NOT via static
|
|
* `inject`: an inject entry would hold `ctx.tools` — and every tool plugin
|
|
* behind it — hostage to a code runtime existing even under `mode:
|
|
* 'native'` (the loop's optional-backend idiom, same as
|
|
* `sessionPersistence`).
|
|
*
|
|
* Assembly and `run_code` execution read separately, so the language is not
|
|
* bound to a request. Harmless while one published backend exists — both
|
|
* reads return the same flavor — but a reload that swapped in a second
|
|
* language between them would hand a program written against one SDK to the
|
|
* other. Binding it is deferred until a second backend ships (the first
|
|
* point it is testable); rationale in the
|
|
* [language-dispatch note](../../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md).
|
|
*/
|
|
private requireCodeRuntime(mode: ToolPresentationMode): CodeRuntime {
|
|
const runtime = this.ctx.get('codeRuntime')
|
|
if (!runtime) {
|
|
throw new Error(`dsh-tools: mode "${mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`)
|
|
}
|
|
if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) {
|
|
const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')
|
|
throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`)
|
|
}
|
|
return runtime
|
|
}
|
|
|
|
/**
|
|
* Register globally or in the calling agent scope. Scoped tools shadow
|
|
* globals; duplicates within one layer and the reserved `run_code` name fail.
|
|
* @param definition - tool schema, execution, and optional finalization/presentation callbacks.
|
|
* @returns the exact disposer that unregisters the tool.
|
|
*/
|
|
register(definition: ToolDefinition): () => void {
|
|
const name = definition.name
|
|
const output = (definition as Partial<ToolDefinition>).output
|
|
if (output === undefined || typeof output !== 'object'
|
|
|| typeof output.render !== 'function'
|
|
|| (output.presentationMeta !== undefined && typeof output.presentationMeta !== 'function')) {
|
|
throw new TypeError(`tool "${name}" must declare output { schema, render, presentationMeta? }`)
|
|
}
|
|
assertSupportedJsonSchema(output.schema)
|
|
const timeoutMs = definition.timeoutMs
|
|
if (timeoutMs !== undefined
|
|
&& (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
|
|
throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`)
|
|
}
|
|
// Reserved unconditionally: any agent may select a code mode for itself,
|
|
// so a name free to take under the deployment default would become a
|
|
// collision the moment a preset mounted.
|
|
if (name === RUN_CODE_NAME) {
|
|
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
|
|
}
|
|
return this.layers.effect(
|
|
this.ctx,
|
|
layer => layer.tools.insert(name, definition),
|
|
{ label: 'tools.register()' },
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Restrict global tools for the calling agent scope. Empty filters, unknown
|
|
* names, scope-local names, and reserved transport names fail. Restrictions
|
|
* intersect; scoped registrations remain visible.
|
|
* @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).
|
|
* @returns the exact disposer that lifts this restriction.
|
|
*/
|
|
restrict(filter: ToolRestriction): () => void {
|
|
const scope = scopeOf(this.ctx)
|
|
if (scope === undefined) {
|
|
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')
|
|
}
|
|
const allow = filter.allow
|
|
const deny = filter.deny
|
|
if (allow === undefined && deny === undefined) {
|
|
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)')
|
|
}
|
|
const compiled: CompiledToolRestriction = {
|
|
...allow !== undefined ? { allow: new Set(allow) } : {},
|
|
...deny !== undefined ? { deny: new Set(deny) } : {},
|
|
}
|
|
if ([...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {
|
|
throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`)
|
|
}
|
|
const known = this.view(scope).restrictableNames
|
|
const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))
|
|
if (unknown.length > 0) {
|
|
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)'}`)
|
|
}
|
|
return this.layers.effect(
|
|
this.ctx,
|
|
layer => layer.restrictions.append(compiled),
|
|
{ label: 'tools.restrict()' },
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Register a monotonic guard after the extensible `tools/pre-execute`
|
|
* waterfall. A plain-context guard applies globally; one registered through
|
|
* `agent.ctx` applies only to that agent. Any matching guard may deny by
|
|
* returning a reason, while no guard can force-allow a call another guard
|
|
* denied. The exact effect disposer is returned for ordered ownership and
|
|
* HMR cleanup.
|
|
* @param guard - synchronous check; a returned string denies the execution.
|
|
* @returns the exact disposer that unregisters the guard.
|
|
*/
|
|
guard(guard: ToolGuard): () => void {
|
|
return this.layers.effect(
|
|
this.ctx,
|
|
layer => layer.guards.append(guard),
|
|
{ label: 'tools.guard()', notify: false },
|
|
)
|
|
}
|
|
|
|
/** First monotonic denial from the global then the scope chain's guard layers, farthest first. */
|
|
private guardReason(exec: ToolExecution): string | undefined {
|
|
const globalReason = this.layers.global.guardReason(exec)
|
|
if (globalReason !== undefined) return globalReason
|
|
if (exec.agent === undefined) return undefined
|
|
for (const layer of this.layers.chainLayers(exec.agent)) {
|
|
const reason = layer.guardReason(exec)
|
|
if (reason !== undefined) return reason
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Resolve every registry fact one scope needs in one layer traversal. The
|
|
* visible map applies global restrictions, scoped shadowing, and the reserved
|
|
* presentation transport; the other sets retain the pre-restriction facts
|
|
* needed by restriction and prompt-order validation.
|
|
* @param scope - the viewing scope (the agent), or undefined for the global view.
|
|
* @returns the complete derived view for that scope.
|
|
*/
|
|
private view(scope?: ScopeKey): ToolView {
|
|
// Scope-chain layers, farthest ancestor first, the exact scope last.
|
|
const layers = this.layers.chainLayers(scope)
|
|
const visible = new Map<string, ToolDefinition>()
|
|
const knownNames = new Set<string>()
|
|
const restrictableNames = new Set<string>()
|
|
for (const [name, definition] of this.layers.global.tools.entries()) {
|
|
knownNames.add(name)
|
|
restrictableNames.add(name)
|
|
// Restrictions intersect across the whole chain: any scope on it may
|
|
// mask a global-surface name for everything nested inside it.
|
|
if (layers.every(layer => layer.admits(name))) visible.set(name, definition)
|
|
}
|
|
// Chain layers second, nearest last: same-name entries REPLACE (shadow)
|
|
// the global and farther-scope ones, and scope-local registrations are
|
|
// never part of the global filter above.
|
|
for (const layer of layers) {
|
|
for (const [name, definition] of layer.tools.entries()) {
|
|
knownNames.add(name)
|
|
visible.set(name, definition)
|
|
}
|
|
}
|
|
// Presentation infrastructure is resolved last and outside capability
|
|
// filtering. Registration rejects this reserved name, so the insertion is
|
|
// an invariant assertion as well as protection against future layer
|
|
// changes. Per scope: a native agent must not find `run_code` in its
|
|
// dispatch table because some other agent in the process presents it.
|
|
if (this.modeFor(scope) !== 'native') {
|
|
visible.set(RUN_CODE_NAME, this.requireCodeTransport())
|
|
}
|
|
return { visible, knownNames, restrictableNames }
|
|
}
|
|
|
|
/**
|
|
* Look up a tool as one scope sees it (scoped
|
|
* shadows global; a restricted-away global reads as absent). Presenters pass
|
|
* the calling agent so the rendered card matches the definition that
|
|
* actually executed.
|
|
* @param name - the tool name as registered.
|
|
* @param scope - the viewing scope (the agent); omitted = the global view.
|
|
* @returns the definition the scope resolves, or undefined when none is visible.
|
|
*/
|
|
get(name: string, scope?: ScopeKey): ToolDefinition | undefined {
|
|
return this.view(scope).visible.get(name)
|
|
}
|
|
|
|
/**
|
|
* Project visible definitions onto the allowlisted model-facing schema fields,
|
|
* excluding execution and presentation callbacks.
|
|
* @param scope - the viewing scope (the agent); omitted = the global view.
|
|
* @returns one deep-cloned schema per visible tool.
|
|
*/
|
|
schemas(scope?: ScopeKey): ToolSchema[] {
|
|
return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true))
|
|
}
|
|
|
|
/** Project visible callable tools onto the generated Code Mode SDK contract. */
|
|
private sdkSchemas(scope?: ScopeKey): ToolSdkSchema[] {
|
|
return [...this.view(scope).visible.values()]
|
|
.filter(definition => definition.name !== RUN_CODE_NAME)
|
|
.map((definition): ToolSdkSchema => {
|
|
const output = snapshotJsonValue(definition.output.schema)
|
|
/* v8 ignore next -- registration already validated and retained this schema as lossless JSON. */
|
|
if (output === undefined) {
|
|
throw new Error(`tool "${definition.name}" output schema must be lossless JSON before SDK projection`)
|
|
}
|
|
return {
|
|
...this.schemaOf(definition, true),
|
|
output,
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Project one definition onto the model-facing schema fields. */
|
|
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
|
|
const { name, description, parameters } = definition
|
|
const detached = detachParameters ? snapshotJsonValue(parameters) : parameters
|
|
if (detached === undefined) {
|
|
throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`)
|
|
}
|
|
return {
|
|
name,
|
|
description,
|
|
parameters: detached,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Classify a pending call through the caller's visible tool definition. Only
|
|
* an exact `true` is parallel; unknown, hidden, undeclared, invalid, or
|
|
* throwing classifiers are exclusive.
|
|
* @param exec - call name, parsed arguments, and optional agent scope.
|
|
* @returns the fail-closed scheduling mode.
|
|
*/
|
|
executionMode(exec: ToolExecutionInput): ToolExecutionMode {
|
|
const tool = this.get(exec.name, exec.agent)
|
|
if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }
|
|
try {
|
|
const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments)
|
|
return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' }
|
|
} catch {
|
|
return { kind: 'exclusive' }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch
|
|
* and return the content the bridge should log on `tool/code-dispatch`.
|
|
* Contained: a throwing listener falls back to the unshaped content — log
|
|
* shaping must never fail the dispatch or lose the settle event. Private:
|
|
* the ONE consumer is the `run_code` bridge this registry constructs, which
|
|
* receives it as a capability parameter (the `requireRuntime` idiom) — the
|
|
* waterfall, not this invoker, is the public extension point.
|
|
*/
|
|
private async shapeDispatchLog(dispatch: CodeDispatchLog): Promise<ContentBlock[]> {
|
|
try {
|
|
return await this.ctx.waterfall(
|
|
scopeTarget(this, dispatch.agent), 'tools/code-dispatch-log', dispatch,
|
|
() => Promise.resolve(dispatch.content),
|
|
)
|
|
} catch (error: unknown) {
|
|
this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the unshaped content`)
|
|
return dispatch.content
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute through pre-policy, guards, around-dispatch, post-policy,
|
|
* definition-owned content finalization, and final notification. Tool and
|
|
* listener failures resolve as materialized error results; an invisible tool
|
|
* reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen
|
|
* snapshot final observers receive. Cancellation
|
|
* arriving after entry and before final result materialization skips a
|
|
* not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a
|
|
* successful started outcome with `ABORTED`; already-started work is still
|
|
* drained and may retain a tool-owned structured error.
|
|
* @param exec - the typed same-process call input. The registry assigns its
|
|
* correlation token before policy begins.
|
|
* @returns the materialized final result.
|
|
*/
|
|
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
|
|
return this.prepareExecution(exec, prepared => this.completeScheduledExecution(prepared))
|
|
}
|
|
|
|
private async completeScheduledExecution(prepared: ScheduledToolPreparation): Promise<ToolExecutionResult> {
|
|
switch (prepared.kind) {
|
|
case 'dispatch': {
|
|
const dispatched = await this.dispatchScheduledExecution(prepared.exec)
|
|
return dispatched.kind === 'post-result'
|
|
? await this.finalizeScheduledExecution(prepared.exec, dispatched.result)
|
|
: this.finishScheduledExecution(prepared.exec, dispatched.result)
|
|
}
|
|
case 'post-result':
|
|
return await this.finalizeScheduledExecution(prepared.exec, prepared.result)
|
|
case 'final-result':
|
|
return this.finishScheduledExecution(prepared.exec, prepared.result)
|
|
/* v8 ignore next -- closed-union exhaustiveness guard */
|
|
default:
|
|
return assertNever(prepared, 'scheduled tool preparation')
|
|
}
|
|
}
|
|
|
|
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } {
|
|
const deferredContexts: UserMessage[] = []
|
|
const token = createExecutionToken()
|
|
const callId = exec.callId
|
|
const rootCallId = exec.rootCallId ?? callId
|
|
const name = exec.name
|
|
const agent = exec.agent
|
|
const parent = exec.parent
|
|
const signal = exec.signal
|
|
const definition = this.get(name, agent)
|
|
const finalizeContent = definition?.finalizeContent?.bind(definition)
|
|
const concludingExecutions = this.concludingExecutions
|
|
const base = {
|
|
token,
|
|
callId,
|
|
rootCallId,
|
|
name,
|
|
signal,
|
|
...agent !== undefined ? { agent } : {},
|
|
...parent !== undefined ? { parent } : {},
|
|
deferContext(context: UserMessage): void {
|
|
deferredContexts.push(context)
|
|
},
|
|
concludeTurn(): void {
|
|
concludingExecutions.add(this as unknown as ToolExecution)
|
|
},
|
|
}
|
|
try {
|
|
const detached = snapshotJsonValue(exec.arguments)
|
|
if (detached === undefined) {
|
|
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
|
|
}
|
|
const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) }
|
|
this.deferredContexts.set(execution, deferredContexts)
|
|
this.contentFinalizers.set(execution, finalizeContent)
|
|
this.cancellationStates.set(execution, {
|
|
callerSignal: signal,
|
|
bodyInvoked: false,
|
|
})
|
|
return { kind: 'ready', exec: execution }
|
|
} catch (error: unknown) {
|
|
const execution: MutableToolRunContext = { ...base, arguments: undefined }
|
|
this.contentFinalizers.set(execution, finalizeContent)
|
|
return { kind: 'final-result', exec: execution, result: toolErrorResult(error) }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run the ordered pre-execute and monotonic guard stages for the scheduler.
|
|
* @param input - the caller-supplied execution input.
|
|
* @returns the prepared execution plus the next scheduler stage.
|
|
* @internal
|
|
*/
|
|
private async prepareScheduledExecution(input: ToolExecutionInput): Promise<ScheduledToolPreparation> {
|
|
return this.prepareExecution(input, prepared => prepared)
|
|
}
|
|
|
|
private async prepareExecution<T>(
|
|
input: ToolExecutionInput,
|
|
next: (prepared: ScheduledToolPreparation) => T | PromiseLike<T>,
|
|
): Promise<T> {
|
|
const created = this.createExecution(input)
|
|
if (created.kind !== 'ready') return next(created)
|
|
const exec = created.exec
|
|
if (this.callerCancelled(exec)) {
|
|
return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() })
|
|
}
|
|
try {
|
|
const carrier = scopeTarget(this, exec.agent)
|
|
const gate = await this.ctx.waterfall(
|
|
carrier, 'tools/pre-execute', exec,
|
|
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
|
|
)
|
|
const askResolution: ToolAskResolution = gate.kind === 'ask'
|
|
? await this.serviceAsk(exec, gate)
|
|
: { decision: gate, approvalCancelled: false }
|
|
const { decision } = askResolution
|
|
if (this.callerCancelled(exec) && askResolution.approvalCancelled) {
|
|
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
|
|
}
|
|
const denialReason = decision.kind === 'allow'
|
|
? this.guardReason(exec)
|
|
: decision.reason
|
|
if (denialReason !== undefined) {
|
|
return await next({
|
|
kind: 'post-result',
|
|
exec,
|
|
result: this.materializeFinalResult({
|
|
content: [{ type: 'text', text: `Error: ${denialReason}` }],
|
|
isError: true,
|
|
error: { message: denialReason },
|
|
}),
|
|
})
|
|
}
|
|
if (this.callerCancelled(exec)) {
|
|
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
|
|
}
|
|
return await next({ kind: 'dispatch', exec })
|
|
} catch (error: unknown) {
|
|
return next({ kind: 'final-result', exec, result: toolErrorResult(error) })
|
|
}
|
|
}
|
|
|
|
/** Whether the original caller signal is currently aborted. */
|
|
private callerCancelled(exec: ToolRunContext): boolean {
|
|
const state = this.cancellationStates.get(exec)
|
|
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
|
|
if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
|
|
return state.callerSignal.aborted
|
|
}
|
|
|
|
/** Canonical cancellation outcome selected by whether the tool body started. */
|
|
private cancellationResult(exec: ToolRunContext, prior?: ToolExecutionResult): ToolExecutionResult {
|
|
const state = this.cancellationStates.get(exec)
|
|
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
|
|
if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
|
|
return state.bodyInvoked
|
|
? toolAbortedResult(prior)
|
|
: toolAbortedBeforeDispatchResult(prior)
|
|
}
|
|
|
|
/**
|
|
* Dispatch the registered body with the original caller signal fused back
|
|
* into any around-wrapper replacement. Cancellation never abandons the body:
|
|
* a started promise reaches quiescence before its outcome becomes `ABORTED`.
|
|
*/
|
|
private async dispatchToolBody(exec: MutableToolRunContext): Promise<ToolExecutionResult> {
|
|
const state = this.cancellationStates.get(exec)
|
|
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
|
|
if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
|
|
const wrapperSignal = exec.signal
|
|
const fused = fuseToolSignals(state.callerSignal, wrapperSignal)
|
|
const signal = fused.signal
|
|
|
|
if (isAborted(signal)) {
|
|
fused.dispose()
|
|
return toolAbortedBeforeDispatchResult()
|
|
}
|
|
exec.signal = signal
|
|
try {
|
|
const tool = this.get(exec.name, exec.agent)
|
|
if (!tool) throw new ToolNotFoundError(exec.name)
|
|
state.bodyInvoked = true
|
|
const returned = await tool.execute(exec.arguments, exec)
|
|
const result = this.createSuccessResult(exec, tool, returned)
|
|
return isAborted(signal)
|
|
? toolAbortedResult(result)
|
|
: result
|
|
} catch (error: unknown) {
|
|
return toolErrorResult(error)
|
|
} finally {
|
|
fused.dispose()
|
|
exec.signal = wrapperSignal
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run around-dispatch and the tool body. Tool and unknown-tool failures still
|
|
* receive post-execute; pipeline failures are already final.
|
|
* @param exec - the prepared execution.
|
|
* @returns whether the result still needs post-execute.
|
|
* @internal
|
|
*/
|
|
private async dispatchScheduledExecution(exec: ToolRunContext): Promise<ScheduledToolDispatch> {
|
|
try {
|
|
const mutableExec = exec as MutableToolRunContext
|
|
const carrier = scopeTarget(this, exec.agent)
|
|
const result = await this.ctx.waterfall(
|
|
carrier, 'tools/execute', mutableExec,
|
|
() => this.dispatchToolBody(mutableExec),
|
|
)
|
|
const normalized = this.normalizeDispatchResult(exec, result)
|
|
const deferredContexts = this.deferredContexts.get(exec)
|
|
/* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
|
|
if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution')
|
|
const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
|
|
? normalized
|
|
: this.markCanonical(exec, {
|
|
...normalized,
|
|
additionalContexts: [
|
|
...deferredContexts,
|
|
...normalized.additionalContexts ?? [],
|
|
],
|
|
})
|
|
return {
|
|
kind: 'post-result',
|
|
result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError
|
|
? this.cancellationResult(exec, resultWithDeferredContexts)
|
|
: resultWithDeferredContexts,
|
|
}
|
|
} catch (error: unknown) {
|
|
return { kind: 'final-result', result: toolErrorResult(error) }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run ordered post-execute, then apply definition-owned content finalization,
|
|
* materialize, and notify the final outcome.
|
|
* @param exec - the prepared execution.
|
|
* @param result - dispatch/pre result that still needs post-execute.
|
|
* @returns the materialized final result.
|
|
* @internal
|
|
*/
|
|
private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult> {
|
|
try {
|
|
const postResult = await this.postExecute(exec, result)
|
|
return this.finishScheduledExecution(
|
|
exec,
|
|
this.callerCancelled(exec) && !postResult.isError
|
|
? this.cancellationResult(exec, postResult)
|
|
: postResult,
|
|
)
|
|
} catch (error: unknown) {
|
|
return this.finishScheduledExecution(exec, toolErrorResult(error))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Materialize the candidate, apply definition-owned content finalization,
|
|
* then materialize and notify the authoritative result.
|
|
* @param exec - the prepared execution.
|
|
* @param result - final result.
|
|
* @returns the materialized final result.
|
|
* @internal
|
|
*/
|
|
private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
|
|
let materializedResult: ToolExecutionResult
|
|
try {
|
|
materializedResult = this.materializeFinalResult(result)
|
|
} catch (error: unknown) {
|
|
materializedResult = this.materializeFinalResult(toolErrorResult(error))
|
|
}
|
|
let finalResult: ToolExecutionResult
|
|
try {
|
|
finalResult = this.materializeFinalResult(this.applyFinalContent(exec, materializedResult))
|
|
} catch (error: unknown) {
|
|
finalResult = this.materializeFinalResult(toolErrorResult(error))
|
|
}
|
|
this.notifyResult(exec, finalResult)
|
|
return finalResult
|
|
}
|
|
|
|
/** Apply the snapshotted tool-owned content transform without exposing other result fields. */
|
|
private applyFinalContent(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult {
|
|
const finalizeContent = this.contentFinalizers.get(exec)
|
|
if (finalizeContent === undefined) return result
|
|
const content = finalizeContent(exec, result)
|
|
return content === undefined ? result : { ...result, content }
|
|
}
|
|
|
|
/** Notify observers without exposing a mutation or error channel into the outcome. */
|
|
private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
|
|
// Freeze the registry's live object before observers receive its readonly
|
|
// WeakMap-keyable view.
|
|
Object.freeze(exec)
|
|
const { name: toolName, callId } = exec
|
|
const reportFailure = (error: unknown): void => {
|
|
this.ctx.logger.warn(`tool "${toolName}" (${callId}): tools/result observer failed: ${errorMessage(error)}`)
|
|
}
|
|
const callbacks = this.ctx.events.dispatch('emit', [
|
|
scopeTarget(this, exec.agent), 'tools/result', exec, result,
|
|
])
|
|
for (const callback of callbacks) {
|
|
try {
|
|
const returned: unknown = callback(exec, result)
|
|
void Promise.resolve(returned).catch(reportFailure)
|
|
} catch (error: unknown) {
|
|
reportFailure(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve an `ask` decision to allow/deny through the approval seam. The
|
|
* seam is consumed opportunistically with `ctx.get('approval')` — a
|
|
* deployment that composes no ApprovalService keeps the historical degrade
|
|
* to deny, and an unmount mid-session degrades the same way on the next ask.
|
|
* An agent-less execution also degrades: without an agent there is no
|
|
* session to audit to and no UI to route to. Otherwise the outcome maps
|
|
* one-to-one — `allowed-once` proceeds; the three non-grants deny with
|
|
* distinct reasons so the model can tell a human "no" from an absent
|
|
* approval channel.
|
|
*/
|
|
private async serviceAsk(
|
|
exec: ToolExecution,
|
|
ask: Extract<PreToolDecision, { kind: 'ask' }>,
|
|
): Promise<ToolAskResolution> {
|
|
const approval = this.ctx.get('approval')
|
|
if (approval === undefined) {
|
|
return {
|
|
decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` },
|
|
approvalCancelled: false,
|
|
}
|
|
}
|
|
if (exec.agent === undefined) {
|
|
return {
|
|
decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` },
|
|
approvalCancelled: false,
|
|
}
|
|
}
|
|
const outcome = await approval.request({
|
|
agent: exec.agent,
|
|
toolName: exec.name,
|
|
callId: exec.callId,
|
|
...ask.reason !== undefined ? { reason: ask.reason } : {},
|
|
signal: exec.signal,
|
|
})
|
|
switch (outcome) {
|
|
case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false }
|
|
case 'rejected': return {
|
|
decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` },
|
|
approvalCancelled: false,
|
|
}
|
|
case 'cancelled': return {
|
|
decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` },
|
|
approvalCancelled: true,
|
|
}
|
|
case 'unavailable': return {
|
|
decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` },
|
|
approvalCancelled: false,
|
|
}
|
|
default: return assertNever(outcome, 'ApprovalOutcome')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply
|
|
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing
|
|
* `content` when given), `block` turns it into an `isError` whose content is
|
|
* the corrective `feedback`. Either decision may attach `additionalContexts`,
|
|
* which are ferried on the returned result for the loop's active-batch FIFO.
|
|
* Context deferred by the tool body survives an accepted result but is
|
|
* discarded when the outer call is blocked; a block exposes only context the
|
|
* blocking decision explicitly supplied.
|
|
* Runs inside `execute`'s outer try/catch (a throwing listener → isError).
|
|
*/
|
|
private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
|
|
const decision = await this.ctx.waterfall(
|
|
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
|
|
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
|
|
)
|
|
const decisionContexts = decision.additionalContexts ?? []
|
|
if (decision.kind === 'block') {
|
|
const message = failureMessageFromContent(decision.feedback)
|
|
return this.markCanonical(exec, {
|
|
content: decision.feedback,
|
|
isError: true,
|
|
error: { message },
|
|
...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},
|
|
})
|
|
}
|
|
if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) {
|
|
throw new TypeError('tools/post-execute accept decision cannot replace both value and content')
|
|
}
|
|
const additionalContexts = [
|
|
...result.additionalContexts ?? [],
|
|
...decisionContexts,
|
|
]
|
|
if (Object.hasOwn(decision, 'value')) {
|
|
if (result.isError) {
|
|
throw new TypeError('tools/post-execute cannot replace the value of a failed result')
|
|
}
|
|
const tool = this.get(exec.name, exec.agent)
|
|
if (tool === undefined) throw new ToolNotFoundError(exec.name)
|
|
const replaced = this.createSuccessResult(exec, tool, decision.value)
|
|
return this.markCanonical(exec, {
|
|
...replaced,
|
|
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
|
})
|
|
}
|
|
return this.markCanonical(exec, {
|
|
...result,
|
|
...decision.content !== undefined ? { content: decision.content } : {},
|
|
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
|
})
|
|
}
|
|
|
|
/** Registry-normalized results and the exact dispatch that validated each value. */
|
|
private readonly canonicalResults = new WeakMap<object, ToolExecutionToken>()
|
|
|
|
/** Mark one registry-normalized result as canonical only for its owning dispatch. */
|
|
private markCanonical<T extends ToolExecutionResult>(exec: ToolExecution, result: T): T {
|
|
this.canonicalResults.set(result, exec.token)
|
|
return result
|
|
}
|
|
|
|
/** Snapshot, validate, render, and optionally project one successful body value. */
|
|
private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess {
|
|
const detached = snapshotToolValue(tool.name, candidate)
|
|
const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
|
|
if (violations.length > 0) throw new ToolOutputError(tool.name, violations)
|
|
const value = deepFreeze(detached)
|
|
let rendered: ContentBlock[]
|
|
try {
|
|
rendered = tool.output.render(exec.arguments, value)
|
|
} catch (error: unknown) {
|
|
throw projectionError(tool.name, 'render', error)
|
|
}
|
|
const content = snapshotProjection(tool.name, 'render', rendered)
|
|
let meta: JsonValue | undefined
|
|
if (exec.parent === undefined && tool.output.presentationMeta !== undefined) {
|
|
let projected: JsonValue
|
|
try {
|
|
projected = tool.output.presentationMeta(exec.arguments, value)
|
|
} catch (error: unknown) {
|
|
throw projectionError(tool.name, 'presentationMeta', error)
|
|
}
|
|
meta = snapshotProjection(tool.name, 'presentationMeta', projected)
|
|
}
|
|
const concludesTurn = this.concludingExecutions.has(exec)
|
|
return this.markCanonical(exec, this.materializeFinalResult({
|
|
isError: false,
|
|
value,
|
|
content,
|
|
...meta !== undefined ? { meta } : {},
|
|
...concludesTurn ? { concludesTurn: true as const } : {},
|
|
}) as ToolExecutionSuccess)
|
|
}
|
|
|
|
/** Normalize an around-dispatch wrapper's authored result through the owning output contract. */
|
|
private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult {
|
|
if (this.canonicalResults.get(result) === exec.token) return result
|
|
if (result.isError) {
|
|
return this.markCanonical(exec, {
|
|
isError: true,
|
|
error: result.error,
|
|
content: result.content,
|
|
...result.meta !== undefined ? { meta: result.meta } : {},
|
|
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
|
|
})
|
|
}
|
|
const tool = this.get(exec.name, exec.agent)
|
|
if (tool === undefined) throw new ToolNotFoundError(exec.name)
|
|
const normalized = this.createSuccessResult(exec, tool, result.value)
|
|
return this.markCanonical(exec, {
|
|
...normalized,
|
|
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
|
|
})
|
|
}
|
|
|
|
/** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
|
|
private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult {
|
|
const presentation = {
|
|
content: result.content,
|
|
...result.meta !== undefined ? { meta: result.meta } : {},
|
|
...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {},
|
|
}
|
|
if (result.isError) {
|
|
return materializePresentation({ isError: true as const, error: result.error, ...presentation })
|
|
}
|
|
const detached = materializePresentation({
|
|
isError: false as const,
|
|
...presentation,
|
|
...result.concludesTurn === true ? { concludesTurn: true as const } : {},
|
|
})
|
|
return deepFreeze({ ...detached, value: result.value })
|
|
}
|
|
}
|
|
|
|
/** Mint a same-process correlation token whose identity is its value. */
|
|
function createExecutionToken(): ToolExecutionToken {
|
|
return Symbol('dsh.tool.execution') as ToolExecutionToken
|
|
}
|
|
|
|
function toolErrorResult(error: unknown): ToolExecutionResult {
|
|
const info = errorInfo(error)
|
|
const message = errorMessage(error)
|
|
return {
|
|
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
isError: true,
|
|
error: { message, ...info ? { info } : {} },
|
|
}
|
|
}
|
|
|
|
/** Read live abort state across an await without treating it as synchronously immutable. */
|
|
function isAborted(signal: AbortSignal): boolean {
|
|
return signal.aborted
|
|
}
|
|
|
|
/**
|
|
* Fuse caller and wrapper cancellation without nesting `AbortSignal.any`.
|
|
* Keeping the relay dispatch-scoped also removes listeners when work settles.
|
|
*/
|
|
function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal {
|
|
if (caller === wrapper) return { signal: caller, dispose() {} }
|
|
|
|
const controller = new AbortController()
|
|
let listening = false
|
|
const dispose = (): void => {
|
|
if (!listening) return
|
|
listening = false
|
|
caller.removeEventListener('abort', abortFromCaller)
|
|
wrapper.removeEventListener('abort', abortFromWrapper)
|
|
}
|
|
const abortFrom = (source: AbortSignal): void => {
|
|
const reason: unknown = source.reason
|
|
controller.abort(reason)
|
|
dispose()
|
|
}
|
|
const abortFromCaller = (): void => { abortFrom(caller) }
|
|
const abortFromWrapper = (): void => { abortFrom(wrapper) }
|
|
|
|
if (wrapper.aborted) abortFromWrapper()
|
|
else if (caller.aborted) abortFromCaller()
|
|
else {
|
|
listening = true
|
|
caller.addEventListener('abort', abortFromCaller, { once: true })
|
|
wrapper.addEventListener('abort', abortFromWrapper, { once: true })
|
|
}
|
|
return { signal: controller.signal, dispose }
|
|
}
|
|
|
|
/** Canonical result when cancellation supersedes success after body invocation. */
|
|
function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult {
|
|
const additionalContexts = prior?.additionalContexts ?? []
|
|
return {
|
|
content: [{ type: 'text', text: 'Error: tool call aborted' }],
|
|
isError: true,
|
|
error: {
|
|
message: 'tool call aborted',
|
|
info: { name: 'AbortError', code: TOOL_ABORTED },
|
|
},
|
|
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
|
}
|
|
}
|
|
|
|
/** Canonical result when cancellation prevents tool body invocation. */
|
|
function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecutionResult {
|
|
const additionalContexts = prior?.additionalContexts ?? []
|
|
return {
|
|
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
|
|
isError: true,
|
|
error: {
|
|
message: 'tool call aborted before dispatch',
|
|
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
|
},
|
|
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
|
}
|
|
}
|
|
|
|
export default ToolRegistry
|