refactor(core): simplify tools prompts and trusted services

This commit is contained in:
Tianyi Cui
2026-07-12 22:39:01 +08:00
parent 28e04ff4fb
commit 02ca71db57
24 changed files with 636 additions and 2695 deletions

View File

@@ -11,18 +11,16 @@ tools:
mode: native # native (default) | code | both
```
`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry's canonical contribution is the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); an assembly listener may still deliberately add unrelated schemas. `both` contributes the visible native definitions, `run_code`, and the SDK section. In non-native modes both infrastructure pieces are protected rather than filterable capabilities: restrictions and assembly listeners cannot remove them, a scoped section cannot shadow the globally protected `tools:sdk`, and registering, shadowing, or explicitly filtering `run_code` fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry's canonical contribution is the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); an assembly listener may still deliberately add unrelated schemas. `both` contributes the visible native definitions, `run_code`, and the SDK section. In non-native modes both infrastructure pieces are owner-final rather than filterable capabilities: restrictions and assembly listeners cannot remove them, a scoped section cannot shadow the globally owner-final `tools:sdk`, and registering, shadowing, or explicitly filtering `run_code` fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
### Public API
- `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; scope-local registrations are merged after the global filter. The registry reads `allow`/`deny` once, so the values checked for an empty filter and unknown names are exactly the values enforced. A deny-list admits a later global tool unless it names that tool; an allow-list excludes later names; neither filters a later scope-local registration. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Filter-value snapshot at registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap). This is live registration composition, not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
- `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.register(definition: ToolDefinition): () => Promise<void> | void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. `ownerFinal: true` makes the tool's canonical wire presence or absence survive prompt assembly listeners. Disposed with the calling fiber.
- `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 global tools, `deny` removes them; multiple restrictions intersect; scope-local registrations are merged afterward. The readonly arrays compile once into private sets. Every listed name must exist in the current pre-restriction global registry; scope-local, unknown, and reserved `run_code` names fail loudly. A deny-list admits a later global tool unless it names that tool; an allow-list excludes later names; neither filters a later scope-local registration. `restrict({})` rejects. This is live registration composition, not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
- `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.
- `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>` Read each caller-owned top-level field once, require `callId` and `name` to yield strings, 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 string 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 later 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 accessor or non-string value in `callId` or `name` rejects before `tools/result` because no trustworthy result identity exists yet.
- `ctx.tools.execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>` Assign a fresh opaque correlation token, losslessly materialize and deep-freeze arguments once at the model/tool boundary, then run the call through `tools/pre-execute` → guards → `tools/execute``tools/post-execute`. Invalid arguments normalize through the same authoritative result path without reaching policy or the body. The final outcome is independently materialized and deep-frozen once before `tools/result`. Optional `signal` remains the operational field an around-dispatch wrapper may replace.
### Injected services
@@ -34,12 +32,12 @@ The live registry pipeline has three transformable waterfalls followed by the ow
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model. Registration stores a frozen snapshot with detached JSON parameters and once-bound callback identities.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; `callId` and `name` must be strings, `arguments` must be losslessly JSON-serializable, and callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionToken` — a frozen, property-free identity value assigned by the registry. It supports equality correlation only and exposes no live outer execution state.
- `ToolDefinition``ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional `ownerFinal`. `ownerFinal` is reserved for protocol tools such as `run_code` and structured-output capture whose canonical wire state must survive assembly listeners.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry validates the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. The registry validates this exact union at runtime: a JavaScript/casted value with an unknown kind, a malformed reason, or extra fields fails closed as an `isError`; the tool body does not run and final observers still receive one result. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when a deployment mounts it (`allowed-once` proceeds to dispatch; `rejected`/`cancelled`/`unavailable` deny with distinct reasons) and degrades to `deny` when none is mounted or the execution carries no agent.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
- `PostToolDecision``{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
- `ToolGuard``(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
@@ -77,7 +75,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. 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.
The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format and uses the same typed spec for execute/presentation validation. 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.
@@ -138,7 +136,7 @@ Under `mode: code` (or `both`) the registry turns the tool surface into a progra
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. A sub-call's `additionalContext` is deliberately dropped because inserting it inside a running parent call would break tool-call/result adjacency.
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free. With no deliberate schema-adding assembly listener, `code` assembles exactly `[run_code]`, pinned by tests and the snapshot goldens; protection guarantees that `run_code` and `tools:sdk` remain present, not that unrelated listener additions are erased. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL.
The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free. With no deliberate schema-adding assembly listener, `code` assembles exactly `[run_code]`, pinned by tests and the snapshot goldens; contribution-owned finality guarantees that `run_code` and `tools:sdk` remain present, not that unrelated listener additions are erased. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL.
### What is NOT here (TODO)

View File

@@ -151,6 +151,7 @@ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime): ToolDefinition {
return defineTool({
name: RUN_CODE_NAME,
ownerFinal: true,
description:
'Execute a TypeScript program against the available tools. Write the BODY of an '
+ 'async function (erasable syntax only; top-level `await` and `return` work) and '

View File

@@ -91,9 +91,6 @@ declare module 'cordis' {
* tool body never runs. Input rewrite is deliberately NOT offered here (see
* {@link PreToolDecision}); `ask` is serviced by the `ctx.approval` seam
* when one is mounted, and degrades to deny otherwise.
* The returned union is validated as an exact runtime shape before approval
* or guards run; a malformed JavaScript/casted decision fails closed as an
* `isError` result and the tool body never runs.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a
* listener registered through `agent.ctx` fires only for that agent's
* calls, while a plain plugin listener fires for every call (including
@@ -148,7 +145,7 @@ declare module 'cordis' {
* @param result - the dispatch outcome a listener may accept, replace, or block.
* @mode waterfall
*/
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
/**
* Awaited notification of the authoritative FINAL tool outcome, after the
* complete pre/execute/post pipeline, final lossless-JSON validation, and
@@ -202,6 +199,12 @@ export interface ToolDefinition extends ToolSchema {
* cooperative implementation that can reach quiescence when the signal aborts.
*/
timeoutMs?: number
/**
* Whether this tool name's canonical wire presence or absence survives the
* complete system-prompt assembly waterfall. Reserved for protocol tools
* whose owner must retain the final definition.
*/
readonly ownerFinal?: boolean
/**
* Optional: how to present the PENDING state of one call in a UI, derived from
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
@@ -239,23 +242,18 @@ export interface ToolResult {
declare const toolExecutionTokenBrand: unique symbol
/** Tokens minted in this module; a cast or JavaScript object cannot forge membership. */
const executionTokens = new WeakSet<object>()
/**
* Opaque, immutable identity for one trip through the tool pipeline. Nested
* Opaque identity for one trip through the tool pipeline. Nested
* transports carry the enclosing execution's token instead of its live object,
* so observe-only result listeners can correlate calls without gaining a
* mutation path into an outer around-dispatch wrapper.
*/
export interface ToolExecutionToken {
readonly [toolExecutionTokenBrand]: true
}
export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
/**
* Caller-supplied description of one tool call. {@link ToolRegistry.execute}
* snapshots this input into a pipeline-owned {@link ToolExecution}; callers do
* not choose the execution token.
* adds the registry-owned token to form a pipeline {@link ToolExecution};
* callers do not choose that token.
*/
export interface ToolExecutionInput {
readonly callId: CallId
@@ -274,11 +272,11 @@ export interface ToolExecutionInput {
}
/**
* One pending tool call inside the registry pipeline. Call identity, the
* 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.
* One pending tool call inside the registry pipeline. Parsed arguments cross
* one lossless-JSON materialization boundary before policy and are deep-frozen;
* call identity and the registry-assigned {@link token} are readonly. An
* around-dispatch wrapper may set, replace, or remove `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. */
@@ -430,8 +428,8 @@ export interface Config {
* `deny` removes the listed ones; both present = allow first, then deny.
* Restrictions never touch scoped registrations — a tool registered through
* the same scope is merged after the global filter (which is what keeps e.g. a
* structured-output capture tool alive under an allow-list). The filter values
* are snapshotted at registration, but resolution uses the live global registry:
* structured-output capture tool alive under an allow-list). The readonly
* filter values compile to private sets at registration, but resolution uses the live global registry:
* a later global name passes a deny-only filter unless explicitly denied and
* fails an allow-list unless explicitly allowed. The
* reserved `run_code` presentation transport is likewise outside capability
@@ -440,9 +438,27 @@ export interface Config {
*/
export interface ToolRestriction {
/** Global tool names that stay visible; everything else is removed. */
allow?: string[]
readonly allow?: readonly string[]
/** Global tool names removed from visibility. */
deny?: string[]
readonly deny?: readonly string[]
}
/** One restriction compiled at registration for repeated live-global lookup. */
interface CompiledToolRestriction {
readonly allow?: ReadonlySet<string>
readonly deny?: ReadonlySet<string>
}
/** One scope's complete registry view, derived in a single layer traversal. */
interface ToolView {
/** Visible definitions after restrictions, scoped shadowing, and transport insertion. */
readonly visible: ReadonlyMap<string, ToolDefinition>
/** Pre-restriction capability names used by prompt-order validation. */
readonly knownNames: ReadonlySet<string>
/** Current global names that a scoped restriction may name. */
readonly restrictableNames: ReadonlySet<string>
/** Canonical names whose wire presence or absence is owner-final. */
readonly ownerFinalNames: ReadonlySet<string>
}
/**
@@ -475,7 +491,7 @@ interface ToolGuardRegistration {
* that agent alone, disposed with the scope, and SHADOWING a global tool of
* the same name for that agent (most-specific-wins; within one layer a
* duplicate name still throws). {@link restrict} masks the global layer per
* scope. One visibility function ({@link visible}) feeds prompt assembly,
* scope. One private visibility resolver feeds prompt assembly,
* {@link get}, and {@link execute} — and, under a non-native mode, the SDK
* section and `run_code`'s bindings — so what the model is shown, what a
* presenter renders, what a program can call, and what dispatches can never
@@ -490,8 +506,8 @@ export class ToolRegistry extends Service {
private global = new Map<string, ToolDefinition>()
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
/** Snapshot-at-registration restriction filters, per scope (see {@link restrict}). */
private restrictions = new Map<ScopeKey, ToolRestriction[]>()
/** Compiled restriction filters, per scope (see {@link restrict}). */
private restrictions = new Map<ScopeKey, CompiledToolRestriction[]>()
/** Monotonic post-policy guards, split into global and per-agent layers. */
private globalGuards = new Set<ToolGuardRegistration>()
private scopedGuards = new Map<ScopeKey, Set<ToolGuardRegistration>>()
@@ -511,12 +527,13 @@ export class ToolRegistry extends Service {
// the filterable global/scoped capability layers.
this.codeTransport = this.mode === 'native'
? undefined
: deepFreeze(createRunCodeTool(this, () => this.requireCodeRuntime()))
: createRunCodeTool(this, () => this.requireCodeRuntime())
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
if (this.mode !== 'native') {
ctx.systemPrompt.section({
name: 'tools:sdk',
order: SDK_SECTION_ORDER,
ownerFinal: true,
// A lazy thunk over the live registry, per assembly CONTEXT:
// regenerated at each assembly over the CALLING SCOPE's visible set
// (scoped tools join, restricted globals vanish — the SDK declares
@@ -529,11 +546,6 @@ export class ToolRegistry extends Service {
return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME))
},
})
// These are presentation infrastructure, not optional end capabilities.
// Protect them at their owner: assembly listeners may still transform
// ordinary tools and prose, but cannot silently leave Code Mode without
// its only wire transport or the SDK that tells the model how to use it.
ctx.systemPrompt.protect({ sections: ['tools:sdk'], tools: [RUN_CODE_NAME] })
}
}
@@ -553,16 +565,24 @@ export class ToolRegistry extends Service {
* `mode: 'code'` the universe is `[run_code]` and a `toolOrder` naming a
* native tool is dead configuration that fails every assembly loud. Under
* `mode: 'both'`, the provider adds the reserved transport to the
* capability-only {@link knownNames} universe for `toolOrder` validation.
* capability-only known-name universe for `toolOrder` validation.
*/
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
if (this.mode === 'native') return { schemas: this.schemas(scope), knownNames: this.knownNames(scope) }
this.requireCodeRuntime()
const all = this.schemas(scope)
if (this.mode === 'code') {
return { schemas: all.filter(schema => schema.name === RUN_CODE_NAME), knownNames: [RUN_CODE_NAME] }
const view = this.view(scope)
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
const ownerFinalNames = [...view.ownerFinalNames]
if (this.mode === 'native') {
return { schemas, knownNames: [...view.knownNames], ownerFinalNames }
}
return { schemas: all, knownNames: [...this.knownNames(scope), RUN_CODE_NAME] }
this.requireCodeRuntime()
if (this.mode === 'code') {
return {
schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
knownNames: [RUN_CODE_NAME],
ownerFinalNames,
}
}
return { schemas, knownNames: [...view.knownNames, RUN_CODE_NAME], ownerFinalNames }
}
/**
@@ -593,13 +613,10 @@ 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 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.
* flows into prompt assembly automatically. Definitions are trusted typed
* same-process contributions; JSON materialization happens when the schema or
* result reaches its model/log boundary. 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
@@ -608,78 +625,39 @@ export class ToolRegistry extends Service {
*/
register(definition: ToolDefinition): () => Promise<void> | void {
const scope = scopeOf(this.ctx)
// 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)) {
&& (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`)
}
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 snapshot: ToolDefinition = deepFreeze({
name,
description,
parameters,
execute,
...timeoutMs !== undefined ? { timeoutMs } : {},
...presentCall !== undefined ? { presentCall } : {},
...presentResult !== undefined ? { presentResult } : {},
})
if (this.codeTransport !== undefined && snapshot.name === RUN_CODE_NAME) {
if (this.codeTransport !== undefined && name === RUN_CODE_NAME) {
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
}
if (scope !== undefined && this.global.get(name)?.ownerFinal === true) {
throw new Error(`tool "${name}" is globally owner-final and cannot be shadowed in an agent scope`)
}
if (scope === undefined && definition.ownerFinal === true) {
const hasScopedShadow = [...this.scoped.values()].some(layer => layer.has(name))
if (hasScopedShadow) {
throw new Error(`owner-final tool "${name}" cannot be registered while a scoped shadow exists`)
}
}
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
const layer = scope === undefined ? this.global : this.layerFor(scope)
if (layer.has(snapshot.name)) {
if (layer.has(name)) {
throw new Error(scope === undefined
? `tool "${snapshot.name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
: `tool "${snapshot.name}" is already registered in this scope`)
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
: `tool "${name}" is already registered in this scope`)
}
layer.set(snapshot.name, snapshot)
layer.set(name, definition)
// Yield the rollback BEFORE emitting `tools/change`: a generator effect
// collects each yielded disposer before the next step runs, so a throwing
// `tools/change` listener removes the tool instead of leaking it (a leak
// would wedge the duplicate-name check until restart). The duplicate
// throw above fires before any mutation — it leaks nothing.
yield () => {
layer.delete(snapshot.name)
layer.delete(name)
// An emptied scope layer is dropped so a disposed scope leaves no
// residue keyed by its (dead) key.
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
@@ -701,16 +679,14 @@ export class ToolRegistry extends Service {
* through a scoped context (`agent.ctx`) — restricting "everyone" is not a
* thing (throw), and an empty filter (neither `allow` nor `deny`) is a no-op
* that can only be a bug (throw — the materialized-empty-config trap).
* Validates every listed name against the scope's CURRENT pre-restriction
* name universe ({@link knownNames}) and throws on an unknown one (fail loud
* Validates every listed name against the CURRENT global end-capability
* universe and throws on an unknown or scope-local name (fail loud
* beats a typo silently filtering nothing) — register restrictions after the
* 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. `allow` and `deny` are each read
* once, then the filter VALUES are snapshotted at registration: the values
* checked are the values enforced, and later caller mutation of the arrays
* changes nothing. Resolution still uses the live global registry, so a later
* it from an allow-list cannot remove it. The readonly arrays are compiled to
* private sets at registration. Resolution still uses the live global registry, so a later
* global name passes a deny-only filter unless named and fails an allow-list
* unless named. Multiple restrictions compose by intersection. Scoped
* registrations are merged after restrictions and therefore remain visible.
@@ -726,36 +702,31 @@ 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')
}
// 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 = {
...allow !== undefined ? { allow: [...allow] } : {},
...deny !== undefined ? { deny: [...deny] } : {},
const compiled: CompiledToolRestriction = {
...allow !== undefined ? { allow: new Set(allow) } : {},
...deny !== undefined ? { deny: new Set(deny) } : {},
}
if (this.codeTransport !== undefined
&& [...snapshot.allow ?? [], ...snapshot.deny ?? []].includes(RUN_CODE_NAME)) {
&& [...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {
throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`)
}
const known = new Set(this.knownNames(scope))
const unknown = [...snapshot.allow ?? [], ...snapshot.deny ?? []].filter(name => !known.has(name))
const known = this.view(scope).restrictableNames
const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))
if (unknown.length > 0) {
throw new Error(`tools.restrict() names unknown tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known tools for this scope: ${[...known].sort().join(', ') || '(none)'}`)
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
}
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
const list = this.restrictions.get(scope) ?? []
this.restrictions.set(scope, list)
list.push(snapshot)
list.push(compiled)
yield () => {
const index = list.indexOf(snapshot)
/* v8 ignore next 3 -- defensive: the snapshot was pushed, so indexOf is guaranteed >= 0 */
const index = list.indexOf(compiled)
/* v8 ignore next 3 -- defensive: the compiled restriction was pushed, so indexOf is guaranteed >= 0 */
if (index >= 0) list.splice(index, 1)
if (list.length === 0) this.restrictions.delete(scope)
this.ctx.emit('tools/change')
@@ -817,69 +788,68 @@ export class ToolRegistry extends Service {
/** First monotonic denial from the global then matching scoped guard layers. */
private guardReason(exec: ToolExecution): string | undefined {
// Guards are policy, not another transform seam. The pipeline execution's
// identity and arguments are already protected; freeze a detached view so
// an untyped guard cannot replace the wrapper-mutable signal either.
const view: Readonly<ToolExecution> = Object.freeze({ ...exec })
for (const { guard } of this.globalGuards) {
const reason = guard(view)
if (reason !== undefined) return this.assertGuardReason(reason)
const reason = guard(exec)
if (reason !== undefined) return reason
}
if (exec.agent !== undefined) {
for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) {
const reason = guard(view)
if (reason !== undefined) return this.assertGuardReason(reason)
const reason = guard(exec)
if (reason !== undefined) return reason
}
}
return undefined
}
/** Runtime boundary for JavaScript/casted guards: only strings can deny. */
private assertGuardReason(reason: unknown): string {
if (typeof reason !== 'string') {
throw new TypeError(`tools.guard() must return a denial string or undefined, got ${typeof reason}`)
}
return reason
}
/** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */
private admits(scope: ScopeKey | undefined, name: string): boolean {
if (scope === undefined) return true
const filters = this.restrictions.get(scope)
if (!filters) return true
return filters.every(filter =>
(filter.allow === undefined || filter.allow.includes(name))
&& (filter.deny === undefined || !filter.deny.includes(name)))
(filter.allow === undefined || filter.allow.has(name))
&& (filter.deny === undefined || !filter.deny.has(name)))
}
/**
* THE visibility function — one resolution feeding prompt assembly,
* {@link get}, and {@link execute}: the global layer masked by the scope's
* restrictions, unioned with the scope's own layer, scoped shadowing global
* on a name conflict, then the non-native mode's reserved `run_code`
* presentation transport. No scope = the unrestricted global view.
* Resolve every registry fact one scope needs in one layer traversal. The
* visible map applies global restrictions, scoped shadowing, and the reserved
* presentation transport; the other sets retain the pre-restriction facts
* needed by restriction and prompt-order validation and owner-final restore.
* @param scope - the viewing scope (the agent), or undefined for the global view.
* @returns the visible definitions (scoped shadows applied), in per-layer
* registration order, global layer first.
* @returns the complete derived view for that scope.
*/
visible(scope?: ScopeKey): ToolDefinition[] {
private view(scope?: ScopeKey): ToolView {
const layer = scope === undefined ? undefined : this.scoped.get(scope)
const result = new Map<string, ToolDefinition>()
const visible = new Map<string, ToolDefinition>()
const knownNames = new Set<string>()
const restrictableNames = new Set<string>()
const ownerFinalNames = new Set<string>()
for (const [name, definition] of this.global) {
if (this.admits(scope, name)) result.set(name, definition)
knownNames.add(name)
restrictableNames.add(name)
if (definition.ownerFinal === true) ownerFinalNames.add(name)
if (this.admits(scope, name)) visible.set(name, definition)
}
// Scoped layer second: same-name entries REPLACE (shadow) the global ones,
// and scope-local registrations are never part of the global filter above.
for (const [name, definition] of layer ?? []) result.set(name, definition)
for (const [name, definition] of layer ?? []) {
knownNames.add(name)
if (definition.ownerFinal === true) ownerFinalNames.add(name)
visible.set(name, definition)
}
// Presentation infrastructure is resolved last and outside capability
// filtering. Registration rejects this reserved name, so this set is an
// invariant assertion as well as protection against future layer changes.
if (this.codeTransport !== undefined) result.set(RUN_CODE_NAME, this.codeTransport)
return [...result.values()]
if (this.codeTransport !== undefined) {
visible.set(RUN_CODE_NAME, this.codeTransport)
if (this.codeTransport.ownerFinal === true) ownerFinalNames.add(RUN_CODE_NAME)
}
return { visible, knownNames, restrictableNames, ownerFinalNames }
}
/**
* Look up a tool as one scope sees it ({@link visible} semantics: scoped
* Look up a tool as one scope sees it (scoped
* shadows global; a restricted-away global reads as absent). Presenters pass
* the calling agent so the rendered card matches the definition that
* actually executed.
@@ -888,11 +858,7 @@ export class ToolRegistry extends Service {
* @returns the definition the scope resolves, or undefined when none is visible.
*/
get(name: string, scope?: ScopeKey): ToolDefinition | undefined {
if (name === RUN_CODE_NAME && this.codeTransport !== undefined) return this.codeTransport
const shadowed = scope === undefined ? undefined : this.scoped.get(scope)?.get(name)
if (shadowed) return shadowed
if (!this.admits(scope, name)) return undefined
return this.global.get(name)
return this.view(scope).visible.get(name)
}
/**
@@ -908,30 +874,17 @@ export class ToolRegistry extends Service {
* @returns one deep-cloned schema per visible tool.
*/
schemas(scope?: ScopeKey): ToolSchema[] {
return this.visible(scope).map(({ name, description, parameters }): ToolSchema => ({
name,
description,
parameters: structuredClone(parameters),
}))
return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true))
}
/**
* The PRE-restriction END-CAPABILITY name universe for `scope`: every global
* name plus the scope's own layer, ignoring restrictions. This is the set
* `restrict()` validates against, so a typo fails loud while a
* restricted-away tool remains a normal, non-erroneous absence. Reserved
* presentation transports are deliberately absent: `restrict()` rejects
* naming one, while {@link wireSchemas} adds it to the separate `toolOrder`
* validation universe when its presentation mode contributes it.
* @param scope - the viewing scope (the agent); omitted = global names only.
* @returns the known names, deduplicated.
*/
knownNames(scope?: ScopeKey): string[] {
const names = new Set(this.global.keys())
if (scope !== undefined) {
for (const name of this.scoped.get(scope)?.keys() ?? []) names.add(name)
/** Project one definition onto the model-facing schema fields. */
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
const { name, description, parameters } = definition
return {
name,
description,
parameters: detachParameters ? structuredClone(parameters) : parameters,
}
return [...names]
}
/**
@@ -952,129 +905,54 @@ export class ToolRegistry extends Service {
* 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 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.
* `callId` and `name` must each yield a string before that identity snapshot
* is protected and policy begins.
* @returns the final result after every waterfall. Once the required string
* `callId` and `name` correlation identity has been captured, later accessor,
* validation, listener, and tool failures resolve as `isError` results rather
* than rejections. A throwing accessor or non-string value in either identity
* field rejects because no trustworthy result correlation exists yet.
* @param exec - the typed same-process call input. The registry assigns its
* correlation token before policy begins.
* @returns the materialized final result after every waterfall; listener and
* tool failures resolve as `isError` results rather than rejections.
*/
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
// callId/name are the minimum correlation identity needed to construct a
// result at all. Capture each once, then validate the captured scalar before
// anything can treat it as a trustworthy identity. A JavaScript/casted
// caller that supplies another type rejects at this outer boundary: an error
// result carrying the same malformed value would not satisfy the correlation
// contract and might itself fail lossless-JSON materialization. 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 token = createExecutionToken()
const callId = exec.callId
const name = exec.name
if (typeof callId !== 'string') {
throw new TypeError('tool execution callId must be a string')
const agent = exec.agent
const parent = exec.parent
const signal = exec.signal
const base = {
token,
callId,
name,
...agent !== undefined ? { agent } : {},
...parent !== undefined ? { parent } : {},
...signal !== undefined ? { signal } : {},
}
if (typeof name !== 'string') {
throw new TypeError('tool execution name must be a string')
}
let agent: Agent | undefined
let parent: ToolExecutionToken | undefined
let signal: AbortSignal | undefined
let execution: ToolExecution
try {
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)
const detached = snapshotJsonValue(exec.arguments)
if (detached === undefined) {
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
}
execution = {
...base,
arguments: deepFreeze(detached),
}
} catch (error: unknown) {
// 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,
name,
arguments: undefined,
...agent !== undefined ? { agent } : {},
...isExecutionToken(parent) ? { parent } : {},
...signal !== undefined ? { signal } : {},
})
const result = toolErrorResult(execution.callId, error)
execution = { ...base, arguments: undefined }
const result = this.materializeFinalResult(toolErrorResult(callId, error))
await this.notifyResult(execution, result)
return result
}
let result: ToolExecutionResult
try {
// 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 outside
// the lossless-JSON vocabulary before the observe-only `tools/result`
// commit point sees success.
result = this.snapshotExecutionResult(execution, await this.executePipeline(execution))
result = this.materializeFinalResult(await this.executePipeline(execution))
} catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener, guard, or the
// waterfall machinery becomes an isError result, never a turn failure.
result = toolErrorResult(execution.callId, error)
result = this.materializeFinalResult(toolErrorResult(execution.callId, error))
}
await this.notifyResult(execution, result)
return result
}
/** Snapshot one call into a shared pipeline object with immutable identity and mutable cancellation. */
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')
}
const args = snapshotJsonValue(input.arguments)
if (args === undefined) {
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
}
const execution: ToolExecution = {
token: createExecutionToken(),
callId: input.callId,
name: input.name,
arguments: deepFreeze(args),
...input.agent !== undefined ? { agent: input.agent } : {},
...input.parent !== undefined ? { parent: input.parent } : {},
...input.signal !== undefined ? { signal: input.signal } : {},
}
Object.defineProperties(execution, {
token: { value: execution.token, enumerable: true, writable: false, configurable: false },
callId: { value: execution.callId, enumerable: true, writable: false, configurable: false },
name: { value: execution.name, enumerable: true, writable: false, configurable: false },
arguments: { value: execution.arguments, enumerable: true, writable: false, configurable: false },
agent: { value: input.agent, enumerable: true, writable: false, configurable: false },
parent: { value: input.parent, enumerable: true, writable: false, configurable: false },
})
if (input.signal !== undefined) {
Object.defineProperty(execution, 'signal', {
value: input.signal,
enumerable: true,
writable: true,
configurable: true,
})
}
return execution
}
/** Run the transformable pipeline; {@link execute} owns final normalization and notification. */
private async executePipeline(exec: ToolExecution): Promise<ToolExecutionResult> {
// --- Gate: tools/pre-execute. An `ask` resolves through the optional
@@ -1082,10 +960,10 @@ export class ToolRegistry extends Service {
// carrier keys dispatch by exec.agent, so an `agent.ctx` listener gates only
// its own agent's calls (agent-less calls are subject-less).
const carrier = scopeTarget(this, exec.agent)
const gate = this.snapshotPreDecision(await this.ctx.waterfall(
const gate = await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
))
)
const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
const denialReason = decision.kind === 'allow'
? this.guardReason(exec)
@@ -1109,7 +987,7 @@ export class ToolRegistry extends Service {
// before delegating and inspect the normalized result after. Dispatched with the
// same carrier as the gate, so an `agent.ctx` wrapper wraps only its own
// agent's calls. ---
const result = this.snapshotExecutionResult(exec, await this.ctx.waterfall(
const result = await this.ctx.waterfall(
carrier, 'tools/execute', exec,
async (): Promise<ToolExecutionResult> => {
try {
@@ -1130,64 +1008,25 @@ export class ToolRegistry extends Service {
return toolErrorResult(exec.callId, error)
}
},
))
)
if (result.callId !== exec.callId) {
throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`)
}
return await this.postExecute(exec, result)
}
/** Validate and detach the extensible gate's decision before any grant can dispatch. */
private snapshotPreDecision(value: unknown): PreToolDecision {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError('tools/pre-execute must return a PreToolDecision object')
}
const decision = value as { kind?: unknown; reason?: unknown }
const keys = Reflect.ownKeys(decision)
const hasExactKeys = (...expected: string[]): boolean =>
keys.length === expected.length && expected.every(key => Object.hasOwn(decision, key))
switch (decision.kind) {
case 'allow':
if (!hasExactKeys('kind')) {
throw new TypeError('tools/pre-execute allow decision must contain only kind')
}
return { kind: 'allow' }
case 'deny': {
const reason = decision.reason
if (!hasExactKeys('kind', 'reason') || typeof reason !== 'string') {
throw new TypeError('tools/pre-execute deny decision must contain only kind and a string reason')
}
return { kind: 'deny', reason }
}
case 'ask': {
const reason = decision.reason
if (!(hasExactKeys('kind') || hasExactKeys('kind', 'reason'))
|| (reason !== undefined && typeof reason !== 'string')) {
throw new TypeError('tools/pre-execute ask decision must contain only kind and an optional string reason')
}
return { kind: 'ask', ...reason !== undefined ? { reason } : {} }
}
default:
throw new TypeError('tools/pre-execute must return an allow, deny, or ask decision')
}
}
/** Notify final-result observers without giving them a mutation/error channel into the outcome. */
private async notifyResult(exec: ToolExecution, result: ToolExecutionResult): Promise<void> {
// 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)
// 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,
scopeTarget(this, exec.agent), 'tools/result', exec, result,
])
await Promise.all(callbacks.map(async (callback) => {
try {
await callback(exec, snapshot)
await callback(exec, result)
} catch (error: unknown) {
this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`)
}
@@ -1241,112 +1080,40 @@ export class ToolRegistry extends Service {
* Runs inside `execute`'s outer try/catch (a throwing listener → isError).
*/
private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
// Snapshot the protected outcome BEFORE the waterfall. A listener receives
// the same `result` reference, so a post-waterfall read of `result.callId`/
// `.isError`/`.error` could carry a listener's mutation — violating the
// 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`. The one-pass snapshot
// protects nested content, error, and meta from in-place listener mutation.
const dispatched = this.snapshotExecutionResult(exec, result)
const decision = snapshotJsonValue(await this.ctx.waterfall(
const decision = 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') {
return {
callId: dispatched.callId,
callId: result.callId,
content: decision.feedback,
isError: true,
...additionalContext ? { additionalContext } : {},
}
}
// accept: replace content if supplied, preserve the dispatched isError/error.
// Accept: replace content if supplied and preserve the dispatched outcome.
return {
...dispatched,
...result,
...decision.content ? { content: decision.content } : {},
...additionalContext ? { additionalContext } : {},
}
}
/** Validate and detach an around-dispatch result before policy can observe or mutate it. */
private snapshotExecutionResult(exec: ToolExecution, value: unknown): ToolExecutionResult {
if (typeof value !== 'object' || value === null) {
throw new TypeError('tools/execute must return a ToolExecutionResult object')
}
const result = value as Partial<ToolExecutionResult>
// 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 (callId !== exec.callId) {
throw new TypeError(`tools/execute returned callId "${String(callId)}" for authoritative call "${exec.callId}"`)
}
const candidate = {
callId: exec.callId,
content,
isError,
...error !== undefined ? { error } : {},
...additionalContext !== undefined ? { additionalContext } : {},
...meta !== undefined ? { meta } : {},
}
// 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')
}
return snapshot
}
/** Reject malformed JavaScript/casted post decisions at the public event boundary. */
private assertPostDecision(value: unknown): asserts value is PostToolDecision {
if (typeof value !== 'object' || value === null) {
throw new TypeError('tools/post-execute must return a PostToolDecision object')
}
const decision = value as Partial<PostToolDecision>
switch (decision.kind) {
case 'accept':
if (decision.content !== undefined && !Array.isArray(decision.content)) {
throw new TypeError('tools/post-execute accept content must be an array')
}
return
case 'block':
if (!Array.isArray(decision.feedback)) {
throw new TypeError('tools/post-execute block feedback must be an array')
}
return
default:
throw new TypeError('tools/post-execute must return an accept or block decision')
/** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult {
const detached = snapshotJsonValue(result)
if (detached === undefined) {
throw new TypeError('tool result must be losslessly JSON-serializable')
}
return deepFreeze(detached)
}
}
/** Mint a frozen, property-free correlation token whose identity is its value. */
/** Mint a same-process correlation token whose identity is its value. */
function createExecutionToken(): ToolExecutionToken {
const token = Object.freeze(Object.create(null)) as ToolExecutionToken
executionTokens.add(token)
return token
}
/** Runtime counterpart of the opaque token type, including `undefined` input. */
function isExecutionToken(value: unknown): value is ToolExecutionToken {
return typeof value === 'object' && value !== null && executionTokens.has(value)
return Symbol('dsh.tool.execution') as ToolExecutionToken
}
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {

View File

@@ -20,7 +20,6 @@
*/
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'
@@ -288,21 +287,23 @@ export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
/** Options for {@link defineTool}. */
export interface DefineToolOptions<S extends SchemaSpec> {
/** Tool name (must be unique). */
name: string
readonly name: string
/** Human-readable description sent to the model. */
description: string
readonly description: string
/**
* Parameter schema using the per-property-required DSL. Converted to
* standard JSON Schema at runtime.
*/
parameters: S
readonly parameters: S
/**
* Optional cooperative tool-call timeout budget in milliseconds. When given it
* must be a positive finite number; it is attached to the produced
* {@link ToolDefinition} for `@deepseek-ai/dsh-timeout-policy` to enforce and
* is never sent to the model.
*/
timeoutMs?: number
readonly timeoutMs?: number
/** Make this protocol tool's canonical wire presence or absence owner-final. */
readonly ownerFinal?: boolean
/**
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
@@ -355,11 +356,6 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* 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
@@ -369,13 +365,6 @@ 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
@@ -383,31 +372,21 @@ 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 (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`)
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
}
const tool: ToolDefinition = {
name,
description,
parameters: wireParameters as unknown as Record<string, unknown>,
...(timeoutMs !== undefined ? { timeoutMs } : {}),
name: options.name,
description: options.description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
...(options.ownerFinal === true ? { ownerFinal: true } : {}),
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(parameterSpec, args)
const violations = validateArgs(options.parameters, args)
if (violations.length > 0) throw new ToolArgsError(violations)
return userExecute(args as InferArgs<S>, exec)
},
@@ -418,13 +397,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(parameterSpec, args).length > 0) return undefined
if (validateArgs(options.parameters, args).length > 0) return undefined
return userPresentCall(args as InferArgs<S>)
}
}
if (userPresentResult) {
tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => {
if (validateArgs(parameterSpec, args).length > 0) return undefined
if (validateArgs(options.parameters, args).length > 0) return undefined
return userPresentResult(args as InferArgs<S>, result)
}
}

View File

@@ -215,41 +215,24 @@ describe('mode-aware wire contribution', () => {
expect(() => scope.ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
expect(() => scope.ctx.systemPrompt.section({ name: 'tools:sdk', order: -999, text: 'malicious SDK' }))
.toThrow(/globally protected and cannot be shadowed/)
.toThrow(/globally owner-final and cannot be shadowed/)
expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
const transport = ctx.tools.get(RUN_CODE_NAME)!
expect(Object.isFrozen(transport)).toBe(true)
expect(Object.isFrozen(transport.parameters)).toBe(true)
expect(() => { transport.name = 'mutated_transport' }).toThrow(TypeError)
const mutableSection = { name: 'scoped-note', order: 149, text: 'safe note' }
scope.ctx.systemPrompt.section(mutableSection)
mutableSection.name = 'tools:sdk'
mutableSection.text = 'mutated SDK'
const mutableTool = defineTool({
scope.ctx.systemPrompt.section({ name: 'scoped-note', order: 149, text: 'safe note' })
scope.ctx.tools.register(defineTool({
name: 'scoped_safe',
description: 'Safe scoped tool.',
parameters: {},
execute: () => Promise.resolve([{ type: 'text' as const, text: 'safe' }]),
})
scope.ctx.tools.register(mutableTool)
mutableTool.name = RUN_CODE_NAME
mutableTool.description = 'Mutated transport impostor.'
const stored = ctx.tools.get('scoped_safe', agent)!
expect(Object.isFrozen(stored)).toBe(true)
expect(Object.isFrozen(stored.parameters)).toBe(true)
expect(() => { stored.name = RUN_CODE_NAME }).toThrow(TypeError)
}))
const assembly = await systemPrompt.assemble({ scope: agent })
const transports = assembly.tools.filter(tool => tool.name === RUN_CODE_NAME)
expect(transports).toHaveLength(1)
expect(transports[0]?.description).toContain('Execute a TypeScript program')
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).not.toContain('mutated SDK')
expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note')
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe(args:')
expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME))
expect(ctx.tools.knownNames(agent)).not.toContain(RUN_CODE_NAME)
const result = await runCode(ctx, 'return 1', { agent })
expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }])
})
@@ -262,7 +245,6 @@ describe('mode-aware wire contribution', () => {
registerEcho(ctx)
const { agent } = await mintAgentScope(ctx)
expect(ctx.tools.knownNames(agent)).toEqual(['echo'])
const assembly = await systemPrompt.assemble({ scope: agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code'
? [RUN_CODE_NAME]

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, ToolExecutionInput, ToolExecutionToken, ToolRestriction } from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken } 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'
@@ -89,6 +89,20 @@ describe('scoped tool registration', () => {
expect(() => scope.ctx.tools.register(tool('y'))).toThrow(/already registered in this scope/)
})
it('rejects either registration order between a global owner-final tool and a scoped shadow', async () => {
const first = await mount()
const { scope: firstScope } = await mintAgentScope(first, 'first')
first.tools.register({ ...tool('reserved'), ownerFinal: true })
expect(() => firstScope.ctx.tools.register(tool('reserved')))
.toThrow(/globally owner-final and cannot be shadowed/)
const second = await mount()
const { scope: secondScope } = await mintAgentScope(second, 'second')
secondScope.ctx.tools.register(tool('reserved'))
expect(() => second.tools.register({ ...tool('reserved'), ownerFinal: true }))
.toThrow(/owner-final tool "reserved" cannot be registered while a scoped shadow exists/)
})
it('disposing the scope unwinds its registrations and leaves no residue', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
@@ -96,7 +110,7 @@ describe('scoped tool registration', () => {
expect(ctx.tools.get('mine', key)).toBeDefined()
await scope.dispose()
expect(ctx.tools.get('mine', key)).toBeUndefined()
expect(ctx.tools.knownNames(key)).toEqual([])
expect(ctx.tools.schemas(key)).toEqual([])
})
})
@@ -153,7 +167,7 @@ describe('restrict()', () => {
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['a', 'c'])
})
it('snapshots the filter at registration (caller mutation changes nothing)', async () => {
it('compiles the readonly filter values at registration', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('a'))
@@ -164,38 +178,21 @@ 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 () => {
it('fails loud on an unscoped call, an empty filter, and non-global names', async () => {
const ctx = await mount()
const { scope } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('real'))
scope.ctx.tools.register(tool('local'))
expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/)
expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown tool "reall"; known tools for this scope: real/)
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown tools "ghost", "wraith"/)
expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/)
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall"; known global tools: real/)
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/)
const emptyCtx = await mount()
const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
.toThrow(/known tools for this scope: \(none\)/)
.toThrow(/known global tools: \(none\)/)
})
})
@@ -230,9 +227,8 @@ describe('scoped execution dispatch', () => {
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
},
})
let guardViewFrozen = false
const guard = (execution: Readonly<ToolExecution>): string => {
guardViewFrozen = Object.isFrozen(execution) && Object.isFrozen(execution.arguments)
expect(Object.isFrozen(execution.arguments)).toBe(true)
return 'terminal policy'
}
const liftFirst = scope.ctx.tools.guard(guard)
@@ -243,7 +239,6 @@ describe('scoped execution dispatch', () => {
scope.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'allow' }), { prepend: true })
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
expect(guardViewFrozen).toBe(true)
expect(await run(ctx, 't', other)).toBe('ran:t')
expect(bodyCalls).toBe(1)
@@ -271,13 +266,14 @@ describe('scoped execution dispatch', () => {
expect(bodyCalls).toBe(0)
})
it('protects call identity before policy and dispatch while leaving only signal mutable', async () => {
it('shares one token and materialized argument value across the pipeline', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
let safeCalls = 0
let dangerCalls = 0
let scopedResults = 0
let safeArguments: unknown
const tokens = new Set<ToolExecutionToken>()
ctx.tools.register({
...tool('safe'),
execute: (args) => {
@@ -295,17 +291,16 @@ describe('scoped execution dispatch', () => {
})
scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined)
ctx.on('tools/pre-execute', (exec, next) => {
expect(Reflect.set(exec, 'agent', undefined)).toBe(false)
expect(Reflect.set(exec, 'name', 'safe')).toBe(false)
expect(Reflect.set(exec.arguments as object, 'injected', true)).toBe(false)
tokens.add(exec.token)
expect(Object.isFrozen(exec.arguments)).toBe(true)
return next()
})
ctx.on('tools/execute', (exec, next) => {
expect(Reflect.set(exec, 'name', 'danger')).toBe(false)
tokens.add(exec.token)
return next()
})
ctx.on('tools/post-execute', (exec, _result, next) => {
expect(Reflect.set(exec, 'agent', undefined)).toBe(false)
tokens.add(exec.token)
return next()
})
scope.ctx.on('tools/result', () => { scopedResults += 1 })
@@ -323,6 +318,8 @@ describe('scoped execution dispatch', () => {
expect(safeArguments).not.toBe(callerArguments)
expect(Object.isFrozen(safeArguments)).toBe(true)
expect(callerArguments).toEqual({ source: true })
// One token for danger and one shared by every phase of safe.
expect(tokens.size).toBe(2)
expect({ safeCalls, dangerCalls, scopedResults }).toEqual({
safeCalls: 1,
dangerCalls: 0,
@@ -395,25 +392,6 @@ describe('scoped execution dispatch', () => {
expect(callerArguments.invalid).toBeTypeOf('function')
})
it('rejects a forged mutable parent token without exposing it to final observers', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
const forged = { mutable: true } as unknown as ToolExecutionToken
let observedParent: ToolExecutionToken | undefined = forged
ctx.on('tools/result', (exec) => { observedParent = exec.parent })
const result = await ctx.tools.execute({
callId: CallId('forged-parent'), name: 't', arguments: {}, parent: forged,
})
expect(result.isError).toBe(true)
expect(result.content).toEqual([{
type: 'text', text: 'Error: tool execution parent must be a registry-minted opaque token',
}])
expect(observedParent).toBeUndefined()
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)[] = []

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 DefineToolOptions, type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolDefinition, type ToolExecution, type ToolExecutionInput, type ToolExecutionResult, type ToolGuard,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -83,95 +83,6 @@ describe('ToolRegistry', () => {
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
})
it.each([
{ field: 'callId', value: 1n },
{ field: 'callId', value: 123 },
{ field: 'name', value: 1n },
{ field: 'name', value: 123 },
] as const)('rejects a non-string $field before final observation', async ({ field, value }) => {
const ctx = await setup()
let observed = 0
ctx.on('tools/result', () => { observed += 1 })
const input: Record<string, unknown> = {
callId: CallId('valid-call'),
name: 'missing',
arguments: {},
}
input[field] = value
await expect(ctx.tools.execute(input as unknown as ToolExecutionInput))
.rejects.toThrow(`tool execution ${field} must be a string`)
expect(observed).toBe(0)
})
it('reads correlation accessors once and normalizes a later hostile accessor', async () => {
const ctx = await setup()
const reads = { callId: 0, name: 0, arguments: 0 }
let observed: { callId: unknown; name: unknown; isError: boolean } | undefined
ctx.on('tools/result', (exec, result) => {
observed = { callId: exec.callId, name: exec.name, isError: result.isError }
})
const input = Object.defineProperties({}, {
callId: {
enumerable: true,
get: () => {
reads.callId += 1
if (reads.callId > 1) throw new Error('callId reread')
return CallId('one-read-call')
},
},
name: {
enumerable: true,
get: () => {
reads.name += 1
if (reads.name > 1) throw new Error('name reread')
return 'missing'
},
},
arguments: {
enumerable: true,
get: () => {
reads.arguments += 1
throw new Error('arguments accessor broke')
},
},
}) as unknown as ToolExecutionInput
const result = await ctx.tools.execute(input)
expect(reads).toEqual({ callId: 1, name: 1, arguments: 1 })
expect(result).toMatchObject({ callId: CallId('one-read-call'), isError: true })
expect(result.content[0]).toMatchObject({ text: 'Error: arguments accessor broke' })
expect(observed).toEqual({ callId: CallId('one-read-call'), name: 'missing', isError: true })
})
it('reads callId once before a hostile name accessor rejects correlation', async () => {
const ctx = await setup()
const reads = { callId: 0, name: 0 }
let observed = 0
ctx.on('tools/result', () => { observed += 1 })
const input = Object.defineProperties({ arguments: {} }, {
callId: {
enumerable: true,
get: () => {
reads.callId += 1
return CallId('hostile-name')
},
},
name: {
enumerable: true,
get: () => {
reads.name += 1
throw new Error('name accessor broke')
},
},
}) as unknown as ToolExecutionInput
await expect(ctx.tools.execute(input)).rejects.toThrow('name accessor broke')
expect(reads).toEqual({ callId: 1, name: 1 })
expect(observed).toBe(0)
})
it('threads a tool-attached meta (object return form) onto the result', async () => {
const ctx = await setup()
ctx.tools.register({
@@ -224,77 +135,6 @@ describe('ToolRegistry', () => {
expect(observedError).toBe(true)
})
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
const hostileBlock = Object.defineProperty({ type: 'text' }, 'text', {
enumerable: true,
get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]),
})
ctx.on('tools/execute', async exec => ({
callId: exec.callId,
content: [hostileBlock],
isError: false,
}) as unknown as ToolExecutionResult)
const result = await ctx.tools.execute({
callId: CallId('unstable-result'), name: 'echo', arguments: {},
})
expect(reads).toBe(1)
expect(result).toEqual({
callId: CallId('unstable-result'),
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({
@@ -362,85 +202,6 @@ describe('ToolRegistry', () => {
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
})
it.each([
{
name: 'non-object decision',
replacement: null,
message: 'tools/pre-execute must return a PreToolDecision object',
},
{
name: 'unknown decision kind',
replacement: { kind: 'permit' },
message: 'tools/pre-execute must return an allow, deny, or ask decision',
},
{
name: 'allow decision carrying extra fields',
replacement: { kind: 'allow', reason: 'smuggled' },
message: 'tools/pre-execute allow decision must contain only kind',
},
{
name: 'deny decision without a reason',
replacement: { kind: 'deny' },
message: 'tools/pre-execute deny decision must contain only kind and a string reason',
},
{
name: 'deny decision with a non-string reason',
replacement: { kind: 'deny', reason: 42 },
message: 'tools/pre-execute deny decision must contain only kind and a string reason',
},
{
name: 'ask decision with a non-string reason',
replacement: { kind: 'ask', reason: true },
message: 'tools/pre-execute ask decision must contain only kind and an optional string reason',
},
{
name: 'ask decision carrying extra fields',
replacement: { kind: 'ask', cache: true },
message: 'tools/pre-execute ask decision must contain only kind and an optional string reason',
},
])('fails closed on a malformed tools/pre-execute $name', async ({ replacement, message }) => {
const ctx = await setup()
let bodyCalls = 0
const observed: ToolExecutionResult[] = []
ctx.tools.register({
...echoTool,
async execute() {
bodyCalls += 1
return []
},
})
ctx.on('tools/pre-execute', async () => replacement as unknown as PreToolDecision)
ctx.on('tools/result', (_exec, result) => { observed.push(result) })
const result = await ctx.tools.execute({
callId: CallId('malformed-pre'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
expect(bodyCalls).toBe(0)
expect(observed).toEqual([result])
})
it('rejects a JavaScript guard that returns an async/non-string decision', async () => {
const ctx = await setup()
let bodyCalls = 0
ctx.tools.register({
...echoTool,
async execute() {
bodyCalls += 1
return []
},
})
ctx.tools.guard((() => Promise.resolve('late denial')) as unknown as ToolGuard)
const result = await ctx.tools.execute({ callId: CallId('bad-guard'), name: 'echo', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]?.type === 'text' && result.content[0].text)
.toContain('tools.guard() must return')
expect(bodyCalls).toBe(0)
})
it('an ask decision degrades to deny when no approval seam is mounted', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -617,55 +378,6 @@ describe('ToolRegistry', () => {
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
})
it('a post-execute listener cannot mutate any nested part of the dispatched result', async () => {
// The decision is the ONLY sanctioned channel to change the outcome. A
// listener that reaches in and mutates the passed result reference (flipping
// isError, rewriting callId, attaching a bogus error) must NOT affect what
// execute() returns — the registry snapshots the authoritative fields before
// the waterfall and rebuilds from the snapshot + decision.
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async (_exec, next) => {
await next()
return {
callId: CallId('c1'),
content: [{ type: 'text', text: 'original' }],
isError: true,
error: { name: 'OriginalError', code: 'ORIGINAL' },
meta: { nested: { label: 'original' } },
}
})
ctx.on('tools/post-execute', async (_exec, result, next) => {
const mutable = result as {
callId: string
isError: boolean
error?: { name: string; code: string }
content: { type: 'text'; text: string }[]
meta?: { nested: { label: string } }
}
mutable.callId = 'hijacked'
mutable.isError = false
if (mutable.error) {
mutable.error.name = 'Evil'
mutable.error.code = 'EVIL'
}
mutable.content[0]!.text = 'MUTATED'
mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation
if (mutable.meta) mutable.meta.nested.label = 'MUTATED'
return next() // delegate to the default accept — no decision-level override
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'OriginalError', code: 'ORIGINAL' })
expect(result.content).toHaveLength(1) // the in-place push did not leak in
expect(result.content[0]).toMatchObject({ text: 'original' })
expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false)
expect(result.meta).toEqual({ nested: { label: 'original' } })
})
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -844,115 +556,20 @@ describe('ToolRegistry', () => {
})
})
it('normalizes malformed tools/execute results instead of treating them as success', async () => {
it('normalizes a tools/execute result with the wrong call id', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let observedError: boolean | undefined
ctx.on('tools/execute', async (_exec, next) => {
await next()
return {} as ToolExecutionResult
})
ctx.on('tools/result', (_exec, result) => { observedError = result.isError })
const result = await ctx.tools.execute({ callId: CallId('malformed'), name: 'echo', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({
text: 'Error: tools/execute must return a ToolExecutionResult with content[] and boolean isError',
})
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',
replacement: null,
message: 'tools/execute must return a ToolExecutionResult object',
},
{
name: 'wrong call id',
replacement: { callId: CallId('other'), content: [], isError: false },
message: 'tools/execute returned callId "other" for authoritative call "malformed-shape"',
},
])('normalizes a tools/execute $name', async ({ replacement, message }) => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => replacement as ToolExecutionResult)
ctx.on('tools/execute', async () => ({ callId: CallId('other'), content: [], isError: false }))
const result = await ctx.tools.execute({
callId: CallId('malformed-shape'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
})
it('normalizes malformed tools/post-execute decisions', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async () => ({ kind: 'accept', content: 'not blocks' }) as unknown as PostToolDecision)
const result = await ctx.tools.execute({ callId: CallId('malformed-post'), name: 'echo', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({
text: 'Error: tools/post-execute accept content must be an array',
text: 'Error: tools/execute returned callId "other" for authoritative call "malformed-shape"',
})
})
it.each([
{
name: 'non-object decision',
replacement: null,
message: 'tools/post-execute must return a PostToolDecision object',
},
{
name: 'block without feedback blocks',
replacement: { kind: 'block', feedback: 'not blocks' },
message: 'tools/post-execute block feedback must be an array',
},
{
name: 'unknown decision kind',
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)
ctx.on('tools/post-execute', async () => replacement as unknown as PostToolDecision)
const result = await ctx.tools.execute({
callId: CallId('malformed-post-shape'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
})
it('returns an isError result when a tools/execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -1030,109 +647,12 @@ describe('ToolRegistry', () => {
}])
})
it.each([
['Map', new Map([['mutable', true]])],
['class instance', new (class Parameters { value = 1 })()],
])('rejects cloneable non-JSON tool parameters (%s) without registry residue', async (_kind, parameters) => {
it('rejects a non-positive or non-finite registration timeout', async () => {
const ctx = await setup()
const definition = {
...echoTool,
name: 'invalid-parameters',
parameters,
} as unknown as typeof echoTool
expect(() => ctx.tools.register(definition)).toThrow(
'tool parameters must be losslessly JSON-serializable',
)
expect(ctx.tools.get('invalid-parameters')).toBeUndefined()
})
it('reads nested tool parameters once into the accepted snapshot', async () => {
const ctx = await setup()
let reads = 0
const parameters = Object.defineProperty({}, 'properties', {
enumerable: true,
get: () => ++reads === 1 ? {} : new Map([['mutable', true]]),
})
expect(() => ctx.tools.register({
...echoTool,
name: 'unstable-parameters',
parameters,
})).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 () => {
const ctx = await setup()
const receivers: object[] = []
const definition = {
...echoTool,
name: 'callback-snapshot',
async execute() {
receivers.push(this)
return [{ type: 'text' as const, text: 'original' }]
},
}
ctx.tools.register(definition)
definition.execute = async () => [{ type: 'text' as const, text: 'replacement' }]
const result = await ctx.tools.execute({
callId: CallId('callback-snapshot'), name: definition.name, arguments: {},
})
expect(receivers).toEqual([definition])
expect(result.content).toEqual([{ type: 'text', text: 'original' }])
expect(() => ctx.tools.register({ ...echoTool, name: 'infinite-timeout', timeoutMs: Number.POSITIVE_INFINITY }))
.toThrow('timeoutMs must be a positive finite number')
})
it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
@@ -1305,101 +825,6 @@ 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).