Skip to content

refactor(tools): move name to ToolDefinition, delete ToolManifest, retype core tools#32631

Merged
dvargasfuertes merged 10 commits into
mainfrom
apollo/name-to-tool-definition
May 30, 2026
Merged

refactor(tools): move name to ToolDefinition, delete ToolManifest, retype core tools#32631
dvargasfuertes merged 10 commits into
mainfrom
apollo/name-to-tool-definition

Conversation

@vellum-apollo-bot
Copy link
Copy Markdown
Contributor

What

Closes the ToolDefinition refactor cycle started by #32362 (executionMode) and #32472 (category): every tool author writes a ToolDefinition literal, the registry finalizes it into a Tool.

1. name moves onto ToolDefinition

Per Vargas's review comment on #32472:

In the next PR, let's move this field into ToolDefinition too as optional, noting that we default to using the file name if not specified

name?: string is now an optional field on ToolDefinition with a JSDoc note that loaders default it to the source file basename (tools/read.tsread). Tool collapses from Required<ToolDefinition> & { name: string } to bare Required<ToolDefinition> — name is now part of the base shape.

finalizeTool(tool, defaultName) prefers tool.name over the file-derived default, so a literal that overrides the file name still wins.

2. Delete ToolManifest

The Zod-derived ToolManifest type in ipc/skill-routes/registries.ts was a near-duplicate of ToolDefinition minus execute (functions don't cross IPC). Replaced with ToolDefinition directly; renamed the schema to WireToolDefinitionSchema to reflect that it's the wire form of the canonical type. buildProxyTool(definition: ToolDefinition) uses ! assertions on fields the Zod schema guarantees are set.

3. Core tools are ToolDefinition, not Tool

Every core tool literal — 22 class XxxTool implements ToolDefinition sites plus 14 : ToolDefinition = { ... } object literals across apps/, ui-surface/, computer-use/ definitions — now uses the relaxed authoring contract. Aggregate lists (explicitTools, cesTools, coreAppProxyTools, allUiSurfaceTools) are typed ToolDefinition[].

registerTool widens to accept ToolDefinition and finalizes internally via a WeakMap<ToolDefinition, Tool> so idempotent re-registration (test reset helpers, ESM module re-imports) stays the same silent no-op the old existing === tool check provided. Throws if tool.name is missing — file-basename defaulting is a loader concern, not a registry concern.

Why

The "Workspace Tools" PR review (#31949) asked for the ToolDefinition shape to be the canonical author-facing contract for every tool source — workspace, plugin, skill, MCP, and core. PRs #32362 and #32472 lifted executionMode and category from Tool to ToolDefinition; this PR finishes the cycle with name and applies the same authoring contract to core-tool literals.

After this lands, the order of operations to load any tool is uniform regardless of source:

  1. Author writes a ToolDefinition literal (omitting any field with a documented default).
  2. The owning loader either runs finalizeTool(def, fileBasename) or hands it to registerTool, which finalizes internally.
  3. The registry stores the finalized Tool.

Verification

  • eslint src/tools src/ipc/skill-routes/registries.ts — clean.
  • bun test plugin-bootstrap plugin-tool-contribution plugin-types registry skill-tool-manifest tool-executor tool-approval-handler conversation-skill-tools conversation-app-control-instantiation skill-projection-feature-flag — 263/263 pass.
  • Broader bun test src/tools/ reproduces a 410/489 pass baseline identical to origin/main HEAD — no new regressions.

Stack / follow-ups

cc @dvargasfuertes

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b99899588d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

}
let tool = finalizedByDefinition.get(definition);
if (!tool) {
tool = finalizeTool(definition, name);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve class method receivers when finalizing tools

When a class-based core tool is registered, this now stores the shallow object returned by finalizeTool() rather than the original instance. That changes the receiver for prototype execute methods: in local/no-proxy host file transfer, HostFileTransferTool.execute() reaches this.executeLocal(...), but the finalized plain object does not inherit that private prototype method, so host_file_transfer throws a TypeError instead of copying the file. Binding execute to the original definition or preserving the prototype avoids breaking class tools that call helper methods through this.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would it take to make these all objects instead of class + instances? What do we lose? Was making these class + instances an intentional decision?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by converting all 22 class-based tools to plain ToolDefinition literals in 867810963c. There are no instances or prototype chains anymore, so finalizeTool can't lose a method receiver — it just spreads the literal and fills defaults.

Two tools needed light surgery to lose this:

  • host-filesystem/transfer.ts: executeLocal is now a module-level function.
  • ask-question/ask-question-tool.ts: the constructor-injected prompterFactory is now a createAskQuestionTool(prompterFactory?) factory; askQuestionTool is the default-wired export.

The finalizedByDefinition WeakMap stays — its purpose now is idempotent re-registration (silent no-op for test resets / module re-imports), not prototype preservation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 867810963c. All 22 class-based tools are now plain ToolDefinition literals. What we lose: nothing material. What changed:

  • No this. 20 of 22 tools never used this. to begin with — pure mechanical conversion. The two that did:
    • host-filesystem/transfer: private async executeLocal(...) lifted to a module-level function.
    • ask-question: constructor-injected prompterFactory became a createAskQuestionTool(prompterFactory?) factory; askQuestionTool is the default-wired export.
  • No factories required for the rest. Every other tool is a single export const fooTool: ToolDefinition = { name, description, input_schema, defaultRiskLevel, category, executionTarget, async execute(...) { ... } }.
  • finalizeTool is now structural-only. It spreads the literal, fills defaults, and returns a new object — no prototype concerns left. The finalizedByDefinition WeakMap stays only for idempotent re-registration (silent no-op for test resets / module re-imports).
  • Schema consolidation (your other comment). ToolDefinitionSchema in tools/types.ts is the source of truth; WireToolDefinitionSchema is .required({...}) over it. ToolDefinition is z.infer<...> & { execute? }.

Why classes felt right earlier: TypeScript's autocomplete on class bodies is nicer than on object literals, and the abstract base hinted at "must implement execute". But neither outweighs the runtime simplification — and ToolDefinition typed on the literal still gives field-level completion. Lint catches missing execute at the call site instead of at type-check time.

…type core tools

Closes the ToolDefinition refactor cycle started by #32362 (executionMode)
and #32472 (category) — every tool author writes a `ToolDefinition`
literal, the registry finalizes it into a `Tool`.

## name → ToolDefinition (PR #32472 follow-up review comment)

`name?: string` is now an optional field on `ToolDefinition` with a
JSDoc note that loaders default it to the source file basename
(`tools/read.ts` → `read`). `Tool` collapses from
`Required<ToolDefinition> & { name: string }` to bare
`Required<ToolDefinition>` since name is now part of the base shape.

`finalizeTool(tool, defaultName)` now prefers `tool.name` over the
file-derived default, so a literal that overrides the file name still
wins.

## Delete ToolManifest

The Zod-derived `ToolManifest` type in `ipc/skill-routes/registries.ts`
was a near-duplicate of `ToolDefinition` minus `execute` (functions
don't cross IPC). Replaced with `ToolDefinition` directly; renamed the
schema to `WireToolDefinitionSchema` to reflect that it's the wire form
of the canonical type. `buildProxyTool(definition: ToolDefinition)`
uses `!` assertions on fields the Zod schema guarantees are set.

## Core tools as ToolDefinition

Every core tool literal — 22 `class XxxTool implements ToolDefinition`
sites plus 14 `: ToolDefinition = { ... }` object literals across
`apps/`, `ui-surface/`, `computer-use/` definitions — now uses the
relaxed authoring contract. Aggregate lists (`explicitTools`,
`cesTools`, `coreAppProxyTools`, `allUiSurfaceTools`) are typed
`ToolDefinition[]`.

`registerTool` widens to accept `ToolDefinition` and finalizes
internally via a `WeakMap<ToolDefinition, Tool>` so idempotent
re-registration (test reset helpers, ESM module re-imports) stays the
same silent no-op the old `existing === tool` check provided. Throws
if `tool.name` is missing — the file-basename default is a loader
concern, not a registry concern.

## Verified locally

- `eslint src/tools src/ipc/skill-routes/registries.ts` — clean.
- `bun test plugin-bootstrap plugin-tool-contribution plugin-types
  registry skill-tool-manifest tool-executor tool-approval-handler
  conversation-skill-tools conversation-app-control-instantiation
  skill-projection-feature-flag` — 263/263 pass.
- Broader `bun test src/tools/` reproduces a 410/489 pass baseline
  identical to `origin/main` HEAD — no new regressions.
PR #32472 widened `ToolDefinition` (made name/description/etc.
optional). This commit fixes the cascade of typecheck failures
in core-tool tests + two production-side mismatches.

## Registry returns Tool[], not ToolDefinition[]

`getAllToolDefinitions()` and `getMcpToolDefinitions()` previously
returned `ToolDefinition[]`. At runtime they always return finalized
`Tool` objects (full Required<ToolDefinition>); the loose typing was
lying. Tightening to `Tool[]` fixes:

- `conversation.ts` and `conversation-tool-setup.ts` `.name` reads
  that became `string | undefined` under the widened type.
- Cross-package assignability to `@vellumai/skill-host-contracts`'s
  stricter `ToolDefinition` (which requires name/description/input_schema).
- `btw-routes.ts` passing the result to `runBtwSidechain` whose
  `tools` parameter is the contracts-package shape.

## buildProxyTool takes Zod-inferred wire shape

`buildProxyTool(definition: ToolDefinition)` was being passed elements
from `z.array(WireToolDefinitionSchema)`. The Zod-inferred type has
strict field types (e.g. `defaultRiskLevel: "low"|"medium"|"high"`)
that don't match assistant's enum-typed `ToolDefinition` even though
they're structurally equivalent. Switching the parameter to
`z.infer<typeof WireToolDefinitionSchema>` aligns the types and drops
the `!` assertions / `as` casts that the prior version needed.

## Tests: ! on .execute() calls and tool.description

13 test files invoke `.execute()` directly on imported tool exports
typed as `ToolDefinition`. Added non-null assertions matching the
runtime invariant — these tools are class instances or fully-specified
literals where every field is implemented.

Also: `computer-use-tools.test.ts` `schema()` helper now accepts
`input_schema?: object` instead of requiring it; `background-shell-bash`
and `terminal-tools` local `shellTool: Tool` declarations changed to
`shellTool: ToolDefinition` matching the module export type;
`credential-execution-tools` drops an explicit `(t: Tool)` callback
annotation that tripped the variance check.
PR #32595 ('Observability for subprocess orphans') merged into main
after this branch opened. It introduced shell-observability.test.ts
using `let shellTool: Tool` + `shellTool.execute(...)` — the same
pattern fad576a fixed in the other shell tests. CI's typecheck on
the merge commit catches this; rebasing surfaces the file so the fix
goes in-branch.

Same mechanical fix: `Tool` → `ToolDefinition` for the variable
type, `execute!` on the four call sites.
@vellum-apollo-bot vellum-apollo-bot Bot force-pushed the apollo/name-to-tool-definition branch from fad576a to ee1d51b Compare May 29, 2026 22:26
* over IPC.
* Wire form of a {@link ToolDefinition} sent over IPC by a skill process.
* Identical structurally to {@link ToolDefinition} except `execute` is
* dropped (a closure cannot cross the socket) — the daemon synthesizes
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to move this schema to tools/types.ts, and then ToolDefinition is just an infer of the schema so that these two are consolidated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 867810963c. WireToolDefinitionSchema now lives in tools/types.ts as ToolDefinitionSchema.required({ name, description, input_schema, defaultRiskLevel, category }) (with executionTarget left optional so the daemon's resolveExecutionTarget still gets to fill it).

ToolDefinition is z.infer<typeof ToolDefinitionSchema> & { execute?: ... } — the schema is the source of truth for both the in-process author shape and the IPC wire shape, with execute added back as a TS-only overlay since closures can't cross the socket.

Bonus: defaultRiskLevel is now z.enum(RiskLevel) (the native enum from skill-host-contracts), so the as RiskLevel cast in buildProxyTool is gone — the inferred type already IS RiskLevel.

}
let tool = finalizedByDefinition.get(definition);
if (!tool) {
tool = finalizeTool(definition, name);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would it take to make these all objects instead of class + instances? What do we lose? Was making these class + instances an intentional decision?

… + consolidate schemas

Replaces all 22 class-based tools with plain object literals typed as
`ToolDefinition`, addressing PR review feedback. Class+instance
indirection is gone; `finalizeTool` no longer has anything to do with
prototypes.

Schema consolidation:
- `ToolDefinitionSchema` (tools/types.ts) is the single source of truth.
  `WireToolDefinitionSchema = ToolDefinitionSchema.required({...})` with
  `executionTarget` optional.
- `ipc/skill-routes/registries.ts` imports the canonical schema; the
  local duplicate and `RiskLevel` cast are removed
  (`z.enum(RiskLevel)` infers as the native enum).

Refactors:
- ask-question: constructor-injected `prompterFactory` becomes a
  `createAskQuestionTool()` factory; default export `askQuestionTool`.
  Test file updated to import the literal + factory.
- host-filesystem/transfer: `executeLocal` lifted from a private method
  to a module-level function (registry stores finalized literal
  references, so `this`-based dispatch is unavailable).
- 20 other tools converted to literals (zero `this.` usage).

Tests:
- ask-question 23/23, host-filesystem 29/29, memory 14/14, network
  web-search 36/36 pass in isolation. Broad-batch failures are
  pre-existing Bun mock-leakage: baseline at merge-base shows
  167 fail / 537 tests; this branch shows 79 fail / 489 tests under
  the same conditions.
… to object

CI typecheck caught three regressions from the schema-driven type:

1. `ExecutionTarget` type import was removed by accident while
   restructuring the import block. Restored.

2. `ToolDefinition.execute` was inferred as optional (because the
   schema doesn't include execute), so every test calling
   `tool.execute(...)` failed with TS18048. Changed the type overlay
   to require execute — every in-process tool literal has one, and the
   wire schema still parses without it (closures can't cross IPC;
   `buildProxyTool` synthesizes execute on arrival).

3. `input_schema` was inferred as `Record<string, unknown>` from
   `z.record(z.string(), z.unknown())`, which is stricter than the
   prior `object` interface field. Factories and tests assigning
   typed JSON-schema objects (`as object`) failed. Widened the
   overlay to `input_schema?: object` so authors can keep using JSON
   schema literals; wire form still parses to `Record<string,
   unknown>` via `WireToolDefinitionSchema`.

Also updated `managed-skill-lifecycle.test.ts` to use the literal
`skillLoadTool` import instead of the (removed) `SkillLoadTool`
class.
…ites

Reverting the previous `execute: required` change — there are
schema-only ToolDefinitions in the codebase (`graphRememberDefinition`,
`storeStyleAnalysisTool`, `SWEEP_TOOL`) that are handed to providers
as function-calling schemas without ever being registered for
execution. Requiring execute on the type would break those legitimate
use cases, plus the plugin-api contract test that asserts an empty
literal satisfies `ToolDefinition`.

Instead: keep `execute` optional, and add the `!` bang to test
call sites (mirroring the existing pattern in `host-filesystem/*.test.ts`).
Sed-replaced 111 call sites across 8 test files. Affected tools:
credentialStoreTool, fileListTool, makeAuthenticatedRequestTool,
recallTool, rememberTool, runAuthenticatedCommandTool, webSearchTool.

Also added a comment in types.ts documenting why execute is optional
and the schema-only ToolDefinition pattern, with pointers to the
three existing instances.

describe("bash tool background mode", () => {
let shellTool: Tool;
let shellTool: ToolDefinition;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For all of these files, we can avoid the execute!(...) by just importing the shell tool above instead of the inline import in the before each, then remove this let and type definition

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — refactored 3 Pattern A test files (background-shell-bash, shell-observability, terminal-tools) and dropped .execute!( bangs across all 27 affected tests.

The root cause of the bangs was : ToolDefinition = { ... } widening execute back to optional at the export site. Switched the 28 tool source files to ... } satisfies ToolDefinition so the inferred per-export type preserves execute as required at static-import call sites. ToolDefinition itself keeps execute optional because schema-only tools like graphRememberDefinition, storeStyleAnalysisTool, and SWEEP_TOOL legitimately omit it.

Two test files intentionally kept the explicit annotation:

  • openai-responses-provider.test.ts — passes the shape as ToolDefinition to the provider, doesn't call .execute.
  • plugin-api-tool-definition.test.ts — contract test that asserts const tool: ToolDefinition = {} compiles (the empty-literal contract).

One file (shell-observability.test.ts) had to keep the dynamic import in beforeEach — it mocks ../util/logger.js and shell.ts captures the logger at module-eval time, so a static import would hoist past mock.module() and the test would see the real pino logger. Used let shellTool: typeof import("../tools/terminal/shell.js")["shellTool"] so the satisfies-narrowed shape is preserved (no bang needed) with a comment explaining the ordering requirement. Confirmed the ordering behavior with a probe in /tmp/bunmock-chain.

Comment on lines +37 to +41
// `WireToolDefinitionSchema` is the single source of truth for the wire
// form of a tool. It lives in `tools/types.ts` alongside the loose
// `ToolDefinitionSchema` that `ToolDefinition` is inferred from, so both
// the in-process author shape and the IPC wire shape derive from one
// schema declaration.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete this comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — deleted the comment block. Updated ToolDefinitionSchema's own doc to reflect that author and wire share one schema and the daemon re-finalizes locally.

const RegisterToolsParams = z.object({
skillId: z.string().min(1),
tools: z.array(ToolManifestSchema).min(1),
tools: z.array(WireToolDefinitionSchema).min(1),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be ToolDefinitionSchema

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — RegisterToolsParams.tools is now z.array(ToolDefinitionSchema).min(1).

function buildProxyTool(manifest: ToolManifest): Tool {
// RiskLevel is a string enum whose values are "low" | "medium" | "high",
// matching the schema above exactly — the cast is a no-op at runtime.
type WireToolDefinition = z.infer<typeof WireToolDefinitionSchema>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need this type WireToolDefinition is redundant with ToolDefinition

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — deleted along with buildProxyTool. The handler now takes ToolDefinitionSchema-parsed entries and runs them through finalizeTool.

* proxy in the registry so the rest of the tool-manifest plumbing can be
* exercised end-to-end.
*/
function buildProxyTool(manifest: ToolManifest): Tool {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we shouldn't need this helper at all, we have a finalizeTool to use instead

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — deleted buildProxyTool and routed through finalizeTool(tool, tool.name ?? "") instead. finalizeTool already synthesizes a no-op error closure when execute is absent (closures can't cross IPC), so the no-supervisor path surfaces a clear "not wired up" isError: true result instead of throwing. Updated the registries test "proxy execute throws" → "proxy execute surfaces an error result" to match.

Comment thread assistant/src/tools/types.ts Outdated
* the socket, so {@link buildProxyTool} synthesizes one that forwards
* invocations back over IPC.
*/
export const WireToolDefinitionSchema = ToolDefinitionSchema.required({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete this redundant schema, we only want ToolDefinitionSchema, ToolDefinition, and Tool

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — deleted WireToolDefinitionSchema and its doc block in cdb986a. ToolDefinitionSchema is now the single source of truth; the IPC layer parses incoming skill tools against it directly and re-finalizes locally via finalizeTool.

…dary

Round 2 feedback on PR #32631: kill the dual-schema design.

types.ts:
- Delete WireToolDefinitionSchema and its doc block.
- ToolDefinitionSchema is the single source of truth — all fields
  optional, both author and wire paths share it.
- Update ToolDefinition's doc to reflect the new contract and note the
  `satisfies ToolDefinition` pattern.

registries.ts (skill-routes):
- Drop the Wire-level-schemas comment block.
- Use ToolDefinitionSchema in z.array().
- Delete buildProxyTool + WireToolDefinition type.
- Replace tools.map(buildProxyTool) with
  tools.map((t) => finalizeTool(t, t.name ?? "")) — finalizeTool already
  synthesizes a no-op error closure for the no-supervisor path.

Tool sources (28 files: terminal/shell, host-terminal/host-shell,
host-filesystem/*, filesystem/*, credentials/vault, credential-execution/*,
memory/register, memory/graph/tools, memory/v2/sweep-job,
messaging/style-analyzer, ui-surface, computer-use/definitions,
network/{web-fetch,web-search}, system/request-permission,
subagent/notify-parent, skills/execute, apps/definitions):
- Switch from `: ToolDefinition = { ... }` to
  `... } satisfies ToolDefinition` so the inferred per-export type
  preserves `execute` as required at call sites that statically import
  the literal. The schema-only definitions (graphRememberDefinition,
  storeStyleAnalysisTool, SWEEP_TOOL) intentionally keep `execute`
  absent — ToolDefinition itself still has it optional.

Test files (27 files):
- Drop the `.execute!(` bang now that satisfies preserves the required
  type at the call site.
- Pattern A refactor: replace inline `await import("../tools/...")` in
  beforeEach with module-scope static imports for background-shell-bash
  and terminal-tools. shell-observability keeps a dynamic import because
  the test mocks `../util/logger.js` and shell.ts captures the logger at
  module-eval time — static import there would hoist past mock.module()
  and the test would see the real logger. Type-narrow via
  `typeof import(...)["shellTool"]` so no bang is needed.

Intentional reverts:
- openai-responses-provider.test.ts and plugin-api-tool-definition.test.ts
  keep `: ToolDefinition = { ... }` — they assert type shape (including
  the empty-literal contract test) rather than calling .execute, so the
  widened type is the right tool there.

registries.test.ts:
- "rejects missing required fields" → "fills defaults for partial tool
  entries": partial entries no longer reject (one all-optional schema +
  finalizeTool fills defaults).
- "proxy execute throws when no supervisor is attached" → "proxy execute
  surfaces an error result when no supervisor is attached": finalizeTool
  synthesizes a no-op error closure now, not a throw.
…ze before supervisor short-circuit

Round 2 CI follow-up — three type-check failures from PR #32631 round 2:

1. `skillLoadTool` (skills/load.ts) and `askQuestionTool`
   (ask-question/ask-question-tool.ts) source files were missed in the
   first satisfies pass:
   - `skills/load.ts` contains a regex literal (`/!`[^`]*`/g`) inside the
     execute body. The AST converter at scratch/satisfies-converter.ts
     doesn't track regex literals, so its brace matcher walked through
     the backticks inside the character class, lost depth tracking, and
     silently emitted "no spots found". Converted manually.
   - `ask-question/ask-question-tool.ts` wraps a factory call
     (`createAskQuestionTool()` returns ToolDefinition). The cleanest
     fix is to drop the factory's explicit return annotation and apply
     `satisfies ToolDefinition` inside the returned literal so the
     inferred return type carries the narrow shape through the export.

2. `ipc/skill-routes/registries.ts` supervisor short-circuit log + return
   read `tools.map((t) => t.name)` where `t.name` is `string | undefined`
   per the all-optional schema. Pulled `finalizeTool` ahead of the
   supervisor branch so both paths see a `Tool[]` (name guaranteed).

3. `ipc/skill-routes/__tests__/registries.test.ts` "fills defaults"
   assertion compared `installed.defaultRiskLevel` (typed `RiskLevel`)
   against the string literal `"medium"` — strict enum compare needs
   `RiskLevel.Medium`.
…estion test

The helper had `tool: ToolDefinition` in its explicit return type, which
collapses the satisfies-narrowed shape returned by `createAskQuestionTool()`
back to the public author-facing shape (execute optional). Remove the
explicit return type so callers see the inferred narrow shape and can
invoke `tool.execute(...)` without a `!` bang.

Also drop the now-unused `ToolDefinition` type import.
// error. The execute closure arrives as a no-op error closure from
// `finalizeTool`; the production (supervisor) path replaces it with
// the dispatching closure installed by the manifest loader at boot.
const proxies = tools.map((tool) => finalizeTool(tool, tool.name ?? ""));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

finalizeTool's second arg can be optional so it can house this ?? logic there and not force consumers to pass tool twice

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in #32683 (2809e31). finalizeTool's second arg is now optional with a "" default — the ?? "" lives inside finalizeTool, not at the call site. Empty-string is fail-loud (registerSkillTools rejects empty names with a clear error).

Comment on lines +161 to +168
// Finalize before branching so both the supervisor short-circuit and
// the in-memory registration path see a `Tool[]` with guaranteed
// `name`. Skills run `finalizeTool` locally before sending, so the
// `?? ""` is a defensive empty-string default — `registerSkillTools`
// will reject an empty name on the non-supervisor path with a clear
// error. The execute closure arrives as a no-op error closure from
// `finalizeTool`; the production (supervisor) path replaces it with
// the dispatching closure installed by the manifest loader at boot.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make a one-liner

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in #32683 (2809e31): tools.map((t) => finalizeTool(t)). Comment trimmed too.

Comment on lines +147 to +150
export function createAskQuestionTool(
prompterFactory: () => Pick<QuestionPrompter, "prompt"> = () =>
new QuestionPrompter({ broadcastMessage }),
) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't need this double factory. We should be able to use the same satisfies ToolDefinition pattern that we have elsewhere

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Killed in #32683 (2809e31). askQuestionTool is now a plain } satisfies ToolDefinition literal matching every other tool. The mock seam moved to the module boundary — the test uses mock.module("../../permissions/question-prompter.js", …) + a dynamic await import("./ask-question-tool.js"), which replaces both layers (the factory and the prompter-factory parameter) with a single hook.

Comment on lines +370 to +373
export type ToolDefinition = Omit<
z.infer<typeof ToolDefinitionSchema>,
"input_schema"
> & {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Figure out a way to make this just export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>, meaning input_schema and execute are both typed correctly in ToolDefinitionSchema

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in #32683 (2809e31). ToolDefinitionSchema now uses z.custom<object>(predicate) for input_schema (rejects null/arrays/primitives at runtime, accepts plain objects) and z.custom<(input, ctx) => Promise<ToolExecutionResult>>() for execute (runtime no-op — the wire path drops closures anyway, and finalizeTool synthesizes a no-op error closure on arrival). ToolDefinition = z.infer<typeof ToolDefinitionSchema> — no overlay, both fields typed correctly from the schema.

@dvargasfuertes dvargasfuertes merged commit 2200650 into main May 30, 2026
13 checks passed
@dvargasfuertes dvargasfuertes deleted the apollo/name-to-tool-definition branch May 30, 2026 19:00
dvargasfuertes pushed a commit that referenced this pull request May 30, 2026
…32683)

Address all 4 review comments from PR #32631 (post-merge):

1. types.ts — ToolDefinitionSchema now uses z.custom<object>(predicate)
   for input_schema and z.custom<(input, ctx) => Promise<...>>() for
   execute. ToolDefinition collapses to a direct z.infer<typeof
   ToolDefinitionSchema> — single declaration, no Omit overlay, both
   fields typed correctly. z.custom on execute is a runtime no-op (the
   wire path drops closures anyway, finalizeTool synthesizes a no-op
   error closure on arrival).

2. tool-defaults.ts — finalizeTool's defaultName arg is now optional
   with an empty-string default. The empty-string fallback is
   fail-loud because registerSkillTools rejects an empty name with a
   clear error.

3. registries.ts — call site is now a one-liner:
   tools.map((t) => finalizeTool(t)). The defensive '?? ""' moved
   inside finalizeTool itself, so it doesn't repeat at every IPC
   handler. Comment trimmed to match.

4. ask-question-tool.ts — killed the createAskQuestionTool factory and
   the prompterFactory parameter. askQuestionTool is now a plain
   literal matching every other tool: '} satisfies ToolDefinition'.
   Test rewrite uses mock.module() on ../../permissions/question-
   prompter.js + a dynamic import of the tool, replacing
   makeToolWithStub's prompter-factory seam.

Tests: 55/55 across ask-question, registries, plugin-api-tool-
definition, plugin-tool-contribution. Lint clean.

Co-authored-by: vellum-apollo-bot[bot] <242025090+vellum-apollo-bot[bot]@users.noreply.github.com>
vellum-apollo-bot Bot pushed a commit that referenced this pull request May 30, 2026
Workspace tools are user-authored files dropped under
`<workspaceDir>/tools/<name>.{ts,js,json}` that authoritatively replace
same-named core tools at registry-load time. The original core tool is
stashed and restored on workspace-tool unregister, so workspace overrides
are non-destructive to the core baseline.

Highlights:

- New `registerWorkspaceTools` / `unregisterWorkspaceTool` /
  `removeCoreToolViaWorkspace` / `restoreStrippedCoreTool` registry
  entry points. Ownership is tracked in the existing `ownersByName` map
  (`kind: "workspace"`, `id: <workspacePath>`) — no extra fields land
  on the `Tool` object, keeping the post-#32631 `Required<ToolDefinition>`
  shape pristine.
- Workspace ownership wins over plugin / skill / MCP: registrations of
  the same name from those surfaces warn-skip (or throw, for skills) so
  the workspace override stays canonical for its lifetime.
- `<name>.removed` sentinel strips a core tool from the live registry
  while keeping it in the stash. Deleting the sentinel restores it.
  Workspace-owned names can't be sentinel-stripped (the user must
  remove the workspace tool file first).
- File watcher (`workspace-tools-watcher.ts`) drives hot reload:
  re-imports on change, unregister on delete, re-strip / re-restore on
  sentinel toggle. Uses `getToolOwner` to detect workspace ownership
  (no `origin` field needed).
- Loader (`workspace-tools/loader.ts`) delegates default-filling to the
  shared `finalizeTool` from `tool-defaults.ts`, layering only two
  workspace-specific overrides on top: `defaultRiskLevel: "high"` (user
  code from disk runs with full privilege) and a workspace-flavored
  default `execute` error message.

Followup items from PR #31949 round-1 review (now incorporated):

- Generalized the `workspace-tools.md` doc example to use
  `ToolDefinition` instead of `PluginTool` (comment 3295343340).
- Default-risk-level table entry shows `"high"` (comment 3295345328).

Followup from PR #32683 review (also incorporated):

- `QuestionPrompter` dropped its `{ broadcastMessage }` constructor arg
  and imports `broadcastMessage` directly from `runtime/assistant-event-hub.js`.
  Updated `ask-question-tool.ts` caller + `question-prompter.test.ts`
  setup (the test now mocks the module export rather than injecting via
  constructor; `mock.module` calls in that file also spread the real
  module exports so other tests in the same `bun test` run still see
  e.g. `assistantEventHub` and `setNestedValue`).

Tests: 88/88 passing across the 4 critical files (registry.test.ts,
workspace-tool-loader.test.ts, question-prompter.test.ts,
ask-question-tool.test.ts).
vellum-apollo-bot Bot added a commit that referenced this pull request May 30, 2026
Workspace tools are user-authored files dropped under
`<workspaceDir>/tools/<name>.{ts,js,json}` that authoritatively replace
same-named core tools at registry-load time. The original core tool is
stashed and restored on workspace-tool unregister, so workspace overrides
are non-destructive to the core baseline.

Highlights:

- New `registerWorkspaceTools` / `unregisterWorkspaceTool` /
  `removeCoreToolViaWorkspace` / `restoreStrippedCoreTool` registry
  entry points. Ownership is tracked in the existing `ownersByName` map
  (`kind: "workspace"`, `id: <workspacePath>`) — no extra fields land
  on the `Tool` object, keeping the post-#32631 `Required<ToolDefinition>`
  shape pristine.
- Workspace ownership wins over plugin / skill / MCP: registrations of
  the same name from those surfaces warn-skip (or throw, for skills) so
  the workspace override stays canonical for its lifetime.
- `<name>.removed` sentinel strips a core tool from the live registry
  while keeping it in the stash. Deleting the sentinel restores it.
  Workspace-owned names can't be sentinel-stripped (the user must
  remove the workspace tool file first).
- File watcher (`workspace-tools-watcher.ts`) drives hot reload:
  re-imports on change, unregister on delete, re-strip / re-restore on
  sentinel toggle. Uses `getToolOwner` to detect workspace ownership
  (no `origin` field needed).
- Loader (`workspace-tools/loader.ts`) delegates default-filling to the
  shared `finalizeTool` from `tool-defaults.ts`, layering only two
  workspace-specific overrides on top: `defaultRiskLevel: "high"` (user
  code from disk runs with full privilege) and a workspace-flavored
  default `execute` error message.

Followup items from PR #31949 round-1 review (now incorporated):

- Generalized the `workspace-tools.md` doc example to use
  `ToolDefinition` instead of `PluginTool` (comment 3295343340).
- Default-risk-level table entry shows `"high"` (comment 3295345328).

Followup from PR #32683 review (also incorporated):

- `QuestionPrompter` dropped its `{ broadcastMessage }` constructor arg
  and imports `broadcastMessage` directly from `runtime/assistant-event-hub.js`.
  Updated `ask-question-tool.ts` caller + `question-prompter.test.ts`
  setup (the test now mocks the module export rather than injecting via
  constructor; `mock.module` calls in that file also spread the real
  module exports so other tests in the same `bun test` run still see
  e.g. `assistantEventHub` and `setNestedValue`).

Tests: 88/88 passing across the 4 critical files (registry.test.ts,
workspace-tool-loader.test.ts, question-prompter.test.ts,
ask-question-tool.test.ts).
devin-ai-integration Bot pushed a commit that referenced this pull request May 31, 2026
Workspace tools are user-authored files dropped under
`<workspaceDir>/tools/<name>.{ts,js,json}` that authoritatively replace
same-named core tools at registry-load time. The original core tool is
stashed and restored on workspace-tool unregister, so workspace overrides
are non-destructive to the core baseline.

Highlights:

- New `registerWorkspaceTools` / `unregisterWorkspaceTool` /
  `removeCoreToolViaWorkspace` / `restoreStrippedCoreTool` registry
  entry points. Ownership is tracked in the existing `ownersByName` map
  (`kind: "workspace"`, `id: <workspacePath>`) — no extra fields land
  on the `Tool` object, keeping the post-#32631 `Required<ToolDefinition>`
  shape pristine.
- Workspace ownership wins over plugin / skill / MCP: registrations of
  the same name from those surfaces warn-skip (or throw, for skills) so
  the workspace override stays canonical for its lifetime.
- `<name>.removed` sentinel strips a core tool from the live registry
  while keeping it in the stash. Deleting the sentinel restores it.
  Workspace-owned names can't be sentinel-stripped (the user must
  remove the workspace tool file first).
- File watcher (`workspace-tools-watcher.ts`) drives hot reload:
  re-imports on change, unregister on delete, re-strip / re-restore on
  sentinel toggle. Uses `getToolOwner` to detect workspace ownership
  (no `origin` field needed).
- Loader (`workspace-tools/loader.ts`) delegates default-filling to the
  shared `finalizeTool` from `tool-defaults.ts`, layering only two
  workspace-specific overrides on top: `defaultRiskLevel: "high"` (user
  code from disk runs with full privilege) and a workspace-flavored
  default `execute` error message.

Followup items from PR #31949 round-1 review (now incorporated):

- Generalized the `workspace-tools.md` doc example to use
  `ToolDefinition` instead of `PluginTool` (comment 3295343340).
- Default-risk-level table entry shows `"high"` (comment 3295345328).

Followup from PR #32683 review (also incorporated):

- `QuestionPrompter` dropped its `{ broadcastMessage }` constructor arg
  and imports `broadcastMessage` directly from `runtime/assistant-event-hub.js`.
  Updated `ask-question-tool.ts` caller + `question-prompter.test.ts`
  setup (the test now mocks the module export rather than injecting via
  constructor; `mock.module` calls in that file also spread the real
  module exports so other tests in the same `bun test` run still see
  e.g. `assistantEventHub` and `setNestedValue`).

Tests: 88/88 passing across the 4 critical files (registry.test.ts,
workspace-tool-loader.test.ts, question-prompter.test.ts,
ask-question-tool.test.ts).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant