diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md index 9af018d2d6..96355941b3 100644 --- a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md @@ -31,7 +31,9 @@ The boot manifest (`TOOL_PACKAGES`) is a hand-written list — in tension with t ### Scope -Shipped product tools under `packages/*/tool-*` only: `dsh-tool-bash` (`bash`, `bash_output`, `bash_kill`), `dsh-tool-todo` (`todo_write`), `dsh-tool-subagent` (`subagent`). The `examples/` demo tools (`echo`) are excluded, matching the cordis catalog's packages-only scope — a demo tool is not part of the product surface a reader is cataloguing. +Shipped product tool PACKAGES under `packages/*/tool-*`, each booted with its default config: `dsh-tool-bash` (`bash`, `bash_output`, `bash_kill`), `dsh-tool-todo` (`todo_write`), `dsh-tool-subagent` (`subagent`). The `examples/` demo tools (`echo`) are excluded, matching the cordis catalog's packages-only scope — a demo tool is not part of the product surface a reader is cataloguing. + +The unit is the PACKAGE, not the deployed tool instance. A package's registered tool name can be a load-time config — `tool-subagent`'s `toolName` — so the same package surfaces as `subagent` (spawn backend) AND `subagent_fork` (fork backend) in the shipped `coding-agent` / `acp-agent` configs, with an identical schema. The generator boots each package once at its default and records such shipped aliases in a per-package note, rather than enumerating every deployment permutation. Cataloguing at the package level keeps the source of truth the package (what a plugin author reads) and avoids leaking example-app `cordis.yml` config into a packages-scoped generator; the note keeps the doc honest about the names a reader will actually see the model receive. The design deliberately does not attempt to catalog "every configured tool instance across every leaf config" — that is a deployment inventory, a different (and unbounded) surface. ### A plain `json` fence diff --git a/docs/tool-catalog/tools.md b/docs/tool-catalog/tools.md index 9e625d17ad..97400f8223 100644 --- a/docs/tool-catalog/tools.md +++ b/docs/tool-catalog/tools.md @@ -3,11 +3,11 @@ # Tool Schema Catalog -Every model-facing tool a shipped plugin contributes to `ctx.tools`: the exact `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. +Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md). -Scope: shipped product tools under `packages/*/tool-*`. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. +Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. ## `@deepseek-ai/dsh-tool-bash` @@ -119,6 +119,8 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../../packages/subagent/tool-subagent/src/index.ts) +The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. + ## `@deepseek-ai/dsh-tool-todo` ### `todo_write` diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 57d6b6ccc4..eba74c833d 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -60,6 +60,18 @@ describe('gen-tool-catalog 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', () => { diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8e62277713..be318f753c 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -72,6 +72,14 @@ interface ToolPackage { /** Plug the injected seams + the tool plugin onto a context that already * carries `systemPrompt` + `tools`. */ mount: (ctx: Context) => Promise + /** + * A deployment note rendered after the package's tools, for a fact that + * booting the package alone cannot show. The registered tool NAME can be a + * load-time config (`tool-subagent`'s `toolName`), so one package may surface + * under several names across deployments — the boot yields the package + * DEFAULT, and this note records the shipped alternatives the model sees. + */ + note?: string } /** @@ -99,6 +107,8 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(SubagentMock, { name: 'mock' }) await ctx.plugin(ToolSubagent, { provider: 'mock' }) }, + note: + 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.', }, { pkg: '@deepseek-ai/dsh-tool-todo', @@ -115,6 +125,8 @@ interface CatalogPackage { pkg: string source: string schemas: ToolSchema[] + /** A deployment note (see {@link ToolPackage.note}), rendered after the tools. */ + note?: string } /** The whole catalog: one entry per booted tool package, in manifest order. */ @@ -153,13 +165,18 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES const catalog: ToolCatalog = [] for (const entry of packages) { const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await entry.mount(ctx) - // Copy the schemas out before the context is torn down. - const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name)) - await ctx.fiber.dispose() - catalog.push({ pkg: entry.pkg, source: entry.source, schemas }) + // Dispose in `finally` so a throw from `mount`/`schemas()` after earlier + // plugins mounted still tears the context down (no leaked executor/provider + // fiber) — the repo's "dispose must reach quiescence" rule. + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await entry.mount(ctx) + const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name)) + catalog.push({ pkg: entry.pkg, source: entry.source, schemas, ...entry.note !== undefined ? { note: entry.note } : {} }) + } finally { + await ctx.fiber.dispose() + } } return catalog } @@ -182,16 +199,17 @@ export function render(catalog: ToolCatalog): string { '', '# Tool Schema Catalog', '', - 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the exact `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', + 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', '', 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', '', - 'Scope: shipped product tools under `packages/*/tool-*`. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', + 'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', '', ] for (const entry of catalog) { lines.push(`## \`${entry.pkg}\``, '') for (const schema of entry.schemas) lines.push(...renderTool(schema, entry.source)) + if (entry.note) lines.push(entry.note, '') } return lines.join('\n') }