Merge remote-tracking branch 'origin/master' into codex/skill-system

This commit is contained in:
Yichen Jiang
2026-07-09 23:11:10 +08:00
438 changed files with 24091 additions and 1723 deletions

View File

@@ -1,6 +1,6 @@
# dsh-tools
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → core dispatch`tools/post-execute` (inspect/replace the result, attach context).
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins)`tools/post-execute` (inspect/replace the result, attach context).
## Service: `ToolRegistry` (ctx key: `tools`)
@@ -8,8 +8,8 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber.
- `ctx.tools.get(name: string): ToolDefinition | undefined`
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog/tools.md](../../../docs/tool-catalog/tools.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.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute`dispatch`tools/post-execute` pipeline.
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (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.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute``tools/execute``tools/post-execute` pipeline.
### Injected services
@@ -20,12 +20,13 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
| Event | Mode | Purpose |
|---|---|---|
| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision` |
| `tools/execute` | waterfall | Around-dispatch wrapper (timeout, retry, metrics): `(exec, next)` → the dispatched `ToolExecutionResult`; `next()` is dispatch-with-normalization |
| `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` |
| `tools/change` | emit | A tool was registered or unregistered |
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model.
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands.
@@ -35,7 +36,7 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
### Extension points
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
- `tools/pre-execute` is the allow/deny 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`/`ask` skips dispatch and yields an `isError` result. `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 sits between them as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. Both follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)).
- `tools/pre-execute` is the allow/deny 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`/`ask` skips dispatch and yields an `isError` result. `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/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `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 try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
### Typed tool parameter schemas
@@ -71,6 +72,14 @@ A `defineTool` tool also **validates the model-generated arguments against its `
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.
### 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).
### 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`):

View File

@@ -1,9 +1,10 @@
/**
* 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 allow/deny gate) → core dispatch →
* `tools/post-execute` (inspect/replace the result, attach context) for
* sandbox, permission, and hook plugins to gate or transform a call.
* through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an
* around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute`
* (inspect/replace the result, attach context) for sandbox, permission, and hook
* plugins to gate or transform a call.
*
* @module @deepseek-ai/dsh-tools
*/
@@ -28,6 +29,16 @@ export {
type JsonSchemaObject,
} from './schema.ts'
export {
assertSupportedOutputSchema,
validateStructuredValue,
OutputSchemaError,
type StructuredOutputSchema,
type StructuredSchemaNode,
type StructuredSchemaType,
type StructuredScalar,
} from './json-schema.ts'
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
// stays the single public surface for consumers (producers + the ACP bridge).
@@ -64,17 +75,37 @@ declare module 'cordis' {
* @mode waterfall
*/
'tools/pre-execute'(this: 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
* mutate `exec` (e.g. replace `exec.signal` with a per-call deadline) BEFORE
* `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed
* arguments and re-invokes downstream with the shared payload, so a wrapper
* mutates `exec` 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.
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
* @mode waterfall
*/
'tools/execute'(this: 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. The core tool
* dispatch sits between the two waterfalls as plain code, 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).
* 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).
* @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
@@ -107,6 +138,14 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta
/** A registered tool: its schema plus the execution function. */
export interface ToolDefinition extends ToolSchema {
execute(args: unknown, exec: ToolExecution): 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
* is NEVER sent to the model — `schemas()` whitelists only name/description/
* parameters. Declaring it asserts this tool forwards `exec.signal` to a
* cooperative implementation that can reach quiescence when the signal aborts.
*/
timeoutMs?: number
/**
* Optional: how to present the PENDING state of one call in a UI, derived from
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
@@ -261,7 +300,7 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined {
/**
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
* loop executes calls through the `tools/pre-execute` → dispatch
* loop executes calls through the `tools/pre-execute` → `tools/execute`
* `tools/post-execute` pipeline. The registry contributes its schemas into the
* system-prompt assembly.
*/
@@ -335,18 +374,20 @@ export class ToolRegistry extends Service {
}
/**
* Execute one tool call through the `tools/pre-execute` → dispatch →
* `tools/post-execute` pipeline. The two waterfalls are the gate (allow/deny)
* and the inspect/transform seam; core dispatch sits between them as plain
* code. The whole thing is wrapped in one outer try/catch so a throwing
* listener (in either 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 `post-execute` listeners can
* still inspect. If the tool is not registered, the result is an `isError`
* carrying a `UNKNOWN_TOOL` structured error. A thrown {@link HarnessError}
* surfaces its `{ name, code }` on the result.
* Execute one tool call through the `tools/pre-execute` → `tools/execute`
* (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate
* (allow/deny), `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, the result is an `isError` carrying a `UNKNOWN_TOOL`
* structured error. A thrown {@link HarnessError} surfaces its `{ name, code }`
* on the result.
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
* @returns the final result after both waterfalls; failures resolve as
* @returns the final result after every waterfall; failures resolve as
* `isError` results, never rejections.
*/
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
@@ -372,23 +413,30 @@ export class ToolRegistry extends Service {
return await this.postExecute(exec, denied)
}
// --- Core dispatch (plain code between the waterfalls). The tool body's
// own try/catch turns a throw into an isError result so post-execute can
// inspect it; an unknown tool routes through the same catch. ---
let result: ToolExecutionResult
try {
const tool = this.store.get(exec.name)
if (!tool) throw new ToolNotFoundError(exec.name)
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
// presentation payload). An array IS the content; the object carries it.
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
result = { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
result = toolErrorResult(exec.callId, error)
}
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
// with-normalization thunk — the tool body's own try/catch turns a throw
// into an isError result so a wrapper (and post-execute) can inspect it;
// an unknown tool routes through the same catch. A `tools/execute` listener
// (e.g. a timeout plugin) wraps this thunk: it may mutate `exec` before
// delegating and inspect the normalized result after. ---
const result = await this.ctx.waterfall(
this, 'tools/execute', exec,
async (): Promise<ToolExecutionResult> => {
try {
const tool = this.store.get(exec.name)
if (!tool) throw new ToolNotFoundError(exec.name)
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
// presentation payload). An array IS the content; the object carries it.
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 } : {} }
} catch (error: unknown) {
return toolErrorResult(exec.callId, error)
}
},
)
return await this.postExecute(exec, result)
} catch (error: unknown) {

View File

@@ -0,0 +1,345 @@
/**
* 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).
*
* @module dsh-tools/json-schema
*/
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
/** The scalar values `enum`/`const` may carry (finite numbers only). */
export type StructuredScalar = string | number | boolean | null
/** The `type` keywords the subset accepts. */
export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
/**
* One node of the structured-output schema subset. Recursive via `properties`
* and `items`; see the module doc for the exact keyword semantics.
*/
export interface StructuredSchemaNode {
type: StructuredSchemaType
/** Nested property schemas (`type: 'object'` only). */
properties?: Record<string, StructuredSchemaNode>
/** Required property names; each must appear in `properties`. */
required?: string[]
/** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */
additionalProperties?: boolean
/** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */
items?: StructuredSchemaNode
/** Allowed values (scalar types only). */
enum?: StructuredScalar[]
/** The single allowed value (scalar types only). */
const?: StructuredScalar
/** Annotation, ignored for validation. */
description?: string
/** Annotation, ignored for validation. */
title?: string
/** Annotation, ignored for validation (must still be JSON data). */
default?: unknown
/** Annotation, ignored for validation (must still be JSON data). */
examples?: unknown
}
/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */
export type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
/**
* Thrown by {@link assertSupportedOutputSchema} when a schema falls outside the
* supported subset. Extends {@link HarnessError} (`code: 'UNSUPPORTED_SCHEMA'`)
* so seam code and tool results can route on it; `violations` lists every
* offending path, not just the first.
*/
export class OutputSchemaError extends HarnessError {
/** The individual violation messages, in walk order. */
readonly violations: string[]
constructor(violations: string[]) {
super(`unsupported output schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
this.name = 'OutputSchemaError'
this.violations = violations
}
}
/** The keywords the subset accepts, checked (`constraint`) or ignored (`annotation`). */
const CONSTRAINT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const'])
const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
const SCHEMA_TYPES: readonly StructuredSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
/**
* Whether a value is a PLAIN JSON object — non-null, non-array, and with a
* prototype chain of at most one link (`null`-proto, or any realm's
* `Object.prototype`, whose own prototype is `null`). Realm-agnostic on
* purpose: a schema materialized in another realm carries THAT realm's
* `Object.prototype`, which an identity check would wrongly reject. Exotic
* hosts (`Date`, `Map`, class instances) have longer chains and are rejected —
* they would serialize lossily (`Date` → string, `Map` → `{}`) instead of
* failing loud.
*/
function isObjectLike(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const proto: unknown = Object.getPrototypeOf(value)
return proto === null || Object.getPrototypeOf(proto) === null
}
/** Whether a value is a supported scalar (`enum`/`const` member): string, finite number, boolean, or null. */
function isStructuredScalar(value: unknown): value is StructuredScalar {
return value === null || typeof value === 'string' || typeof value === 'boolean'
|| (typeof value === 'number' && Number.isFinite(value))
}
/**
* Whether a value is JSON data (annotation payloads only): scalars, arrays, and
* object-likes of such values. Realm-agnostic on purpose (no prototype check) —
* the schema may have been materialized from another realm; structural JSON-ness
* is what the wire needs. Cycles are rejected via `seen`.
*/
function isJsonData(value: unknown, seen: Set<object>): boolean {
if (isStructuredScalar(value)) return true
// The scalar check above already returned for null, so `object` here is a real object.
if (typeof value !== 'object') return false
if (seen.has(value)) return false
seen.add(value)
try {
if (Array.isArray(value)) return value.every(entry => isJsonData(entry, seen))
// A non-plain object (Date, Map, class instance) is NOT JSON data even when
// it has no enumerable values — it would serialize lossily, not loudly.
if (!isObjectLike(value)) return false
return Object.values(value).every(entry => isJsonData(entry, seen))
} finally {
seen.delete(value)
}
}
/** Collect subset violations for one schema node (recursive walk). */
function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set<object>): void {
if (!isObjectLike(node)) {
violations.push(`${path} must be a schema object`)
return
}
if (seen.has(node)) {
violations.push(`${path} is circular`)
return
}
seen.add(node)
for (const key of Object.keys(node)) {
if (CONSTRAINT_KEYWORDS.has(key)) continue
if (ANNOTATION_KEYWORDS.has(key)) {
if (!isJsonData(node[key], new Set())) violations.push(`${path}.${key} annotation must be JSON data`)
continue
}
violations.push(`${path}.${key} is not a supported keyword (subset: type/properties/required/additionalProperties/items/enum/const + annotations)`)
}
if (typeof node.description !== 'undefined' && typeof node.description !== 'string') {
violations.push(`${path}.description must be a string`)
}
if (typeof node.title !== 'undefined' && typeof node.title !== 'string') {
violations.push(`${path}.title must be a string`)
}
const type = node.type
if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) {
violations.push(Array.isArray(type)
? `${path}.type must be a single type string (type arrays are not supported)`
: `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
seen.delete(node)
return
}
const schemaType = type as StructuredSchemaType
// Keywords that only make sense on one type are rejected elsewhere — an
// `items` on an object (or `properties` on a string) is a schema-author bug
// the subset surfaces rather than ignores.
const allowedFor: Record<string, StructuredSchemaType[]> = {
properties: ['object'],
required: ['object'],
additionalProperties: ['object'],
items: ['array'],
enum: ['string', 'number', 'integer', 'boolean', 'null'],
const: ['string', 'number', 'integer', 'boolean', 'null'],
}
for (const [key, types] of Object.entries(allowedFor)) {
if (key in node && !types.includes(schemaType)) {
violations.push(`${path}.${key} is not supported on type "${schemaType}"`)
}
}
switch (schemaType) {
case 'object': {
const properties = node.properties
if (properties !== undefined) {
if (!isObjectLike(properties)) {
violations.push(`${path}.properties must be an object of schemas`)
} else {
for (const [key, child] of Object.entries(properties)) {
checkSchemaNode(child, `${path}.properties.${key}`, violations, seen)
}
}
}
const required = node.required
if (required !== undefined) {
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
violations.push(`${path}.required must be an array of strings`)
} else {
const declared = isObjectLike(properties) ? properties : {}
// The guard above proved every entry is a string.
for (const key of required as string[]) {
// Own-property check: `in` would let inherited names (`toString`)
// satisfy the declared-in-properties contract via the prototype.
if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
}
}
}
if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') {
violations.push(`${path}.additionalProperties must be a boolean`)
}
break
}
case 'array': {
if (node.items !== undefined) checkSchemaNode(node.items, `${path}.items`, violations, seen)
break
}
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null': {
const allowed = node.enum
if (allowed !== undefined) {
if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => isStructuredScalar(entry))) {
violations.push(`${path}.enum must be a non-empty array of scalars`)
}
}
if ('const' in node && !isStructuredScalar(node.const)) {
violations.push(`${path}.const must be a scalar`)
}
break
}
/* v8 ignore start -- defensive: schemaType was membership-checked against SCHEMA_TYPES above, so no runtime value reaches here */
default:
assertNever(schemaType, 'assertSupportedOutputSchema')
/* v8 ignore stop */
}
seen.delete(node)
}
/**
* Assert `schema` is a supported {@link StructuredOutputSchema} — object-rooted
* and entirely within the enforced subset. Throws {@link OutputSchemaError}
* (`UNSUPPORTED_SCHEMA`) listing EVERY violation; returns (and narrows) on
* success. Call this at the seam boundary, before any child is created.
* @param schema - the caller-supplied schema (unknown until asserted).
* @returns nothing — the assertion signature narrows `schema` to
* {@link StructuredOutputSchema} in the caller's scope on normal return.
*/
export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema {
const violations: string[] = []
checkSchemaNode(schema, 'schema', violations, new Set())
if (violations.length === 0 && (schema as StructuredSchemaNode).type !== 'object') {
violations.push('schema.type must be "object" (structured output is object-rooted)')
}
if (violations.length > 0) throw new OutputSchemaError(violations)
}
/** Collect violations for one value against an (already asserted) schema node. */
function checkValue(node: StructuredSchemaNode, value: unknown, path: string): string[] {
switch (node.type) {
case 'object': {
if (!isObjectLike(value)) return [`"${path}" must be an object`]
const violations: string[] = []
const properties = node.properties ?? {}
// Own-property discipline throughout: JSON carries own enumerable
// properties only, so an inherited `toString` must not satisfy
// `required`, dodge `additionalProperties: false`, or be validated as if
// the value carried it.
for (const key of node.required ?? []) {
if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${path}.${key}"`)
}
for (const [key, child] of Object.entries(properties)) {
if (!Object.hasOwn(value, key) || value[key] === undefined) continue
violations.push(...checkValue(child, value[key], `${path}.${key}`))
}
if (node.additionalProperties === false) {
for (const key of Object.keys(value)) {
if (!Object.hasOwn(properties, key)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`)
}
}
return violations
}
case 'array': {
if (!Array.isArray(value)) return [`"${path}" must be an array`]
if (!node.items) return []
const items = node.items
return value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`))
}
case 'string': {
if (typeof value !== 'string') return [`"${path}" must be a string`]
break
}
case 'number': {
if (typeof value !== 'number' || !Number.isFinite(value)) return [`"${path}" must be a finite number`]
break
}
case 'integer': {
if (typeof value !== 'number' || !Number.isInteger(value)) return [`"${path}" must be an integer`]
break
}
case 'boolean': {
if (typeof value !== 'boolean') return [`"${path}" must be a boolean`]
break
}
case 'null': {
if (value !== null) return [`"${path}" must be null`]
break
}
default:
return assertNever(node.type, 'validateStructuredValue')
}
// Scalar constraint checks, shared by every scalar branch above.
if (node.enum && !node.enum.includes(value)) {
return [`"${path}" must be one of ${JSON.stringify(node.enum)}`]
}
if ('const' in node && value !== node.const) {
return [`"${path}" must be ${JSON.stringify(node.const)}`]
}
return []
}
/**
* Validate a value against an (already {@link assertSupportedOutputSchema}-
* asserted) schema. Returns human-readable, path-qualified violation messages
* — empty means valid. Total: never throws, however malformed the value.
* @param schema - the asserted schema to check against.
* @param value - the candidate value (e.g. parsed tool-call arguments).
* @returns every violation found, in walk order (empty = valid).
*/
export function validateStructuredValue(schema: StructuredOutputSchema, value: unknown): string[] {
return checkValue(schema, value, 'value')
}

View File

@@ -155,6 +155,9 @@ export interface JsonSchemaObject {
* `properties`, `required` array).
*
* This is a plain function — no schemastery or other framework dependency.
* @param spec - the author-facing per-property schema to convert.
* @returns the wire-format JSON Schema; the top-level `required` array is
* omitted entirely when no property is marked required.
*/
export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
const properties: Record<string, unknown> = {}
@@ -269,6 +272,9 @@ function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
* keys are allowed (no `additionalProperties: false`); `default` is not
* applied; an `object`/`array` prop without `properties`/`items` only
* type-checks; `enum` is membership (strings only).
* @param spec - the declared parameter schema to validate against.
* @param args - the model-generated arguments, however malformed.
* @returns the violation messages in declaration order; empty means valid.
*/
export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
return checkSpec(spec, args, '')
@@ -289,6 +295,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* standard JSON Schema at runtime.
*/
parameters: S
/**
* Optional cooperative tool-call timeout budget in milliseconds. When given it
* must be a positive finite number; it is attached to the produced
* {@link ToolDefinition} for `@deepseek-ai/dsh-timeout-policy` to enforce and
* is never sent to the model.
*/
timeoutMs?: number
/**
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
@@ -340,6 +353,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* Raw JSON-Schema tool definitions (from MCP servers) are still accepted
* by `ToolRegistry.register()` directly — `defineTool` is sugar for
* first-party plugin authors.
* @param options - the tool's name, description, typed parameter schema,
* execute body, and optional presenters.
* @returns a registry-ready {@link ToolDefinition}: its `execute` validates the
* 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).
*/
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
// Object-literal execute methods don't use `this`; the reference is safe.
@@ -349,10 +369,14 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
const userPresentCall = options.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentResult = options.presentResult
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
}
const tool: ToolDefinition = {
name: options.name,
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> {
// Validate the model-generated args before the typed body runs. On
// mismatch we throw ToolArgsError; the registry turns it into an

View File

@@ -35,7 +35,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(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'skill', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {

View File

@@ -0,0 +1,304 @@
import { describe, expect, it } from 'vitest'
import {
assertSupportedOutputSchema,
OutputSchemaError,
validateStructuredValue,
type StructuredOutputSchema,
} from '../src/json-schema.ts'
/** Assert-and-narrow helper: the asserted schema, typed. */
function asserted(schema: unknown): StructuredOutputSchema {
assertSupportedOutputSchema(schema)
return schema
}
/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */
function violationsOf(schema: unknown): string[] {
try {
assertSupportedOutputSchema(schema)
} catch (error: unknown) {
if (error instanceof OutputSchemaError) return error.violations
throw error
}
throw new Error('expected the schema to be rejected')
}
describe('assertSupportedOutputSchema', () => {
it('accepts a representative subset schema (all supported keywords)', () => {
const schema = asserted({
type: 'object',
description: 'a finding',
title: 'Finding',
properties: {
file: { type: 'string', description: 'path' },
line: { type: 'integer' },
severity: { type: 'string', enum: ['low', 'high'] },
kind: { type: 'string', const: 'bug' },
score: { type: 'number' },
confirmed: { type: 'boolean' },
parent: { type: 'null' },
tags: { type: 'array', items: { type: 'string' } },
nested: {
type: 'object',
properties: { x: { type: 'number', default: 3, examples: [1, 2] } },
additionalProperties: false,
},
anything: { type: 'array' },
},
required: ['file', 'line'],
additionalProperties: true,
})
expect(schema.type).toBe('object')
})
it('rejects a non-object root (scalar/array-rooted schemas)', () => {
expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
expect(violationsOf({ type: 'array', items: { type: 'string' } }))
.toContain('schema.type must be "object" (structured output is object-rooted)')
})
it('rejects non-object schema nodes and missing/unknown type', () => {
expect(violationsOf('nope')).toEqual(['schema must be a schema object'])
expect(violationsOf(null)).toEqual(['schema must be a schema object'])
expect(violationsOf([])).toEqual(['schema must be a schema object'])
expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null'])
expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/)
expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object'])
})
it('rejects type ARRAYS with a dedicated message', () => {
expect(violationsOf({ type: ['string', 'null'] }))
.toEqual(['schema.type must be a single type string (type arrays are not supported)'])
})
it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => {
for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
const bad = violationsOf({ type: 'object', [keyword]: [] })
expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true)
}
})
it('reports EVERY violation, not just the first', () => {
const bad = violationsOf({
type: 'object',
pattern: 'x',
properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } },
})
expect(bad.length).toBe(3)
})
it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => {
expect(violationsOf({ type: 'object', items: { type: 'string' } }))
.toEqual(['schema.items is not supported on type "object"'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } }))
.toEqual(['schema.properties.a.properties is not supported on type "string"'])
expect(violationsOf({ type: 'object', enum: [1] }))
.toEqual(['schema.enum is not supported on type "object"'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } }))
.toEqual(['schema.properties.a.const is not supported on type "array"'])
})
it('validates required: must be string[] naming declared properties', () => {
expect(violationsOf({ type: 'object', required: 'file' }))
.toEqual(['schema.required must be an array of strings'])
expect(violationsOf({ type: 'object', required: [1] }))
.toEqual(['schema.required must be an array of strings'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] }))
.toEqual(['schema.required names "b" which is not in properties'])
expect(violationsOf({ type: 'object', required: ['a'] }))
.toEqual(['schema.required names "a" which is not in properties'])
})
it('validates additionalProperties must be boolean and enum/const must be scalars', () => {
expect(violationsOf({ type: 'object', additionalProperties: {} }))
.toEqual(['schema.additionalProperties must be a boolean'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } }))
.toEqual(['schema.properties.a.const must be a scalar'])
})
it('rejects non-string description/title and non-JSON annotation payloads', () => {
expect(violationsOf({ type: 'object', description: 7 }))
.toEqual(['schema.description must be a string'])
expect(violationsOf({ type: 'object', title: 7 }))
.toEqual(['schema.title must be a string'])
expect(violationsOf({ type: 'object', default: () => 1 }))
.toEqual(['schema.default annotation must be JSON data'])
expect(violationsOf({ type: 'object', examples: [undefined] }))
.toEqual(['schema.examples annotation must be JSON data'])
expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] }))
.toEqual(['schema.examples annotation must be JSON data'])
// A cyclic annotation payload is caught by the JSON-data walk.
const cyclicAnnotation: Record<string, unknown> = {}
cyclicAnnotation.self = cyclicAnnotation
expect(violationsOf({ type: 'object', default: cyclicAnnotation }))
.toEqual(['schema.default annotation must be JSON data'])
// Object/array annotations that ARE JSON data pass.
asserted({ type: 'object', default: { a: [1, 'x', null, true] } })
})
it('rejects a circular schema instead of recursing forever', () => {
const node: Record<string, unknown> = { type: 'object' }
node.properties = { self: node }
expect(violationsOf(node)).toEqual(['schema.properties.self is circular'])
})
it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => {
const leaf = { type: 'string' }
asserted({ type: 'object', properties: { a: leaf, b: leaf } })
})
it('required cannot be satisfied by INHERITED names — `toString` is not a declared property', () => {
// `'toString' in {}` is true via Object.prototype; the declared-property
// contract must be an own-property check.
expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
.toEqual(['schema.required names "toString" which is not in properties'])
})
it('rejects exotic host objects where the subset expects plain JSON structure', () => {
// A Map as `properties` has no own enumerable entries: structurally it
// would read as "no properties" and serialize to {} — lossy, not loud.
expect(violationsOf({ type: 'object', properties: new Map() }))
.toEqual(['schema.properties must be an object of schemas'])
// A Date node is not a schema object even though Object.values(date) is [].
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
.toEqual(['schema.properties.at must be a schema object'])
})
it('rejects exotic annotation payloads that would serialize lossily', () => {
expect(violationsOf({ type: 'object', default: new Date(0) }))
.toEqual(['schema.default annotation must be JSON data'])
expect(violationsOf({ type: 'object', examples: [new Map()] }))
.toEqual(['schema.examples annotation must be JSON data'])
})
})
describe('validateStructuredValue', () => {
const schema = asserted({
type: 'object',
properties: {
file: { type: 'string' },
line: { type: 'integer' },
score: { type: 'number' },
confirmed: { type: 'boolean' },
parent: { type: 'null' },
severity: { type: 'string', enum: ['low', 'high'] },
kind: { type: 'string', const: 'bug' },
tags: { type: 'array', items: { type: 'string' } },
free: { type: 'array' },
nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false },
},
required: ['file'],
})
it('accepts a fully valid value (empty violations)', () => {
expect(validateStructuredValue(schema, {
file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null,
severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 },
})).toEqual([])
})
it('reports missing required and wrong root type', () => {
expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"'])
expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object'])
expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object'])
})
it('type-checks every scalar branch with path-qualified messages', () => {
expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string'])
expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer'])
expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer'])
expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number'])
expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number'])
expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean'])
expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null'])
})
it('enforces enum membership and const equality', () => {
expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' }))
.toEqual(['"value.severity" must be one of ["low","high"]'])
expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' }))
.toEqual(['"value.kind" must be "bug"'])
})
it('checks arrays per index; an items-less array accepts anything', () => {
expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array'])
expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string'])
expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([])
})
it('recurses into nested objects: required + additionalProperties: false', () => {
expect(validateStructuredValue(schema, { file: 'a', nested: {} }))
.toEqual(['missing required property "value.nested.x"'])
expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } }))
.toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)'])
expect(validateStructuredValue(schema, { file: 'a', nested: 3 }))
.toEqual(['"value.nested" must be an object'])
})
it('a required key present-but-undefined counts as missing', () => {
expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"'])
})
it('inherited properties satisfy nothing: required, additionalProperties, and recursion are own-property only', () => {
// required: ['toString'] must NOT be satisfied by Object.prototype.toString.
expect(validateStructuredValue(
asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }),
{},
)).toEqual(['missing required property "value.toString"'])
// additionalProperties: false must flag an OWN `toString` key even though
// `'toString' in properties` is true via the prototype.
expect(validateStructuredValue(
asserted({ type: 'object', additionalProperties: false }),
{ toString: 1 },
)).toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
// A declared property the value does NOT carry must not be validated
// against the value's INHERITED member (constructor is a function on
// every plain object's prototype, not a carried property).
expect(validateStructuredValue(
asserted({ type: 'object', properties: { constructor: { type: 'string' } } }),
{},
)).toEqual([])
})
it('a non-plain object value is not an object in the JSON sense', () => {
expect(validateStructuredValue(asserted({ type: 'object' }), new Date(0)))
.toEqual(['"value" must be an object'])
})
it('collects multiple violations across branches in one pass', () => {
expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([
'missing required property "value.file"',
'"value.line" must be an integer',
'"value.severity" must be one of ["low","high"]',
])
})
it('null-typed const/enum work through the scalar path', () => {
const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } })
expect(validateStructuredValue(nullish, { a: null })).toEqual([])
})
it('rejects a non-object properties value in the schema walk', () => {
expect(violationsOf({ type: 'object', properties: [] }))
.toEqual(['schema.properties must be an object of schemas'])
})
it('an object schema without properties/required only type-checks its value', () => {
const bare = asserted({ type: 'object' })
expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([])
expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object'])
})
it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => {
const forged = { type: 'tuple' } as unknown as StructuredOutputSchema
expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/)
})
})

View File

@@ -5,6 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -62,6 +63,17 @@ describe('ToolRegistry', () => {
expect(schema.execute).toBeUndefined()
})
it('schemas() excludes timeoutMs — the budget must never reach the model', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'budgeted', description: 'has a budget', parameters: {}, timeoutMs: 5_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
}))
const schema = ctx.tools.schemas().find(s => s.name === 'budgeted')
expect(schema).toBeDefined()
expect('timeoutMs' in (schema as object)).toBe(false)
})
it('executes a tool and returns its content', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -272,6 +284,151 @@ describe('ToolRegistry', () => {
expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after'])
})
it('runs tools/execute after an allowed pre-execute, around dispatch, and before post-execute', async () => {
const ctx = await setup()
const order: string[] = []
ctx.tools.register(defineTool({
name: 'traced',
description: 'echo',
parameters: { text: { type: 'string' } },
async execute(args) {
order.push('dispatch')
return [{ type: 'text' as const, text: args.text ?? '' }]
},
}))
ctx.on('tools/pre-execute', async (_exec, next) => { order.push('pre'); return next() })
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
order.push('execute:before')
const result = await next()
order.push('execute:after')
return result
})
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 })
// The around seam wraps dispatch; pre gates before it, post runs over its result.
expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
})
it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let entered = false
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'deny', reason: 'nope' }))
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
entered = true
return next()
})
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: 'Error: nope' })
expect(entered).toBe(false) // a denied call never enters the around-dispatch seam
})
it('a thrown tool is normalized to an isError result BEFORE a tools/execute listener sees next()', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'boom',
async execute() { throw new HarnessError('kaboom', 'BOOM') },
})
let seen: { isError: boolean; error?: unknown } | undefined
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
const result = await next()
// The base next() IS dispatch-with-normalization: the wrapper sees the
// normalized isError result, never a raw throw from the tool body.
seen = { isError: result.isError, error: result.error }
return result
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
})
it('a thrown tool normalized inside tools/execute still reaches post-execute', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'boom',
async execute() { throw new Error('exploded') },
})
let postSaw: boolean | undefined
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => next())
ctx.on('tools/post-execute', async (_exec, result, next) => {
postSaw = result.isError
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
expect(postSaw).toBe(true) // the normalized isError still flows through post-execute
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: exploded' })
})
it('a tools/execute listener can replace exec.signal for the dispatched tool (deadline pattern)', async () => {
const ctx = await setup()
let seenSignal: AbortSignal | undefined
ctx.tools.register({
...echoTool,
name: 'signal-probe',
async execute(_args, exec) {
seenSignal = exec.signal
return [{ type: 'text' as const, text: 'ok' }]
},
})
const upstream = new AbortController().signal
const replacement = new AbortController().signal
ctx.on('tools/execute', async (exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
expect(exec.signal).toBe(upstream)
// Cordis next() ignores passed arguments, so a wrapper mutates exec in
// place (the documented "mutate the shared object, then delegate" idiom).
exec.signal = replacement
return next()
})
await ctx.tools.execute({ callId: CallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream })
expect(seenSignal).toBe(replacement) // dispatch saw the wrapper's replacement, not the upstream
})
it('a tools/execute listener can short-circuit dispatch by returning a result without next()', async () => {
const ctx = await setup()
let dispatched = false
ctx.tools.register({
...echoTool,
name: 'never-runs',
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 }))
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('returns an isError result when a tools/execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => { throw new Error('wrapper broke') })
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,
})
})
it('returns an isError result when a tools/pre-execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -989,6 +1146,38 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} })
expect(result.isError).toBe(false)
})
it('attaches a positive-finite timeoutMs to the definition', () => {
const tool = defineTool({
name: 'x', description: 'd', parameters: {}, timeoutMs: 30_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
expect(tool.timeoutMs).toBe(30_000)
})
it('omits timeoutMs when not declared', () => {
const tool = defineTool({
name: 'x', description: 'd', parameters: {},
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
expect(tool.timeoutMs).toBeUndefined()
})
it('throws when timeoutMs is zero or negative', () => {
const make = (ms: number) => defineTool({
name: 'x', description: 'd', parameters: {}, timeoutMs: ms,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
expect(() => make(0)).toThrow('timeoutMs must be a positive finite number')
expect(() => make(-5)).toThrow('positive finite number')
})
it('throws when timeoutMs is non-finite', () => {
expect(() => defineTool({
name: 'x', description: 'd', parameters: {}, timeoutMs: Infinity,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})).toThrow('positive finite number')
})
})
describe('defineTool presentation (presentCall / presentResult)', () => {