fix(scope): close remaining ownership boundaries
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user