Merge remote-tracking branch 'origin/worktree-agent-scope-design' into codex/package-readme-limitations-audit-20260712

# Conflicts:
#	packages/core/scope/README.md
#	packages/session-persistence/session-persistence-jsonl/README.md
#	packages/session-persistence/session-persistence-sqlite/README.md
#	packages/subagent/subagent-acp/README.md
#	packages/subagent/subagent-fork/README.md
#	packages/subagent/subagent-inprocess/README.md
#	packages/subagent/subagent/README.md
#	packages/subagent/tool-subagent/README.md
#	packages/support/invariants/README.md
#	packages/support/subagent-mock/README.md
#	packages/workflow/workflow-workerthread/README.md
#	packages/workflow/workflow/README.md
This commit is contained in:
Tianyi Cui
2026-07-12 23:35:47 +08:00
164 changed files with 8906 additions and 7154 deletions

View File

@@ -11,18 +11,16 @@ tools:
mode: native # native (default) | code | both
```
`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry's canonical contribution is the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); an assembly listener may still deliberately add unrelated schemas. `both` contributes the visible native definitions, `run_code`, and the SDK section. In non-native modes both infrastructure pieces are protected rather than filterable capabilities: restrictions and assembly listeners cannot remove them, a scoped section cannot shadow the globally protected `tools:sdk`, and registering, shadowing, or explicitly filtering `run_code` fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry's canonical contribution is the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); an assembly listener may still deliberately add unrelated schemas. `both` contributes the visible native definitions, `run_code`, and the SDK section. In non-native modes both infrastructure pieces are owner-final rather than filterable capabilities: restrictions and assembly listeners cannot remove them, a scoped section cannot shadow the globally owner-final `tools:sdk`, and registering, shadowing, or explicitly filtering `run_code` fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
### Public API
- `ctx.tools.register(definition: ToolDefinition): () => Promise<void> | void` Register a tool as a frozen snapshot. Parameters must survive lossless-JSON validation before and after cloning; scalar fields are copied, and execute/presentation callbacks are bound once to the original definition as their method receiver, so later callback-property replacement cannot change dispatch. The layer is the CALLING context's scope (`dsh-scope`): a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, SHADOWING a same-named global tool there (per-agent tool variants). Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. Disposed with the calling fiber (= the agent, for scoped registrations).
- `ctx.tools.restrict(filter: ToolRestriction): () => Promise<void> | void` Scoped-only (throws on a plain context): mask the GLOBAL end-capability surface for the calling agent — `allow` keeps only the listed tools, `deny` removes them; multiple restrictions intersect; scoped registrations bypass restriction as explicit grants. The reserved `run_code` transport remains available automatically and cannot be named explicitly. Snapshot-at-registration, loud unknown-name validation, `restrict({})` rejects (the materialized-empty-config trap).
- `ctx.tools.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>` Snapshot one single-use call input into a pipeline-owned execution, assign its opaque correlation token, require `arguments` to be losslessly JSON-serializable before and after cloning, deep-freeze the detached arguments, and protect its identity before running `tools/pre-execute` → guards → `tools/execute``tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. Validate the final result as losslessly JSON-serializable and freeze the complete execution before `tools/result` observers run. Invalid or unstable input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log.
- `ctx.tools.execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>` 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? }`; `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. 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.
## Known Limitations and Deferred Work

View File

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

View File

@@ -23,7 +23,7 @@ import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import { isJsonValue } from '@deepseek-ai/dsh-session'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
@@ -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
@@ -115,8 +112,8 @@ declare module 'cordis' {
* set or replace the one mutable field, `exec.signal` (e.g. with a per-call
* deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity
* (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the
* pipeline so a wrapper cannot change which capability or scope was
* authorized. (Cordis `next()` ignores passed arguments and re-invokes
* pipeline so a wrapper cannot change which tool and scope the pipeline
* accepted. (Cordis `next()` ignores passed arguments and re-invokes
* downstream with the shared payload, so a wrapper changes `exec.signal` in
* place rather than passing a new object to `next()`.)
* Multiple listeners compose by registration order — an outer one wraps the
@@ -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 lossless-JSON-validated, deep-frozen
* clone of the parsed arguments are immutable from the first policy listener onward, while an
* around-dispatch wrapper may set, replace, or remove only `signal`. The
* registry freezes the complete object before `tools/result` observers run.
* 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. */
@@ -429,17 +427,38 @@ export interface Config {
* {@link ToolRegistry.restrict}. `allow` keeps only the listed global tools;
* `deny` removes the listed ones; both present = allow first, then deny.
* Restrictions never touch scoped registrations — a tool registered through
* the same scope is an explicit grant that bypasses them (which is what keeps
* e.g. a structured-output capture tool alive under an allow-list). The
* 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 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
* filtering, and naming it explicitly is rejected. Multiple restrictions on
* one scope compose by intersection: every one must admit.
*/
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>
}
/**
@@ -472,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
@@ -487,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>>()
@@ -508,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
@@ -526,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] })
}
}
@@ -550,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 }
}
/**
@@ -590,12 +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 validates and
* clones the JSON parameters, copies scalar fields, binds each callback once
* to the caller's definition as its method receiver, and freezes the stored
* snapshot; later mutation or callback replacement on the input object does
* not rewrite the registry. Disposed with the calling fiber. Emits
* `tools/change` on register/unregister.
* flows into prompt assembly automatically. 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
@@ -604,52 +625,39 @@ export class ToolRegistry extends Service {
*/
register(definition: ToolDefinition): () => Promise<void> | void {
const scope = scopeOf(this.ctx)
// A schema crosses the same model/log boundary as execution arguments.
// Validate BEFORE cloning because structuredClone silently turns some
// forbidden values (for example class instances) into plain records, then
// validate the detached value again to contain hostile getters that change
// between inspection and snapshotting. A frozen Map is still mutable, so
// deepFreeze alone is not a sufficient registration boundary.
if (!isJsonValue(definition.parameters)) {
throw new TypeError('tool parameters must be losslessly JSON-serializable')
const name = definition.name
const timeoutMs = definition.timeoutMs
if (timeoutMs !== undefined
&& (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`)
}
const parameters = structuredClone(definition.parameters)
if (!isJsonValue(parameters)) {
throw new TypeError('tool parameters must be stable losslessly JSON-serializable data')
}
// Bind once so replacing a callback on the caller-owned definition after
// registration cannot change dispatch, while preserving the historical
// method receiver (`this === definition`) for callbacks that use it.
const execute = definition.execute.bind(definition)
const presentCall = definition.presentCall?.bind(definition)
const presentResult = definition.presentResult?.bind(definition)
const snapshot: ToolDefinition = deepFreeze({
name: definition.name,
description: definition.description,
parameters,
execute,
...definition.timeoutMs !== undefined ? { timeoutMs: definition.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)
@@ -671,17 +679,19 @@ 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. The filter is SNAPSHOT at
* registration: later caller mutation of the arrays changes nothing.
* Multiple restrictions compose by intersection. Scoped registrations
* bypass restrictions (explicit grants win). Disposed with the calling
* fiber (revocable independently); emits `tools/change`.
* 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.
* Disposed with the calling fiber (revocable independently); emits
* `tools/change`.
* @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).
* @returns the disposer that lifts this restriction. The exact
* Cordis effect disposer (single-shot): composite (generator) effects may
@@ -692,30 +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')
}
if (filter.allow === undefined && filter.deny === undefined) {
const allow = filter.allow
const deny = filter.deny
if (allow === undefined && deny === undefined) {
throw new Error('tools.restrict({}) is a no-op: pass `allow` and/or `deny` (an empty filter is almost always a materialized-empty-config bug)')
}
// Snapshot BEFORE validation so what was checked is what is enforced.
const snapshot: ToolRestriction = {
...filter.allow !== undefined ? { allow: [...filter.allow] } : {},
...filter.deny !== undefined ? { deny: [...filter.deny] } : {},
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')
@@ -777,69 +788,69 @@ 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 grants bypass restrictions by construction (never filtered above).
for (const [name, definition] of layer ?? []) result.set(name, definition)
// and scope-local registrations are never part of the global filter above.
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)
// createRunCodeTool() owns this internal transport and always marks it owner-final.
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.
@@ -848,11 +859,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)
}
/**
@@ -868,30 +875,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]
}
/**
@@ -909,97 +903,57 @@ export class ToolRegistry extends Service {
* restricted-away global is exactly as absent as a nonexistent one), the
* result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown
* {@link HarnessError} surfaces its `{ name, code }` on the result. Before
* the final observe-only notification, the authoritative outcome must survive
* a lossless JSON round trip; an invalid outcome is normalized to an error.
* A malformed runtime/casted `tools/pre-execute` decision likewise normalizes
* to an error before approval, guards, or the tool body.
* Caller-owned arguments must survive lossless-JSON validation before and
* after cloning; a violation normalizes to an error before policy or dispatch.
* @param exec - the single-use call input; its identity is snapshotted and
* protected before policy runs.
* @returns the final result after every waterfall; failures resolve as
* `isError` results, never rejections.
* the final observe-only notification, the authoritative outcome is
* materialized as a detached lossless-JSON snapshot; an invalid outcome is
* normalized to an error.
* @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> {
const token = createExecutionToken()
const callId = exec.callId
const name = exec.name
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 } : {},
}
let execution: ToolExecution
try {
execution = this.prepareExecution(exec)
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 non-JSON or non-cloneable arguments cannot enter a
// pipeline whose logged and executed forms must agree. Still publish one
// scoped final outcome, using an immutable identity shell, so result
// observers retain their every-call guarantee without seeing the invalid
// value.
execution = Object.freeze({
token: createExecutionToken(),
callId: exec.callId,
name: exec.name,
arguments: undefined,
...exec.agent !== undefined ? { agent: exec.agent } : {},
...isExecutionToken(exec.parent) ? { parent: exec.parent } : {},
...exec.signal !== undefined ? { signal: exec.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 {
// Validate the authoritative FINAL result, not merely the tool body's
// intermediate return. Post-policy may replace content or attach context,
// and every one of these fields is session-bound. Reject anything that
// cannot round-trip losslessly through the durable JSON log before the
// observe-only `tools/result` commit point sees success.
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: ToolExecutionInput): ToolExecution {
if (input.parent !== undefined && !isExecutionToken(input.parent)) {
throw new TypeError('tool execution parent must be a registry-minted opaque token')
}
if (!isJsonValue(input.arguments)) {
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
}
const args = structuredClone(input.arguments)
if (!isJsonValue(args)) {
throw new TypeError('tool execution arguments must be stable losslessly JSON-serializable data')
}
const execution: ToolExecution = {
token: createExecutionToken(),
callId: input.callId,
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
@@ -1007,10 +961,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)
@@ -1034,7 +988,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 {
@@ -1055,61 +1009,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)
// postExecute clones every accepted result/decision before rebuilding the
// outcome; all error paths construct plain data. The final result is thus
// structurally cloneable before it reaches this observe-only boundary.
const snapshot = deepFreeze(structuredClone(result))
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)}`)
}
@@ -1163,104 +1081,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`. Deep cloning protects
// nested content, error, and meta data from in-place listener mutation.
const dispatched = this.snapshotExecutionResult(exec, result)
const decision = structuredClone(await this.ctx.waterfall(
const decision = await this.ctx.waterfall(
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
))
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>
if (!Array.isArray(result.content) || typeof result.isError !== 'boolean') {
throw new TypeError('tools/execute must return a ToolExecutionResult with content[] and boolean isError')
}
if (result.callId !== exec.callId) {
throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`)
}
const candidate = {
callId: exec.callId,
content: result.content,
isError: result.isError,
...result.error !== undefined ? { error: result.error } : {},
...result.additionalContext !== undefined ? { additionalContext: result.additionalContext } : {},
...result.meta !== undefined ? { meta: result.meta } : {},
}
// Validate BEFORE cloning: structuredClone turns some forbidden exotic or
// class instances into plain objects, which would hide a lossy JSON
// boundary violation. Validate the detached clone again to contain hostile
// getters whose value changes between inspection and snapshotting.
if (!isJsonValue(candidate)) {
throw new TypeError('tools/execute must return a losslessly JSON-serializable ToolExecutionResult')
}
const snapshot = structuredClone(candidate)
if (!isJsonValue(snapshot)) {
throw new TypeError('tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult')
}
return snapshot
}
/** Reject malformed JavaScript/casted post decisions at the public event boundary. */
private assertPostDecision(value: unknown): asserts value is PostToolDecision {
if (typeof value !== 'object' || value === null) {
throw new TypeError('tools/post-execute must return a PostToolDecision object')
}
const decision = value as Partial<PostToolDecision>
switch (decision.kind) {
case 'accept':
if (decision.content !== undefined && !Array.isArray(decision.content)) {
throw new TypeError('tools/post-execute accept content must be an array')
}
return
case 'block':
if (!Array.isArray(decision.feedback)) {
throw new TypeError('tools/post-execute block feedback must be an array')
}
return
default:
throw new TypeError('tools/post-execute must return an accept or block decision')
/** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult {
const detached = snapshotJsonValue(result)
if (detached === undefined) {
throw new TypeError('tool result must be losslessly JSON-serializable')
}
return deepFreeze(detached)
}
}
/** Mint a frozen, property-free correlation token whose identity is its value. */
/** Mint a same-process correlation token whose identity is its value. */
function createExecutionToken(): ToolExecutionToken {
const token = Object.freeze(Object.create(null)) as ToolExecutionToken
executionTokens.add(token)
return token
}
/** Runtime counterpart of the opaque token type, including `undefined` input. */
function isExecutionToken(value: unknown): value is ToolExecutionToken {
return typeof value === 'object' && value !== null && executionTokens.has(value)
return Symbol('dsh.tool.execution') as ToolExecutionToken
}
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {

View File

@@ -287,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
@@ -353,6 +355,7 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* Raw JSON-Schema tool definitions (from MCP servers) are still accepted
* by `ToolRegistry.register()` directly — `defineTool` is sugar for
* first-party plugin authors.
*
* @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
@@ -377,6 +380,7 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
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

View File

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

View File

@@ -4,7 +4,7 @@ import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionToken } 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,35 @@ 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('restores global and scoped owner-final tools removed by assembly middleware', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'owner-final')
ctx.tools.register({ ...tool('required'), ownerFinal: true })
scope.ctx.tools.register({ ...tool('scoped-required'), ownerFinal: true })
ctx.on('system-prompt/assemble', async assembly => ({
...assembly,
tools: assembly.tools.filter(schema => !schema.name.includes('required')),
}))
expect((await ctx.systemPrompt.assemble()).tools.map(schema => schema.name)).toContain('required')
expect((await ctx.systemPrompt.assemble({ scope: key })).tools.map(schema => schema.name))
.toEqual(expect.arrayContaining(['required', 'scoped-required']))
})
it('disposing the scope unwinds its registrations and leaves no residue', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
@@ -96,12 +125,12 @@ 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([])
})
})
describe('restrict()', () => {
it('masks global tools for the scope; grants bypass; assembly and execute agree', async () => {
it('masks global tools, merges scope-local tools afterward, and keeps assembly with execution', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
ctx.tools.register(tool('read'))
@@ -109,7 +138,7 @@ describe('restrict()', () => {
scope.ctx.tools.register(tool('capture'))
scope.ctx.tools.restrict({ allow: ['read'] })
// The scoped grant survives the allow-list; the unlisted global is gone.
// The scope-local registration survives the allow-list; the unlisted global is gone.
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['capture', 'read'])
expect(await run(ctx, 'bash', key)).toBe('Error: unknown tool "bash"')
expect(await run(ctx, 'read', key)).toBe('ran:read')
@@ -118,6 +147,29 @@ describe('restrict()', () => {
expect(ctx.tools.schemas().map(t => t.name).sort()).toEqual(['bash', 'read'])
})
it('applies snapshotted filters to the live global registry before merging later scope-local tools', async () => {
const ctx = await mount()
const denied = await mintAgentScope(ctx, 'denied')
const allowed = await mintAgentScope(ctx, 'allowed')
ctx.tools.register(tool('read'))
ctx.tools.register(tool('bash'))
denied.scope.ctx.tools.restrict({ deny: ['bash'] })
allowed.scope.ctx.tools.restrict({ allow: ['read'] })
ctx.tools.register(tool('web'))
denied.scope.ctx.tools.register(tool('denied-local'))
allowed.scope.ctx.tools.register(tool('allowed-local'))
expect(ctx.tools.schemas(denied.key).map(t => t.name).sort())
.toEqual(['denied-local', 'read', 'web'])
expect(ctx.tools.schemas(allowed.key).map(t => t.name).sort())
.toEqual(['allowed-local', 'read'])
expect(await run(ctx, 'web', denied.key)).toBe('ran:web')
expect(await run(ctx, 'web', allowed.key)).toBe('Error: unknown tool "web"')
expect(await run(ctx, 'denied-local', denied.key)).toBe('ran:denied-local')
expect(await run(ctx, 'allowed-local', allowed.key)).toBe('ran:allowed-local')
})
it('composes multiple restrictions by intersection and lifts each independently', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'a')
@@ -130,7 +182,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'))
@@ -141,19 +193,21 @@ describe('restrict()', () => {
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b'])
})
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\)/)
})
})
@@ -188,9 +242,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)
@@ -201,7 +254,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)
@@ -229,13 +281,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) => {
@@ -253,17 +306,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 })
@@ -281,6 +333,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,
@@ -353,23 +407,114 @@ describe('scoped execution dispatch', () => {
expect(callerArguments.invalid).toBeTypeOf('function')
})
it('rejects a forged mutable parent token without exposing it to final observers', async () => {
it('reads a stateful parent accessor once before policy, dispatch, and result observation', 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,
const observed: (ToolExecutionToken | undefined)[] = []
ctx.tools.register({
...tool('t'),
execute: (_args, exec) => {
observed.push(exec.parent)
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
},
})
ctx.on('tools/pre-execute', (exec, next) => {
observed.push(exec.parent)
return next()
})
ctx.on('tools/execute', (exec, next) => {
observed.push(exec.parent)
return next()
})
ctx.on('tools/result', (exec) => { observed.push(exec.parent) })
const forged = { fake: true } as unknown as ToolExecutionToken
let parentReads = 0
const input = {
callId: CallId('stateful-parent'),
name: 't',
arguments: {},
get parent(): ToolExecutionToken | undefined {
parentReads += 1
return parentReads === 1 ? undefined : forged
},
} as ToolExecutionInput
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(false)
expect(parentReads).toBe(1)
expect(observed).toEqual([undefined, undefined, undefined, undefined])
})
it('uses one input snapshot for the normalized error shell', async () => {
const ctx = await mount()
const { scope, key } = await mintAgentScope(ctx, 'accepted')
const driftAgent = { id: 'drift' as AgentId } as Agent
ctx.tools.register(tool('parent'))
ctx.tools.register(tool('t'))
let parent!: ToolExecutionToken
const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
if (exec.name === 'parent') parent = exec.token
return next()
})
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
stopCapture()
const acceptedSignal = new AbortController().signal
const driftSignal = new AbortController().signal
const forged = { fake: true } as unknown as ToolExecutionToken
const reads = { callId: 0, name: 0, arguments: 0, agent: 0, parent: 0, signal: 0 }
const input = {
get callId() { reads.callId += 1; return CallId('unstable-error') },
get name() { reads.name += 1; return 't' },
get arguments(): unknown { reads.arguments += 1; return { invalid: () => undefined } },
get agent() { reads.agent += 1; return reads.agent === 1 ? key : driftAgent },
get parent() { reads.parent += 1; return reads.parent <= 2 ? parent : forged },
get signal() { reads.signal += 1; return reads.signal === 1 ? acceptedSignal : driftSignal },
} as ToolExecutionInput
let observed: Readonly<ToolExecution> | undefined
let scopedObserved = 0
ctx.on('tools/result', (exec) => { observed = exec })
scope.ctx.on('tools/result', () => { scopedObserved += 1 })
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(true)
expect(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)
expect(reads).toEqual({ callId: 1, name: 1, arguments: 1, agent: 1, parent: 1, signal: 1 })
expect(scopedObserved).toBe(1)
expect(observed).toMatchObject({
callId: CallId('unstable-error'),
name: 't',
agent: key,
parent,
signal: acceptedSignal,
})
expect(Object.isFrozen(observed)).toBe(true)
})
it('normalizes a throwing arguments accessor without rereading it or losing the final notification', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
let argumentReads = 0
let observed = 0
ctx.on('tools/result', (exec, result) => {
observed += 1
expect(exec.arguments).toBeUndefined()
expect(result.isError).toBe(true)
})
const input = {
callId: CallId('throwing-arguments'),
name: 't',
get arguments(): unknown {
argumentReads += 1
throw new Error('getter exploded')
},
} as ToolExecutionInput
const result = await ctx.tools.execute(input)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{ type: 'text', text: 'Error: getter exploded' }])
expect(argumentReads).toBe(1)
expect(observed).toBe(1)
})
it.each([
@@ -408,7 +553,7 @@ describe('scoped execution dispatch', () => {
expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 })
})
it('rejects arguments that change to non-JSON data while being snapshotted', async () => {
it('reads nested arguments once into the executed snapshot', async () => {
const ctx = await mount()
ctx.tools.register(tool('t'))
let reads = 0
@@ -421,12 +566,11 @@ describe('scoped execution dispatch', () => {
callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
})
expect(reads).toBe(1)
expect(result).toEqual({
callId: CallId('unstable-arguments'),
content: [{
type: 'text', text: 'Error: tool execution arguments must be stable losslessly JSON-serializable data',
}],
isError: true,
content: [{ type: 'text', text: 'ran:t' }],
isError: false,
})
})

View File

@@ -7,7 +7,7 @@ import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@de
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult, type ToolGuard,
type ToolExecution, type ToolExecutionResult,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -135,33 +135,6 @@ describe('ToolRegistry', () => {
expect(observedError).toBe(true)
})
it('normalizes a result that changes to non-JSON data while being snapshotted', 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(result).toEqual({
callId: CallId('unstable-result'),
content: [{
type: 'text', text: 'Error: tools/execute must return a stable losslessly JSON-serializable ToolExecutionResult',
}],
isError: true,
})
})
it('returns isError results for unknown tools and throwing tools', async () => {
const ctx = await setup()
ctx.tools.register({
@@ -229,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)
@@ -484,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)
@@ -711,87 +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.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',
},
])('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)
@@ -869,59 +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('rejects tool parameters that change to non-JSON data while being snapshotted', 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,
})).toThrow('tool parameters must be stable losslessly JSON-serializable data')
expect(ctx.tools.get('unstable-parameters')).toBeUndefined()
})
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: 'zero-timeout', timeoutMs: 0 }))
.toThrow('timeoutMs must be a positive finite number')
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 () => {