Merge master into codex/grep-glob-require-rg
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 a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
|
||||
|
||||
### 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 and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live 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)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`.
|
||||
|
||||
### Injected services
|
||||
|
||||
@@ -36,16 +36,20 @@ The live registry pipeline has three transformable waterfalls followed by the ob
|
||||
- `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 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.
|
||||
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately.
|
||||
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. 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. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
|
||||
- `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.
|
||||
- `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
|
||||
- `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").
|
||||
|
||||
### 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 ordered contexts; `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,68 +81,74 @@ 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 missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. Extra keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. 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 accepts one scalar `type`, object `properties`/`required`/boolean `additionalProperties`, array `items`, and scalar `enum`/`const`. The annotations `description`, `title`, `default`, and `examples` are ignored but must remain JSON data. Type arrays, undeclared required keys, and unsupported keywords fail through `OutputSchemaError` rather than being ignored; `validateStructuredValue()` returns path-qualified violations without throwing.
|
||||
|
||||
### 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`):
|
||||
Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names:
|
||||
|
||||
- `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).
|
||||
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`.
|
||||
|
||||
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 }
|
||||
},
|
||||
})
|
||||
```
|
||||
Returning `undefined` selects generic fallback. Presenters depend only on their arguments because UIs call them during live streaming and log replay. Result presentation may read JSON-serializable `result.meta`, which persists with the result; `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the 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.
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. 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`.
|
||||
|
||||
- **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.
|
||||
- **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. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/envelope/meta even when the program later fails.
|
||||
- **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.
|
||||
## Model Experience
|
||||
|
||||
### What is NOT here (TODO)
|
||||
### Normal tool schemas
|
||||
|
||||
- **Concurrency metadata** — tool definitions do not declare whether executions are safe to overlap.
|
||||
- **Parallel execution** — the loop and Code Mode bridge execute tool calls sequentially until that metadata exists.
|
||||
**What the model sees**: In normal mode the model sees each visible definition's exact name, description, and JSON schema; the shipped definitions are recorded in the generated [tool package map and schema sections](../../../docs/tool-catalog.md#tool-package-map). Agent-scoped restrictions, shadows, and extension registrations change that agent's end-tool set.
|
||||
|
||||
**Token effect**: Fixed per-request cost proportional to the visible definitions. Restrictions that hide tools remove their entire schema cost for that agent.
|
||||
|
||||
### Code Mode schema and system prompt
|
||||
|
||||
**What the model sees**: Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact `declare const tools` block. `both` exposes normal schemas and this Code Mode surface.
|
||||
|
||||
**Token effect**: Fixed per-request cost proportional to the visible definitions. Code Mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction.
|
||||
|
||||
#### Code Mode SDK instructions
|
||||
|
||||
```markdown
|
||||
## Writing code for run_code
|
||||
|
||||
Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:
|
||||
|
||||
- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable.
|
||||
- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue.
|
||||
- Calls execute sequentially, even under `Promise.all`.
|
||||
- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
|
||||
|
||||
The available tools:
|
||||
```
|
||||
|
||||
### Tool-call history and results
|
||||
|
||||
**What the model sees**: The loop retains model-emitted arguments and the registry's final content. Any thrown or denied call becomes exactly `Error: <message>`. Code Mode returns only the outer program's printed lines and rendered return value, `(run_code completed with no output)` when both are empty, or `Error: code run failed (<kind>): <message>` followed conditionally by `Captured output:` and the captured lines. Inner dispatch events stay log-only; post-execute listeners may append source-attributed context after the result.
|
||||
|
||||
**Token effect**: Arguments, results, and additional context are data-dependent and resent until compaction. Restrictions that hide tools also remove their schemas before the model can call them.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Native tool calls execute sequentially** — `ToolDefinition` carries no concurrency-safety metadata; adding it (and parallel execution in the loop) waits on the deferred tool-shapes review (`TODO(review)`).
|
||||
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
|
||||
- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and never applies `default` (`XXX(unused-default)` flags removing that field); raw-registered JSON-Schema tools validate their own input.
|
||||
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
|
||||
- **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only.
|
||||
- **Code Mode bindings return text only** — non-text content blocks in a sub-call result collapse to `[<type> content]` placeholders.
|
||||
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md).
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
@@ -42,6 +42,6 @@
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
/**
|
||||
* Code Mode: the `run_code` tool and its dispatch bridge. The model writes a
|
||||
* TypeScript program; the bridge hands it to `ctx.codeRuntime` with one async
|
||||
* binding per end capability visible to the calling agent, then serializes
|
||||
* every binding call through a per-run queue onto `ToolRegistry.execute()`.
|
||||
* Sub-calls therefore traverse the complete pre/guard/around/post/final-result
|
||||
* pipeline exactly like native calls and carry the outer execution's opaque
|
||||
* token for correlation. The bridge logs each sub-dispatch as a
|
||||
* `tool/code-dispatch` session event and returns only the program's curated
|
||||
* output. The registry itself decides WHEN this tool exists (its `mode`
|
||||
* config); this module owns only the tool and the bridge.
|
||||
*
|
||||
* Code Mode `run_code` transport. Programs call the registry's agent-visible
|
||||
* tools through nested, sequential executions; each sub-dispatch is logged for
|
||||
* reconstruction, while only the outer curated result enters model history.
|
||||
* @module @deepseek-ai/dsh-tools/src/code-mode
|
||||
*/
|
||||
|
||||
import { parse } from 'node:path'
|
||||
import { inspect } from 'node:util'
|
||||
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
@@ -29,7 +22,10 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
* (`<parent>:code:<n>`), the tool `name` with its JSON-normalized
|
||||
* `arguments` — the exact value dispatched, normalized BEFORE dispatch,
|
||||
* so this append can never fail on payload shape — whether the sub-call
|
||||
* errored, and a bounded `resultSummary` of its model-facing text.
|
||||
* errored, and a bounded `resultSummary` of its model-facing text. Before
|
||||
* bounding, occurrences of a non-root session workspace path are
|
||||
* normalized to `.` so host-specific absolute path lengths cannot change
|
||||
* the summary.
|
||||
* Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
|
||||
* model context; persistence and UIs get every call. Appended inside the
|
||||
* parent `run_code`'s execution (the bridge drains its queue before
|
||||
@@ -83,22 +79,20 @@ function textOf(content: ContentBlock[]): string {
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/** Bound a sub-call's model-facing text for the log event's `resultSummary`. */
|
||||
function summarize(text: string): string {
|
||||
return text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, SUMMARY_MAX_CHARS)}…` : text
|
||||
/** Normalize workspace paths, then bound a sub-call's model-facing text for its durable log summary. */
|
||||
function summarize(text: string, cwd: string | undefined): string {
|
||||
const stableText = cwd === undefined || cwd === parse(cwd).root
|
||||
? text
|
||||
: text.replaceAll(cwd, '.')
|
||||
return stableText.length > SUMMARY_MAX_CHARS ? `${stableText.slice(0, SUMMARY_MAX_CHARS)}…` : stableText
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-normalize one binding call's argument into TWO independent parses of
|
||||
* the same canonical text: `dispatched` goes to the tool, `logged` to the
|
||||
* `tool/code-dispatch` event — identical by construction (the runtime's
|
||||
* structured-clone boundary is wider than JSON; the session log accepts only
|
||||
* JSON), and separate objects, so a tool mutating its args can neither
|
||||
* desync the log from what was dispatched nor re-poison the append. A value
|
||||
* that does not survive the round-trip (`undefined` — the log rejects it as
|
||||
* event data — `BigInt`, a circular structure, a bare function) rejects that
|
||||
* one call BEFORE dispatch with a model-correctable error: nothing ever
|
||||
* executes unlogged.
|
||||
* JSON-normalize one binding call's argument into TWO independent parses of the same canonical
|
||||
* text: `dispatched` goes to the tool, `logged` to the `tool/code-dispatch` event — identical
|
||||
* by construction (the runtime's structured-clone boundary is wider than JSON; the session log
|
||||
* accepts only JSON), and separate objects, so a tool mutating its args can neither desync the
|
||||
* log from what was dispatched nor re-poison the append.
|
||||
*/
|
||||
function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
|
||||
if (value === undefined) {
|
||||
@@ -126,20 +120,19 @@ function renderValue(value: unknown): string {
|
||||
/** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */
|
||||
interface RunCodeMeta {
|
||||
logs: CodeRunResult['logs']
|
||||
dispatches: number
|
||||
}
|
||||
|
||||
/** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */
|
||||
function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
|
||||
if (typeof meta !== 'object' || meta === null) return undefined
|
||||
const m = meta as Record<string, unknown>
|
||||
if (!Array.isArray(m.logs) || typeof m.dispatches !== 'number') return undefined
|
||||
if (!Array.isArray(m.logs) || !m.logs.every(log => typeof log === 'string')) return undefined
|
||||
return m as unknown as RunCodeMeta
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `run_code` {@link ToolDefinition}: one required `code` parameter,
|
||||
* executed through the dispatch bridge described in the module doc. The
|
||||
* executed through the dispatch bridge described above. The
|
||||
* registry reserves it as presentation infrastructure under non-native modes,
|
||||
* outside the filterable global/scoped capability layers.
|
||||
* @param registry - the owning registry (sub-calls go through its `execute`,
|
||||
@@ -172,11 +165,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
exec.signal?.addEventListener('abort', onOuterAbort, { once: true })
|
||||
|
||||
let dispatches = 0
|
||||
// The per-run serialization queue: every binding call chains onto the
|
||||
// tail, so even `Promise.all` executes the underlying tool calls one at
|
||||
// a time in submission order (the tool contract carries no
|
||||
// concurrency-safety metadata yet). The fold keeps the tail non-rejecting
|
||||
// so one failed dispatch never poisons the chain.
|
||||
// The per-run serialization queue: every binding call chains onto the tail, so even
|
||||
// `Promise.all` executes the underlying tool calls one at a time in submission order (the
|
||||
// tool contract carries no concurrency-safety metadata yet).
|
||||
let queue: Promise<void> = Promise.resolve()
|
||||
const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
|
||||
const turn = queue.then(() => {
|
||||
@@ -210,12 +201,10 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
parent: exec.token,
|
||||
signal: runController.signal,
|
||||
})
|
||||
for (const context of result.additionalContexts ?? []) {
|
||||
exec.deferContext(context)
|
||||
}
|
||||
const text = textOf(result.content)
|
||||
// Sub-call `additionalContext` is deliberately DROPPED here: the
|
||||
// loop's buffering (append after the step's tool/results) has no
|
||||
// safe analogue from inside a running run_code — injecting now
|
||||
// would break tool-call/result adjacency. Deferred until a real
|
||||
// hook needs it through Code Mode.
|
||||
exec.agent?.session.append('tool/code-dispatch', {
|
||||
parentCallId: exec.callId,
|
||||
subCallId,
|
||||
@@ -225,7 +214,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
// this record from what it actually received.
|
||||
arguments: normalized.logged,
|
||||
isError: result.isError,
|
||||
resultSummary: summarize(text),
|
||||
resultSummary: summarize(text, exec.agent.session.header.cwd),
|
||||
})
|
||||
return { text, isError: result.isError }
|
||||
})
|
||||
@@ -266,29 +255,19 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
signal: runController.signal,
|
||||
})
|
||||
} finally {
|
||||
// Quiescence before returning, whether the runtime fulfilled or
|
||||
// REJECTED (a backend that starts a binding call and then throws
|
||||
// must not leak a live sub-dispatch past this settlement): fire
|
||||
// the run-scoped abort (cancelling an in-flight sub-dispatch,
|
||||
// abandoning queued ones), then await the queue's drain — an
|
||||
// aborted sub-call still settles and logs its event INSIDE the
|
||||
// open turn; nothing can append after we return. `queue` is the
|
||||
// FOLDED tail (every link swallows its rejection into undefined),
|
||||
// so this await cannot itself reject — an abandoned queued call
|
||||
// can never mask the runtime's own failure, returned or thrown;
|
||||
// rejections surface only on the per-call promises the program
|
||||
// holds.
|
||||
// Abort sub-dispatches and drain the folded queue before closing the turn.
|
||||
// Binding failures remain observable through their individual promises.
|
||||
runController.abort('run_code settled')
|
||||
await queue
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.map(entry => entry.text).join('\n')}` : ''
|
||||
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : ''
|
||||
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
|
||||
}
|
||||
const rendered = renderValue(result.value)
|
||||
const parts = [result.logs.map(entry => entry.text).join('\n'), rendered].filter(part => part.length > 0)
|
||||
const meta: RunCodeMeta = { logs: result.logs, dispatches }
|
||||
const parts = [result.logs.join('\n'), rendered].filter(part => part.length > 0)
|
||||
const meta: RunCodeMeta = { logs: result.logs }
|
||||
return {
|
||||
content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }],
|
||||
meta,
|
||||
@@ -297,13 +276,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
exec.signal?.removeEventListener('abort', onOuterAbort)
|
||||
}
|
||||
},
|
||||
// The program IS the title, the way command tools title their cards with
|
||||
// the command: an execute-card's title is the one slot an ACP client
|
||||
// always shows (Zed's execute cards render no body content and no raw
|
||||
// input without a real terminal attached), so anywhere else the code
|
||||
// would be invisible. Multi-line titles are the execute-card idiom —
|
||||
// capable clients render them whole; others truncate to the first line
|
||||
// and still hold the full program in rawInput.
|
||||
// ACP execute cards use the program as their visible title.
|
||||
presentCall: args => ({
|
||||
card: 'generic',
|
||||
title: args.code,
|
||||
@@ -316,7 +289,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
presentResult: (_args, result) => {
|
||||
const meta = asRunCodeMeta(result.meta)
|
||||
if (!meta) return undefined
|
||||
const output = meta.logs.map(entry => entry.text).join('\n')
|
||||
const output = meta.logs.join('\n')
|
||||
return {
|
||||
card: 'generic',
|
||||
...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {},
|
||||
|
||||
@@ -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
|
||||
@@ -174,23 +117,15 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(review): revisit these shapes when concurrency metadata becomes useful
|
||||
// TODO(concurrency): 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. */
|
||||
export interface ToolDefinition extends ToolSchema {
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
|
||||
execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
|
||||
/**
|
||||
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
|
||||
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
|
||||
@@ -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 }
|
||||
|
||||
/**
|
||||
@@ -277,6 +207,21 @@ export interface ToolExecution extends ToolExecutionInput {
|
||||
readonly token: ToolExecutionToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime context handed to a tool implementation after the registry has
|
||||
* accepted a {@link ToolExecution}. A composite tool uses
|
||||
* {@link deferContext} to ferry context produced by nested dispatches back to
|
||||
* the outer result; the loop appends it only after the outer `tool/result`.
|
||||
*/
|
||||
export interface ToolRunContext extends ToolExecution {
|
||||
/**
|
||||
* Defer one nested-dispatch context until this tool's final result reaches
|
||||
* the agent loop. Contexts retain their individual source, envelope, and
|
||||
* metadata and are emitted in call order.
|
||||
*/
|
||||
deferContext(context: HookContext): void
|
||||
}
|
||||
|
||||
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
|
||||
export interface ToolErrorInfo {
|
||||
name: string
|
||||
@@ -290,7 +235,7 @@ export interface ToolErrorInfo {
|
||||
* distinguish it from a tool body's own error.
|
||||
*/
|
||||
export class ToolNotFoundError extends HarnessError {
|
||||
constructor(public readonly toolName: string) {
|
||||
constructor(toolName: string) {
|
||||
super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL')
|
||||
this.name = 'ToolNotFoundError'
|
||||
}
|
||||
@@ -298,7 +243,6 @@ export class ToolNotFoundError extends HarnessError {
|
||||
|
||||
/** The outcome of one tool call. */
|
||||
export interface ToolExecutionResult {
|
||||
callId: CallId
|
||||
content: ContentBlock[]
|
||||
isError: boolean
|
||||
/**
|
||||
@@ -308,16 +252,10 @@ 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
|
||||
additionalContexts?: HookContext[]
|
||||
/**
|
||||
* The tool-private presentation payload from a successful `execute` (the object
|
||||
* return form). Threaded onto the `tool/result` session event and back into
|
||||
@@ -328,19 +266,10 @@ export interface ToolExecutionResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. `allow` runs the call; `deny` materializes an error;
|
||||
* `ask` runs only after an approval service returns `allowed-once` and otherwise
|
||||
* denies. Input rewriting is excluded because arguments are already logged and
|
||||
* presented.
|
||||
*/
|
||||
export type PreToolDecision =
|
||||
| { kind: 'allow' }
|
||||
@@ -348,20 +277,12 @@ export type PreToolDecision =
|
||||
| { 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 for the next
|
||||
* request, or block by turning corrective feedback into an error result.
|
||||
*/
|
||||
export type PostToolDecision =
|
||||
| { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext }
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
|
||||
| { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] }
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] }
|
||||
|
||||
/**
|
||||
* Best-effort human-readable message from an arbitrary thrown value: Error
|
||||
@@ -400,35 +321,17 @@ 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` (default) sends every visible schema; `code`
|
||||
* sends only `run_code` plus a generated SDK prompt; `both` sends both forms.
|
||||
* Code modes require a TypeScript runtime and fail prompt assembly when it is
|
||||
* absent or mismatched. Under `code`, native names in `toolOrder` are invalid.
|
||||
*/
|
||||
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 +372,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 +411,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))
|
||||
@@ -542,22 +421,8 @@ 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 names for prompt-order validation.
|
||||
* Restrictions do not make known tools invalid, but a mode collapse does.
|
||||
*/
|
||||
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
|
||||
const view = this.view(scope)
|
||||
@@ -595,23 +460,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 +484,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 +541,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 +663,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,29 +683,16 @@ 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`. The returned outcome is
|
||||
* the same lossless, frozen snapshot final observers receive.
|
||||
* @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 deferredContexts: HookContext[] = []
|
||||
const token = createExecutionToken()
|
||||
const callId = exec.callId
|
||||
const name = exec.name
|
||||
@@ -905,8 +706,11 @@ export class ToolRegistry extends Service {
|
||||
...agent !== undefined ? { agent } : {},
|
||||
...parent !== undefined ? { parent } : {},
|
||||
...signal !== undefined ? { signal } : {},
|
||||
deferContext(context: HookContext): void {
|
||||
deferredContexts.push(context)
|
||||
},
|
||||
}
|
||||
let execution: ToolExecution
|
||||
let execution: ToolRunContext
|
||||
try {
|
||||
const detached = snapshotJsonValue(exec.arguments)
|
||||
if (detached === undefined) {
|
||||
@@ -918,24 +722,24 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
execution = { ...base, arguments: undefined }
|
||||
const result = this.materializeFinalResult(toolErrorResult(callId, error))
|
||||
const result = this.materializeFinalResult(toolErrorResult(error))
|
||||
this.notifyResult(execution, result)
|
||||
return result
|
||||
}
|
||||
let result: ToolExecutionResult
|
||||
try {
|
||||
result = this.materializeFinalResult(await this.executePipeline(execution))
|
||||
result = this.materializeFinalResult(await this.executePipeline(execution, deferredContexts))
|
||||
} catch (error: unknown) {
|
||||
// Outer backstop: a throwing pre/post-execute listener, guard, or the
|
||||
// waterfall machinery becomes an isError result, never a turn failure.
|
||||
result = this.materializeFinalResult(toolErrorResult(execution.callId, error))
|
||||
result = this.materializeFinalResult(toolErrorResult(error))
|
||||
}
|
||||
this.notifyResult(execution, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Run the transformable pipeline; {@link execute} owns final normalization and notification. */
|
||||
private async executePipeline(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
private async executePipeline(exec: ToolRunContext, deferredContexts: HookContext[]): Promise<ToolExecutionResult> {
|
||||
// --- Gate: tools/pre-execute. An `ask` resolves through the optional
|
||||
// approval seam (or degrades to deny) before the monotonic guards run. The
|
||||
// carrier keys dispatch by exec.agent, so an `agent.ctx` listener gates only
|
||||
@@ -953,7 +757,6 @@ export class ToolRegistry extends Service {
|
||||
// Every non-grant, including a failed/unavailable approval request, takes
|
||||
// the same deny path and still reaches post-policy plus result observers.
|
||||
const denied: ToolExecutionResult = {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${denialReason}` }],
|
||||
isError: true,
|
||||
}
|
||||
@@ -984,17 +787,22 @@ export class ToolRegistry extends Service {
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
const content = Array.isArray(returned) ? returned : returned.content
|
||||
const meta = Array.isArray(returned) ? undefined : returned.meta
|
||||
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
|
||||
return { content, isError: false, ...meta !== undefined ? { meta } : {} }
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
return toolErrorResult(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (result.callId !== exec.callId) {
|
||||
throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`)
|
||||
}
|
||||
|
||||
return await this.postExecute(exec, result)
|
||||
const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0
|
||||
? result
|
||||
: {
|
||||
...result,
|
||||
additionalContexts: [
|
||||
...deferredContexts,
|
||||
...result.additionalContexts ?? [],
|
||||
],
|
||||
}
|
||||
return await this.postExecute(exec, resultWithDeferredContexts)
|
||||
}
|
||||
|
||||
/** Notify final-result observers without giving them a mutation/error channel into the outcome. */
|
||||
@@ -1056,8 +864,11 @@ export class ToolRegistry extends Service {
|
||||
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply
|
||||
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing
|
||||
* `content` when given), `block` turns it into an `isError` whose content is
|
||||
* the corrective `feedback`. Either decision may attach `additionalContext`,
|
||||
* which is ferried on the returned result for the loop's per-step buffer.
|
||||
* the corrective `feedback`. Either decision may attach `additionalContexts`,
|
||||
* which are ferried on the returned result for the loop's per-step buffer.
|
||||
* Context deferred by the tool body survives an accepted result but is
|
||||
* discarded when the outer call is blocked; a block exposes only context the
|
||||
* blocking decision explicitly supplied.
|
||||
* Runs inside `execute`'s outer try/catch (a throwing listener → isError).
|
||||
*/
|
||||
private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
|
||||
@@ -1065,20 +876,24 @@ export class ToolRegistry extends Service {
|
||||
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
|
||||
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
|
||||
)
|
||||
const additionalContext = decision.additionalContext
|
||||
const decisionContexts = decision.additionalContexts ?? []
|
||||
if (decision.kind === 'block') {
|
||||
return {
|
||||
callId: result.callId,
|
||||
content: decision.feedback,
|
||||
isError: true,
|
||||
...additionalContext ? { additionalContext } : {},
|
||||
...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {},
|
||||
}
|
||||
}
|
||||
// Accept: replace content if supplied and preserve the dispatched outcome.
|
||||
// Accept: replace content if supplied, preserve the dispatched outcome, and
|
||||
// append decision contexts after contexts deferred by the tool body.
|
||||
const additionalContexts = [
|
||||
...result.additionalContexts ?? [],
|
||||
...decisionContexts,
|
||||
]
|
||||
return {
|
||||
...result,
|
||||
...decision.content ? { content: decision.content } : {},
|
||||
...additionalContext ? { additionalContext } : {},
|
||||
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1097,10 +912,9 @@ function createExecutionToken(): ToolExecutionToken {
|
||||
return Symbol('dsh.tool.execution') as ToolExecutionToken
|
||||
}
|
||||
|
||||
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
|
||||
function toolErrorResult(error: unknown): ToolExecutionResult {
|
||||
const info = errorInfo(error)
|
||||
return {
|
||||
callId,
|
||||
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
|
||||
isError: true,
|
||||
...info ? { error: info } : {},
|
||||
|
||||
@@ -1,31 +1,9 @@
|
||||
/**
|
||||
* Structured-output JSON Schema subset: the vocabulary a caller uses to demand
|
||||
* a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`)
|
||||
* or a workflow `agent()` call.
|
||||
*
|
||||
* This is deliberately NOT full JSON Schema. The schema travels verbatim to the
|
||||
* model as a forced tool's `parameters`, and the value the model produces is
|
||||
* validated here — so every accepted keyword must be one this module actually
|
||||
* enforces. Accepting a keyword we don't enforce would validate less than the
|
||||
* schema promises (accepted-then-ignored), so anything outside the subset is
|
||||
* REJECTED LOUD by {@link assertSupportedOutputSchema} instead. The subset:
|
||||
*
|
||||
* - `type` — a single string (`object`/`array`/`string`/`number`/`integer`/
|
||||
* `boolean`/`null`); type ARRAYS (`["string","null"]`) are rejected.
|
||||
* - `properties`/`required`/`additionalProperties` (boolean) on objects; every
|
||||
* `required` key must be declared in `properties`. `additionalProperties`
|
||||
* absent keeps standard JSON Schema semantics (extra keys allowed).
|
||||
* - `items` on arrays (absent ⇒ any JSON items).
|
||||
* - `enum` (non-empty, scalars only) and `const` (scalar) on scalar types.
|
||||
* - Annotations `description`/`title`/`default`/`examples` are allowed and
|
||||
* ignored (they constrain nothing), except that they must still be JSON data
|
||||
* — the schema is serialized onto the wire, so a non-JSON annotation would be
|
||||
* silently mangled.
|
||||
*
|
||||
* Values checked by {@link validateStructuredValue} are expected to be plain
|
||||
* host-realm JSON data (model tool-call arguments are parsed wire JSON; a
|
||||
* caller holding foreign-realm data materializes it first).
|
||||
*
|
||||
* Structured-output JSON Schema subset for subagents and workflows. It supports
|
||||
* one scalar `type`; object `properties`/`required`/boolean
|
||||
* `additionalProperties`; array `items`; scalar `enum`/`const`; and JSON-valued
|
||||
* annotations. Unsupported or misplaced keywords reject rather than being
|
||||
* accepted without enforcement, and structured-output roots must be objects.
|
||||
* @module dsh-tools/json-schema
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
/**
|
||||
* Tool render-intent vocabulary: the provider-neutral types a tool declares via
|
||||
* `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say
|
||||
* how ONE of its calls renders in a UI (an editor's tool-call card, a CLI log
|
||||
* line). A UI bridge switches on the `card` tag to map each intent to its own
|
||||
* wire shape, so a UI never special-cases tool names.
|
||||
*
|
||||
* This is the UI-facing surface of `dsh-tools`, kept separate from the registry
|
||||
* and execution core in `index.ts`: this module owns ONLY presentation
|
||||
* vocabulary and references none of the execution types, so the dependency runs
|
||||
* one way (`index.ts` imports these views for the `ToolDefinition` method
|
||||
* signatures). The opaque `meta` presentation channel is execution plumbing and
|
||||
* lives with the registry in `index.ts`, not here.
|
||||
*
|
||||
* See the render-intent-union RFC
|
||||
* (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
|
||||
*
|
||||
* `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say how one of its calls
|
||||
* renders in a UI (an editor's tool-call card, a CLI log line).
|
||||
* @module @deepseek-ai/dsh-tools/src/presentation
|
||||
*/
|
||||
|
||||
@@ -56,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
|
||||
|
||||
@@ -186,16 +167,10 @@ export interface TerminalResultView {
|
||||
}
|
||||
|
||||
/**
|
||||
* A completed file mutation rendered as an inline diff card, the *result-time*
|
||||
* analogue of {@link DiffCallView}. Set by a tool whose `execute` applied a file
|
||||
* change (e.g. `write`, `edit`): `diffs` are the change to show — typically the
|
||||
* APPLIED hunks computed from the before/after content (one entry per hunk, each
|
||||
* with surrounding context lines), so the editor shows the real change in place;
|
||||
* a tool with no before-image (e.g. a file create) may instead give a whole-file
|
||||
* diff (`oldText: null`). A `tool_call_update`'s content REPLACES the call's
|
||||
* content in an editor, so a mutation tool returns this even when it duplicates
|
||||
* the call-time snippet — otherwise the model-facing result text would replace
|
||||
* (clobber) the pending diff card.
|
||||
* A completed file mutation rendered as an inline diff card, the result-time
|
||||
* analogue of {@link DiffCallView}. Because a completed UI update replaces the
|
||||
* pending card content, mutation tools return this even when it repeats the
|
||||
* call-time diff; otherwise raw result text would replace the diff.
|
||||
*/
|
||||
export interface DiffResultView {
|
||||
card: 'diff'
|
||||
|
||||
@@ -1,26 +1,7 @@
|
||||
/**
|
||||
* 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'
|
||||
import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts'
|
||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -308,7 +289,7 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
* content only) or a `{ content, meta }` object to also attach a tool-private
|
||||
* presentation payload (see {@link ToolExecuteReturn}).
|
||||
*/
|
||||
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ToolExecuteReturn>
|
||||
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<ToolExecuteReturn>
|
||||
/**
|
||||
* Optional: how to present the PENDING state of one call in a UI (an editor
|
||||
* tool-call card, a CLI log line). `args` is the typed, schema-validated
|
||||
@@ -328,39 +309,13 @@ 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. Raw JSON-Schema definitions remain
|
||||
* valid inputs to {@link ToolRegistry.register}; this helper is authoring sugar.
|
||||
* @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.
|
||||
@@ -378,7 +333,7 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
description: options.description,
|
||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
||||
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
||||
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
|
||||
async execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn> {
|
||||
// Validate the model-generated args before the typed body runs. On
|
||||
// mismatch we throw ToolArgsError; the registry turns it into an
|
||||
// isError result so the model can self-correct. After this guard, the
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
/**
|
||||
* Code Mode codegen: the pure projection from registered tool schemas to the
|
||||
* TypeScript SDK text the model programs against (the `tools:sdk` prompt
|
||||
* section). Sibling of `json-schema.ts` — `schemas()` (native function
|
||||
* calling) and this module (the generated `declare const tools` surface) are
|
||||
* two projections of the same store.
|
||||
*
|
||||
* TOTAL by design: {@link jsonSchemaToTs} maps the JSON-Schema subset the
|
||||
* `defineTool` DSL emits and degrades every construct outside it (`$ref`,
|
||||
* `oneOf`, `integer`, future MCP shapes, …) to `unknown` without ever
|
||||
* throwing — codegen must never be the thing that fails an assembly.
|
||||
* Deterministic: a fixed tool set renders byte-identical text (tools in
|
||||
* lexicographic name order), so the section is prefix-cache-friendly.
|
||||
*
|
||||
* Code Mode codegen: the pure projection from registered tool schemas to the TypeScript SDK
|
||||
* text the model programs against (the `tools:sdk` prompt section). Sibling of
|
||||
* `json-schema.ts` — `schemas()` (native function calling) and this module (the generated
|
||||
* `declare const tools` surface) are two projections of the same store.
|
||||
* @module @deepseek-ai/dsh-tools/src/ts-types
|
||||
*/
|
||||
|
||||
@@ -33,10 +24,8 @@ function pad(indent: number): string {
|
||||
/** A one-line JSDoc block for a schema `description`, or no lines when there is none. */
|
||||
function docLines(description: unknown, indent: number): string[] {
|
||||
if (typeof description !== 'string' || description.length === 0) return []
|
||||
// Keep the doc a single-line comment per property: descriptions are prose
|
||||
// (possibly with newlines); collapse whitespace so the rendered SDK stays
|
||||
// stable and compact. A comment-closer inside the description is escaped so
|
||||
// it cannot terminate the generated JSDoc early.
|
||||
// Collapse prose to stable one-line docs and escape comment closers so a
|
||||
// schema description cannot terminate generated JSDoc.
|
||||
const collapsed = description.replace(/\s+/g, ' ').trim()
|
||||
return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`]
|
||||
}
|
||||
|
||||
@@ -82,10 +82,11 @@ function registerEcho(ctx: Context, name = 'echo'): unknown[] {
|
||||
}
|
||||
|
||||
/** A structural fake of the owning agent: captures session appends. */
|
||||
function fakeAgent(): { agent: Agent; events: { type: string; data: unknown }[] } {
|
||||
function fakeAgent(options: { cwd?: string } = { cwd: '/workspace' }): { agent: Agent; events: { type: string; data: unknown }[] } {
|
||||
const events: { type: string; data: unknown }[] = []
|
||||
const agent = {
|
||||
session: {
|
||||
header: options.cwd === undefined ? {} : { cwd: options.cwd },
|
||||
append: (type: string, data: unknown) => { events.push({ type, data }) },
|
||||
},
|
||||
} as unknown as Agent
|
||||
@@ -328,7 +329,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
const tools = request.bindings[0]!.functions
|
||||
const first = await tools.echo!({ value: 'one' })
|
||||
const second = await tools.echo!({ value: 'two' })
|
||||
return { logs: [{ source: 'console', level: 'log', text: `saw ${String(first)}` }], value: second }
|
||||
return { logs: [`saw ${String(first)}`], value: second }
|
||||
}
|
||||
const result = await runCode(ctx, 'const …: string = …', { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
@@ -339,7 +340,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
{ parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, resultSummary: 'echo:one' },
|
||||
{ parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, resultSummary: 'echo:two' },
|
||||
])
|
||||
expect(result.meta).toEqual({ logs: [{ source: 'console', level: 'log', text: 'saw echo:one' }], dispatches: 2 })
|
||||
expect(result.meta).toEqual({ logs: ['saw echo:one'] })
|
||||
})
|
||||
|
||||
it('exposes only an opaque parent token to nested result observers', async () => {
|
||||
@@ -350,10 +351,8 @@ describe('the run_code dispatch bridge', () => {
|
||||
return { logs: [], value: 'done' }
|
||||
}
|
||||
|
||||
// Model a timeout-style outer wrapper: it temporarily installs a signal,
|
||||
// delegates, then restores the exact prior shape. A nested result observer
|
||||
// is observe-only and must not receive the live outer execution object;
|
||||
// freezing the correlation value it sees therefore cannot break restore.
|
||||
// Freeze the nested observer's parent correlation. If that were the live
|
||||
// outer execution object, the timeout-style wrapper could not restore it.
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
if (exec.name !== RUN_CODE_NAME) return next()
|
||||
const previous = exec.signal
|
||||
@@ -479,33 +478,77 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(dispatch.arguments).toEqual({ value: 'x', when: '1970-01-01T00:00:00.000Z' })
|
||||
})
|
||||
|
||||
it('suppresses sub-call additionalContext (deliberately; pinned)', async () => {
|
||||
it('defers sub-call additionalContexts onto the outer run_code result', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
registerEcho(ctx)
|
||||
ctx.on('tools/post-execute', (exec, _result, next): Promise<PostToolDecision> => {
|
||||
if (exec.name === 'echo') {
|
||||
return Promise.resolve({
|
||||
kind: 'accept' as const,
|
||||
additionalContext: { content: [{ type: 'text' as const, text: 'context for the next request' }], source: { kind: 'plugin' as const, plugin: 'test' } },
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text' as const, text: `context for ${exec.callId}` }],
|
||||
source: { kind: 'plugin' as const, plugin: 'test' },
|
||||
envelope: 'raw' as const,
|
||||
meta: { callId: exec.callId },
|
||||
}],
|
||||
})
|
||||
}
|
||||
return next()
|
||||
})
|
||||
runtime.behavior = async (request) => {
|
||||
await request.bindings[0]!.functions.echo!({ value: 'x' })
|
||||
await request.bindings[0]!.functions.echo!({ value: 'y' })
|
||||
return { logs: [], value: 'done' }
|
||||
}
|
||||
const result = await runCode(ctx, 'program')
|
||||
expect(result.isError).toBe(false)
|
||||
// The sub-call's context has no safe outlet mid-run; the parent result
|
||||
// must not carry it either.
|
||||
expect(result.additionalContext).toBeUndefined()
|
||||
expect(result.additionalContexts).toEqual([
|
||||
{
|
||||
content: [{ type: 'text', text: 'context for call-1:code:1' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
envelope: 'raw',
|
||||
meta: { callId: 'call-1:code:1' },
|
||||
},
|
||||
{
|
||||
content: [{ type: 'text', text: 'context for call-1:code:2' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
envelope: 'raw',
|
||||
meta: { callId: 'call-1:code:2' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps sub-call contexts when run_code fails after the nested dispatch', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'both' })
|
||||
registerEcho(ctx)
|
||||
ctx.on('tools/post-execute', (exec, _result, next): Promise<PostToolDecision> => {
|
||||
if (exec.name !== 'echo') return next()
|
||||
return Promise.resolve({
|
||||
kind: 'accept',
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: 'nested context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}],
|
||||
})
|
||||
})
|
||||
runtime.behavior = async (request) => {
|
||||
await request.bindings[0]!.functions.echo!({ value: 'x' })
|
||||
return { logs: [], error: { kind: 'exception', message: 'program failed later' } }
|
||||
}
|
||||
|
||||
const result = await runCode(ctx, 'program')
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.additionalContexts).toEqual([{
|
||||
content: [{ type: 'text', text: 'nested context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('converts a failed run into a structured isError result carrying kind, message, and captured logs', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
runtime.behavior = () => Promise.resolve({
|
||||
logs: [{ source: 'console', level: 'log', text: 'got this far' }],
|
||||
logs: ['got this far'],
|
||||
error: { kind: 'timeout', message: 'compute budget exhausted (300ms busy)' },
|
||||
})
|
||||
const result = await runCode(ctx, 'program')
|
||||
@@ -577,11 +620,8 @@ describe('the run_code dispatch bridge', () => {
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
// Start a sub-dispatch, keep its rejection held, and fail the run once
|
||||
// the tool is genuinely in flight — a seam error AFTER work has begun.
|
||||
// The bridge's settlement still owes quiescence: without the finally,
|
||||
// run_code would return now and the slow tool would finish (and log)
|
||||
// afterwards.
|
||||
// Start a sub-dispatch, keep its rejection held, and fail the run once the tool is
|
||||
// genuinely in flight — a seam error after work has begun.
|
||||
request.bindings[0]!.functions.slow!({ id: 'orphan' }).catch(() => 'held')
|
||||
await inFlight
|
||||
throw new Error('backend exploded')
|
||||
@@ -632,7 +672,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
const view = tool.presentResult?.({ code: 'return 1' }, {
|
||||
content: [{ type: 'text', text: 'model-facing' }],
|
||||
isError: false,
|
||||
meta: { logs: [{ source: 'console', level: 'log', text: 'printed' }], dispatches: 1 },
|
||||
meta: { logs: ['printed'] },
|
||||
})
|
||||
// The result omits the title — an update replaces only provided fields,
|
||||
// so the pending card's program title persists through completion.
|
||||
@@ -641,9 +681,10 @@ describe('the run_code dispatch bridge', () => {
|
||||
content: [{ type: 'text', text: 'printed' }],
|
||||
})
|
||||
// No captured output → no content either; everything pending persists.
|
||||
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [], dispatches: 2 } }))
|
||||
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [] } }))
|
||||
.toEqual({ card: 'generic' })
|
||||
// Replay with an unrecognizable meta falls back to the generic rendering.
|
||||
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [{ text: 'legacy' }], dispatches: 1 } })).toBeUndefined()
|
||||
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined()
|
||||
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined()
|
||||
})
|
||||
@@ -675,6 +716,52 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(dispatch.resultSummary.endsWith('…')).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes the session workspace root before bounding durable result summaries', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'workspace_path',
|
||||
description: 'Return a path beneath the session workspace.',
|
||||
parameters: {},
|
||||
execute(_args, exec) {
|
||||
const cwd = exec.agent?.session.header.cwd ?? ''
|
||||
return Promise.resolve([{ type: 'text' as const, text: `<path>${cwd}/nested/task.txt</path>\n${'x'.repeat(240)}` }])
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async request => ({
|
||||
logs: [],
|
||||
value: await request.bindings[0]!.functions.workspace_path!({}),
|
||||
})
|
||||
|
||||
const short = fakeAgent({ cwd: '/tmp/workspace' })
|
||||
const long = fakeAgent({ cwd: `/tmp/${'long-segment/'.repeat(30)}workspace` })
|
||||
const shortResult = await runCode(ctx, 'program', { agent: short.agent })
|
||||
const longResult = await runCode(ctx, 'program', { agent: long.agent })
|
||||
const shortDispatch = short.events[0]!.data as SessionEventMap['tool/code-dispatch']
|
||||
const longDispatch = long.events[0]!.data as SessionEventMap['tool/code-dispatch']
|
||||
|
||||
expect(shortResult.content).not.toEqual(longResult.content)
|
||||
expect(shortDispatch.resultSummary).toBe(longDispatch.resultSummary)
|
||||
expect(shortDispatch.resultSummary).toHaveLength(201)
|
||||
expect(shortDispatch.resultSummary).toMatch(/^<path>\.\/nested\/task\.txt<\/path>\n.+…$/)
|
||||
})
|
||||
|
||||
it('leaves result summaries unchanged when a session cwd is absent or is the filesystem root', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
registerEcho(ctx)
|
||||
runtime.behavior = async request => ({
|
||||
logs: [],
|
||||
value: await request.bindings[0]!.functions.echo!({ value: '/workspace/value' }),
|
||||
})
|
||||
|
||||
const absent = fakeAgent({})
|
||||
const root = fakeAgent({ cwd: '/' })
|
||||
await runCode(ctx, 'program', { agent: absent.agent })
|
||||
await runCode(ctx, 'program', { agent: root.agent })
|
||||
|
||||
expect((absent.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value')
|
||||
expect((root.events[0]!.data as SessionEventMap['tool/code-dispatch']).resultSummary).toBe('echo:/workspace/value')
|
||||
})
|
||||
|
||||
it('rejects undefined, JSON-throwing, and JSON-unrepresentable binding arguments BEFORE dispatch', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const calls = registerEcho(ctx)
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
/**
|
||||
* Guarantee tests for the tool-schema catalog generator
|
||||
* (`scripts/gen-tool-catalog.ts`).
|
||||
*
|
||||
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
|
||||
* the freshness half is exercised by `pnpm run verify-tool-catalog` in CI. What
|
||||
* a freshness diff CANNOT prove is (a) that BOOTING the tool plugins yields the
|
||||
* shipped schema — the whole reason this generator boots instead of parsing
|
||||
* source (a runtime-spread enum resolves to its literal members) — and (b) that
|
||||
* the completeness guard REJECTS a tool package missing from the boot manifest,
|
||||
* the property that replaces the AST pass's "nothing silently omitted". These
|
||||
* tests drive the exported `collectToolCatalog` / `assertManifestComplete` /
|
||||
* `render` directly, mirroring the negative-path style of the cordis-catalog
|
||||
* generator tests.
|
||||
* Guarantee tests for the tool-schema catalog generator (`scripts/gen-tool-catalog.ts`).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -35,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
@@ -75,11 +63,8 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
})
|
||||
|
||||
it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => {
|
||||
// `tool-subagent`'s registered name is the load-time `toolName` config, so
|
||||
// the shipped agents surface this one package as both `subagent` and
|
||||
// `subagent_fork`. Booting yields only the default name; the note is how a
|
||||
// reader learns the fork alias the model also sees. Without it the catalog
|
||||
// would silently under-report the shipped tool surface.
|
||||
// `tool-subagent`'s registered name is the load-time `toolName` config, so the shipped
|
||||
// agents surface this one package as both `subagent` and `subagent_fork`.
|
||||
const catalog = await collectToolCatalog()
|
||||
const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent')
|
||||
expect(subagent?.schemas.map(s => s.name)).toEqual(['subagent'])
|
||||
|
||||
@@ -548,7 +548,6 @@ describe('scoped execution dispatch', () => {
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(result).toEqual({
|
||||
callId: CallId('unstable-arguments'),
|
||||
content: [{ type: 'text', text: 'ran:t' }],
|
||||
isError: false,
|
||||
})
|
||||
@@ -564,10 +563,9 @@ describe('scoped execution dispatch', () => {
|
||||
ctx.on('internal/dispatch', (mode, name) => {
|
||||
if (name === 'tools/result') dispatchModes.push(mode)
|
||||
})
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
ctx.on('tools/execute', async (_exec, next) => {
|
||||
await next()
|
||||
return {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: 'outer failure' }],
|
||||
isError: true,
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('ToolRegistry', () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
|
||||
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
|
||||
})
|
||||
|
||||
it('threads a tool-attached meta (object return form) onto the result', async () => {
|
||||
@@ -94,7 +94,6 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} })
|
||||
expect(result).toEqual({
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] },
|
||||
@@ -111,7 +110,7 @@ describe('ToolRegistry', () => {
|
||||
},
|
||||
})
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
|
||||
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
|
||||
expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
|
||||
expect('meta' in result).toBe(false)
|
||||
})
|
||||
|
||||
@@ -178,13 +177,12 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ToolNotFoundError carries the tool name and a stable code', async () => {
|
||||
it('ToolNotFoundError carries a stable message and code', async () => {
|
||||
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
const err = new ToolNotFoundError('ghost')
|
||||
expect(err).toBeInstanceOf(HarnessError)
|
||||
expect(err.name).toBe('ToolNotFoundError')
|
||||
expect(err.code).toBe('UNKNOWN_TOOL')
|
||||
expect(err.toolName).toBe('ghost')
|
||||
expect(err.message).toBe('unknown tool "ghost"')
|
||||
})
|
||||
|
||||
@@ -350,7 +348,7 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' })
|
||||
})
|
||||
|
||||
it('a block decision can ALSO attach additionalContext', async () => {
|
||||
it('a block decision can ALSO attach additionalContexts', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
@@ -358,24 +356,95 @@ describe('ToolRegistry', () => {
|
||||
({
|
||||
kind: 'block',
|
||||
feedback: [{ type: 'text', text: 'rejected' }],
|
||||
additionalContext: { content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
additionalContexts: [{ content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }],
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'rejected' })
|
||||
expect(result.additionalContext).toMatchObject({ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }])
|
||||
})
|
||||
|
||||
it('a post-execute additionalContext rides on the result for the loop to buffer', async () => {
|
||||
it('post-execute additionalContexts ride on the result for the loop to buffer', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
|
||||
({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } } }))
|
||||
({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }] }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }])
|
||||
})
|
||||
|
||||
it('preserves tool-deferred, execute-wrapper, and post-execute contexts in order', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'composite',
|
||||
description: 'composite',
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' }, meta: { n: 1 } })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' }, envelope: 'raw' })
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
}))
|
||||
ctx.on('tools/execute', async (_exec, next) => {
|
||||
const result = await next()
|
||||
return {
|
||||
...result,
|
||||
additionalContexts: [
|
||||
...result.additionalContexts ?? [],
|
||||
{ content: [{ type: 'text', text: 'wrapper' }], source: { kind: 'plugin', plugin: 'wrapper' } },
|
||||
],
|
||||
}
|
||||
})
|
||||
ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
|
||||
const downstream = await next()
|
||||
return {
|
||||
...downstream,
|
||||
additionalContexts: [
|
||||
{ content: [{ type: 'text', text: 'post' }], source: { kind: 'plugin', plugin: 'post' } },
|
||||
...downstream.additionalContexts ?? [],
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('composite'), name: 'composite', arguments: {} })
|
||||
|
||||
expect(result.additionalContexts?.map(context => context.source)).toEqual([
|
||||
{ kind: 'plugin', plugin: 'nested-1' },
|
||||
{ kind: 'plugin', plugin: 'nested-2' },
|
||||
{ kind: 'plugin', plugin: 'wrapper' },
|
||||
{ kind: 'plugin', plugin: 'post' },
|
||||
])
|
||||
expect(result.additionalContexts?.[0]?.meta).toEqual({ n: 1 })
|
||||
expect(result.additionalContexts?.[1]?.envelope).toBe('raw')
|
||||
})
|
||||
|
||||
it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'failing-composite',
|
||||
description: 'failing composite',
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested' }], source: { kind: 'plugin', plugin: 'nested' } })
|
||||
throw new Error('outer failure')
|
||||
},
|
||||
}))
|
||||
|
||||
const failed = await ctx.tools.execute({ callId: CallId('failed'), name: 'failing-composite', arguments: {} })
|
||||
expect(failed.isError).toBe(true)
|
||||
expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'nested' }])
|
||||
|
||||
ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
|
||||
kind: 'block',
|
||||
feedback: [{ type: 'text', text: 'blocked' }],
|
||||
additionalContexts: [{ content: [{ type: 'text', text: 'block-only' }], source: { kind: 'plugin', plugin: 'blocker' } }],
|
||||
}))
|
||||
const blocked = await ctx.tools.execute({ callId: CallId('blocked'), name: 'failing-composite', arguments: {} })
|
||||
expect(blocked.isError).toBe(true)
|
||||
expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'blocker' }])
|
||||
})
|
||||
|
||||
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
|
||||
@@ -425,7 +494,7 @@ describe('ToolRegistry', () => {
|
||||
ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
|
||||
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
|
||||
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
|
||||
// The around seam wraps dispatch; pre gates before it, post runs over its result.
|
||||
expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
|
||||
})
|
||||
@@ -526,48 +595,33 @@ describe('ToolRegistry', () => {
|
||||
async execute() { dispatched = true; return [] },
|
||||
})
|
||||
|
||||
ctx.on('tools/execute', async (exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
|
||||
({ callId: exec.callId, content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
|
||||
ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
|
||||
({ content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
|
||||
expect(dispatched).toBe(false) // returning without next() skips core dispatch
|
||||
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
|
||||
})
|
||||
|
||||
it('preserves additionalContext supplied by an around-dispatch result', async () => {
|
||||
it('preserves additionalContexts supplied by an around-dispatch result', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async exec => ({
|
||||
callId: exec.callId,
|
||||
ctx.on('tools/execute', async () => ({
|
||||
content: [{ type: 'text', text: 'short-circuited with context' }],
|
||||
isError: false,
|
||||
additionalContext: {
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: 'from around dispatch' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
},
|
||||
}],
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('around-context'), name: 'echo', arguments: {},
|
||||
})
|
||||
expect(result.additionalContext).toEqual({
|
||||
expect(result.additionalContexts).toEqual([{
|
||||
content: [{ type: 'text', text: 'from around dispatch' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a tools/execute result with the wrong call id', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
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: tools/execute returned callId "other" for authoritative call "malformed-shape"',
|
||||
})
|
||||
}])
|
||||
})
|
||||
|
||||
it('returns an isError result when a tools/execute listener throws', async () => {
|
||||
@@ -577,7 +631,6 @@ describe('ToolRegistry', () => {
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result).toEqual({
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'Error: wrapper broke' }],
|
||||
isError: true,
|
||||
})
|
||||
@@ -593,7 +646,6 @@ describe('ToolRegistry', () => {
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
|
||||
expect(result).toEqual({
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'Error: permission hook broke' }],
|
||||
isError: true,
|
||||
})
|
||||
@@ -609,7 +661,6 @@ describe('ToolRegistry', () => {
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
|
||||
expect(result).toEqual({
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'Error: post hook broke' }],
|
||||
isError: true,
|
||||
})
|
||||
@@ -625,7 +676,6 @@ describe('ToolRegistry', () => {
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
callId: CallId('c1'),
|
||||
isError: true,
|
||||
error: { name: 'HarnessError', code: 'DENIED' },
|
||||
})
|
||||
@@ -703,16 +753,9 @@ 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).
|
||||
// Registry methods return the exact Cordis effect disposer so a composite yield places
|
||||
// unregistration at its LIFO position. A wrapper would create a concurrent sibling; this async
|
||||
// probe yields during earlier teardown and would then observe the tool already removed.
|
||||
const ctx = await setup()
|
||||
const order: string[] = []
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
@@ -999,20 +1042,18 @@ describe('schema DSL edge cases', () => {
|
||||
port: { type: 'number' },
|
||||
},
|
||||
})
|
||||
// no 'required' key in the nested object because nothing is required
|
||||
const config = jsonSchema.properties['config'] as Record<string, unknown>
|
||||
expect('required' in config).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('schema DSL regressions (Codex review round 2)', () => {
|
||||
describe('schema DSL optional and nested contracts', () => {
|
||||
it('InferArgs makes non-required keys genuinely optional (omittable)', () => {
|
||||
type Args = InferArgs<{
|
||||
path: { type: 'string'; required: true }
|
||||
limit: { type: 'number' }
|
||||
}>
|
||||
expectTypeOf<Args>().toEqualTypeOf<{ path: string; limit?: number }>()
|
||||
// omitting the optional key is assignable — the actual regression
|
||||
const omitted: Args = { path: '/tmp' }
|
||||
expect(omitted.limit).toBeUndefined()
|
||||
})
|
||||
@@ -1263,7 +1304,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
|
||||
},
|
||||
}))
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
|
||||
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'read /x' }], isError: false })
|
||||
expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false })
|
||||
})
|
||||
|
||||
it('ToolArgsError carries a stable code and the violation list', () => {
|
||||
|
||||
Reference in New Issue
Block a user