fix(scope): close remaining ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 03:51:55 +08:00
parent 3dca90261c
commit 36b8370027
79 changed files with 3957 additions and 817 deletions

View File

@@ -15,14 +15,14 @@ tools:
### Public API
- `ctx.tools.register(definition: ToolDefinition): () => Promise<void> | void` Register a tool as a frozen snapshot. Parameters must survive lossless-JSON validation before and after cloning; scalar fields are copied, and execute/presentation callbacks are bound once to the original definition as their method receiver, so later callback-property replacement cannot change dispatch. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations).
- `ctx.tools.restrict(filter: ToolRestriction): () => Promise<void> | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap).
- `ctx.tools.register(definition: ToolDefinition): () => Promise<void> | void` Register a tool as a frozen snapshot. Every top-level caller field is read once into one coherent acceptance record; `name`/`description` must be strings and `timeoutMs`, when present, must be positive and finite before the snapshot can own them. Parameters are validated and detached by one recursive lossless-JSON traversal, so a stateful getter cannot show one value to a check and another to a prototype-erasing clone. Execute/presentation callbacks are bound once to the original definition as their method receiver, so later caller mutation cannot change the executable definition. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations).
- `ctx.tools.restrict(filter: ToolRestriction): () => Promise<void> | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The registry reads `allow`/`deny` once, so the values checked for an empty filter and unknown names are exactly the values enforced. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap).
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. Returned definitions are the registry's frozen snapshots.
- `ctx.tools.visible(scope?: ScopeKey): ToolDefinition[]` The canonical executable view — restricted global layer the scope's own layer, plus the reserved transport in non-native modes — feeding prompt assembly, `get`, and `execute`, so presentation and dispatch resolve the same frozen definitions.
- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.guard(guard: ToolGuard): () => Promise<void> | void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
- `ctx.tools.execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>` Snapshot one single-use call input into a pipeline-owned execution, assign its opaque correlation token, require `arguments` to be losslessly JSON-serializable before and after cloning, deep-freeze the detached arguments, and protect its identity before running `tools/pre-execute` → guards → `tools/execute``tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. Validate the final result as losslessly JSON-serializable and freeze the complete execution before `tools/result` observers run. Invalid or unstable input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log.
- `ctx.tools.execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>` Read each caller-owned top-level field once, snapshot the single-use call into a pipeline-owned execution, assign its opaque correlation token, materialize `arguments` through one lossless-JSON traversal, deep-freeze them, and protect identity before running `tools/pre-execute` → guards → `tools/execute``tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. After the required `callId`/`name` correlation identity is captured, the same captured optional fields build the normalized error shell if a later accessor or validation fails, so policy, dispatch, routing, and `tools/result` cannot observe different caller values. Every top-level result field is likewise captured once and the complete result or post-decision is losslessly materialized before final observation. Invalid input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log. A throwing `callId` or `name` accessor rejects because no trustworthy result identity exists yet.
### Injected services
@@ -77,7 +77,7 @@ ctx.tools.register(defineTool({
}))
```
The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly.
The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format. Definition is a snapshot boundary: `defineTool` reads every top-level option once, detaches the schema, and derives both an independent wire schema and every later execute/presentation validation from that accepted snapshot. Stateful accessors or later caller mutation therefore cannot make the schema shown to the model disagree with the schema enforced at runtime. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly.
A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs<S>` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, `default` is not applied, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input.

View File

@@ -23,7 +23,7 @@ import type { ScopeKey, 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, HookContext } from '@deepseek-ai/dsh-agent'
import { isJsonValue } from '@deepseek-ai/dsh-session'
import { snapshotJsonValue } 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
@@ -275,10 +275,10 @@ export interface ToolExecutionInput {
/**
* One pending tool call inside the registry pipeline. Call identity, the
* registry-assigned {@link token}, and a lossless-JSON-validated, deep-frozen
* clone of the parsed arguments are immutable from the first policy listener onward, while an
* around-dispatch wrapper may set, replace, or remove only `signal`. The
* registry freezes the complete object before `tools/result` observers run.
* registry-assigned {@link token}, and a deep-frozen lossless-JSON snapshot of
* the parsed arguments are immutable from the first policy listener onward,
* while an around-dispatch wrapper may set, replace, or remove only `signal`.
* The registry freezes the complete object before `tools/result` observers run.
*/
export interface ToolExecution extends ToolExecutionInput {
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
@@ -590,12 +590,13 @@ export class ToolRegistry extends Service {
* the shadowing feature, not an error; the global-duplicate message names
* `agent.ctx` as the per-agent alternative), or if a non-native mode reserves
* the `run_code` name for its presentation transport. The visible schema set
* flows into prompt assembly automatically. Registration validates and
* clones the JSON parameters, copies scalar fields, binds each callback once
* to the caller's definition as its method receiver, and freezes the stored
* snapshot; later mutation or callback replacement on the input object does
* not rewrite the registry. Disposed with the calling fiber. Emits
* `tools/change` on register/unregister.
* flows into prompt assembly automatically. Registration materializes the JSON
* parameters in one pass, copies scalar fields, binds each callback once to the
* caller's definition as its method receiver, and freezes the stored snapshot;
* later mutation or callback replacement on the input object does not rewrite
* the registry. Every top-level field is read once into one coherent acceptance
* snapshot, so stateful accessors cannot make validation and storage use
* different values. Emits `tools/change` on register/unregister.
* @param definition - the tool's schema plus its execute (and optional
* presentation) functions.
* @returns the disposer that unregisters the tool. The exact
@@ -604,31 +605,57 @@ export class ToolRegistry extends Service {
*/
register(definition: ToolDefinition): () => Promise<void> | void {
const scope = scopeOf(this.ctx)
// A schema crosses the same model/log boundary as execution arguments.
// Validate BEFORE cloning because structuredClone silently turns some
// forbidden values (for example class instances) into plain records, then
// validate the detached value again to contain hostile getters that change
// between inspection and snapshotting. A frozen Map is still mutable, so
// deepFreeze alone is not a sufficient registration boundary.
if (!isJsonValue(definition.parameters)) {
throw new TypeError('tool parameters must be losslessly JSON-serializable')
// One coherent acceptance snapshot: a caller may expose fields through
// accessors, so every top-level value is read exactly once before any
// validation or binding. Checked parameters and stored parameters must be
// the same reference, and a callback cannot change between lookup/bind.
const name = definition.name
const description = definition.description
const inputParameters = definition.parameters
const timeoutMs = definition.timeoutMs
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputExecute = definition.execute
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputPresentCall = definition.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method
const inputPresentResult = definition.presentResult
// Reject malformed fixed fields before any caller-owned value can enter the
// frozen snapshot. In particular, a boxed string/object must not become a
// Map key or get recursively frozen as though it were a scalar.
if (typeof name !== 'string') throw new TypeError('tool name must be a string')
if (typeof description !== 'string') throw new TypeError(`tool "${name}" description must be a string`)
if (timeoutMs !== undefined
&& (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`)
}
const parameters = structuredClone(definition.parameters)
if (!isJsonValue(parameters)) {
throw new TypeError('tool parameters must be stable losslessly JSON-serializable data')
if (typeof inputExecute !== 'function') throw new TypeError(`tool "${name}" execute must be a function`)
if (inputPresentCall !== undefined && typeof inputPresentCall !== 'function') {
throw new TypeError(`tool "${name}" presentCall must be a function when provided`)
}
if (inputPresentResult !== undefined && typeof inputPresentResult !== 'function') {
throw new TypeError(`tool "${name}" presentResult must be a function when provided`)
}
const execute = inputExecute.bind(definition)
const presentCall = inputPresentCall?.bind(definition)
const presentResult = inputPresentResult?.bind(definition)
// A schema crosses the same model/log boundary as execution arguments.
// Validate and detach it in one traversal: validate-then-structuredClone
// would reread getters and could erase an exotic prototype returned only to
// the clone. A frozen Map is still mutable, so deepFreeze alone is not a
// sufficient registration boundary.
const parameters = snapshotJsonValue(inputParameters)
if (parameters === undefined) {
throw new TypeError('tool parameters must be losslessly JSON-serializable')
}
// Bind once so replacing a callback on the caller-owned definition after
// registration cannot change dispatch, while preserving the historical
// method receiver (`this === definition`) for callbacks that use it.
const execute = definition.execute.bind(definition)
const presentCall = definition.presentCall?.bind(definition)
const presentResult = definition.presentResult?.bind(definition)
const snapshot: ToolDefinition = deepFreeze({
name: definition.name,
description: definition.description,
name,
description,
parameters,
execute,
...definition.timeoutMs !== undefined ? { timeoutMs: definition.timeoutMs } : {},
...timeoutMs !== undefined ? { timeoutMs } : {},
...presentCall !== undefined ? { presentCall } : {},
...presentResult !== undefined ? { presentResult } : {},
})
@@ -677,8 +704,9 @@ export class ToolRegistry extends Service {
* global tools they mask exist (the agent-creation `setup` window satisfies
* this). A non-native mode's reserved `run_code` presentation transport is
* not a filterable capability; naming it explicitly throws, while omitting
* it from an allow-list cannot remove it. The filter is SNAPSHOT at
* registration: later caller mutation of the arrays changes nothing.
* it from an allow-list cannot remove it. `allow` and `deny` are each read
* once, then the filter is SNAPSHOT at registration: the values checked are
* the values enforced, and later caller mutation of the arrays changes nothing.
* Multiple restrictions compose by intersection. Scoped registrations
* bypass restrictions (explicit grants win). Disposed with the calling
* fiber (revocable independently); emits `tools/change`.
@@ -692,13 +720,19 @@ export class ToolRegistry extends Service {
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')
}
if (filter.allow === undefined && filter.deny === undefined) {
// Read each caller-owned accessor once. The same values must decide
// whether the filter is meaningful AND become the enforced snapshot: a
// stateful getter must not pass the no-op check as `allow: []` and then
// disappear when the snapshot is built.
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)')
}
// Snapshot BEFORE validation so what was checked is what is enforced.
const snapshot: ToolRestriction = {
...filter.allow !== undefined ? { allow: [...filter.allow] } : {},
...filter.deny !== undefined ? { deny: [...filter.deny] } : {},
...allow !== undefined ? { allow: [...allow] } : {},
...deny !== undefined ? { deny: [...deny] } : {},
}
if (this.codeTransport !== undefined
&& [...snapshot.allow ?? [], ...snapshot.deny ?? []].includes(RUN_CODE_NAME)) {
@@ -909,35 +943,62 @@ export class ToolRegistry extends Service {
* restricted-away global is exactly as absent as a nonexistent one), the
* result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown
* {@link HarnessError} surfaces its `{ name, code }` on the result. Before
* the final observe-only notification, the authoritative outcome must survive
* a lossless JSON round trip; an invalid outcome is normalized to an error.
* the final observe-only notification, the authoritative outcome is
* materialized as a detached lossless-JSON snapshot; an invalid outcome is
* normalized to an error.
* A malformed runtime/casted `tools/pre-execute` decision likewise normalizes
* to an error before approval, guards, or the tool body.
* Caller-owned arguments must survive lossless-JSON validation before and
* after cloning; a violation normalizes to an error before policy or dispatch.
* @param exec - the single-use call input; its identity is snapshotted and
* protected before policy runs.
* @returns the final result after every waterfall; failures resolve as
* `isError` results, never rejections.
* Caller-owned arguments are validated and detached in one recursive
* lossless-JSON traversal; a violation normalizes to an error before policy
* or dispatch.
* @param exec - the single-use call input; every top-level field is read once
* and that identity snapshot is protected before policy runs (and reused by
* the normalized error shell if validation fails).
* @returns the final result after every waterfall. Once the required
* `callId` and `name` correlation identity has been captured, later
* accessor, validation, listener, and tool failures resolve as `isError`
* results rather than rejections. A throwing `callId` or `name` accessor
* rejects because no trustworthy result identity exists yet.
*/
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
// callId/name are the minimum correlation identity needed to construct a
// result at all. Every other caller-controlled accessor is read once
// INSIDE the normalization boundary; if one throws, the error shell uses
// the fields captured before it and never rereads the hostile record.
const callId = exec.callId
const name = exec.name
let agent: Agent | undefined
let parent: ToolExecutionToken | undefined
let signal: AbortSignal | undefined
let execution: ToolExecution
try {
execution = this.prepareExecution(exec)
agent = exec.agent
parent = exec.parent
signal = exec.signal
const args = exec.arguments
const input: Readonly<ToolExecutionInput> = Object.freeze({
callId,
name,
arguments: args,
...agent !== undefined ? { agent } : {},
...parent !== undefined ? { parent } : {},
...signal !== undefined ? { signal } : {},
})
execution = this.prepareExecution(input)
} catch (error: unknown) {
// Contract-violating non-JSON or non-cloneable arguments cannot enter a
// pipeline whose logged and executed forms must agree. Still publish one
// scoped final outcome, using an immutable identity shell, so result
// observers retain their every-call guarantee without seeing the invalid
// value.
// Contract-violating arguments outside the lossless-JSON vocabulary cannot
// enter a pipeline whose logged and executed forms must agree. Still
// publish one scoped final outcome, using an immutable identity shell, so
// result observers retain their every-call guarantee without seeing the
// invalid value.
execution = Object.freeze({
token: createExecutionToken(),
callId: exec.callId,
name: exec.name,
callId,
name,
arguments: undefined,
...exec.agent !== undefined ? { agent: exec.agent } : {},
...isExecutionToken(exec.parent) ? { parent: exec.parent } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},
...agent !== undefined ? { agent } : {},
...isExecutionToken(parent) ? { parent } : {},
...signal !== undefined ? { signal } : {},
})
const result = toolErrorResult(execution.callId, error)
await this.notifyResult(execution, result)
@@ -945,11 +1006,11 @@ export class ToolRegistry extends Service {
}
let result: ToolExecutionResult
try {
// Validate the authoritative FINAL result, not merely the tool body's
// Materialize the authoritative FINAL result, not merely the tool body's
// intermediate return. Post-policy may replace content or attach context,
// and every one of these fields is session-bound. Reject anything that
// cannot round-trip losslessly through the durable JSON log before the
// observe-only `tools/result` commit point sees success.
// and every one of these fields is session-bound. Reject anything outside
// the lossless-JSON vocabulary before the observe-only `tools/result`
// commit point sees success.
result = this.snapshotExecutionResult(execution, await this.executePipeline(execution))
} catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener, guard, or the
@@ -961,17 +1022,14 @@ export class ToolRegistry extends Service {
}
/** Snapshot one call into a shared pipeline object with immutable identity and mutable cancellation. */
private prepareExecution(input: ToolExecutionInput): ToolExecution {
private prepareExecution(input: Readonly<ToolExecutionInput>): ToolExecution {
if (input.parent !== undefined && !isExecutionToken(input.parent)) {
throw new TypeError('tool execution parent must be a registry-minted opaque token')
}
if (!isJsonValue(input.arguments)) {
const args = snapshotJsonValue(input.arguments)
if (args === undefined) {
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
}
const args = structuredClone(input.arguments)
if (!isJsonValue(args)) {
throw new TypeError('tool execution arguments must be stable losslessly JSON-serializable data')
}
const execution: ToolExecution = {
token: createExecutionToken(),
callId: input.callId,
@@ -1100,10 +1158,13 @@ export class ToolRegistry extends Service {
// The pipeline is over: freeze the remaining mutable signal slot so every
// observer sees the SAME WeakMap-keyable execution without a mutation race.
Object.freeze(exec)
// postExecute clones every accepted result/decision before rebuilding the
// outcome; all error paths construct plain data. The final result is thus
// structurally cloneable before it reaches this observe-only boundary.
const snapshot = deepFreeze(structuredClone(result))
// Materialize once more at the observer boundary so every listener receives
// the same detached result even when an internal error path constructed it.
const detached = snapshotJsonValue(result)
if (detached === undefined) {
throw new TypeError('tool result notification must be losslessly JSON-serializable')
}
const snapshot = deepFreeze(detached)
const callbacks = this.ctx.events.dispatch('parallel', [
scopeTarget(this, exec.agent), 'tools/result', exec, snapshot,
])
@@ -1169,13 +1230,16 @@ export class ToolRegistry extends Service {
// authoritative-call-id requirement and the "preserve the dispatched
// isError/error" contract. The decision is the ONLY sanctioned channel for a
// listener to change the outcome (block, or accept-with-replacement); the
// call id is always the authoritative `exec.callId`. Deep cloning protects
// nested content, error, and meta data from in-place listener mutation.
// call id is always the authoritative `exec.callId`. The one-pass snapshot
// protects nested content, error, and meta from in-place listener mutation.
const dispatched = this.snapshotExecutionResult(exec, result)
const decision = structuredClone(await this.ctx.waterfall(
const decision = snapshotJsonValue(await this.ctx.waterfall(
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
))
if (decision === undefined) {
throw new TypeError('tools/post-execute must return a losslessly JSON-serializable decision')
}
this.assertPostDecision(decision)
const additionalContext = decision.additionalContext
if (decision.kind === 'block') {
@@ -1200,31 +1264,36 @@ export class ToolRegistry extends Service {
throw new TypeError('tools/execute must return a ToolExecutionResult object')
}
const result = value as Partial<ToolExecutionResult>
if (!Array.isArray(result.content) || typeof result.isError !== 'boolean') {
// Capture the provider/listener-owned result exactly once. The same values
// must pass shape/correlation checks and become the detached final outcome;
// a stateful accessor cannot validate one result and publish another.
const callId = result.callId
const content = result.content
const isError = result.isError
const error = result.error
const additionalContext = result.additionalContext
const meta = result.meta
if (!Array.isArray(content) || typeof isError !== 'boolean') {
throw new TypeError('tools/execute must return a ToolExecutionResult with content[] and boolean isError')
}
if (result.callId !== exec.callId) {
throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`)
if (callId !== exec.callId) {
throw new TypeError(`tools/execute returned callId "${String(callId)}" for authoritative call "${exec.callId}"`)
}
const candidate = {
callId: exec.callId,
content: result.content,
isError: result.isError,
...result.error !== undefined ? { error: result.error } : {},
...result.additionalContext !== undefined ? { additionalContext: result.additionalContext } : {},
...result.meta !== undefined ? { meta: result.meta } : {},
content,
isError,
...error !== undefined ? { error } : {},
...additionalContext !== undefined ? { additionalContext } : {},
...meta !== undefined ? { meta } : {},
}
// Validate BEFORE cloning: structuredClone turns some forbidden exotic or
// class instances into plain objects, which would hide a lossy JSON
// boundary violation. Validate the detached clone again to contain hostile
// getters whose value changes between inspection and snapshotting.
if (!isJsonValue(candidate)) {
// One traversal both validates and detaches the accepted result. A separate
// check followed by structuredClone would reread getters and could sanitize
// a class instance into an apparently valid plain record.
const snapshot = snapshotJsonValue(candidate)
if (snapshot === undefined) {
throw new TypeError('tools/execute must return a losslessly JSON-serializable ToolExecutionResult')
}
const snapshot = structuredClone(candidate)
if (!isJsonValue(snapshot)) {
throw new TypeError('tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult')
}
return snapshot
}

View File

@@ -20,6 +20,7 @@
*/
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts'
import type { ToolCallView, ToolResultView } from './presentation.ts'
@@ -353,6 +354,12 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* Raw JSON-Schema tool definitions (from MCP servers) are still accepted
* by `ToolRegistry.register()` directly — `defineTool` is sugar for
* first-party plugin authors.
*
* Definition is an acceptance boundary: every top-level option is read once,
* and the parameter spec is detached before either the wire schema or the
* runtime validators are built. Later mutation of the caller's options or
* schema therefore cannot make the model-visible schema disagree with execute
* or presentation validation.
* @param options - the tool's name, description, typed parameter schema,
* execute body, and optional presenters.
* @returns a registry-ready {@link ToolDefinition}: its `execute` validates the
@@ -362,6 +369,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* args).
*/
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
// Capture every caller-owned top-level field before inspecting any nested
// schema value. Accessors may be stateful, so validation, presentation, and
// the returned definition must all derive from this one accepted record.
const name = options.name
const description = options.description
const inputParameters = options.parameters
const timeoutMs = options.timeoutMs
// Object-literal execute methods don't use `this`; the reference is safe.
// eslint-disable-next-line @typescript-eslint/unbound-method
const userExecute = options.execute
@@ -369,20 +383,31 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
const userPresentCall = options.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentResult = options.presentResult
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
throw new Error(`defineTool(${name}): timeoutMs must be a positive finite number`)
}
// The internal SchemaSpec and public wire schema must not share mutable
// subobjects. Each is materialized through the lossless one-pass boundary;
// structuredClone alone could sanitize an exotic default or nested getter.
const parameterSpec = snapshotJsonValue(inputParameters)
if (parameterSpec === undefined) {
throw new Error(`defineTool(${name}): parameters must be losslessly JSON-serializable`)
}
const wireParameters = snapshotJsonValue(schemaSpecToJsonSchema(parameterSpec))
if (wireParameters === undefined) {
throw new Error(`defineTool(${name}): generated parameters must be losslessly JSON-serializable`)
}
const tool: ToolDefinition = {
name: options.name,
description: options.description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
name,
description,
parameters: wireParameters as unknown as Record<string, unknown>,
...(timeoutMs !== undefined ? { timeoutMs } : {}),
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
// Validate the model-generated args before the typed body runs. On
// mismatch we throw ToolArgsError; the registry turns it into an
// isError result so the model can self-correct. After this guard, the
// cast to InferArgs<S> reflects the validated shape.
const violations = validateArgs(options.parameters, args)
const violations = validateArgs(parameterSpec, args)
if (violations.length > 0) throw new ToolArgsError(violations)
return userExecute(args as InferArgs<S>, exec)
},
@@ -393,13 +418,13 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
// than the hard `ToolArgsError` the execute path raises.
if (userPresentCall) {
tool.presentCall = (args: unknown): ToolCallView | undefined => {
if (validateArgs(options.parameters, args).length > 0) return undefined
if (validateArgs(parameterSpec, args).length > 0) return undefined
return userPresentCall(args as InferArgs<S>)
}
}
if (userPresentResult) {
tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => {
if (validateArgs(options.parameters, args).length > 0) return undefined
if (validateArgs(parameterSpec, args).length > 0) return undefined
return userPresentResult(args as InferArgs<S>, result)
}
}

View File

@@ -4,7 +4,7 @@ import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken, ToolRestriction } from '@deepseek-ai/dsh-tools'
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
@@ -141,6 +141,25 @@ describe('restrict()', () => {
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b'])
})
it('reads restriction accessors once so the checked filter is the enforced filter', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('global'))
let allowReads = 0
const filter = {
get allow(): string[] | undefined {
allowReads += 1
return allowReads === 1 ? [] : undefined
},
} as ToolRestriction
scope.ctx.tools.restrict(filter)
expect(allowReads).toBe(1)
expect(ctx.tools.schemas(key)).toEqual([])
expect(await run(ctx, 'global', key)).toBe('Error: unknown tool "global"')
})
it('fails loud on an unscoped call, an empty filter, and unknown names', async () => {
const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a')
@@ -372,6 +391,116 @@ describe('scoped execution dispatch', () => {
expect(Object.isFrozen(forged)).toBe(false)
})
it('reads a stateful parent accessor once before policy, dispatch, and result observation', async () => {
const ctx = await mount()
const observed: (ToolExecutionToken | undefined)[] = []
ctx.tools.register({
...tool('t'),
execute: (_args, exec) => {
observed.push(exec.parent)
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
},
})
ctx.on('tools/pre-execute', (exec, next) => {
observed.push(exec.parent)
return next()
})
ctx.on('tools/execute', (exec, next) => {
observed.push(exec.parent)
return next()
})
ctx.on('tools/result', (exec) => { observed.push(exec.parent) })
const forged = { fake: true } as unknown as ToolExecutionToken
let parentReads = 0
const input = {
callId: CallId('stateful-parent'),
name: 't',
arguments: {},
get parent(): ToolExecutionToken | undefined {
parentReads += 1
return parentReads === 1 ? undefined : forged
},
} as ToolExecutionInput
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(false)
expect(parentReads).toBe(1)
expect(observed).toEqual([undefined, undefined, undefined, undefined])
})
it('uses one input snapshot for the normalized error shell', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'accepted')
const driftAgent = { id: 'drift' as AgentId } as Agent
ctx.tools.register(tool('parent'))
ctx.tools.register(tool('t'))
let parent!: ToolExecutionToken
const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
if (exec.name === 'parent') parent = exec.token
return next()
})
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
stopCapture()
const acceptedSignal = new AbortController().signal
const driftSignal = new AbortController().signal
const forged = { fake: true } as unknown as ToolExecutionToken
const reads = { callId: 0, name: 0, arguments: 0, agent: 0, parent: 0, signal: 0 }
const input = {
get callId() { reads.callId += 1; return CallId('unstable-error') },
get name() { reads.name += 1; return 't' },
get arguments(): unknown { reads.arguments += 1; return { invalid: () => undefined } },
get agent() { reads.agent += 1; return reads.agent === 1 ? key : driftAgent },
get parent() { reads.parent += 1; return reads.parent <= 2 ? parent : forged },
get signal() { reads.signal += 1; return reads.signal === 1 ? acceptedSignal : driftSignal },
} as ToolExecutionInput
let observed: Readonly<ToolExecution> | undefined
let scopedObserved = 0
ctx.on('tools/result', (exec) => { observed = exec })
scope.ctx.on('tools/result', () => { scopedObserved += 1 })
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(true)
expect(reads).toEqual({ callId: 1, name: 1, arguments: 1, agent: 1, parent: 1, signal: 1 })
expect(scopedObserved).toBe(1)
expect(observed).toMatchObject({
callId: CallId('unstable-error'),
name: 't',
agent: key,
parent,
signal: acceptedSignal,
})
expect(Object.isFrozen(observed)).toBe(true)
})
it('normalizes a throwing arguments accessor without rereading it or losing the final notification', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
let argumentReads = 0
let observed = 0
ctx.on('tools/result', (exec, result) => {
observed += 1
expect(exec.arguments).toBeUndefined()
expect(result.isError).toBe(true)
})
const input = {
callId: CallId('throwing-arguments'),
name: 't',
get arguments(): unknown {
argumentReads += 1
throw new Error('getter exploded')
},
} as ToolExecutionInput
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: getter exploded' }])
expect(argumentReads).toBe(1)
expect(observed).toBe(1)
})
it.each([
['Map', new Map([['mutable', true]])],
['class instance', new (class Arguments { value = 1 })()],
@@ -408,7 +537,7 @@ describe('scoped execution dispatch', () => {
expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 })
})
it('rejects arguments that change to non-JSON data while being snapshotted', async () => {
it('reads nested arguments once into the executed snapshot', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
let reads = 0
@@ -421,12 +550,11 @@ describe('scoped execution dispatch', () => {
callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
})
expect(reads).toBe(1)
expect(result).toEqual({
callId: CallId('unstable-arguments'),
content: [{
type: 'text', text: 'Error: tool execution arguments must be stable losslessly JSON-serializable data',
}],
isError: true,
content: [{ type: 'text', text: 'ran:t' }],
isError: false,
})
})

View File

@@ -6,8 +6,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult, type ToolGuard,
type DefineToolOptions, type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolDefinition, type ToolExecution, type ToolExecutionResult, type ToolGuard,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -135,7 +135,7 @@ describe('ToolRegistry', () => {
expect(observedError).toBe(true)
})
it('normalizes a result that changes to non-JSON data while being snapshotted', async () => {
it('reads each result value once so later getter drift cannot change the snapshot', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let reads = 0
@@ -153,15 +153,59 @@ describe('ToolRegistry', () => {
callId: CallId('unstable-result'), name: 'echo', arguments: {},
})
expect(reads).toBe(1)
expect(result).toEqual({
callId: CallId('unstable-result'),
content: [{
type: 'text', text: 'Error: tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult',
}],
isError: true,
content: [{ type: 'text', text: 'safe' }],
isError: false,
})
})
it('reads every top-level execution result field once before validation', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const reads = { callId: 0, content: 0, isError: 0, error: 0, additionalContext: 0, meta: 0 }
ctx.on('tools/execute', async exec => Object.defineProperties({}, {
callId: { enumerable: true, get: () => { reads.callId += 1; return reads.callId === 1 ? exec.callId : CallId('drifted') } },
content: { enumerable: true, get: () => { reads.content += 1; return reads.content === 1 ? [{ type: 'text', text: 'accepted' }] : [] } },
isError: { enumerable: true, get: () => { reads.isError += 1; return reads.isError !== 1 } },
error: { enumerable: true, get: () => { reads.error += 1; return undefined } },
additionalContext: { enumerable: true, get: () => { reads.additionalContext += 1; return undefined } },
meta: { enumerable: true, get: () => { reads.meta += 1; return undefined } },
}) as ToolExecutionResult)
const result = await ctx.tools.execute({
callId: CallId('one-read-result'), name: 'echo', arguments: {},
})
expect(reads).toEqual({ callId: 1, content: 1, isError: 1, error: 1, additionalContext: 1, meta: 1 })
expect(result).toEqual({
callId: CallId('one-read-result'),
content: [{ type: 'text', text: 'accepted' }],
isError: false,
})
})
it('rejects an exotic nested result before its prototype can be sanitized', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
class ExoticText { readonly value = 'not text' }
ctx.on('tools/execute', exec => Promise.resolve({
callId: exec.callId,
content: [{ type: 'text', text: new ExoticText() }],
isError: false,
} as unknown as ToolExecutionResult))
const result = await ctx.tools.execute({
callId: CallId('exotic-result'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content).toEqual([{
type: 'text', text: 'Error: tools/execute must return a losslessly JSON-serializable ToolExecutionResult',
}])
})
it('returns isError results for unknown tools and throwing tools', async () => {
const ctx = await setup()
ctx.tools.register({
@@ -729,6 +773,29 @@ describe('ToolRegistry', () => {
expect(observedError).toBe(true)
})
it('rejects non-JSON data at the defensive final-result notification boundary', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let execution: ToolExecution | undefined
ctx.on('tools/execute', async (exec, next) => {
execution = exec
return next()
})
await ctx.tools.execute({ callId: CallId('capture-execution'), name: 'echo', arguments: {} })
if (execution === undefined) throw new Error('test fixture did not capture the execution')
const internal = ctx.tools as unknown as {
notifyResult(exec: ToolExecution, result: ToolExecutionResult): Promise<void>
}
const invalid = {
callId: CallId('capture-execution'),
content: new Map() as unknown as ToolExecutionResult['content'],
isError: false,
}
await expect(internal.notifyResult(execution, invalid))
.rejects.toThrow('tool result notification must be losslessly JSON-serializable')
})
it.each([
{
name: 'non-object result',
@@ -780,6 +847,11 @@ describe('ToolRegistry', () => {
replacement: { kind: 'defer' },
message: 'tools/post-execute must return an accept or block decision',
},
{
name: 'non-JSON decision',
replacement: { kind: 'accept', content: new Map() },
message: 'tools/post-execute must return a losslessly JSON-serializable decision',
},
])('normalizes a tools/post-execute $name', async ({ replacement, message }) => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -886,7 +958,7 @@ describe('ToolRegistry', () => {
expect(ctx.tools.get('invalid-parameters')).toBeUndefined()
})
it('rejects tool parameters that change to non-JSON data while being snapshotted', async () => {
it('reads nested tool parameters once into the accepted snapshot', async () => {
const ctx = await setup()
let reads = 0
const parameters = Object.defineProperty({}, 'properties', {
@@ -898,8 +970,58 @@ describe('ToolRegistry', () => {
...echoTool,
name: 'unstable-parameters',
parameters,
})).toThrow('tool parameters must be stable losslessly JSON-serializable data')
expect(ctx.tools.get('unstable-parameters')).toBeUndefined()
})).not.toThrow()
expect(reads).toBe(1)
expect(ctx.tools.get('unstable-parameters')?.parameters).toEqual({ properties: {} })
})
it('reads a top-level parameters accessor once so validation and storage use one value', async () => {
const ctx = await setup()
const accepted = { type: 'object', properties: { accepted: { type: 'string' } } }
class DriftedParameters {
readonly type = 'object'
readonly properties = { drifted: { type: 'number' } }
}
let reads = 0
const definition = { ...echoTool, name: 'top-level-parameters' }
Object.defineProperty(definition, 'parameters', {
enumerable: true,
get: () => {
reads += 1
return reads === 1 ? accepted : new DriftedParameters()
},
})
ctx.tools.register(definition)
expect(reads).toBe(1)
expect(ctx.tools.get('top-level-parameters')?.parameters).toEqual(accepted)
})
it('rejects malformed fixed definition fields without freezing caller objects', async () => {
const ctx = await setup()
const badName = { value: 'object-name' }
const badDescription = { value: 'object-description' }
const badTimeout = { value: 100 }
expect(() => ctx.tools.register({ ...echoTool, name: badName as unknown as string }))
.toThrow('tool name must be a string')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-description', description: badDescription as unknown as string }))
.toThrow('description must be a string')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-timeout', timeoutMs: badTimeout as unknown as number }))
.toThrow('timeoutMs must be a positive finite number')
expect(() => ctx.tools.register({ ...echoTool, name: 'zero-timeout', timeoutMs: 0 }))
.toThrow('timeoutMs must be a positive finite number')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-execute', execute: { bind() {} } as unknown as typeof echoTool.execute }))
.toThrow('execute must be a function')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-present-call', presentCall: 1 as unknown as NonNullable<ToolDefinition['presentCall']> }))
.toThrow('presentCall must be a function')
expect(() => ctx.tools.register({ ...echoTool, name: 'bad-present-result', presentResult: 1 as unknown as NonNullable<ToolDefinition['presentResult']> }))
.toThrow('presentResult must be a function')
expect(Object.isFrozen(badName)).toBe(false)
expect(Object.isFrozen(badDescription)).toBe(false)
expect(Object.isFrozen(badTimeout)).toBe(false)
expect(ctx.tools.schemas()).toEqual([])
})
it('snapshots callbacks while preserving their registration-time method receiver', async () => {
@@ -1094,6 +1216,101 @@ describe('defineTool / schema DSL', () => {
expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }])
})
it('reads defineTool options once and keeps wire and runtime schemas on one detached snapshot', async () => {
const accepted: SchemaSpec = { value: { type: 'string', required: true, enum: ['accepted'] } }
const drifted: SchemaSpec = { count: { type: 'number', required: true } }
const reads = {
name: 0,
description: 0,
parameters: 0,
timeoutMs: 0,
execute: 0,
presentCall: 0,
presentResult: 0,
}
const options = {} as DefineToolOptions<SchemaSpec>
Object.defineProperties(options, {
name: { enumerable: true, get: () => { reads.name += 1; return reads.name === 1 ? 'accepted' : 'drifted' } },
description: { enumerable: true, get: () => { reads.description += 1; return reads.description === 1 ? 'accepted description' : 'drifted description' } },
parameters: { enumerable: true, get: () => { reads.parameters += 1; return reads.parameters === 1 ? accepted : drifted } },
timeoutMs: { enumerable: true, get: () => { reads.timeoutMs += 1; return reads.timeoutMs === 1 ? 250 : 0 } },
execute: {
enumerable: true,
get: () => {
reads.execute += 1
return (args: Record<string, unknown>) => Promise.resolve([{ type: 'text' as const, text: String(args['value']) }])
},
},
presentCall: {
enumerable: true,
get: () => {
reads.presentCall += 1
return (args: Record<string, unknown>) => ({ card: 'generic' as const, title: String(args['value']) })
},
},
presentResult: {
enumerable: true,
get: () => {
reads.presentResult += 1
return (args: Record<string, unknown>) => ({ card: 'generic' as const, title: String(args['value']) })
},
},
})
const tool = defineTool(options)
accepted.value!.type = 'number'
accepted.value!.enum!.push('mutated')
expect(tool).toMatchObject({
name: 'accepted',
description: 'accepted description',
timeoutMs: 250,
parameters: {
type: 'object',
properties: { value: { type: 'string', enum: ['accepted'] } },
required: ['value'],
},
})
await expect(tool.execute({ value: 'accepted' }, {} as ToolExecution))
.resolves.toEqual([{ type: 'text', text: 'accepted' }])
expect(tool.presentCall?.({ value: 'accepted' })).toEqual({ card: 'generic', title: 'accepted' })
expect(tool.presentResult?.(
{ value: 'accepted' },
{ content: [], isError: false },
)).toEqual({ card: 'generic', title: 'accepted' })
expect(reads).toEqual({
name: 1,
description: 1,
parameters: 1,
timeoutMs: 1,
execute: 1,
presentCall: 1,
presentResult: 1,
})
})
it('rejects an exotic defineTool schema before it can be normalized for the wire', () => {
class ExoticDefault { readonly value = 'not JSON' }
expect(() => defineTool({
name: 'exotic-schema',
description: 'must reject exotic defaults',
parameters: {
value: { type: 'string', default: new ExoticDefault() },
},
execute: () => Promise.resolve([]),
})).toThrow(/parameters must be losslessly JSON-serializable/)
})
it('rejects a malformed defineTool spec whose generated wire schema is not JSON', () => {
expect(() => defineTool({
name: 'malformed-schema',
description: 'missing property type',
parameters: { value: {} } as unknown as SchemaSpec,
execute: () => Promise.resolve([]),
})).toThrow(/generated parameters must be losslessly JSON-serializable/)
})
it('type-level: InferArgs maps required properties to non-optional', () => {
// Compile-time check: if this compiles, InferArgs is correct.
// args.a is string (required), args.b is number|undefined (optional).