Merge PR #224 updates into prose cleanup
This commit is contained in:
@@ -11,18 +11,16 @@ tools:
|
||||
mode: native # native (default) | code | both
|
||||
```
|
||||
|
||||
`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry's canonical contribution is the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); an assembly listener may still deliberately add unrelated schemas. `both` contributes the visible native definitions, `run_code`, and the SDK section. In non-native modes both infrastructure pieces are protected rather than filterable capabilities: restrictions and assembly listeners cannot remove them, a scoped section cannot shadow the globally protected `tools:sdk`, and registering, shadowing, or explicitly filtering `run_code` fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
|
||||
`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry's canonical contribution is the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); an assembly listener may still deliberately add unrelated schemas. `both` contributes the visible native definitions, `run_code`, and the SDK section. In non-native modes both infrastructure pieces are owner-final rather than filterable capabilities: restrictions and assembly listeners cannot remove them, a scoped section cannot shadow the globally owner-final `tools:sdk`, and registering, shadowing, or explicitly filtering `run_code` fails loudly. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => Promise<void> | void` Register a tool as a frozen snapshot. 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.
|
||||
|
||||
### What is NOT here (TODO)
|
||||
|
||||
|
||||
@@ -131,6 +131,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 '
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,21 @@
|
||||
/**
|
||||
* Typed tool-parameter schema DSL.
|
||||
*
|
||||
* Plugin authors write per-property specs with `required: true` as a boolean
|
||||
* (the `SchemaSpec` type). A type-level helper (`InferArgs`) maps a SchemaSpec
|
||||
* to the TS argument type. At runtime, `schemaSpecToJsonSchema()` converts a
|
||||
* SchemaSpec to standard JSON Schema (`type: 'object'`, `properties`,
|
||||
* `required` array) for the wire format sent to the model.
|
||||
*
|
||||
* # Why a custom DSL and not schemastery?
|
||||
*
|
||||
* Schemastery is a validation/transformation library (StandardSchema v1) used
|
||||
* for plugin Config. Tool parameters need JSON Schema specifically (the LLM
|
||||
* wire format), not validation. A lightweight DSL focused on JSON Schema
|
||||
* generation, with type inference for the tool's `execute` args, gives plugin
|
||||
* authors the best DX with the smallest surface area. Schemastery would add
|
||||
* unnecessary indirection and wouldn't cleanly produce JSON Schema.
|
||||
*
|
||||
* @module dsh-tools/schema
|
||||
*/
|
||||
|
||||
@@ -247,10 +263,15 @@ function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate model-generated `args` against a {@link SchemaSpec}, returning a list of
|
||||
* human-readable violation messages (empty = valid). Total — never throws, regardless of how
|
||||
* malformed `args` is.
|
||||
* Validate model-generated `args` against a {@link SchemaSpec}, returning a
|
||||
* list of human-readable violation messages (empty = valid). Total — never
|
||||
* throws, regardless of how malformed `args` is.
|
||||
*
|
||||
* Semantics mirror {@link schemaSpecToJsonSchema} exactly: the top level must
|
||||
* be a non-array object; required keys come only from `required: true`; extra
|
||||
* keys are allowed (no `additionalProperties: false`); `default` is not
|
||||
* applied; an `object`/`array` prop without `properties`/`items` only
|
||||
* type-checks; `enum` is membership (strings only).
|
||||
* @param spec - the declared parameter schema to validate against.
|
||||
* @param args - the model-generated arguments, however malformed.
|
||||
* @returns the violation messages in declaration order; empty means valid.
|
||||
@@ -266,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
|
||||
@@ -309,6 +332,30 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
/**
|
||||
* Define a tool with a typed parameter schema.
|
||||
*
|
||||
* Use this instead of constructing a raw {@link ToolDefinition} for all
|
||||
* first-party tools. The `parameters` use the boolean-required style
|
||||
* (`required: true` as a per-property flag), and `execute` receives typed
|
||||
* args derived from the schema.
|
||||
*
|
||||
* ```ts
|
||||
* const tool = defineTool({
|
||||
* name: 'read_file',
|
||||
* description: 'Read a file from disk.',
|
||||
* parameters: {
|
||||
* path: { type: 'string', required: true, description: 'Absolute file path' },
|
||||
* offset: { type: 'number' },
|
||||
* limit: { type: 'number', description: 'Max lines to read' },
|
||||
* },
|
||||
* async execute(args) {
|
||||
* // args: { path: string; offset?: number; limit?: number }
|
||||
* },
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* 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
|
||||
@@ -333,8 +380,12 @@ 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.
|
||||
// Validate the model-generated args before the typed body runs. On
|
||||
// mismatch we throw ToolArgsError; the registry turns it into an
|
||||
// isError result so the model can self-correct. After this guard, the
|
||||
// cast to InferArgs<S> reflects the validated shape.
|
||||
const violations = validateArgs(options.parameters, args)
|
||||
if (violations.length > 0) throw new ToolArgsError(violations)
|
||||
return userExecute(args as InferArgs<S>, exec)
|
||||
|
||||
@@ -215,41 +215,24 @@ describe('mode-aware wire contribution', () => {
|
||||
expect(() => scope.ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
|
||||
expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
|
||||
expect(() => scope.ctx.systemPrompt.section({ name: 'tools:sdk', order: -999, text: 'malicious SDK' }))
|
||||
.toThrow(/globally protected and cannot be shadowed/)
|
||||
.toThrow(/globally owner-final and cannot be shadowed/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
|
||||
const transport = ctx.tools.get(RUN_CODE_NAME)!
|
||||
expect(Object.isFrozen(transport)).toBe(true)
|
||||
expect(Object.isFrozen(transport.parameters)).toBe(true)
|
||||
expect(() => { transport.name = 'mutated_transport' }).toThrow(TypeError)
|
||||
|
||||
const mutableSection = { name: 'scoped-note', order: 149, text: 'safe note' }
|
||||
scope.ctx.systemPrompt.section(mutableSection)
|
||||
mutableSection.name = 'tools:sdk'
|
||||
mutableSection.text = 'mutated SDK'
|
||||
const mutableTool = defineTool({
|
||||
scope.ctx.systemPrompt.section({ name: 'scoped-note', order: 149, text: 'safe note' })
|
||||
scope.ctx.tools.register(defineTool({
|
||||
name: 'scoped_safe',
|
||||
description: 'Safe scoped tool.',
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve([{ type: 'text' as const, text: 'safe' }]),
|
||||
})
|
||||
scope.ctx.tools.register(mutableTool)
|
||||
mutableTool.name = RUN_CODE_NAME
|
||||
mutableTool.description = 'Mutated transport impostor.'
|
||||
const stored = ctx.tools.get('scoped_safe', agent)!
|
||||
expect(Object.isFrozen(stored)).toBe(true)
|
||||
expect(Object.isFrozen(stored.parameters)).toBe(true)
|
||||
expect(() => { stored.name = RUN_CODE_NAME }).toThrow(TypeError)
|
||||
}))
|
||||
|
||||
const assembly = await systemPrompt.assemble({ scope: agent })
|
||||
const transports = assembly.tools.filter(tool => tool.name === RUN_CODE_NAME)
|
||||
expect(transports).toHaveLength(1)
|
||||
expect(transports[0]?.description).toContain('Execute a TypeScript program')
|
||||
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).not.toContain('mutated SDK')
|
||||
expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note')
|
||||
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe(args:')
|
||||
expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME))
|
||||
expect(ctx.tools.knownNames(agent)).not.toContain(RUN_CODE_NAME)
|
||||
const result = await runCode(ctx, 'return 1', { agent })
|
||||
expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }])
|
||||
})
|
||||
@@ -262,7 +245,6 @@ describe('mode-aware wire contribution', () => {
|
||||
registerEcho(ctx)
|
||||
const { agent } = await mintAgentScope(ctx)
|
||||
|
||||
expect(ctx.tools.knownNames(agent)).toEqual(['echo'])
|
||||
const assembly = await systemPrompt.assemble({ scope: agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code'
|
||||
? [RUN_CODE_NAME]
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { PreToolDecision, ToolDefinition, ToolExecution, 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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,51 +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.
|
||||
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)
|
||||
@@ -707,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)
|
||||
@@ -865,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 () => {
|
||||
@@ -968,9 +703,16 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
|
||||
it('register() returns the EXACT effect disposer: a composite yield nests the teardown in order', async () => {
|
||||
// The registry-disposer convention (set by agents.register): the returned function IS the
|
||||
// cordis effect disposer, so a composite (generator) effect that yields it has the
|
||||
// unregistration run at that yield's LIFO position on owner unload.
|
||||
// The registry-disposer convention (set by agents.register): the returned
|
||||
// function IS the cordis effect disposer, so a composite (generator)
|
||||
// effect that yields it has the unregistration run at that yield's LIFO
|
||||
// position on owner unload. A wrapper would leave the inner effect
|
||||
// disposing as a CONCURRENT SIBLING of the composite; the async probe
|
||||
// below (disposed first, LIFO) yields the event loop exactly like the
|
||||
// agent factory's stop-and-drain link, and a sibling unregistration fires
|
||||
// in that window — the probe would observe the tool already gone. Pins
|
||||
// the convention for the whole register-method family (system-prompt
|
||||
// registrars, registerProvider, setFactory share the same return).
|
||||
const ctx = await setup()
|
||||
const order: string[] = []
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
@@ -1670,9 +1412,10 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
|
||||
presentCall: args => ({ card: 'generic', title: args.path }),
|
||||
presentResult: (args, result) => ({ card: 'generic', title: args.path, content: result.content }),
|
||||
})
|
||||
// Unlike execute (which throws ToolArgsError on a mismatch), the display methods
|
||||
// soft-validate and fall back to undefined so a UI never crashes replaying an old/foreign
|
||||
// log entry.
|
||||
// Unlike execute (which throws ToolArgsError on a mismatch), the display
|
||||
// methods soft-validate and fall back to undefined so a UI never crashes
|
||||
// replaying an old/foreign log entry. The ToolDefinition methods take
|
||||
// `unknown`, so malformed shapes pass without a cast.
|
||||
expect(tool.presentCall?.({})).toBeUndefined()
|
||||
expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user