Files
deepseek-harness/packages/core/tools/src/contracts.ts
T
Yichen Jiang 207b30d299 Merge remote-tracking branch 'origin/master' into worktree/dynamic-tool-activation-pr-64796e
# Conflicts:
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	docs/persistence-catalog.i18n.yaml
#	docs/persistence-catalog.md
#	docs/persistence-catalog.zh.md
#	docs/persistence-schema.json
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent-loop/src/tool-calls.ts
#	packages/core/agent-loop/tests/tool-updates.spec.ts
#	packages/core/system-prompt/README.i18n.yaml
#	packages/core/system-prompt/README.md
#	packages/core/system-prompt/README.zh.md
#	packages/core/tool-executor-ptc/src/ptc.ts
#	packages/core/tool-executor-ptc/tests/ptc.spec.ts
#	packages/core/tools/README.i18n.yaml
#	packages/core/tools/README.md
#	packages/core/tools/README.zh.md
#	packages/core/tools/src/index.ts
#	packages/extensions/tool-cordis/src/api-catalog.ts
#	packages/plan/plan-mode/tests/plan-mode.spec.ts
#	snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl
2026-10-21 23:32:15 +08:00

392 lines
18 KiB
TypeScript

/** Tool definitions and invocation data shared by registries and executors. @module */
import { HarnessError } from '@deepseek-ai/dsh-llm'
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 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.
*/
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[] }
/**
* 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, or `undefined` to leave the call allowed.
*/
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined