refactor(core): simplify tools prompts and trusted services
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)[] = []
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user