Merge remote-tracking branch 'origin/master' into feat/adr0016-type-build-check
This commit is contained in:
107
packages/core/tools/README.md
Normal file
107
packages/core/tools/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# dsh-tools
|
||||
|
||||
Tool registry and execution waterfall. Tool plugins register their schemas and executors; the agent loop executes calls through the `tools/execute` waterfall.
|
||||
|
||||
## Service: `ToolRegistry` (ctx key: `tools`)
|
||||
|
||||
### Public API
|
||||
|
||||
- `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).
|
||||
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/execute` waterfall.
|
||||
|
||||
### Injected services
|
||||
|
||||
`SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`.
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Mode | Purpose |
|
||||
|---|---|---|
|
||||
| `tools/execute` | waterfall | Wrap/veto tool execution (sandbox, permission, hooks, plan mode) |
|
||||
| `tools/change` | emit | A tool was registered or unregistered |
|
||||
|
||||
### Key types
|
||||
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[]>`, plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
|
||||
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
|
||||
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error? }`. 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).
|
||||
- `ToolCallPresentation` / `ToolResultPresentation` — provider-neutral shapes 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.
|
||||
- The `tools/execute` waterfall is the single seam for sandbox, permission, hooks, and plan-mode plugins to wrap or veto a call. Listeners receive `(exec, next)`: call `next()` to proceed, or return a result without calling `next()` to short-circuit (veto).
|
||||
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
|
||||
|
||||
### Typed tool parameter schemas
|
||||
|
||||
First-party plugin authors can use the `defineTool()` helper (exported from this package) for typed tool parameter schemas:
|
||||
|
||||
```ts
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
declare const ctx: Context
|
||||
|
||||
ctx.tools.register(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' },
|
||||
},
|
||||
async execute(args, exec) {
|
||||
// args is typed: { path: string; offset?: number; limit?: number }
|
||||
const text = await readFile(args.path, 'utf8')
|
||||
return [{ type: 'text', text }]
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly.
|
||||
|
||||
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.
|
||||
|
||||
See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details.
|
||||
|
||||
### 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:
|
||||
|
||||
- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), 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 shell command as a string, NOT the whole args object), an optional `content` (UI content shown alongside the title/card — e.g. a bash `description` as a text block above the terminal card), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`).
|
||||
- `presentResult(args, result): ToolResultPresentation | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result: an optional replacement `title`, reformatted `content` (e.g. wrap command output in a fenced ` ```console ` block — a UI-only affordance that must NOT appear in the model-facing `execute` result), and an optional `terminal` (the `{ output?, exitCode?, signal? }` for a terminal-rendered call). The `ToolTerminal` shape is provider-neutral; a UI bridge (the ACP bridge) maps it to a terminal card (with an exit-status pill) and a UI that can't ignores it and uses `content`.
|
||||
|
||||
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. 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 shapes are provider-neutral — the ACP bridge (`dsh-acp`) maps them to ACP `tool_call`/`tool_call_update` wire fields, and `dsh-tool-bash` is the reference implementation.
|
||||
|
||||
```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}` }]
|
||||
},
|
||||
// The command is the readable title; the description rides as a content block.
|
||||
presentCall: args => ({ title: args.command, kind: 'execute', rawInput: args.command, content: [{ type: 'text', text: args.description }] }),
|
||||
// Wrap the output as a console block for the UI (not in the model-facing result).
|
||||
presentResult: (_args, result) => {
|
||||
const block = result.content.length === 1 ? result.content[0] : undefined
|
||||
if (block === undefined || block.type !== 'text') return undefined
|
||||
return { content: [{ type: 'text', text: '```console\n' + block.text + '\n```' }] }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### What is NOT here (TODO)
|
||||
|
||||
- **Tool shapes review** — when real tools land (e.g. a concurrency-safety hint for parallel execution); phase 1 executes tool calls sequentially.
|
||||
- **Parallel execution** — the loop currently iterates tool calls sequentially.
|
||||
36
packages/core/tools/package.json
Normal file
36
packages/core/tools/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tools",
|
||||
"description": "Tool registry and execution pipeline for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
376
packages/core/tools/src/index.ts
Normal file
376
packages/core/tools/src/index.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Tool registry and execution waterfall. Plugins register tools; the registry
|
||||
* feeds schemas into the system prompt, and `execute()` dispatches each call
|
||||
* through the `tools/execute` waterfall for sandbox, permission, and hook
|
||||
* plugins to wrap or veto.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tools
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
export {
|
||||
defineTool,
|
||||
schemaSpecToJsonSchema,
|
||||
validateArgs,
|
||||
ToolArgsError,
|
||||
type SchemaSpec,
|
||||
type SchemaProp,
|
||||
type SchemaType,
|
||||
type InferArgs,
|
||||
type DefineToolOptions,
|
||||
type JsonSchemaObject,
|
||||
} from './schema'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
tools: ToolRegistry
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Waterfall around every tool execution — the single seam where sandbox,
|
||||
* permission, hook, and plan-mode plugins wrap or veto a call. Listeners
|
||||
* receive `(exec, next)`: call `next()` to proceed (possibly around your
|
||||
* own logic), or return a {@link ToolExecutionResult} without calling
|
||||
* `next()` to short-circuit (veto).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
/**
|
||||
* A tool was registered or unregistered (the available tool set changed).
|
||||
* @mode emit
|
||||
*/
|
||||
'tools/change'(): void
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(review): revisit these shapes when the first real tools and
|
||||
// sandbox/permission plugins land (e.g. a concurrency-safety hint for
|
||||
// parallel execution — Claude Code partitions read-only tools; phase 1
|
||||
// executes sequentially).
|
||||
|
||||
/**
|
||||
* Category of a tool call, used by a UI to pick an icon / treatment. A neutral
|
||||
* vocabulary owned here (NOT an ACP type) so tools describe themselves without
|
||||
* depending on any client protocol; a UI bridge maps it to its own enum. The
|
||||
* member set mirrors the common ACP `ToolKind` values; `other` is the default.
|
||||
*/
|
||||
export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'
|
||||
|
||||
// FIXME(tool-presentation): the ToolCallPresentation / ToolResultPresentation /
|
||||
// ToolTerminal shapes need a rethink. They grew incrementally (title/kind/
|
||||
// rawInput, then a `content` block, then a `terminal` sub-shape carrying cwd/
|
||||
// output/exit) and the split of responsibility is now muddy: the call vs result
|
||||
// terminal fields overlap, the bridge has to reconcile a `content` block AND a
|
||||
// `terminal` block AND `rawInput` per call, and the "pending vs completed"
|
||||
// boundary doesn't cleanly map to how editors actually render (terminal card,
|
||||
// diff, generic card). Before more tools/UIs depend on this, redesign the type
|
||||
// so a tool declares its render INTENT once (e.g. a tagged union over card
|
||||
// kinds) rather than a bag of optional fields the bridge stitches together.
|
||||
// Pin the design in an RFC and migrate dsh-tool-bash + the ACP bridge together.
|
||||
|
||||
/**
|
||||
* 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. Provider-
|
||||
* neutral: a tool returns this from {@link ToolDefinition.presentCall} and a UI
|
||||
* plugin (e.g. the ACP bridge) maps it to its own wire shape. The tool owns its
|
||||
* own presentation — the UI must not special-case tool names.
|
||||
*/
|
||||
export interface ToolCallPresentation {
|
||||
/**
|
||||
* Human-readable, always-visible label describing what THIS call does (e.g.
|
||||
* the model-written one-line summary of a bash command). Keep it short — a UI
|
||||
* shows it as a card header / log line. Required: a presentation must have a
|
||||
* title (a UI falls back to the tool name only when `presentCall` is absent).
|
||||
*/
|
||||
title: string
|
||||
/** Category for icon/treatment; defaults to `other` when omitted. */
|
||||
kind?: ToolCallKind
|
||||
/**
|
||||
* The salient input to surface in a detail/expanded view — e.g. the bash
|
||||
* COMMAND itself (as a string), so the title can stay a readable summary
|
||||
* while the exact command is still visible. Omit to show nothing; a string is
|
||||
* rendered as-is, an object as pretty JSON. NOT the full raw args object
|
||||
* unless that is genuinely what a reader wants.
|
||||
*/
|
||||
rawInput?: unknown
|
||||
/**
|
||||
* UI-facing content to show on the PENDING call alongside the title/card —
|
||||
* harness {@link ContentBlock}s, in render order. A terminal tool uses this to
|
||||
* surface its human-readable `description` as a text block ABOVE the terminal
|
||||
* card (the card itself is requested via {@link terminal} and labelled by the
|
||||
* command in `title`), since the card has no description slot. Omit to show no
|
||||
* extra content. A UI maps these to its own content blocks and renders a
|
||||
* {@link terminal} block (if any) as a terminal card.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
/**
|
||||
* Ask a capable UI to render this call as a TERMINAL (a command running in a
|
||||
* working directory), not a generic tool card — set by a tool whose call IS a
|
||||
* shell command (e.g. `bash`). Provider-neutral; a UI bridge maps it to its
|
||||
* own terminal affordance and a UI that can't falls back to the normal card.
|
||||
* Pair with {@link ToolResultPresentation.terminal} for the output/exit.
|
||||
*/
|
||||
terminal?: ToolTerminal
|
||||
}
|
||||
|
||||
/**
|
||||
* A request to render a tool call as a terminal. The pending presentation
|
||||
* supplies the working directory; the result presentation (see
|
||||
* {@link ToolResultPresentation.terminal}) supplies the captured output and exit
|
||||
* status. Provider-neutral — no client-protocol types. A UI that supports
|
||||
* terminals shows a cwd-headed terminal card with the command, its output, and
|
||||
* an exit-status pill; a UI that does not ignores this and renders the ordinary
|
||||
* card/content.
|
||||
*/
|
||||
export interface ToolTerminal {
|
||||
/**
|
||||
* Working directory the command ran in, shown as the terminal header. An
|
||||
* ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge
|
||||
* against the session workspace (the pure tool presenter can't see the
|
||||
* session cwd). Omit entirely to let the bridge use the session workspace.
|
||||
*/
|
||||
cwd?: string
|
||||
/** Captured command output (stdout+stderr as the tool chooses to combine them). Result-state only. */
|
||||
output?: string
|
||||
/**
|
||||
* Process exit code, when the run ended by exiting (not a signal). Result-state
|
||||
* only; lets a capable UI show an exit-status pill on the terminal card. Omit
|
||||
* when the command was killed by a signal or the exit code is unknown.
|
||||
*/
|
||||
exitCode?: number
|
||||
/**
|
||||
* Signal name that killed the process (e.g. `SIGTERM`), when it died by signal
|
||||
* rather than exiting. Result-state only; mutually exclusive with `exitCode`.
|
||||
*/
|
||||
signal?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* How a tool wants the COMPLETED call shown — the *result* state, after
|
||||
* `execute` returns. Lets the tool reformat its result for a UI distinctly from
|
||||
* the model-facing text it returned from `execute` (e.g. wrap command output in
|
||||
* a fenced ```console block for monospace rendering, which the model-facing
|
||||
* result must NOT carry). All fields optional: a UI keeps the pending-state
|
||||
* title and renders the raw result content for anything left unset.
|
||||
*/
|
||||
export interface ToolResultPresentation {
|
||||
/** Replacement title for the completed call (e.g. append an exit status). Omit to keep the pending-state title. */
|
||||
title?: string
|
||||
/**
|
||||
* UI-facing result content (harness {@link ContentBlock}s), reformatted from
|
||||
* the model-facing result. Omit to let the UI render the raw result content.
|
||||
* Stays in harness vocabulary; the UI maps these to its own content blocks.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
/**
|
||||
* Terminal output/exit for a call the pending presentation marked as a
|
||||
* terminal (see {@link ToolCallPresentation.terminal}). A capable UI renders
|
||||
* `output` in the terminal card and shows the exit status; an incapable UI
|
||||
* uses `content` (the tool should supply a text fallback there too).
|
||||
*/
|
||||
terminal?: ToolTerminal
|
||||
}
|
||||
|
||||
/** A registered tool: its schema plus the execution function. */
|
||||
export interface ToolDefinition extends ToolSchema {
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]>
|
||||
/**
|
||||
* 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 its own input). Returning `undefined` (or omitting the method) tells
|
||||
* a UI to fall back to a generic presentation (title = tool name, raw args as
|
||||
* input). Pure and side-effect-free: a UI may call it during live streaming
|
||||
* AND a session-log replay, so it must depend only on `args`.
|
||||
*/
|
||||
presentCall?(args: unknown): ToolCallPresentation | undefined
|
||||
/**
|
||||
* Optional: how to present the COMPLETED state, given the same `args` and the
|
||||
* `result` (`execute`'s content + whether it errored). Returning `undefined`
|
||||
* (or omitting the method) tells a UI to keep the pending title and render the
|
||||
* raw result content. Pure and side-effect-free for the same replay reason.
|
||||
*/
|
||||
presentResult?(args: unknown, result: ToolResult): ToolResultPresentation | undefined
|
||||
}
|
||||
|
||||
/** The completed outcome handed to {@link ToolDefinition.presentResult}. */
|
||||
export interface ToolResult {
|
||||
/** The model-facing content `execute` returned (or the error text on failure). */
|
||||
content: ContentBlock[]
|
||||
/** Whether the call failed. */
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
/** One pending tool call, as it flows through the execution waterfall. */
|
||||
export interface ToolExecution {
|
||||
callId: CallId
|
||||
name: string
|
||||
/** Parsed JSON arguments (unknown — tools validate their own input). */
|
||||
arguments: unknown
|
||||
/** The agent on whose behalf the call runs (set by the agent loop). */
|
||||
agent?: Agent
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
|
||||
export interface ToolErrorInfo {
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown (internally) when the model requests a tool that isn't registered.
|
||||
* Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool
|
||||
* failure is as routable as a tool-thrown one — retry/sandbox/replay code can
|
||||
* distinguish it from a tool body's own error.
|
||||
*/
|
||||
export class ToolNotFoundError extends HarnessError {
|
||||
constructor(public readonly toolName: string) {
|
||||
super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL')
|
||||
this.name = 'ToolNotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
/** The outcome of one tool call. */
|
||||
export interface ToolExecutionResult {
|
||||
callId: CallId
|
||||
content: ContentBlock[]
|
||||
isError: boolean
|
||||
/**
|
||||
* Set when the call failed with a {@link HarnessError}: machine-routable
|
||||
* `{ name, code }` for retry/sandbox plugins and replay. The model-facing
|
||||
* text in `content` is always present; this is extra structure for code.
|
||||
*/
|
||||
error?: ToolErrorInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort human-readable message from an arbitrary thrown value: Error
|
||||
* instances use `.message`; non-Error objects with a string `message`
|
||||
* property (e.g. `throw { message: 'denied' }`) use it too; everything else
|
||||
* is stringified.
|
||||
*/
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'object' && error !== null
|
||||
&& 'message' in error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
|
||||
function errorInfo(error: unknown): ToolErrorInfo | undefined {
|
||||
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
|
||||
* loop executes calls through the `tools/execute` waterfall. The registry
|
||||
* contributes its schemas into the system-prompt assembly.
|
||||
*/
|
||||
export class ToolRegistry extends Service {
|
||||
static inject = ['systemPrompt']
|
||||
|
||||
private store = new Map<string, ToolDefinition>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'tools')
|
||||
ctx.systemPrompt.tools(() => this.schemas())
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tool. Throws if a tool with the same name is already
|
||||
* registered. The tool's schema (minus the `execute` function) is
|
||||
* automatically contributed to the system-prompt assembly. Disposed
|
||||
* with the calling fiber. Emits `tools/change` on register/unregister.
|
||||
*/
|
||||
register(definition: ToolDefinition): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
if (this.store.has(definition.name)) {
|
||||
throw new Error(`tool "${definition.name}" is already registered`)
|
||||
}
|
||||
this.store.set(definition.name, definition)
|
||||
// Yield the rollback BEFORE emitting `tools/change`: a generator effect
|
||||
// collects each yielded disposer before the next step runs, so a throwing
|
||||
// `tools/change` listener removes the tool instead of leaking it (a leak
|
||||
// would wedge the duplicate-name check until restart). The duplicate
|
||||
// throw above fires before any mutation — it leaks nothing.
|
||||
yield () => {
|
||||
this.store.delete(definition.name)
|
||||
this.ctx.emit('tools/change')
|
||||
}
|
||||
this.ctx.emit('tools/change')
|
||||
}.bind(this), 'tools.register()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
get(name: string): ToolDefinition | undefined {
|
||||
return this.store.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all registered tool schemas — exactly the model-facing fields
|
||||
* (`name`, `description`, `parameters`, and `strict` when set), as sent to the
|
||||
* model via the system-prompt assembly. 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.
|
||||
*/
|
||||
schemas(): ToolSchema[] {
|
||||
return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({
|
||||
name,
|
||||
description,
|
||||
parameters: structuredClone(parameters),
|
||||
...strict !== undefined ? { strict } : {},
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one tool call through the `tools/execute` waterfall. If the tool is
|
||||
* not registered, the result is an `isError` carrying a `UNKNOWN_TOOL`
|
||||
* structured error. If the tool or a waterfall listener throws, the error is
|
||||
* caught and returned as an `isError` result so the loop records a failed tool
|
||||
* call instead of failing the whole turn; a thrown {@link HarnessError}
|
||||
* surfaces its `{ name, code }` on the result.
|
||||
*/
|
||||
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
try {
|
||||
return await this.ctx.waterfall(this, 'tools/execute', exec, async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
const tool = this.store.get(exec.name)
|
||||
// Unknown tool routes through the same catch as a tool-thrown error, so
|
||||
// both failure classes get structured `{ name, code }` from one path.
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
const content = await tool.execute(exec.arguments, exec)
|
||||
return { callId: exec.callId, content, isError: false }
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
|
||||
const info = errorInfo(error)
|
||||
return {
|
||||
callId,
|
||||
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
|
||||
isError: true,
|
||||
...info ? { error: info } : {},
|
||||
}
|
||||
}
|
||||
|
||||
export default ToolRegistry
|
||||
384
packages/core/tools/src/schema.ts
Normal file
384
packages/core/tools/src/schema.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolCallPresentation, ToolDefinition, ToolExecution, ToolResult, ToolResultPresentation } from './index'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchemaSpec — the author-facing per-property type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Valid JSON Schema primitive types for tool parameters. */
|
||||
export type SchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array'
|
||||
|
||||
/** One schema-spec property entry. */
|
||||
export interface SchemaProp {
|
||||
type: SchemaType
|
||||
/** Per-property required flag (NOT the JSON Schema top-level required array). */
|
||||
required?: true
|
||||
/** Human-readable description, surfaced in the JSON Schema as well. */
|
||||
description?: string
|
||||
/** Enum of allowed values (strings only). */
|
||||
enum?: string[]
|
||||
/**
|
||||
* Default value, emitted into the JSON Schema only (validation never applies
|
||||
* it — see the validator note below).
|
||||
*
|
||||
* XXX(unused-default): no tool definition in the repo sets `default`; it rides
|
||||
* into the wire schema for a model that no tool surfaces it to. Drop the field
|
||||
* and its converter line unless a real tool needs a model-visible default.
|
||||
*/
|
||||
default?: unknown
|
||||
/** Nested properties for type: 'object'. */
|
||||
properties?: SchemaSpec
|
||||
/** Items schema for type: 'array'. */
|
||||
items?: SchemaProp
|
||||
}
|
||||
|
||||
/**
|
||||
* The author-facing parameter schema: a shallow map of property name to
|
||||
* {@link SchemaProp}. Required-ness is a per-property boolean (`required:
|
||||
* true`), not a separate array.
|
||||
*/
|
||||
export type SchemaSpec = Record<string, SchemaProp>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InferArgs — type-level mapping from SchemaSpec to TS argument type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Map a {@link SchemaType} to its TS primitive type. */
|
||||
type TypeOf<T extends SchemaType> =
|
||||
T extends 'string' ? string :
|
||||
T extends 'number' ? number :
|
||||
T extends 'boolean' ? boolean :
|
||||
T extends 'object' ? Record<string, unknown> :
|
||||
T extends 'array' ? unknown[] :
|
||||
never
|
||||
|
||||
/** Flatten an intersection into one object type for readable hovers. */
|
||||
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
||||
|
||||
/** Keys of `S` whose prop is marked `required: true`. */
|
||||
type RequiredKeys<S extends SchemaSpec> =
|
||||
{ [K in keyof S]: S[K] extends { required: true } ? K : never }[keyof S]
|
||||
|
||||
/**
|
||||
* The VALUE type of one {@link SchemaProp} — optionality is handled at the
|
||||
* key level by {@link InferArgs}, never here.
|
||||
* - `properties` on 'object' → recurse into the nested SchemaSpec
|
||||
* - `items` on 'array' → recurse into the item prop (arrays of objects work)
|
||||
* - otherwise → the primitive for `type`
|
||||
*/
|
||||
type InferPropValue<P extends SchemaProp> =
|
||||
P extends { type: 'object'; properties: infer Sub extends SchemaSpec } ? InferArgs<Sub> :
|
||||
P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue<Item>[] :
|
||||
TypeOf<P['type']>
|
||||
|
||||
/**
|
||||
* Infer the TS argument type for a complete {@link SchemaSpec}.
|
||||
*
|
||||
* Properties marked `required: true` are required keys; all others are
|
||||
* genuinely optional keys (`?`), so callers may omit them entirely.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }>
|
||||
* // → { path: string; limit?: number }
|
||||
* ```
|
||||
*/
|
||||
export type InferArgs<S extends SchemaSpec> = Simplify<
|
||||
& { [K in RequiredKeys<S>]: InferPropValue<S[K]> }
|
||||
& { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferPropValue<S[K]> }
|
||||
>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime conversion: SchemaSpec → JSON Schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert a single {@link SchemaProp} to its JSON Schema `properties` entry.
|
||||
* The per-property `required` flag is collected; the caller builds the
|
||||
* top-level `required` array.
|
||||
*/
|
||||
function propToJsonSchema(prop: SchemaProp): { schema: Record<string, unknown>; required: boolean } {
|
||||
const result: Record<string, unknown> = { type: prop.type }
|
||||
if (prop.description) result.description = prop.description
|
||||
if (prop.enum) result.enum = prop.enum
|
||||
if (prop.default !== undefined) result.default = prop.default
|
||||
|
||||
const required = prop.required === true
|
||||
|
||||
if (prop.type === 'object' && prop.properties) {
|
||||
const nested = schemaSpecToJsonSchema(prop.properties)
|
||||
result.properties = nested.properties
|
||||
if (nested.required && nested.required.length > 0) {
|
||||
result.required = nested.required
|
||||
}
|
||||
}
|
||||
|
||||
if (prop.type === 'array' && prop.items) {
|
||||
const { schema: itemsSchema } = propToJsonSchema(prop.items)
|
||||
result.items = itemsSchema
|
||||
}
|
||||
|
||||
return { schema: result, required }
|
||||
}
|
||||
|
||||
/** The return type of {@link schemaSpecToJsonSchema}. */
|
||||
export interface JsonSchemaObject {
|
||||
type: 'object'
|
||||
properties: Record<string, unknown>
|
||||
required?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a {@link SchemaSpec} to standard JSON Schema (`type: 'object'`,
|
||||
* `properties`, `required` array).
|
||||
*
|
||||
* This is a plain function — no schemastery or other framework dependency.
|
||||
*/
|
||||
export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
|
||||
const properties: Record<string, unknown> = {}
|
||||
const required: string[] = []
|
||||
|
||||
for (const [key, prop] of Object.entries(spec)) {
|
||||
const { schema, required: isRequired } = propToJsonSchema(prop)
|
||||
properties[key] = schema
|
||||
if (isRequired) required.push(key)
|
||||
}
|
||||
|
||||
const result: JsonSchemaObject = {
|
||||
type: 'object',
|
||||
properties,
|
||||
}
|
||||
if (required.length > 0) result.required = required
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime validation: model-generated args ↔ SchemaSpec
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Thrown by a {@link defineTool} tool when the model-generated arguments don't
|
||||
* match the declared {@link SchemaSpec}. Extends {@link HarnessError}
|
||||
* (`code: 'INVALID_ARGS'`); the registry's execute waterfall catches it and
|
||||
* returns an `isError` ToolExecutionResult carrying the structured error, so
|
||||
* the model can self-correct and downstream plugins can route on the code.
|
||||
*/
|
||||
export class ToolArgsError extends HarnessError {
|
||||
/** The individual violation messages, in declaration order. */
|
||||
readonly violations: string[]
|
||||
|
||||
constructor(violations: string[]) {
|
||||
super(`invalid arguments: ${violations.join('; ')}`, 'INVALID_ARGS')
|
||||
this.name = 'ToolArgsError'
|
||||
this.violations = violations
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a value is a non-null, non-array object (a JSON Schema `object`). */
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** Collect violations for one property value against its {@link SchemaProp}. */
|
||||
function checkValue(prop: SchemaProp, value: unknown, path: string): string[] {
|
||||
switch (prop.type) {
|
||||
case 'string': {
|
||||
if (typeof value !== 'string') return [`"${path}" must be a string`]
|
||||
break
|
||||
}
|
||||
case 'number': {
|
||||
if (typeof value !== 'number') return [`"${path}" must be a number`]
|
||||
break
|
||||
}
|
||||
case 'boolean': {
|
||||
if (typeof value !== 'boolean') return [`"${path}" must be a boolean`]
|
||||
break
|
||||
}
|
||||
case 'object': {
|
||||
if (!isPlainObject(value)) return [`"${path}" must be an object`]
|
||||
// Mirror the converter: an object without `properties` only type-checks.
|
||||
return prop.properties ? checkSpec(prop.properties, value, path) : []
|
||||
}
|
||||
case 'array': {
|
||||
if (!Array.isArray(value)) return [`"${path}" must be an array`]
|
||||
// Mirror the converter: an array without `items` only type-checks.
|
||||
if (!prop.items) return []
|
||||
const items = prop.items
|
||||
return value.flatMap((el, i) => checkValue(items, el, `${path}[${i}]`))
|
||||
}
|
||||
default: return assertNever(prop.type, 'validateArgs')
|
||||
}
|
||||
// Enum membership, checked uniformly: the converter emits `enum` for any
|
||||
// type ([prop.enum]), so the validator must too. `enum` is `string[]`, so a
|
||||
// non-string value can never be a member — it falls out here, consistent
|
||||
// with the schema the model was given.
|
||||
if (prop.enum && !(prop.enum as unknown[]).includes(value)) {
|
||||
return [`"${path}" must be one of ${JSON.stringify(prop.enum)}`]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** Collect violations for an object value against a {@link SchemaSpec}. */
|
||||
function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
|
||||
if (!isPlainObject(value)) return [`"${path || 'arguments'}" must be an object`]
|
||||
const violations: string[] = []
|
||||
for (const [key, prop] of Object.entries(spec)) {
|
||||
const propPath = path ? `${path}.${key}` : key
|
||||
const v = value[key]
|
||||
if (v === undefined) {
|
||||
// A required key absent OR present-but-undefined is a violation; an
|
||||
// optional absent key is fine. `default` is NOT applied (validation only).
|
||||
if (prop.required === true) violations.push(`missing required property "${propPath}"`)
|
||||
continue
|
||||
}
|
||||
violations.push(...checkValue(prop, v, propPath))
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate model-generated `args` against a {@link SchemaSpec}, returning a
|
||||
* list of human-readable violation messages (empty = valid). Total — never
|
||||
* throws, regardless of how malformed `args` is.
|
||||
*
|
||||
* Semantics mirror {@link schemaSpecToJsonSchema} exactly: the top level must
|
||||
* be a non-array object; required keys come only from `required: true`; extra
|
||||
* keys are allowed (no `additionalProperties: false`); `default` is not
|
||||
* applied; an `object`/`array` prop without `properties`/`items` only
|
||||
* type-checks; `enum` is membership (strings only).
|
||||
*/
|
||||
export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
|
||||
return checkSpec(spec, args, '')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// defineTool — typed helper for first-party plugin authors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Options for {@link defineTool}. */
|
||||
export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
/** Tool name (must be unique). */
|
||||
name: string
|
||||
/** Human-readable description sent to the model. */
|
||||
description: string
|
||||
/**
|
||||
* Parameter schema using the per-property-required DSL. Converted to
|
||||
* standard JSON Schema at runtime.
|
||||
*/
|
||||
parameters: S
|
||||
/**
|
||||
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
|
||||
* casts needed.
|
||||
*/
|
||||
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ContentBlock[]>
|
||||
/**
|
||||
* 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
|
||||
* argument shape — zero casts. Pure and side-effect-free: a UI may call it
|
||||
* during live streaming AND a session-log replay, so depend only on `args`.
|
||||
* The tool owns its presentation so a UI never special-cases tool names. See
|
||||
* {@link ToolCallPresentation}.
|
||||
*/
|
||||
presentCall?(args: InferArgs<S>): ToolCallPresentation | undefined
|
||||
/**
|
||||
* Optional: how to present the COMPLETED state, given the typed `args` and the
|
||||
* `result`. Use it to reformat result content for a UI distinctly from the
|
||||
* model-facing text (e.g. a fenced ```console block). Pure and side-effect-
|
||||
* free for the same replay reason. See {@link ToolResultPresentation}.
|
||||
*/
|
||||
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultPresentation | undefined
|
||||
/** Whether the tool requires structured output (default false). */
|
||||
strict?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
|
||||
// Object-literal execute methods don't use `this`; the reference is safe.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userExecute = options.execute
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userPresentCall = options.presentCall
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userPresentResult = options.presentResult
|
||||
const tool: ToolDefinition = {
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
||||
...options.strict !== undefined ? { strict: options.strict } : {},
|
||||
async execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
|
||||
// Validate the model-generated args before the typed body runs. On
|
||||
// mismatch we throw ToolArgsError; the registry turns it into an
|
||||
// isError result so the model can self-correct. After this guard, the
|
||||
// cast to InferArgs<S> reflects the validated shape.
|
||||
const violations = validateArgs(options.parameters, args)
|
||||
if (violations.length > 0) throw new ToolArgsError(violations)
|
||||
return userExecute(args as InferArgs<S>, exec)
|
||||
},
|
||||
}
|
||||
// Presentation is display-only and may run on REPLAY of arbitrary logged args
|
||||
// (possibly from an older schema), so it must never throw: validate softly and
|
||||
// fall back to `undefined` (a generic UI presentation) on any mismatch, rather
|
||||
// than the hard `ToolArgsError` the execute path raises.
|
||||
if (userPresentCall) {
|
||||
tool.presentCall = (args: unknown): ToolCallPresentation | undefined => {
|
||||
if (validateArgs(options.parameters, args).length > 0) return undefined
|
||||
return userPresentCall(args as InferArgs<S>)
|
||||
}
|
||||
}
|
||||
if (userPresentResult) {
|
||||
tool.presentResult = (args: unknown, result: ToolResult): ToolResultPresentation | undefined => {
|
||||
if (validateArgs(options.parameters, args).length > 0) return undefined
|
||||
return userPresentResult(args as InferArgs<S>, result)
|
||||
}
|
||||
}
|
||||
return tool
|
||||
}
|
||||
144
packages/core/tools/tests/properties.spec.ts
Normal file
144
packages/core/tools/tests/properties.spec.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Property-based tests for the tool-schema DSL (the property-testing RFC), including
|
||||
* the the property-testing ↔ runtime-validation composition composition: generated args that satisfy a SchemaSpec must
|
||||
* pass validateArgs, and targeted corruptions must be rejected. This closes the
|
||||
* validator/InferArgs drift risk noted in the arg-validation RFC.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { schemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools'
|
||||
import type { SchemaProp, SchemaSpec } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
// A leaf prop arbitrary (no nesting) with optional required/enum.
|
||||
function leafPropArb(): fc.Arbitrary<SchemaProp> {
|
||||
return fc.oneof(
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'string', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'number', ...required ? { required: true } : {} })),
|
||||
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'boolean', ...required ? { required: true } : {} })),
|
||||
fc.record({ values: fc.uniqueArray(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 3 }), required: fc.boolean() })
|
||||
.map(({ values, required }): SchemaProp => ({ type: 'string', enum: values, ...required ? { required: true } : {} })),
|
||||
)
|
||||
}
|
||||
|
||||
/** A prop arbitrary up to `depth` levels of nesting (objects and arrays). */
|
||||
function propArb(depth: number): fc.Arbitrary<SchemaProp> {
|
||||
if (depth <= 0) return leafPropArb()
|
||||
return fc.oneof(
|
||||
{ weight: 3, arbitrary: leafPropArb() },
|
||||
{
|
||||
weight: 1,
|
||||
arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean() })
|
||||
.map(({ properties, required }): SchemaProp => ({ type: 'object', properties, ...required ? { required: true } : {} })),
|
||||
},
|
||||
{
|
||||
weight: 1,
|
||||
arbitrary: fc.record({ items: propArb(depth - 1), required: fc.boolean() })
|
||||
.map(({ items, required }): SchemaProp => ({ type: 'array', items, ...required ? { required: true } : {} })),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function specArb(depth: number): fc.Arbitrary<SchemaSpec> {
|
||||
return fc.dictionary(fc.string({ minLength: 1, maxLength: 6 }), propArb(depth), { maxKeys: 4 })
|
||||
}
|
||||
|
||||
/** Generate a value that satisfies a prop (used to build valid args). */
|
||||
function valueForProp(prop: SchemaProp): fc.Arbitrary<unknown> {
|
||||
switch (prop.type) {
|
||||
case 'string': return prop.enum ? fc.constantFrom(...prop.enum) : fc.string()
|
||||
case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true })
|
||||
case 'boolean': return fc.boolean()
|
||||
case 'object': return prop.properties ? validArgsForSpec(prop.properties) : fc.constant({})
|
||||
case 'array': return prop.items ? fc.array(valueForProp(prop.items), { maxLength: 3 }) : fc.constant([])
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate args satisfying every required key of a spec (optionals included randomly). */
|
||||
function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary<Record<string, unknown>> {
|
||||
const entries = Object.entries(spec)
|
||||
return fc.tuple(...entries.map(([key, prop]) =>
|
||||
fc.tuple(
|
||||
fc.constant(key),
|
||||
// required keys are always present; optional keys are present ~half the time
|
||||
prop.required === true
|
||||
? valueForProp(prop).map(v => ({ include: true, value: v }))
|
||||
: fc.oneof(
|
||||
valueForProp(prop).map(v => ({ include: true, value: v })),
|
||||
fc.constant({ include: false, value: undefined }),
|
||||
),
|
||||
),
|
||||
)).map((pairs) => {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, { include, value }] of pairs) if (include) out[key] = value
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
/** Collect the `required: true` keys at the top level of a spec. */
|
||||
function requiredKeys(spec: SchemaSpec): string[] {
|
||||
return Object.entries(spec).filter(([, p]) => p.required === true).map(([k]) => k)
|
||||
}
|
||||
|
||||
describe('schema DSL properties', () => {
|
||||
it('JSON Schema `required` equals the required:true keys at every level', () => {
|
||||
fc.assert(fc.property(specArb(2), (spec) => {
|
||||
const checkLevel = (s: SchemaSpec, json: { required?: string[]; properties: Record<string, unknown> }) => {
|
||||
expect(new Set(json.required ?? [])).toEqual(new Set(requiredKeys(s)))
|
||||
for (const [key, prop] of Object.entries(s)) {
|
||||
const propJson = json.properties[key] as Record<string, unknown>
|
||||
if (prop.type === 'object' && prop.properties) {
|
||||
checkLevel(prop.properties, propJson as { required?: string[]; properties: Record<string, unknown> })
|
||||
}
|
||||
}
|
||||
}
|
||||
checkLevel(spec, schemaSpecToJsonSchema(spec))
|
||||
}))
|
||||
})
|
||||
|
||||
it('conversion is total (never throws) for any spec', () => {
|
||||
fc.assert(fc.property(specArb(3), (spec) => {
|
||||
expect(() => schemaSpecToJsonSchema(spec)).not.toThrow()
|
||||
}))
|
||||
})
|
||||
|
||||
it('validateArgs is total (never throws) for any spec and any input', () => {
|
||||
fc.assert(fc.property(specArb(2), fc.anything(), (spec, args) => {
|
||||
expect(() => validateArgs(spec, args)).not.toThrow()
|
||||
}))
|
||||
})
|
||||
|
||||
it('the property-testing ↔ runtime-validation composition: args satisfying the spec pass validateArgs', () => {
|
||||
fc.assert(fc.property(
|
||||
specArb(2).chain(spec => fc.tuple(fc.constant(spec), validArgsForSpec(spec))),
|
||||
([spec, args]) => {
|
||||
expect(validateArgs(spec, args)).toEqual([])
|
||||
},
|
||||
))
|
||||
})
|
||||
|
||||
it('the property-testing ↔ runtime-validation composition: dropping a required key is always rejected', () => {
|
||||
fc.assert(fc.property(
|
||||
specArb(1)
|
||||
.filter(spec => requiredKeys(spec).length > 0)
|
||||
.chain(spec => fc.tuple(fc.constant(spec), validArgsForSpec(spec))),
|
||||
([spec, args]) => {
|
||||
const required = requiredKeys(spec)
|
||||
const victim = required[0]!
|
||||
const broken = Object.fromEntries(Object.entries(args).filter(([k]) => k !== victim))
|
||||
const violations = validateArgs(spec, broken)
|
||||
expect(violations.some(v => v.includes(`"${victim}"`))).toBe(true)
|
||||
},
|
||||
))
|
||||
})
|
||||
|
||||
it('the property-testing ↔ runtime-validation composition: a non-object top level is always rejected', () => {
|
||||
fc.assert(fc.property(
|
||||
specArb(1),
|
||||
fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null), fc.array(fc.anything())),
|
||||
(spec, notAnObject) => {
|
||||
expect(validateArgs(spec, notAnObject).length).toBeGreaterThan(0)
|
||||
},
|
||||
))
|
||||
})
|
||||
})
|
||||
947
packages/core/tools/tests/tools.spec.ts
Normal file
947
packages/core/tools/tests/tools.spec.ts
Normal file
@@ -0,0 +1,947 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, {
|
||||
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
||||
type InferArgs, type SchemaSpec, type ToolExecutionResult,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const echoTool = defineTool({
|
||||
name: 'echo',
|
||||
description: 'echo arguments back',
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text' as const, text: args.text ?? '' }]
|
||||
},
|
||||
})
|
||||
|
||||
describe('ToolRegistry', () => {
|
||||
it('registers tools, exposes schemas, and feeds the system-prompt assembly', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
expect(ctx.tools.schemas()).toEqual([{
|
||||
name: 'echo',
|
||||
description: 'echo arguments back',
|
||||
parameters: { type: 'object', properties: { text: { type: 'string' } } },
|
||||
}])
|
||||
// schemas() result must not leak execute — ToolSchema deliberately has no
|
||||
// 'execute' key, so widen through unknown to probe for the absent property
|
||||
expect((ctx.tools.schemas()[0] as unknown as Record<string, unknown>).execute).toBeUndefined()
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.tools.map(t => t.name)).toEqual(['echo'])
|
||||
})
|
||||
|
||||
it('schemas() drops the UI presentation callbacks — they must never reach the model', async () => {
|
||||
const ctx = await setup()
|
||||
// A tool that declares presentCall/presentResult (functions). schemas() feeds
|
||||
// the system-prompt assembly → the model request, so those callbacks (and
|
||||
// `execute`) must be stripped: a function in the JSON tool schema would
|
||||
// corrupt the request. schemas() is an explicit allowlist, so it can't leak.
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'present',
|
||||
description: 'has presenters',
|
||||
parameters: { x: { type: 'string', required: true } },
|
||||
async execute() { return [] },
|
||||
presentCall: args => ({ title: args.x }),
|
||||
presentResult: (args, result) => ({ title: args.x, content: result.content }),
|
||||
}))
|
||||
const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown>
|
||||
expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
|
||||
expect(schema.presentCall).toBeUndefined()
|
||||
expect(schema.presentResult).toBeUndefined()
|
||||
expect(schema.execute).toBeUndefined()
|
||||
})
|
||||
|
||||
it('schemas() preserves `strict` when set (allowlist keeps the model-facing fields)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'strict-tool',
|
||||
description: 'd',
|
||||
parameters: { x: { type: 'string', required: true } },
|
||||
strict: true,
|
||||
async execute() { return [] },
|
||||
}))
|
||||
expect(ctx.tools.schemas()[0]).toMatchObject({ name: 'strict-tool', strict: true })
|
||||
})
|
||||
|
||||
it('executes a tool and returns its content', async () => {
|
||||
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 })
|
||||
})
|
||||
|
||||
it('returns isError results for unknown tools and throwing tools', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'boom',
|
||||
async execute() {
|
||||
throw new Error('exploded')
|
||||
},
|
||||
})
|
||||
|
||||
const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'nope', arguments: {} })
|
||||
expect(unknown.isError).toBe(true)
|
||||
expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' })
|
||||
// An unknown tool is a routable failure class, same as a tool-thrown one.
|
||||
expect(unknown.error).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' })
|
||||
|
||||
const thrown = await ctx.tools.execute({ callId: CallId('c2'), name: 'boom', arguments: {} })
|
||||
expect(thrown.isError).toBe(true)
|
||||
expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
|
||||
})
|
||||
|
||||
it('ToolNotFoundError carries the tool name and a stable 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"')
|
||||
})
|
||||
|
||||
it('lets tools/execute waterfall listeners veto a call (permission pattern)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
|
||||
if (exec.name === 'echo') {
|
||||
return {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: 'denied by policy' }],
|
||||
isError: 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: 'denied by policy' })
|
||||
})
|
||||
|
||||
it('composes multiple tools/execute listeners (sandbox-wrap pattern)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
const order: string[] = []
|
||||
ctx.on('tools/execute', async (_exec, next) => {
|
||||
order.push('first:before')
|
||||
const result = await next()
|
||||
order.push('first:after')
|
||||
return result
|
||||
})
|
||||
ctx.on('tools/execute', async (_exec, next) => {
|
||||
order.push('second:before')
|
||||
const result = await next()
|
||||
order.push('second:after')
|
||||
return result
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(order).toEqual(['first:before', 'second:before', 'second:after', 'first:after'])
|
||||
})
|
||||
|
||||
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('permission hook 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: permission hook broke' }],
|
||||
isError: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves structured error info when a tools/execute listener throws HarnessError', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async () => {
|
||||
throw new HarnessError('denied', 'DENIED')
|
||||
})
|
||||
|
||||
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' },
|
||||
})
|
||||
})
|
||||
|
||||
it('schemas() snapshots tool schemas instead of exposing registry objects', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
const first = ctx.tools.schemas()
|
||||
const firstParameters = first[0]!.parameters as { properties: Record<string, unknown> }
|
||||
firstParameters.properties['mutated'] = { type: 'string' }
|
||||
first[0]!.description = 'mutated'
|
||||
|
||||
expect(ctx.tools.schemas()).toEqual([{
|
||||
name: 'echo',
|
||||
description: 'echo arguments back',
|
||||
parameters: { type: 'object', properties: { text: { type: 'string' } } },
|
||||
}])
|
||||
})
|
||||
|
||||
it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
expect(() => ctx.tools.register(echoTool)).toThrow('already registered')
|
||||
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.tools.register({ ...echoTool, name: 'scoped' })
|
||||
}, { inject: ['tools'] }))
|
||||
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'scoped'])
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
|
||||
})
|
||||
|
||||
it('returns a callable disposer from register() that unregisters the tool', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
// Register a second tool and call its returned disposer directly
|
||||
const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' })
|
||||
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable'])
|
||||
|
||||
dispose()
|
||||
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
|
||||
})
|
||||
|
||||
it('rolls back the tool entry when a tools/change listener throws (P1-1)', async () => {
|
||||
const ctx = await setup()
|
||||
|
||||
let threw = false
|
||||
ctx.on('tools/change', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom change listener') }
|
||||
})
|
||||
|
||||
// The throwing emit must roll the entry back, not leak it.
|
||||
expect(() => ctx.tools.register(echoTool)).toThrow('boom change listener')
|
||||
expect(ctx.tools.get('echo')).toBeUndefined() // rolled back, not leaked
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
|
||||
// A subsequent listener-free register of the SAME name succeeds and is
|
||||
// exposed exactly once (the duplicate-name check is not wedged).
|
||||
const dispose = ctx.tools.register(echoTool)
|
||||
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo'])
|
||||
dispose()
|
||||
expect(ctx.tools.get('echo')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineTool / schema DSL', () => {
|
||||
it('converts SchemaSpec to standard JSON Schema with required array', () => {
|
||||
const spec = {
|
||||
path: { type: 'string', required: true, description: 'Absolute path' },
|
||||
offset: { type: 'number' },
|
||||
limit: { type: 'number', description: 'Max lines' },
|
||||
} satisfies SchemaSpec
|
||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
||||
expect(jsonSchema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Absolute path' },
|
||||
offset: { type: 'number' },
|
||||
limit: { type: 'number', description: 'Max lines' },
|
||||
},
|
||||
required: ['path'],
|
||||
})
|
||||
})
|
||||
|
||||
it('handles empty spec (no properties, no required)', () => {
|
||||
expect(schemaSpecToJsonSchema({})).toEqual({
|
||||
type: 'object',
|
||||
properties: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('handles nested object spec', () => {
|
||||
const spec = {
|
||||
config: {
|
||||
type: 'object',
|
||||
required: true,
|
||||
properties: {
|
||||
host: { type: 'string', required: true },
|
||||
port: { type: 'number' },
|
||||
},
|
||||
},
|
||||
} satisfies SchemaSpec
|
||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
||||
expect(jsonSchema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
config: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
host: { type: 'string' },
|
||||
port: { type: 'number' },
|
||||
},
|
||||
required: ['host'],
|
||||
},
|
||||
},
|
||||
required: ['config'],
|
||||
})
|
||||
})
|
||||
|
||||
it('defineTool returns a valid ToolDefinition with typed execute', async () => {
|
||||
const ctx = await setup()
|
||||
const tool = defineTool({
|
||||
name: 'typed-echo',
|
||||
description: 'A typed echo tool',
|
||||
parameters: {
|
||||
text: { type: 'string', required: true },
|
||||
uppercase: { type: 'boolean' },
|
||||
},
|
||||
async execute(args) {
|
||||
// args is typed: { text: string; uppercase?: boolean }
|
||||
const result = args.uppercase ? args.text.toUpperCase() : args.text
|
||||
return [{ type: 'text', text: result }]
|
||||
},
|
||||
})
|
||||
|
||||
ctx.tools.register(tool)
|
||||
expect(ctx.tools.schemas()).toEqual([{
|
||||
name: 'typed-echo',
|
||||
description: 'A typed echo tool',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string' },
|
||||
uppercase: { type: 'boolean' },
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
}])
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('c1'),
|
||||
name: 'typed-echo',
|
||||
arguments: { text: 'hello', uppercase: true },
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'HELLO' }])
|
||||
})
|
||||
|
||||
it('type-level: InferArgs maps required properties to non-optional', () => {
|
||||
// Compile-time check: if this compiles, InferArgs is correct.
|
||||
// args.a is string (required), args.b is number|undefined (optional).
|
||||
const tool = defineTool({
|
||||
name: 'type-check',
|
||||
description: '',
|
||||
parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } },
|
||||
async execute(args) {
|
||||
// Verify types at runtime via typeof
|
||||
expect(typeof args.a).toBe('string')
|
||||
// args.b should be undefined when not provided
|
||||
void args
|
||||
return [{ type: 'text', text: args.a }]
|
||||
},
|
||||
})
|
||||
void tool
|
||||
})
|
||||
|
||||
it('registry round-trips a defineTool definition (register→schemas→execute)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'roundtrip',
|
||||
description: 'Round-trip test',
|
||||
parameters: {
|
||||
req: { type: 'string', required: true },
|
||||
opt: { type: 'number', description: 'Optional number' },
|
||||
},
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: `${args.req}:${args.opt ?? 'none'}` }]
|
||||
},
|
||||
}))
|
||||
|
||||
// Schema round-trip: schemas() returns standard JSON Schema
|
||||
const schemas = ctx.tools.schemas()
|
||||
expect(schemas).toHaveLength(1)
|
||||
expect(schemas[0]!.parameters).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
req: { type: 'string' },
|
||||
opt: { type: 'number', description: 'Optional number' },
|
||||
},
|
||||
required: ['req'],
|
||||
})
|
||||
|
||||
// Execution round-trip
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('c1'),
|
||||
name: 'roundtrip',
|
||||
arguments: { req: 'hello' },
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'hello:none' }])
|
||||
})
|
||||
|
||||
it('still accepts raw JSON-Schema ToolDefinition directly (MCP interop)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
name: 'raw-tool',
|
||||
description: 'Raw JSON Schema tool (like an MCP adapter would register)',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
required: ['path'],
|
||||
},
|
||||
async execute(args: unknown) {
|
||||
const p = args as { path: string }
|
||||
return [{ type: 'text', text: p.path }]
|
||||
},
|
||||
})
|
||||
|
||||
const schemas = ctx.tools.schemas()
|
||||
expect(schemas[0]!.parameters).toEqual({
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
required: ['path'],
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('c1'),
|
||||
name: 'raw-tool',
|
||||
arguments: { path: '/tmp' },
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content).toEqual([{ type: 'text', text: '/tmp' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('schema DSL edge cases', () => {
|
||||
it('emits enum values in JSON Schema property', () => {
|
||||
const spec = {
|
||||
color: { type: 'string', enum: ['red', 'green', 'blue'], description: 'Color choice' },
|
||||
} satisfies SchemaSpec
|
||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
||||
expect(jsonSchema.properties['color']).toMatchObject({
|
||||
type: 'string',
|
||||
enum: ['red', 'green', 'blue'],
|
||||
description: 'Color choice',
|
||||
})
|
||||
})
|
||||
|
||||
it('emits default value in JSON Schema property', () => {
|
||||
const spec = {
|
||||
limit: { type: 'number', default: 25 },
|
||||
} satisfies SchemaSpec
|
||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
||||
expect(jsonSchema.properties['limit']).toMatchObject({
|
||||
type: 'number',
|
||||
default: 25,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles array items without nested properties (plain type array)', () => {
|
||||
const spec = {
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
} satisfies SchemaSpec
|
||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
||||
expect(jsonSchema.properties['tags']).toEqual({
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
})
|
||||
})
|
||||
|
||||
it('defineTool passes through strict flag when set to true', () => {
|
||||
const tool = defineTool({
|
||||
name: 'strict-tool',
|
||||
description: 'A strict tool',
|
||||
parameters: { input: { type: 'string' } },
|
||||
strict: true,
|
||||
async execute(args) {
|
||||
return [{ type: 'text' as const, text: args.input ?? '' }]
|
||||
},
|
||||
})
|
||||
expect(tool.strict).toBe(true)
|
||||
})
|
||||
|
||||
it('defineTool omits strict when not provided', () => {
|
||||
const tool = defineTool({
|
||||
name: 'non-strict-tool',
|
||||
description: 'A non-strict tool',
|
||||
parameters: { input: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text' as const, text: args.input ?? '' }]
|
||||
},
|
||||
})
|
||||
expect('strict' in tool).toBe(false)
|
||||
})
|
||||
|
||||
it('defineTool strict=false is included', () => {
|
||||
const tool = defineTool({
|
||||
name: 'explicitly-non-strict',
|
||||
description: 'Explicitly non-strict',
|
||||
parameters: { input: { type: 'string' } },
|
||||
strict: false,
|
||||
async execute(args) {
|
||||
return [{ type: 'text' as const, text: args.input ?? '' }]
|
||||
},
|
||||
})
|
||||
expect(tool.strict).toBe(false)
|
||||
})
|
||||
|
||||
it('handles enum and default together in one property', () => {
|
||||
const spec = {
|
||||
level: { type: 'string', enum: ['low', 'high'], default: 'low' },
|
||||
} satisfies SchemaSpec
|
||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
||||
expect(jsonSchema.properties['level']).toMatchObject({
|
||||
type: 'string',
|
||||
enum: ['low', 'high'],
|
||||
default: 'low',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits description, enum, default keys when not specified', () => {
|
||||
const spec = {
|
||||
bare: { type: 'string' },
|
||||
} satisfies SchemaSpec
|
||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
||||
const prop = jsonSchema.properties['bare'] as Record<string, unknown>
|
||||
expect(prop).toEqual({ type: 'string' })
|
||||
expect('description' in prop).toBe(false)
|
||||
expect('enum' in prop).toBe(false)
|
||||
expect('default' in prop).toBe(false)
|
||||
})
|
||||
|
||||
it('handles array with no items (items omitted)', () => {
|
||||
const spec = {
|
||||
raw: { type: 'array' },
|
||||
} satisfies SchemaSpec
|
||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
||||
expect(jsonSchema.properties['raw']).toEqual({
|
||||
type: 'array',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles nested object with all-optional properties (no required array)', () => {
|
||||
const spec = {
|
||||
config: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
host: { type: 'string' },
|
||||
port: { type: 'number' },
|
||||
},
|
||||
},
|
||||
} satisfies SchemaSpec
|
||||
const jsonSchema = schemaSpecToJsonSchema(spec)
|
||||
expect(jsonSchema.properties['config']).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
host: { type: 'string' },
|
||||
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)', () => {
|
||||
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()
|
||||
})
|
||||
|
||||
it('InferArgs recurses into array items, including arrays of objects', () => {
|
||||
type Args = InferArgs<{
|
||||
names: { type: 'array'; required: true; items: { type: 'string' } }
|
||||
servers: {
|
||||
type: 'array'
|
||||
items: {
|
||||
type: 'object'
|
||||
properties: {
|
||||
host: { type: 'string'; required: true }
|
||||
port: { type: 'number' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}>
|
||||
expectTypeOf<Args>().toEqualTypeOf<{
|
||||
names: string[]
|
||||
servers?: { host: string; port?: number }[]
|
||||
}>()
|
||||
})
|
||||
|
||||
it('runtime JSON Schema matches the array-of-objects inference', () => {
|
||||
const spec = {
|
||||
servers: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
host: { type: 'string', required: true },
|
||||
port: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies SchemaSpec
|
||||
expect(schemaSpecToJsonSchema(spec)).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
servers: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
host: { type: 'string' },
|
||||
port: { type: 'number' },
|
||||
},
|
||||
required: ['host'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('reports messages from non-Error throws (throw { message })', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'object-thrower',
|
||||
async execute() {
|
||||
// testing non-Error throws on purpose
|
||||
throw { message: 'denied by object' }
|
||||
},
|
||||
})
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-thrower', arguments: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' })
|
||||
})
|
||||
|
||||
it('reports messages from throws of non-objects (throw "string")', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'string-thrower',
|
||||
async execute() {
|
||||
// testing primitive throws on purpose
|
||||
throw 'kaboom'
|
||||
},
|
||||
})
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'string-thrower', arguments: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
|
||||
})
|
||||
|
||||
it('reports messages from throws of objects without message property', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'object-no-message',
|
||||
async execute() {
|
||||
// testing object throw without .message
|
||||
throw { code: 500 }
|
||||
},
|
||||
})
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-no-message', arguments: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
const firstContent = result.content[0]!
|
||||
expect(firstContent.type).toBe('text')
|
||||
if (firstContent.type === 'text') {
|
||||
expect(firstContent.text).toBe('Error: [object Object]')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolRegistry.get', () => {
|
||||
it('get() returns the registered tool definition', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
const tool = ctx.tools.get('echo')
|
||||
expect(tool).toBeDefined()
|
||||
expect(tool!.name).toBe('echo')
|
||||
})
|
||||
|
||||
it('get() returns undefined for unknown tool names', async () => {
|
||||
const ctx = await setup()
|
||||
expect(ctx.tools.get('nope')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateArgs (the runtime-validation RFC, part 1)', () => {
|
||||
it('returns [] for valid args and is total over malformed input', () => {
|
||||
const spec = {
|
||||
path: { type: 'string', required: true },
|
||||
limit: { type: 'number' },
|
||||
} satisfies SchemaSpec
|
||||
expect(validateArgs(spec, { path: '/tmp' })).toEqual([])
|
||||
expect(validateArgs(spec, { path: '/tmp', limit: 5 })).toEqual([])
|
||||
// never throws regardless of shape
|
||||
expect(validateArgs(spec, null)).toHaveLength(1)
|
||||
expect(validateArgs(spec, 'nope')).toHaveLength(1)
|
||||
expect(validateArgs(spec, [])).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('flags a missing required key and a required key present as undefined', () => {
|
||||
const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
|
||||
expect(validateArgs(spec, {})).toEqual(['missing required property "path"'])
|
||||
expect(validateArgs(spec, { path: undefined })).toEqual(['missing required property "path"'])
|
||||
})
|
||||
|
||||
it('allows extra keys (no additionalProperties:false) and omitted optionals', () => {
|
||||
const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
|
||||
expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([])
|
||||
})
|
||||
|
||||
it('does not apply defaults (validation only)', () => {
|
||||
const spec = { limit: { type: 'number', default: 25 } } satisfies SchemaSpec
|
||||
// absent optional is valid, and validation does not synthesize the default
|
||||
expect(validateArgs(spec, {})).toEqual([])
|
||||
})
|
||||
|
||||
it('type-checks primitives', () => {
|
||||
const spec = {
|
||||
s: { type: 'string' },
|
||||
n: { type: 'number' },
|
||||
b: { type: 'boolean' },
|
||||
} satisfies SchemaSpec
|
||||
expect(validateArgs(spec, { s: 1 })).toEqual(['"s" must be a string'])
|
||||
expect(validateArgs(spec, { n: 'x' })).toEqual(['"n" must be a number'])
|
||||
expect(validateArgs(spec, { b: 'x' })).toEqual(['"b" must be a boolean'])
|
||||
})
|
||||
|
||||
it('checks enum membership', () => {
|
||||
const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies SchemaSpec
|
||||
expect(validateArgs(spec, { color: 'red' })).toEqual([])
|
||||
expect(validateArgs(spec, { color: 'blue' })).toEqual(['"color" must be one of ["red","green"]'])
|
||||
})
|
||||
|
||||
it('checks enum uniformly with the converter (enum on a non-string prop)', () => {
|
||||
// The converter emits `enum` regardless of type; the validator must agree.
|
||||
// `enum` is string[], so a number value can never be a member.
|
||||
const spec = { n: { type: 'number', enum: ['1', '2'] } } as unknown as SchemaSpec
|
||||
expect(validateArgs(spec, { n: 1 })).toEqual(['"n" must be one of ["1","2"]'])
|
||||
})
|
||||
|
||||
it('rejects an unknown SchemaType at runtime (assertNever guard)', () => {
|
||||
const spec = { x: { type: 'weird' } } as unknown as SchemaSpec
|
||||
expect(() => validateArgs(spec, { x: 1 })).toThrow(/unreachable variant.*validateArgs/)
|
||||
})
|
||||
|
||||
it('recurses into nested objects (and an object without properties only type-checks)', () => {
|
||||
const spec = {
|
||||
config: {
|
||||
type: 'object',
|
||||
required: true,
|
||||
properties: { host: { type: 'string', required: true }, port: { type: 'number' } },
|
||||
},
|
||||
bag: { type: 'object' },
|
||||
} satisfies SchemaSpec
|
||||
expect(validateArgs(spec, { config: { host: 'h' }, bag: { anything: true } })).toEqual([])
|
||||
expect(validateArgs(spec, { config: { port: 9 }, bag: 5 })).toEqual([
|
||||
'missing required property "config.host"',
|
||||
'"bag" must be an object',
|
||||
])
|
||||
})
|
||||
|
||||
it('recurses into array items (and an array without items only type-checks)', () => {
|
||||
const spec = {
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
raw: { type: 'array' },
|
||||
} satisfies SchemaSpec
|
||||
expect(validateArgs(spec, { tags: ['a', 'b'], raw: [1, {}, 'x'] })).toEqual([])
|
||||
expect(validateArgs(spec, { tags: ['a', 2] })).toEqual(['"tags[1]" must be a string'])
|
||||
// a non-array value for an array-typed prop
|
||||
expect(validateArgs(spec, { tags: 'nope' })).toEqual(['"tags" must be an array'])
|
||||
})
|
||||
|
||||
it('validates arrays of objects element-wise', () => {
|
||||
const spec = {
|
||||
servers: {
|
||||
type: 'array',
|
||||
items: { type: 'object', properties: { host: { type: 'string', required: true } } },
|
||||
},
|
||||
} satisfies SchemaSpec
|
||||
expect(validateArgs(spec, { servers: [{ host: 'a' }, {}] })).toEqual([
|
||||
'missing required property "servers[1].host"',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
|
||||
it('returns an isError result with the violations when the model sends bad args', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'reader',
|
||||
description: 'reads a path',
|
||||
parameters: { path: { type: 'string', required: true } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: args.path }]
|
||||
},
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({
|
||||
text: 'Error: invalid arguments: missing required property "path"',
|
||||
})
|
||||
})
|
||||
|
||||
it('runs execute normally when args are valid', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'reader',
|
||||
description: 'reads a path',
|
||||
parameters: { path: { type: 'string', required: true } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: `read ${args.path}` }]
|
||||
},
|
||||
}))
|
||||
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 })
|
||||
})
|
||||
|
||||
it('ToolArgsError carries a stable code and the violation list', () => {
|
||||
const err = new ToolArgsError(['missing required property "a"', '"b" must be a number'])
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.name).toBe('ToolArgsError')
|
||||
expect(err.code).toBe('INVALID_ARGS')
|
||||
expect(err.violations).toEqual(['missing required property "a"', '"b" must be a number'])
|
||||
expect(err.message).toBe('invalid arguments: missing required property "a"; "b" must be a number')
|
||||
})
|
||||
|
||||
it('a schema-invalid call surfaces the structured error on the result', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'reader',
|
||||
description: 'reads a path',
|
||||
parameters: { path: { type: 'string', required: true } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: args.path }]
|
||||
},
|
||||
}))
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' })
|
||||
})
|
||||
|
||||
it('a tool throwing a HarnessError surfaces its name and code', async () => {
|
||||
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'coded',
|
||||
async execute() {
|
||||
throw new HarnessError('disk full', 'ENOSPC')
|
||||
},
|
||||
})
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' })
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: disk full' })
|
||||
})
|
||||
|
||||
it('a non-HarnessError throw has no structured error (only the text)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'plain',
|
||||
async execute() {
|
||||
throw new Error('just a message')
|
||||
},
|
||||
})
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: just a message' })
|
||||
})
|
||||
|
||||
it('raw-registered tools are NOT validated by defineTool (MCP keeps its own)', async () => {
|
||||
const ctx = await setup()
|
||||
// A raw ToolDefinition: no defineTool wrapping, so no validateArgs guard.
|
||||
ctx.tools.register({
|
||||
name: 'raw',
|
||||
description: 'raw tool',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
||||
async execute(args: unknown) {
|
||||
return [{ type: 'text', text: typeof args }]
|
||||
},
|
||||
})
|
||||
// Missing the "required" path — but raw tools validate their own input, so
|
||||
// this reaches execute rather than being rejected by the harness.
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} })
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineTool presentation (presentCall / presentResult)', () => {
|
||||
it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
|
||||
const tool = defineTool({
|
||||
name: 'demo',
|
||||
description: 'demo',
|
||||
parameters: { path: { type: 'string', required: true }, n: { type: 'number' } },
|
||||
async execute() { return [{ type: 'text', text: 'ok' }] },
|
||||
presentCall(args) {
|
||||
// args is typed { path: string; n?: number } — zero casts.
|
||||
expectTypeOf(args).toEqualTypeOf<{ path: string; n?: number }>()
|
||||
return { title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
|
||||
},
|
||||
presentResult(args, result) {
|
||||
return { title: `Opened ${args.path}`, content: result.content }
|
||||
},
|
||||
})
|
||||
expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ title: 'Open /a', kind: 'read', rawInput: '/a' })
|
||||
expect(tool.presentResult!({ path: '/a' }, { content: [{ type: 'text', text: 'x' }], isError: false }))
|
||||
.toEqual({ title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
|
||||
})
|
||||
|
||||
it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
|
||||
const tool = defineTool({
|
||||
name: 'plain',
|
||||
description: 'plain',
|
||||
parameters: { x: { type: 'string', required: true } },
|
||||
async execute() { return [] },
|
||||
})
|
||||
expect(typeof tool.presentCall).toBe('undefined')
|
||||
expect(typeof tool.presentResult).toBe('undefined')
|
||||
})
|
||||
|
||||
it('presentCall/presentResult validate softly: malformed args return undefined, never throw (display runs on replay)', () => {
|
||||
const tool = defineTool({
|
||||
name: 'demo',
|
||||
description: 'demo',
|
||||
parameters: { path: { type: 'string', required: true } },
|
||||
async execute() { return [] },
|
||||
presentCall: args => ({ title: args.path }),
|
||||
presentResult: (args, result) => ({ title: args.path, content: result.content }),
|
||||
})
|
||||
// Unlike execute (which throws ToolArgsError on a mismatch), the display
|
||||
// methods soft-validate and fall back to undefined so a UI never crashes
|
||||
// replaying an old/foreign log entry. The ToolDefinition methods take
|
||||
// `unknown`, so malformed shapes pass without a cast.
|
||||
expect(tool.presentCall?.({})).toBeUndefined()
|
||||
expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
27
packages/core/tools/tsconfig.json
Normal file
27
packages/core/tools/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user