docs: tighten prose audit after master retarget
This commit is contained in:
@@ -11,16 +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 contributes the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); `both` contributes the visible native definitions and both infrastructure pieces. Restrictions cannot remove `run_code`, and registering, shadowing, or explicitly filtering that reserved name fails loudly. An expert `system-prompt/assemble` listener may replace any prompt or schema contribution; its returned assembly is authoritative, so the listener owns preserving Code Mode when the protocol should remain active. 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 visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a TypeScript `ctx.codeRuntime`, and incompatible tool-order configuration rejects prompt assembly.
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => 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. Disposed with the calling fiber.
|
||||
- `ctx.tools.restrict(filter: ToolRestriction): () => 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.restrict(filter)` applies an agent-scoped allow/deny mask to global tools; multiple masks intersect and scope-local tools merge afterwards. Unknown, local, or reserved names and empty filters reject. This is visibility composition, not an authority boundary; see the [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): () => 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>` 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.
|
||||
- `ctx.tools.execute(exec)` snapshots arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, and snapshots the authoritative outcome before final observation.
|
||||
|
||||
### Injected services
|
||||
|
||||
@@ -45,7 +45,10 @@ The live registry pipeline has three transformable waterfalls followed by the ob
|
||||
### Extension points
|
||||
|
||||
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
|
||||
- `tools/pre-execute` is the reorderable allow/deny/ask gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny` skips dispatch, while an `ask` resolves through the approval seam and dispatches only after a grant. Either non-grant path yields an `isError` result. `ctx.tools.guard()` installs scope-aware monotonic policy after that waterfall when a denial must not be overridable by listener ordering. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` is dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown or unknown tool. A wrapper may change only `exec.signal` before `next()`—adding a per-call deadline, replacing a caller signal, or restoring absence afterwards—because call identity is protected before policy begins. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own error boundary so a thrown tool still reaches `post-execute` as an `isError`. Finally, `tools/result` observes the immutable authoritative result after every transform and error boundary. All follow the typed-decision idiom shared with the `agent/*` seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
|
||||
- `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it.
|
||||
- `tools/execute` wraps normalized core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal.
|
||||
- `tools/post-execute` may replace content, block with feedback, or attach context; `tools/result` observes the immutable final outcome.
|
||||
- Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md).
|
||||
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
|
||||
|
||||
### Typed tool parameter schemas
|
||||
@@ -77,66 +80,23 @@ 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 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.
|
||||
A `defineTool` definition validates model arguments before execution and turns violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. Extra keys are allowed and defaults are not applied. Raw-registered tools own their validation.
|
||||
|
||||
See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details.
|
||||
|
||||
`defineTool` also validates an optional `timeoutMs` at definition time when present: it must be a positive finite number, or the helper throws — the budget is attached to the produced `ToolDefinition` (for `@deepseek-ai/dsh-timeout-policy`) and never reaches the model.
|
||||
Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema.
|
||||
|
||||
### Structured-output schema subset
|
||||
|
||||
A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's `SubagentStartRequest.outputSchema` (and, by extension, a workflow's `agent({ schema })`). Unlike `SchemaSpec` (the author-facing DSL for tool parameters), a `StructuredOutputSchema` is an object-rooted **raw JSON Schema subset** as data: it travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated against it.
|
||||
|
||||
The subset is deliberately narrow and REJECTS LOUD outside it — accepting a keyword the validator doesn't enforce would validate less than the schema promises (accepted-then-ignored). Supported: single-string `type` (`object`/`array`/`string`/`number`/`integer`/`boolean`/`null`; type arrays rejected), `properties`/`required`/`additionalProperties` (boolean; every `required` key must be declared), `items`, scalar-only `enum`/`const`; annotations (`description`/`title`/`default`/`examples`) are ignored but must still be JSON data. `assertSupportedOutputSchema(schema)` throws `OutputSchemaError` (`code: 'UNSUPPORTED_SCHEMA'`, listing every violation) for anything else; `validateStructuredValue(schema, value)` returns path-qualified violations (empty = valid, total — never throws).
|
||||
`StructuredOutputSchema` is the object-rooted raw JSON Schema subset used by subagents and workflows for machine-readable results. It supports scalar types, objects, arrays, scalar `enum`/`const`, and annotations. Unsupported or inconsistent keywords fail through `OutputSchemaError`; `validateStructuredValue()` returns path-qualified violations.
|
||||
|
||||
### Tool-owned UI presentation
|
||||
|
||||
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods that return a **`card`-tagged render intent** (a discriminated union — a tool declares its card kind once and a UI bridge switches on `card`):
|
||||
|
||||
- `presentCall(args): ToolCallView | undefined` — the PENDING state, one of:
|
||||
- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a background task id, NOT the whole args object), optional `content` (extra UI content blocks), and optional `locations` (`{ path, line? }[]` — files this call reads/modifies, so a capable UI can follow along; the ACP bridge forwards them as `tool_call.locations`).
|
||||
- `{ card: 'terminal', title, description?, cwd? }` — a shell command: a capable UI renders a terminal card (the `title` is the command, `description` renders above it, `cwd` heads it); an incapable UI falls back to a generic execute card.
|
||||
- `{ card: 'diff', title, diffs, locations? }` — a file create/modify: a capable UI renders an inline diff card from `diffs` (`{ path, oldText, newText }[]`; `oldText: null` for a new file). Used by `write`/`edit`.
|
||||
- `presentResult(args, result): ToolResultView | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError, meta? }` result, one of:
|
||||
- `{ card: 'generic', title?, content? }` — an optional replacement `title` and reformatted `content`.
|
||||
- `{ card: 'terminal', title?, output?, exitCode?, signal? }` — a terminal run's captured `output` and exit status. A capable UI shows an exit-status pill; an incapable UI gets a fenced ` ```console ` fallback the BRIDGE derives from `output` (the tool does not encode the fences).
|
||||
- `{ card: 'diff', title?, diffs }` — a completed file mutation as an inline diff. `diffs` is `FileDiff[]` — typically the applied hunks with surrounding context computed from the before/after content, or a whole-file diff (`oldText: null`) when there is no before-image (a file create). Used by `write`/`edit`; a `tool_call_update.content` replaces the call's content, so a mutation tool returns this even when it duplicates the call-time snippet (else the result text would clobber the pending diff).
|
||||
|
||||
Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. `result.meta` is the tool's own optional presentation payload (opaque `unknown`, JSON-serializable), attached by `execute` (see below) and persisted on the `tool/result` event, so a `presentResult` reading it stays replay-deterministic (the same `meta` is read back from the log). With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The views are provider-neutral — the ACP bridge (`dsh-acp`) maps each `card` to ACP `tool_call`/`tool_call_update` wire fields (a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (`docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md`) and the applied-hunk-diffs RFC (`docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md`); `dsh-tool-bash` (terminal) and `dsh-tool-fs` (diff/generic) are the reference implementations.
|
||||
|
||||
```ts
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const bash = defineTool({
|
||||
name: 'bash',
|
||||
description: 'Run a shell command.',
|
||||
parameters: {
|
||||
command: { type: 'string', required: true, description: 'The command to run.' },
|
||||
description: { type: 'string', required: true, description: 'One-line summary shown in the UI.' },
|
||||
},
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: `ran: ${args.command}` }]
|
||||
},
|
||||
// A terminal card: the command is the title, the description renders above it.
|
||||
presentCall: args => ({ card: 'terminal', title: args.command, description: args.description }),
|
||||
// A terminal result: the raw output + exit; the bridge derives the fenced fallback.
|
||||
presentResult: (_args, result) => {
|
||||
const block = result.content.length === 1 ? result.content[0] : undefined
|
||||
if (block === undefined || block.type !== 'text') return undefined
|
||||
return { card: 'terminal', output: block.text }
|
||||
},
|
||||
})
|
||||
```
|
||||
Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names. The `card` discriminator is `generic`, `terminal`, or `diff`; returning `undefined` selects generic fallback. Result-time presentation may read JSON-serializable `result.meta`, which is persisted for replay. The [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the shapes and rationale.
|
||||
|
||||
### Code Mode
|
||||
|
||||
Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the reserved wire transport `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per visible end-capability tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. Scope restrictions change those SDK bindings but cannot remove or replace the transport itself.
|
||||
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
|
||||
- **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. When no assembly listener changes the registry's prompt or schema contributions, `code` assembles exactly `[run_code]`, pinned by tests and the snapshot goldens. 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.
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope. Each program binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
|
||||
### What is NOT here (TODO)
|
||||
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
/**
|
||||
* Tool registry and execution pipeline. Plugins register tools; the registry
|
||||
* feeds schemas into the system prompt, and `execute()` dispatches each call
|
||||
* through `tools/pre-execute` (the extensible allow/deny gate) → monotonic
|
||||
* registered guards → `tools/execute` (an around-dispatch wrapper for
|
||||
* timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the
|
||||
* result, attach context) → the observe-only `tools/result` notification.
|
||||
*
|
||||
* The registry also owns HOW its tools are presented to the model — its
|
||||
* `mode` config: `'native'` (every tool as a wire function definition,
|
||||
* today's behavior and the default), `'code'` (the registry's canonical wire
|
||||
* contribution is one tool, `run_code`, plus a generated TypeScript SDK prompt section), or
|
||||
* `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and
|
||||
* `ts-types.ts` (the SDK codegen); design in the Code Mode RFC.
|
||||
*
|
||||
* Tool registry, model presentation modes, and pre/guard/around/post/result
|
||||
* execution pipeline.
|
||||
* @module @deepseek-ai/dsh-tools
|
||||
*/
|
||||
|
||||
@@ -83,79 +71,34 @@ declare module 'cordis' {
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Waterfall BEFORE a tool runs — the gate where sandbox, permission, and
|
||||
* hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners
|
||||
* receive `(exec, next)`: call `next()` to delegate to the default (allow),
|
||||
* or return a {@link PreToolDecision} without calling `next()` to
|
||||
* short-circuit. A `deny` skips dispatch and yields an `isError` result; the
|
||||
* 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.
|
||||
* 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
|
||||
* agent-less ones, which dispatch subject-less).
|
||||
* Allow, deny, or ask before dispatch. `next()` delegates to allow; missing
|
||||
* approval support turns `ask` into denial.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
||||
* @param exec - the pending call (name, parsed arguments, caller agent).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
|
||||
/**
|
||||
* Around-dispatch waterfall wrapping the registry's core tool dispatch,
|
||||
* between the `tools/pre-execute` gate and the `tools/post-execute` seam. A
|
||||
* listener receives `(exec, next)`: call `next()` to delegate to dispatch
|
||||
* (returning its {@link ToolExecutionResult}, optionally wrapped), or return a
|
||||
* replacement result without calling `next()` to short-circuit dispatch. The
|
||||
* base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or
|
||||
* unknown tool) is already normalized to an `isError` result by the time a
|
||||
* listener's `await next()` returns, so a wrapper never sees a raw throw from
|
||||
* the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can
|
||||
* 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 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
|
||||
* inner ones plus dispatch.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
|
||||
* `exec.agent` — a listener registered through `agent.ctx` wraps only that
|
||||
* agent's calls; a plain plugin listener wraps every call (including
|
||||
* agent-less ones, which dispatch subject-less).
|
||||
* Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns
|
||||
* a normalized result; wrappers may change only `exec.signal`, while call
|
||||
* identity remains immutable.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
||||
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
/**
|
||||
* Waterfall AFTER a tool runs — where hook plugins inspect the result and
|
||||
* accept it (optionally REPLACING the model-facing content, and/or attaching
|
||||
* `additionalContext` for the next request) or block it with corrective
|
||||
* `feedback` (Claude Code's `PostToolUse`). Listeners receive
|
||||
* `(exec, result, next)`: call `next()` to delegate to the default (accept
|
||||
* unchanged), or return a {@link PostToolDecision} to override. Core tool
|
||||
* dispatch runs earlier as the base `next()` of the `tools/execute`
|
||||
* waterfall, all inside `execute`'s outer try/catch (and the tool body keeps
|
||||
* its own inner try/catch, so a thrown tool still reaches `post-execute` as an
|
||||
* `isError` result).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
|
||||
* `exec.agent` — a listener registered through `agent.ctx` fires only for
|
||||
* that agent's calls; a plain plugin listener fires for every call
|
||||
* (including agent-less ones, which dispatch subject-less).
|
||||
* Accept, replace, enrich, or block a normalized dispatch result. `next()`
|
||||
* accepts it unchanged; thrown tools still reach this seam as errors.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
||||
* @param exec - the call that just ran (name, parsed arguments, caller agent).
|
||||
* @param result - the dispatch outcome a listener may accept, replace, or block.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
/**
|
||||
* Synchronous notification of the authoritative FINAL tool outcome, after the
|
||||
* complete pre/execute/post pipeline, final lossless-JSON validation, and
|
||||
* outer error normalization.
|
||||
* Unlike the three waterfalls, this seam cannot transform the result: each
|
||||
* listener receives the now-frozen execution object and a deep-frozen result
|
||||
* snapshot; listener failures are contained and logged, and
|
||||
* {@link ToolRegistry.execute} still returns the outcome.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by
|
||||
* `exec.agent`, using the same carrier as the pipeline.
|
||||
* Observe the frozen, lossless-JSON final outcome. Listener failures are contained.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.
|
||||
* @param exec - the execution object that traversed the pipeline.
|
||||
* @param result - a deep-frozen snapshot of the final returned result.
|
||||
* @mode emit
|
||||
@@ -177,15 +120,7 @@ declare module 'cordis' {
|
||||
// TODO(review): revisit these shapes when concurrency metadata becomes useful
|
||||
// (for example, a read-only hint that would permit safe parallel execution).
|
||||
|
||||
/**
|
||||
* What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the
|
||||
* common case (model-facing content only); the object form additionally attaches
|
||||
* a tool-private `meta` presentation payload that the registry threads onto the
|
||||
* `tool/result` session event and hands back to the tool's `presentResult`.
|
||||
* `meta` is opaque to the core (`unknown` — the tool owns and narrows its shape),
|
||||
* and MUST be JSON-serializable: it persists on the durable log (the session
|
||||
* enforces this at `append`), so replay reproduces the card.
|
||||
*/
|
||||
/** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */
|
||||
export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
|
||||
|
||||
/** A registered tool: its schema plus the execution function. */
|
||||
@@ -236,12 +171,7 @@ export interface ToolResult {
|
||||
|
||||
declare const toolExecutionTokenBrand: unique symbol
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Opaque call identity that permits correlation without exposing mutable execution state. */
|
||||
export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
|
||||
|
||||
/**
|
||||
@@ -308,14 +238,8 @@ export interface ToolExecutionResult {
|
||||
*/
|
||||
error?: ToolErrorInfo
|
||||
/**
|
||||
* Extra model-facing context a `tools/post-execute` listener attached for the
|
||||
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
|
||||
* of this call's `content` — `content`/`feedback` shape the tool RESULT, but
|
||||
* `additionalContext` is a SEPARATE `context/message`. A step can carry
|
||||
* multiple tool calls, so the loop BUFFERS every call's `additionalContext`
|
||||
* and appends them only AFTER all `tool/result`s for the step, keeping
|
||||
* tool-call/result adjacency intact. Carried on the result purely to ferry it
|
||||
* from `execute()` up to the loop's per-step buffer.
|
||||
* Model-facing context for the next request, separate from this tool result.
|
||||
* The loop buffers it until all step results are logged, preserving pairing.
|
||||
*/
|
||||
additionalContext?: HookContext
|
||||
/**
|
||||
@@ -327,38 +251,13 @@ export interface ToolExecutionResult {
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The decision a `tools/pre-execute` listener returns for one pending call.
|
||||
* Maps onto Claude Code's `PreToolUse` `permissionDecision`.
|
||||
*
|
||||
* - `allow` proceeds to dispatch. (Input rewrite — changing `exec.arguments` —
|
||||
* is deliberately NOT offered: `tool/call` and `assistant/message` are logged
|
||||
* BEFORE execution and live consumers, e.g. the ACP bridge and `dsh-tool-bash`
|
||||
* presentation, read the pre-execution arguments, so an execution-only rewrite
|
||||
* would desync the UI from what RAN. That consistency redesign is its own
|
||||
* `proposed` RFC; `TODO(pre-tool-input-rewrite)` anchors it at the call site.)
|
||||
* - `deny` skips dispatch; the loop records an `isError` result carrying `reason`.
|
||||
* - `ask` is the permission-prompt intent: serviced as a one-shot decision by
|
||||
* the `ctx.approval` seam when one is mounted (`allowed-once` proceeds to
|
||||
* dispatch; every other outcome denies), degrading to `deny` when none is.
|
||||
*/
|
||||
/** Pre-dispatch decision. Input rewriting is excluded because arguments are already logged and presented. */
|
||||
export type PreToolDecision =
|
||||
| { kind: 'allow' }
|
||||
| { kind: 'deny'; reason: string }
|
||||
| { kind: 'ask'; reason?: string }
|
||||
|
||||
/**
|
||||
* The decision a `tools/post-execute` listener returns for one finished call.
|
||||
* Maps onto Claude Code's `PostToolUse` decision.
|
||||
*
|
||||
* - `accept` keeps the call successful; optional `content` REPLACES the
|
||||
* model-facing result (clean: `tool/result` is logged AFTER `execute()`
|
||||
* returns, so a replaced result is the single source of truth for both derived
|
||||
* history and UI). Optional `additionalContext` rides to the next request.
|
||||
* - `block` turns the call into an `isError` result whose content is the
|
||||
* corrective `feedback` (the model is told the call was rejected and why),
|
||||
* optionally also attaching `additionalContext`.
|
||||
*/
|
||||
/** Post-dispatch decision: accept or replace content, attach context, or block with corrective feedback. */
|
||||
export type PostToolDecision =
|
||||
| { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext }
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
|
||||
@@ -399,36 +298,13 @@ export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||
|
||||
/** Plugin config: how the registered tools are presented to the model. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The presentation mode. `'native'` (the default) contributes every
|
||||
* visible end capability as a native wire function definition. Under
|
||||
* `'code'` this registry contributes exactly ONE wire tool,
|
||||
* `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a
|
||||
* TypeScript API the program calls. `'both'` contributes every native
|
||||
* definition AND `run_code` + the SDK section. Non-native modes require a
|
||||
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
|
||||
* or mismatched runtime rejects every prompt assembly with an actionable
|
||||
* error (misconfiguration fails loud, before any model request). A
|
||||
* configured `systemPrompt.toolOrder` naming native tools likewise rejects
|
||||
* every assembly under `'code'` (those names are no longer contributed) —
|
||||
* a deployment switching modes updates its order config or drops it.
|
||||
*/
|
||||
/** Model presentation: native schemas, `run_code` plus SDK, or both. Code modes require a TypeScript runtime. */
|
||||
mode?: ToolPresentationMode
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-scope restriction over the GLOBAL tool surface, registered via
|
||||
* {@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 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.
|
||||
* Per-scope filter over global tools. Restrictions intersect and do not affect
|
||||
* scoped registrations or the reserved Code Mode transport.
|
||||
*/
|
||||
export interface ToolRestriction {
|
||||
/** Global tool names that stay visible; everything else is removed. */
|
||||
@@ -469,26 +345,8 @@ interface ToolGuardRegistration {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
|
||||
* loop executes calls through the `tools/pre-execute` → guards →
|
||||
* `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The
|
||||
* registry contributes its schemas into the system-prompt assembly — WHICH
|
||||
* schemas is governed by its `mode` config
|
||||
* (see {@link Config.mode}); under a non-native mode it also owns the reserved
|
||||
* `run_code` presentation transport and the `tools:sdk` prompt section.
|
||||
*
|
||||
* Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a
|
||||
* plain plugin context is GLOBAL (visible to every agent); one through a
|
||||
* scoped context (`agent.ctx`) is filed in that scope's layer — visible to
|
||||
* 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 private visibility resolver feeds the registry's prompt
|
||||
* contribution, {@link get}, and {@link execute} — and, under a non-native
|
||||
* mode, the SDK section and `run_code`'s bindings — so those registry-owned
|
||||
* presentation and dispatch paths agree. An expert `system-prompt/assemble`
|
||||
* listener may deliberately replace the final wire composition and owns any
|
||||
* resulting divergence.
|
||||
* Tool registry and execution pipeline. Scoped registrations shadow globals;
|
||||
* one visibility resolver feeds presentation, lookup, and dispatch.
|
||||
*/
|
||||
export class ToolRegistry extends Service {
|
||||
static inject = ['systemPrompt']
|
||||
@@ -526,13 +384,7 @@ export class ToolRegistry extends Service {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tools:sdk',
|
||||
order: SDK_SECTION_ORDER,
|
||||
// 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
|
||||
// exactly what that agent's programs can call), in lexicographic
|
||||
// tool order, so an unchanged tool set renders byte-identical text
|
||||
// (prefix-cache-friendly) and a mid-session registration surfaces
|
||||
// exactly like a native-mode tool change.
|
||||
// Regenerate from the calling scope's visible tools in stable order.
|
||||
text: (context) => {
|
||||
this.requireCodeRuntime()
|
||||
return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME))
|
||||
@@ -541,24 +393,7 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry's contribution to the wire tool list, per {@link Config.mode},
|
||||
* as ONE SCOPE sees it (scoped layer joins, shadowing and restrictions
|
||||
* applied — {@link schemas}). Because `PromptAssembly.tools` is what the
|
||||
* loop's request header snapshots, the mode's collapse is logged and
|
||||
* reconstructable for free. Under a non-native mode this is also the loud
|
||||
* misconfiguration gate: no usable code runtime → every assembly rejects
|
||||
* before any model request.
|
||||
*
|
||||
* The `knownNames` universe distinguishes the two ways a tool can be off
|
||||
* the wire: a per-scope RESTRICTION is runtime state, so `knownNames` stays
|
||||
* pre-restriction and a restricted-away tool in `toolOrder` is a normal
|
||||
* absence — while the MODE collapse is deployment config, so under
|
||||
* `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 known-name universe for `toolOrder` validation.
|
||||
*/
|
||||
/** Build one scope's wire schemas and pre-restriction names for prompt-order validation. */
|
||||
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
|
||||
const view = this.view(scope)
|
||||
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
|
||||
@@ -595,23 +430,10 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tool. The layer is decided by the CALLING context: a plain
|
||||
* plugin context registers globally; a scoped context (`agent.ctx`)
|
||||
* registers into that scope's layer — visible to that agent alone, disposed
|
||||
* with the scope, and shadowing a same-named global tool for that agent.
|
||||
* Throws if the SAME layer already has the name (cross-layer name twins are
|
||||
* 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. 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
|
||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
* Register globally or in the calling agent scope. Scoped tools shadow
|
||||
* globals; duplicates within one layer and the reserved `run_code` name fail.
|
||||
* @param definition - the tool schema, execution, and optional presentation functions.
|
||||
* @returns the exact disposer that unregisters the tool.
|
||||
*/
|
||||
register(definition: ToolDefinition): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
@@ -632,52 +454,26 @@ export class ToolRegistry extends Service {
|
||||
: `tool "${name}" is already registered in this scope`)
|
||||
}
|
||||
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.
|
||||
// Install rollback before notifying listeners.
|
||||
yield () => {
|
||||
layer.delete(name)
|
||||
// An emptied scope layer is dropped so a disposed scope leaves no
|
||||
// residue keyed by its (dead) key.
|
||||
// Drop empty scope layers.
|
||||
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
||||
this.ctx.emit('tools/change')
|
||||
}
|
||||
this.ctx.emit('tools/change')
|
||||
}.bind(this), 'tools.register()')
|
||||
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
|
||||
// effect that owns a teardown ORDER must be able to yield THIS function —
|
||||
// cordis nests a disposer out of the fiber's concurrent sibling list by
|
||||
// exact function identity, so a wrapper would silently break the nesting
|
||||
// (the agents.register() lesson). Cleanup is synchronous because this
|
||||
// registration installs only synchronous state and notifications.
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict the GLOBAL tool surface for the calling scope. Must be called
|
||||
* 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 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 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`.
|
||||
* Restrict global tools for the calling agent scope. Empty filters, unknown
|
||||
* names, scope-local names, and reserved transport names fail. Restrictions
|
||||
* intersect; scoped registrations remain visible.
|
||||
* @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
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
* @returns the exact disposer that lifts this restriction.
|
||||
*/
|
||||
restrict(filter: ToolRestriction): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
@@ -715,12 +511,7 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
this.ctx.emit('tools/change')
|
||||
}.bind(this), 'tools.restrict()')
|
||||
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
|
||||
// effect that owns a teardown ORDER must be able to yield THIS function —
|
||||
// cordis nests a disposer out of the fiber's concurrent sibling list by
|
||||
// exact function identity, so a wrapper would silently break the nesting
|
||||
// (the agents.register() lesson). Cleanup is synchronous because this
|
||||
// registration installs only synchronous state and notifications.
|
||||
// Return the exact disposer so composite effects preserve teardown order.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
@@ -842,15 +633,8 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing schemas of everything `scope` can see — exactly the
|
||||
* fields (`name`, `description`, `parameters`) this registry contributes to
|
||||
* system-prompt assembly before its expert transformation waterfall.
|
||||
* Constructed EXPLICITLY rather than by stripping
|
||||
* known non-schema members: a `ToolDefinition` also carries `execute` and the
|
||||
* optional `presentCall`/`presentResult` UI callbacks, and those (especially
|
||||
* the functions) must never leak into a model request. An allowlist can't
|
||||
* drift when a new non-schema member is added to the definition; a denylist
|
||||
* (rest-destructure) would silently leak it.
|
||||
* Project visible definitions onto the allowlisted model-facing schema fields,
|
||||
* excluding execution and presentation callbacks.
|
||||
* @param scope - the viewing scope (the agent); omitted = the global view.
|
||||
* @returns one deep-cloned schema per visible tool.
|
||||
*/
|
||||
@@ -869,27 +653,12 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one tool call through the `tools/pre-execute` → guards →
|
||||
* `tools/execute` (around dispatch) → `tools/post-execute` → `tools/result`
|
||||
* pipeline. `pre-execute` is the extensible gate
|
||||
* (allow/deny/ask), `tools/execute` wraps core dispatch (a timeout/retry/metrics
|
||||
* seam), and `post-execute` is the inspect/transform seam; core dispatch sits
|
||||
* as the base `next()` of the `tools/execute` waterfall. The whole thing is
|
||||
* wrapped in one outer try/catch so a throwing listener (in any waterfall)
|
||||
* becomes an `isError` result instead of failing the turn; the tool body ALSO
|
||||
* keeps its own inner try/catch, so a thrown tool becomes an `isError` result
|
||||
* that `tools/execute` and `post-execute` listeners can still inspect. If the
|
||||
* tool is not registered (or not visible to the calling agent — a
|
||||
* 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 is
|
||||
* materialized as a detached lossless-JSON snapshot; an invalid outcome is
|
||||
* normalized to an error.
|
||||
* Execute through pre-policy, guards, around-dispatch, post-policy, and final
|
||||
* notification. Tool and listener failures resolve as materialized error
|
||||
* results; an invisible tool reports `UNKNOWN_TOOL`.
|
||||
* @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.
|
||||
* @returns the materialized final result.
|
||||
*/
|
||||
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
|
||||
const token = createExecutionToken()
|
||||
|
||||
@@ -43,14 +43,8 @@ export interface FileDiff {
|
||||
}
|
||||
|
||||
/**
|
||||
* How a tool wants ONE of its calls shown in a UI (an editor's tool-call card, a
|
||||
* CLI log line) BEFORE the result is known — the *pending* state. A `card`-tagged
|
||||
* discriminated union: a tool declares its render INTENT once and a UI bridge
|
||||
* switches on `card` to map it to the bridge's own wire shape. Provider-neutral —
|
||||
* the tool owns its presentation, so a UI never special-cases tool names.
|
||||
*
|
||||
* Returned by `ToolDefinition.presentCall`. See the render-intent-union
|
||||
* RFC (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
|
||||
* Provider-neutral pending-call presentation. Tools declare one tagged intent;
|
||||
* UI bridges map it without special-casing tool names.
|
||||
*/
|
||||
export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView
|
||||
|
||||
|
||||
@@ -1,23 +1,4 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Typed tool-parameter DSL with argument inference and JSON Schema output. @module dsh-tools/schema */
|
||||
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts'
|
||||
@@ -328,39 +309,12 @@ 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.
|
||||
*
|
||||
* Define a first-party tool whose execution and presentation arguments are
|
||||
* inferred from its per-property schema.
|
||||
* @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
|
||||
* raw args first (throwing {@link ToolArgsError} on mismatch, which the
|
||||
* registry turns into an isError result), and its presenters validate softly
|
||||
* (returning undefined on mismatch, since replay may feed them older-schema
|
||||
* args).
|
||||
* @returns a registry-ready definition with strict execution validation and
|
||||
* soft presenter validation for replay compatibility.
|
||||
*/
|
||||
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
|
||||
// Object-literal execute methods don't use `this`; the reference is safe.
|
||||
|
||||
@@ -703,16 +703,7 @@ 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. 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).
|
||||
// The async probe distinguishes nested LIFO teardown from a sibling effect.
|
||||
const ctx = await setup()
|
||||
const order: string[] = []
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
|
||||
Reference in New Issue
Block a user