Files
deepseek-harness/packages/core/tools/src/contracts.ts
T
sjw1231 3368b86e2c refactor(tools): keep approval and context codes off the plugin entry
The plugin entry may export only Loader fields and Service or Error
classes, so the ApprovalError and ToolContextUnavailableError code
constants move to an internal module; the recorded strings are
unchanged.

Refs #7869
2026-11-05 20:12:48 +08:00

419 lines
19 KiB
TypeScript

/** Tool definitions and invocation data shared by registries and executors. @module */
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { TOOL_CONTEXT_UNAVAILABLE } from './error-codes.ts'
import type { ToolCallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { Agent, ModelSelection } from '@deepseek-ai/dsh-agent'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import type { JsonSchemaNode } from './json-schema.ts'
import type { ToolCallView, ToolResultView } from './presentation.ts'
/** Reserved model-facing program transport name. */
export const RUN_CODE_NAME = 'run_code'
declare const toolExecutionTokenBrand: unique symbol
/** 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 for native and nested 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 executor 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>
/**
* Install execution-prepared content before `tools/post-execute` policies.
* The callback is captured when the call starts and runs once for a
* normalized outcome entering post-execute. Policy replacements remain
* authoritative; pipeline failures that bypass post-execute skip projection.
* @param exec - immutable execution identity and arguments.
* @param result - normalized result before post-execute policy.
* @returns replacement content, or undefined to preserve the renderer output.
*/
projectContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
/**
* Synchronous last-mile transform for model-facing content. The executor
* 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 executor-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-tool-call-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
}
/** Inputs for a tool's step-time activation condition. */
export interface ToolActivationContext {
/** Agent preparing the step. */
readonly agent: Agent
/** Provider, model, and optional reasoning effort captured for the step. */
readonly modelSelection: Readonly<ModelSelection>
/** Cancellation of step preparation; asynchronous conditions must observe it. */
readonly signal: AbortSignal
}
/** Lifecycle-owned conditions for a registered tool. */
export interface ToolRegistrationOptions {
/**
* Decide whether the step's requests may call the tool. Omission enables
* it. False excludes it from both declarations and dispatch; rejection fails
* step preparation. Conditions must not mutate registrations or start tool work.
* @param context - preparing agent, captured model selection, and cancellation signal.
* @returns whether the tool participates in this step.
*/
readonly when?: (context: ToolActivationContext) => boolean | Promise<boolean>
}
/** One step's admitted registrations, also used by nested PTC calls. */
export interface ResolvedToolSet {
/** Close admission after the step's tool batch settles. */
dispose(): void
/**
* Read admitted registrations that remain visible in their original scope.
* @param scope - exact requesting agent; another scope is never admitted.
* @returns original definitions excluding removed or restricted registrations.
*/
view(scope: ScopeKey | undefined): ReadonlyMap<string, ToolDefinition>
}
/** Failure to determine a tool's step-time availability. */
export class ToolActivationError extends HarnessError {
/**
* Preserve a condition failure without treating it as normal unavailability.
* @param name - tool whose condition rejected.
* @param cause - underlying condition failure.
*/
constructor(name: string, cause: unknown) {
super(`cannot resolve availability of tool "${name}"`, 'TOOL_ACTIVATION_FAILED', { cause })
this.name = 'ToolActivationError'
}
}
/** 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.
* It is persisted on `tool/result` or `tool/ptc-dispatch` for Host presenters
* and Client renderers to narrow independently. Absent when the tool
* declared no projector.
*/
meta?: JsonValue
}
/** Opaque call identity that permits correlation without exposing mutable execution state. */
export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
/**
* Parsed input to the shared invocation pipeline. The pipeline
* adds its own token to form a {@link ToolExecution};
* callers do not choose that token.
*/
export interface ToolExecutionInput {
readonly callId: ToolCallId
/**
* Root model-requested call owning this execution tree. Callers omit it for
* a root execution; nested dispatchers propagate the enclosing value.
*/
readonly rootCallId?: ToolCallId
readonly name: string
/** Binding-time tool schema for a PTC inner call; frozen by its producer and never logged. */
readonly schema?: ToolSchema
/** 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
/** Step admission shared by Native and PTC dispatch. Conditional tools require it. */
readonly toolSet?: ResolvedToolSet
/**
* Opaque token of the enclosing transport execution, when one exists. PTC
* 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.
* The token also marks the call as a transport sub-dispatch rather than a
* model-direct call: with the PTC executor selected, only calls with a parent may
* execute a native tool name — a model-direct call (no parent) is denied as
* `UNKNOWN_TOOL` before the policy pipeline.
*/
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 pending tool call inside the executor pipeline. Parsed arguments cross
* one lossless-JSON materialization boundary before policy and are deep-frozen;
* call identity, the caller signal, and the executor-assigned {@link token} are
* readonly. The executor 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: ToolCallId
/** Executor-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 executor 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 executor 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
}
/** 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
/** Optional raw user-facing detail; durable projections preserve it but model-facing content does not include it. */
reason?: 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 {
/**
* @param toolName - the name the caller asked for.
* @param reachableFrom - how the model reaches this tool instead, when the
* name IS visible and only the presentation denies calling it directly.
* Omitted for a name that is registered nowhere.
*/
constructor(toolName: string, reachableFrom?: string) {
super(
reachableFrom === undefined
? `unknown tool "${toolName}"`
: `unknown tool "${toolName}": ${reachableFrom}`,
'UNKNOWN_TOOL',
)
this.name = 'ToolNotFoundError'
}
}
/**
* Thrown by a tool body when its composition or call lacks context the tool
* requires, such as a calling Agent, an injected service, or an open turn.
* The failure is not an argument the model can correct; `message` names the
* missing context.
*/
export class ToolContextUnavailableError extends HarnessError {
/**
* @param message - names the tool and the missing context.
* @param options - standard `cause` chaining.
*/
constructor(message: string, options?: ErrorOptions) {
super(message, TOOL_CONTEXT_UNAVAILABLE, options)
this.name = 'ToolContextUnavailableError'
}
}
/** 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
}
}
/** 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 its
* model-facing reason and optional structured error identity; `cancel` selects
* the canonical cancellation result without presenting a policy denial; `ask`
* runs only after an approval service returns `allowed-once` and otherwise
* denies; its `reason` is the audited approval reason and its optional
* `displayReason` is the localized prompt text. Input rewriting is excluded because arguments are already logged and
* presented.
*/
export type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string; info?: ToolErrorInfo }
| { kind: 'cancel' }
| { kind: 'ask'; reason?: string; displayReason?: { readonly en: string; readonly [locale: string]: string } }
/**
* Post-dispatch decision: accept, replace one projection, attach context for the
* next request, or block by turning corrective feedback into an error result
* whose optional `info` is its structured error identity.
*/
export type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] }
| { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] }
| { kind: 'block'; feedback: ContentBlock[]; info?: ToolErrorInfo; additionalContexts?: UserMessage[] }
/**
* Per-scope filter over global tools. Restrictions intersect and do not affect
* scoped registrations or the reserved PTC 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[]
}
/**
* 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, a denial with structured error identity, or `undefined` to leave the call allowed.
*/
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | ToolGuardDenial | undefined
/** A guard denial: the model-facing reason and optional structured error identity recorded on the result. */
export interface ToolGuardDenial {
/** Model-facing reason rendered as the failed call's content. */
readonly reason: string
/** Structured error identity persisted beside the reason. */
readonly info?: ToolErrorInfo
}