Merge origin/master into skill system branch
Use the new filesystem seam for skill file reads and system skill writes when ctx.fs is available, and include the skill tool in the generated tool catalog.
This commit is contained in:
@@ -8,7 +8,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
|
||||
|
||||
- `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.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/execute` waterfall.
|
||||
|
||||
### Injected services
|
||||
@@ -72,7 +72,7 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an
|
||||
|
||||
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`).
|
||||
- `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), an optional `locations` (`{ path, line? }[]` — the files this call reads/modifies, so a capable UI can follow along / jump to them; the ACP bridge forwards them as `tool_call.locations`), 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.
|
||||
|
||||
@@ -109,6 +109,16 @@ export interface ToolCallPresentation {
|
||||
* {@link terminal} block (if any) as a terminal card.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
/**
|
||||
* Files this call reads or modifies, so a capable UI can "follow along" —
|
||||
* highlight or jump to the file (and line) as the tool runs. Provider-neutral
|
||||
* `{ path, line? }` pairs; a UI bridge maps them to its own affordance (the ACP
|
||||
* bridge forwards them as `tool_call.locations`). `path` is what the tool
|
||||
* operated on (the model-facing path); `line` is an optional 1-based line to
|
||||
* focus (e.g. a read's offset). Omit for a call that touches no file (e.g.
|
||||
* `bash`).
|
||||
*/
|
||||
locations?: { path: string; line?: number }[]
|
||||
/**
|
||||
* 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
|
||||
|
||||
117
packages/core/tools/tests/gen-tool-catalog.spec.ts
Normal file
117
packages/core/tools/tests/gen-tool-catalog.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Guarantee tests for the tool-schema catalog generator
|
||||
* (`scripts/gen-tool-catalog.ts`).
|
||||
*
|
||||
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
|
||||
* the freshness half is exercised by `pnpm run verify-tool-catalog` in CI. What
|
||||
* a freshness diff CANNOT prove is (a) that BOOTING the tool plugins yields the
|
||||
* shipped schema — the whole reason this generator boots instead of parsing
|
||||
* source (a runtime-spread enum resolves to its literal members) — and (b) that
|
||||
* the completeness guard REJECTS a tool package missing from the boot manifest,
|
||||
* the property that replaces the AST pass's "nothing silently omitted". These
|
||||
* tests drive the exported `collectToolCatalog` / `assertManifestComplete` /
|
||||
* `render` directly, mirroring the negative-path style of the cordis-catalog
|
||||
* generator tests.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertManifestComplete,
|
||||
collectToolCatalog,
|
||||
render,
|
||||
type ToolCatalog,
|
||||
} from '../../../../scripts/gen-tool-catalog.ts'
|
||||
|
||||
/** JSON Schema shape enough to reach the values AST extraction can't. */
|
||||
interface JsonSchema {
|
||||
type: string
|
||||
properties?: Record<string, JsonSchema>
|
||||
items?: JsonSchema
|
||||
enum?: string[]
|
||||
required?: string[]
|
||||
}
|
||||
|
||||
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', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
expect((schema.parameters as unknown as JsonSchema).type).toBe('object')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves a runtime-spread enum to its literal members (the payoff over AST)', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const todo = catalog
|
||||
.flatMap(entry => entry.schemas)
|
||||
.find(s => s.name === 'todo_write')
|
||||
// `todo-todo` writes `enum: [...STATUSES]` — a source AST would see the
|
||||
// spread, not the values. Booting yields the shipped enum literals.
|
||||
const status = (((todo?.parameters as unknown as JsonSchema).properties?.todos)?.items)?.properties?.status
|
||||
expect(status?.enum).toEqual(['pending', 'in_progress', 'completed'])
|
||||
})
|
||||
|
||||
it('attributes each package with a source pointer that names its index', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const bash = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-bash')
|
||||
expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts')
|
||||
})
|
||||
|
||||
it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => {
|
||||
// `tool-subagent`'s registered name is the load-time `toolName` config, so
|
||||
// the shipped agents surface this one package as both `subagent` and
|
||||
// `subagent_fork`. Booting yields only the default name; the note is how a
|
||||
// reader learns the fork alias the model also sees. Without it the catalog
|
||||
// would silently under-report the shipped tool surface.
|
||||
const catalog = await collectToolCatalog()
|
||||
const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent')
|
||||
expect(subagent?.schemas.map(s => s.name)).toEqual(['subagent'])
|
||||
expect(subagent?.note).toMatch(/subagent_fork/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-tool-catalog assertManifestComplete', () => {
|
||||
it('passes when the manifest lists every on-disk tool package (the default)', () => {
|
||||
expect(() => { assertManifestComplete() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws, naming the omitted package, when a tool package is missing from the manifest', () => {
|
||||
// An empty manifest scanned against the real tree: every `tool-*` package
|
||||
// is unlisted, so the guard must fire and name them.
|
||||
expect(() => { assertManifestComplete([]) }).toThrow(/not in the boot manifest/)
|
||||
expect(() => { assertManifestComplete([]) }).toThrow(/tool-bash/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-tool-catalog render', () => {
|
||||
it('emits a package heading, a tool heading, and a json schema fence', () => {
|
||||
const catalog: ToolCatalog = [
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-demo',
|
||||
source: 'packages/demo/tool-demo/src/index.ts',
|
||||
schemas: [{ name: 'demo', description: 'A demo tool.', parameters: { type: 'object', properties: {} } }],
|
||||
},
|
||||
]
|
||||
const md = render(catalog)
|
||||
expect(md).toContain('## `@deepseek-ai/dsh-tool-demo`')
|
||||
expect(md).toContain('### `demo`')
|
||||
expect(md).toContain('A demo tool.')
|
||||
expect(md).toContain('```json')
|
||||
expect(md).toContain('Source: [`packages/demo/tool-demo/src/index.ts`]')
|
||||
})
|
||||
|
||||
it('renders the strict flag when a schema sets it', () => {
|
||||
const catalog: ToolCatalog = [
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-demo',
|
||||
source: 'packages/demo/tool-demo/src/index.ts',
|
||||
schemas: [{ name: 'demo', description: '', parameters: { type: 'object', properties: {} }, strict: true }],
|
||||
},
|
||||
]
|
||||
expect(render(catalog)).toContain('Strict: `true`')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user