Skip to content

feat(plugin-api): export ToolDefinition for tool-file authoring#31956

Merged
dvargasfuertes merged 5 commits into
mainfrom
apollo/tool-definition-and-default-risk
May 25, 2026
Merged

feat(plugin-api): export ToolDefinition for tool-file authoring#31956
dvargasfuertes merged 5 commits into
mainfrom
apollo/tool-definition-and-default-risk

Conversation

@vellum-apollo-bot
Copy link
Copy Markdown
Contributor

Prereq for #31949 (per review feedback). Adds ToolDefinition as the public author-facing tool spec on @vellumai/plugin-api.

Why

Workspace tool files and plugin tool files both default-export the same shape: { description?, defaultRiskLevel?, input_schema?, execute? }. Today the daemon-internal type is called PluginTool and isn't exported from the public package — the workspace-tools doc example on #31949 was broken on that detail. Switching to ToolDefinition gives both audiences a single neutral name to import.

Scope

Pure addition. No runtime behavior, no rename of existing names.

  • assistant/src/plugin-api/types.ts — new ToolDefinition interface
  • assistant/src/plugin-api/index.ts — adds the type-only re-export + docstring entry
  • assistant/src/__tests__/plugin-api-tool-definition.test.tssatisfies-style shape tests (full literal / empty literal / each risk level / narrow ToolContext exposure)

The new type's execute parameter is the public-narrow ToolContext (PluginToolContext), not the daemon-rich ToolContext, so authors can't accidentally rely on daemon-internal fields.

Naming collision (intentional)

ToolDefinition already exists in @vellumai/skill-host-contracts as the JSON-schema bundle sent to providers ({ name, description, input_schema }). That's the runtime/provider-side shape; this is the author-time shape. Different packages, disjoint imports, plugin authors always land on the right one via @vellumai/plugin-api. Doc comment on the new interface flags this explicitly.

Happy to rename the schema-bundle one to ProviderToolSchema instead and have a single canonical ToolDefinition — say the word and I'll do the sweep.

Default-risk-level: workspace = "high"

The other half of the same comment — bumping WORKSPACE_TOOL_DEFAULTS.defaultRiskLevel from "medium""high" — lives on #31949 alongside the workspace-tool loader (which doesn't exist on main yet). The interface doc here describes the per-origin default contract so both PRs reference the same surface.

Verification

  • bunx tsc --noEmit clean
  • 4/4 new tests pass
  • 11/11 adjacent (plugin-api-shim, plugin-types) tests still pass
  • ESLint clean on touched files

The author-facing tool spec that plugin tool files and workspace tool
files default-export. Lives at `@vellumai/plugin-api`'s public surface,
type-only, structurally identical to the existing internal `PluginTool`
shape so plugin authors can migrate by changing only the import name.

This is the type the upcoming workspace-tool authoring guide (PR #31949)
will reference instead of the daemon-internal `PluginTool` name, which
was never publicly exported.

Workspace tools and plugin tools share this surface but differ in their
default risk floor: plugin tools default to \"medium\" (in-tree-vetted
code), workspace tools default to \"high\" (operator-authored on-disk
code). The default switch lives in each loader; this PR just adds the
shared author-facing type.

The name `ToolDefinition` collides with an internal type of the same name
in `@vellumai/skill-host-contracts` that represents the JSON-schema
bundle sent to providers. The doc comment on the new interface flags
this — the imports are disjoint (different packages), so plugin authors
always land on the right one.

Tests cover full / empty / each risk-level literal and the narrow
ToolContext exposure.
Comment thread assistant/src/plugin-api/index.ts
Per Vargas's review on #31956: the previous `PluginTool` type in
tools/types.ts was `Omit<Tool, 5 fields> & { 4 optional fields }`,
which retained ALL of Tool's internal stamps (origin, ownerPluginId,
executionMode, executionTarget, owner* fields) on the author surface.
Authors were told via docstring to leave them blank, but TypeScript
didn't enforce it. The new `ToolDefinition` is the strict 4-field
author surface — this commit unifies the two.

Source-of-truth structure (mirrors the PluginToolContext/ToolContext
pattern already used in this file):

- `tools/types.ts` defines `PluginToolSpec` (strict author shape).
  All 4 fields optional. Defaults filled by the loader at registration.
- `plugin-api/types.ts` re-exports as `ToolDefinition` for external
  authors. Docstring travels with the symbol via LSP.

`LoadedPluginTool` is the post-defaults, ready-to-register shape.
Overrides two fields from `Required<PluginToolSpec>`:
- `defaultRiskLevel`: `RiskLevel` enum (vs the public string union),
  because `Tool.defaultRiskLevel` is the enum.
- `execute`: rich `ToolContext`/`ToolExecutionResult` (vs the narrow
  public types), because `Tool.execute` is rich. The narrow→rich
  function cast happens once in `applyPluginToolDefaults`, the
  documented loader boundary.

`applyPluginToolDefaults` also tightened: dropped the broad `...tool`
spread so any extra runtime fields a JS-author or transpiled artifact
might ship — origin, executionMode, owner stamps — get filtered out
at the load boundary. TS authors are caught by the new narrow shape;
this is the runtime backstop.

Spoof test updated to cast through `unknown` — the narrow
`LoadedPluginTool` no longer surfaces those fields, so we simulate
a hostile/transpiled artifact arriving with spoofed fields baked in.
The bootstrap-side defense is the second layer that must hold even
when the type-level defense is bypassed.

Typecheck, lint, all 21 affected tests: pass.
@vellum-apollo-bot
Copy link
Copy Markdown
Contributor Author

Pushed d78513e79c consolidating PluginTool into ToolDefinition per your inline review.

What changed:

  • assistant/src/tools/types.ts is now the source-of-truth: defines PluginToolSpec (strict 4-field author shape). Mirrors the existing PluginToolContext / PluginToolExecutionResult convention in this file — narrow internal name, full docstring at the definition site.
  • assistant/src/plugin-api/types.ts drops its inline ToolDefinition interface and just export type { PluginToolSpec as ToolDefinition } from "../tools/types.js";. Docstring travels with the symbol via LSP. Same pattern as PluginToolContext as ToolContext.
  • LoadedPluginTool is the post-defaults shape. Overrides two fields from Required<PluginToolSpec>:
    • defaultRiskLevel: RiskLevel enum (vs public string union) — Tool.defaultRiskLevel is the enum.
    • execute: rich ToolContext/ToolExecutionResult (vs narrow public types) — Tool.execute is rich. The narrow→rich cast happens once in applyPluginToolDefaults, the documented loader boundary.
  • applyPluginToolDefaults tightened: dropped the broad ...tool spread. Any extra runtime fields a JS-author or transpiled artifact might ship (origin, executionMode, owner stamps, …) get filtered out at the load boundary now. TS authors are caught by the narrow ToolDefinition; this is the runtime backstop.
  • Spoof test updated to cast through unknown — narrowed LoadedPluginTool no longer surfaces those fields, so we simulate a hostile/transpiled artifact arriving with spoofed fields baked in. The bootstrap-side defense is the second layer that must hold even when the type-level defense is bypassed.

Why two names (PluginToolSpec internal, ToolDefinition public):

Matches the existing convention in tools/types.ts for the two other narrow/rich pairs. PluginToolSpec carries a Plugin* prefix in the internal file but is shared with workspace tools — same pre-existing convention awkwardness as PluginToolContext (which is also used by workspace tools). External authors only ever see ToolDefinition.

Verification:

  • bun run typecheck clean
  • bun run lint clean
  • 21/21 affected tests pass (plugin-api-tool-definition.test.ts, plugin-tool-contribution.test.ts, plugin-types.test.ts)

The naming-collision question with @vellumai/skill-host-contracts's own ToolDefinition (JSON-schema bundle, NOT this author surface) is still open in the PR body — kept the local-alias treatment for now in tools/types.ts so the published package doesn't shift. Happy to do the ProviderToolSchema rename in a follow-up if you want.

Comment thread assistant/src/tools/types.ts Outdated
* (`RiskLevel.Low === "low"`); the loader casts to the enum at the
* registration boundary in `applyPluginToolDefaults`.
*/
defaultRiskLevel?: "low" | "medium" | "high";
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 this RiskLevel

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. defaultRiskLevel?: RiskLevel now. RiskLevel is re-exported from @vellumai/plugin-api so authors can write defaultRiskLevel: RiskLevel.Low without reaching into internal imports.

Comment thread assistant/src/tools/types.ts Outdated
Comment on lines +412 to +455
context: ToolContext,
) => Promise<ToolExecutionResult>;
};
context: PluginToolContext,
) => Promise<PluginToolExecutionResult>;
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.

revert this back

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.

Reverted toward the simple intersection. Settled on Omit<ToolDefinition, "execute"> & {required fields + rich execute} — the Omit strips the optional narrow execute from ToolDefinition so the rich override is the sole signature on the loaded shape. Without the Omit, TS produces an intersection of the two execute types and requires the function to satisfy both, which breaks because the rich ToolContext adds trustClass that the narrow one lacks.

If you'd rather have a fully simple intersection without the Omit, the alternative is making ToolDefinition.execute itself rich-typed — that gets back to ToolDefinition & {required} clean. Happy to flip if you prefer that shape; current code keeps the public surface narrow on ToolDefinition and does the widening at the loader boundary.

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.

Idk what this means, but in general, we don't want a separate PluginToolContext and a separate PluginToolResult, we want consolidation as much as possible. We should delete the plugin variants if we can.

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 8236f27. Deleted both PluginToolContext and PluginToolExecutionResult — their fields are inlined into the unified ToolContext / ToolExecutionResult interfaces. ToolDefinition.execute now references the rich types directly, and @vellumai/plugin-api re-exports them under their canonical names (no alias dance). Plugin authors see the same shape the daemon uses internally.

Comment thread assistant/src/tools/types.ts Outdated
Tool,
"category" | "getDefinition" | "name" | "description" | "defaultRiskLevel"
> & {
export interface PluginToolSpec {
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.

Just call this ToolDefinition instead of PluginTool and we can export this guy out of plugin-api. also let's reduce all of the comments that were added back to how they were or one liners at most

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 6dca9ac. Renamed PluginToolSpecToolDefinition and the @vellumai/plugin-api package now re-exports it directly (no alias dance). The skill-host-contracts ToolDefinition is imported locally as ProviderToolSchema so the public name is free.

Dropped the unused bulk re-export from tools/types.ts. Two stragglers that were importing ToolDefinition (the JSON-schema bundle) from tools/typesdaemon/meet-manifest-loader.ts and ipc/skill-routes/registries.ts — now import from providers/types.js like the rest of the tool files.

Comments trimmed back to one-liners + an 8-line interface docstring (was a ~35-line block).

…nTool

- Rename PluginToolSpec -> ToolDefinition (exported from plugin-api).
- defaultRiskLevel uses the RiskLevel enum (was string union).
- LoadedPluginTool reverted to a thin intersection: Omit<ToolDefinition,
  "execute"> & {required fields + rich execute}. The Omit drops the
  optional narrow execute so the rich override is the sole execute
  signature on the loaded shape.
- Skill-host-contracts ToolDefinition aliased locally as
  ProviderToolSchema; the bulk re-export from tools/types is gone since
  no consumer used it (all go through providers/types). Two stragglers
  (daemon/meet-manifest-loader.ts, ipc/skill-routes/registries.ts) now
  import from providers/types for consistency.
- plugin-api/types: drops the alias dance, re-exports ToolDefinition
  directly and exposes RiskLevel for authors.
- Comments trimmed back toward one-liners; loader narrow-execute cast
  dropped (assignability holds via contravariance).
ExecutionTarget,
Tool,
ToolContext,
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.

Remove from providers/types.ts and keep it exported from here

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.

Read this two ways and want to confirm the scope before I make the change:

(A) Narrow: flip just this file (and the sibling ipc/skill-routes/registries.ts) back to importing ToolDefinition from ../tools/types.js. Adapt the local definition: ToolDefinition annotation to ToolDefinition & { name: string } (since the new author-facing ToolDefinition has no name). Leave providers/types.ts as the canonical re-export of the JSON-schema-bundle shape that ~40 other files consume.

(B) Broad: drop export type { ToolDefinition } from providers/types.ts entirely. Migrate all ~40 consumers (tool files using getDefinition(): ToolDefinition, provider files with tools?: ToolDefinition[]). The new ToolDefinition (author-facing, optional fields, no name) replaces the JSON-schema-bundle one everywhere — providers handle description/input_schema as optional with runtime fallbacks. Possibly rename the JSON-schema bundle to something like ProviderToolSchema locally.

This round only does the import-alias change (8236f27 removes the direct skill-host-contracts import in tools/types.ts). I held off on (A) or (B) until you confirm — happy to do either. Which?

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 fff69c4 (narrow scope). Both meet-manifest-loader.ts and ipc/skill-routes/registries.ts now import the author-facing ToolDefinition from tools/types.js. The local literal uses satisfies ToolDefinition & { name: string } instead of : annotation so the literal-narrowed shape (with required description/input_schema) is preserved for the getDefinition() closure return — TS otherwise widens to the optional-fields shape and fails the ProviderToolSchema return type check on Tool.getDefinition(). The broader providers/types.ts migration (40+ consumers) is queued for a follow-up.

Comment thread assistant/src/tools/types.ts Outdated
* contravariance and the runtime ctx already carries the rich fields.
*/
export type LoadedPluginTool = PluginTool & {
export type LoadedPluginTool = Omit<ToolDefinition, "execute"> & {
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 just Required<ToolDefinition> & { name: string }

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 8236f27. LoadedPluginTool is now Required<ToolDefinition> & { name: string } — works cleanly since execute is uniformly the rich type everywhere now.

Comment thread assistant/src/tools/types.ts Outdated
Comment on lines +412 to +455
context: ToolContext,
) => Promise<ToolExecutionResult>;
};
context: PluginToolContext,
) => Promise<PluginToolExecutionResult>;
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.

Idk what this means, but in general, we don't want a separate PluginToolContext and a separate PluginToolResult, we want consolidation as much as possible. We should delete the plugin variants if we can.

Comment thread assistant/src/tools/types.ts Outdated
Comment on lines +8 to +10
ToolDefinition,
// The JSON-schema-bundle type sent to LLM providers; aliased locally so
// `ToolDefinition` is free for the author-facing tool spec defined below.
ToolDefinition as ProviderToolSchema,
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.

Would delete this import, we'll deal with this package in the future

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.

Dropped the direct @vellumai/skill-host-contracts ToolDefinition import in 8236f27. To avoid rippling through the ~30 tool files + ~10 provider files that still consume the JSON-schema-bundle shape as ToolDefinition, I aliased it locally as ProviderToolSchema via ../providers/types.js (one indirection away from the package). Net effect: tools/types.ts no longer references the package directly, but the broader migration (renaming ToolDefinitionProviderToolSchema in providers/types.ts and updating ~40 consumers) is deferred. Want me to take that on in this PR or queue it as a follow-up?

ApolloBot added 2 commits May 25, 2026 09:49
- Delete PluginToolContext (inline its fields into ToolContext) and
  PluginToolExecutionResult (inline into ToolExecutionResult). The narrow
  public/rich-internal split is gone; plugin authors see the same
  ToolContext/ToolExecutionResult shape the daemon uses internally.
- ToolDefinition.execute now references the unified types directly.
- LoadedPluginTool collapses to Required<ToolDefinition> & { name: string }.
- @vellumai/plugin-api re-exports ToolContext/ToolExecutionResult directly
  (no alias dance) — same names, same source.
- Drop the direct skill-host-contracts ToolDefinition import in
  tools/types.ts; ProviderToolSchema is aliased via providers/types.ts
  for now (single-source-of-truth migration deferred to a follow-up).
- Trim docstrings to one-liners.

30/30 plugin tests pass; typecheck/lint clean.
Switch the two proxy-tool builders to import the author-facing
ToolDefinition (from tools/types.ts) instead of the JSON-schema-bundle
re-export in providers/types.ts. The local definition literal uses
`satisfies ToolDefinition & { name: string }` so the literal narrowing
is preserved for the getDefinition() closure return.

Narrow scope per review feedback — the broader providers/types.ts
migration is deferred to a follow-up.
* to accurately label lifecycle events for skill-provided tools. */
executionTarget?: ExecutionTarget;
getDefinition(): ToolDefinition;
getDefinition(): ProviderToolSchema;
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.

In the next PR, let's start working towards replacing/consolidating Tool with LoadedTool and the first step towards doing so will be deleting this getDefinition method

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.

In fact, in that PR, define Tool as an extends LoadedTool

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.

Followed up in #32004Tool now extends LoadedTool, getDefinition() deleted across all tools, registry now passes Tool[] where ToolDefinition[] was expected (structurally compatible).

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 #32004. LoadedPluginTool renamed to LoadedTool; Tool extends LoadedTool adds only category + ownership metadata.

@dvargasfuertes dvargasfuertes merged commit 641c12c into main May 25, 2026
13 checks passed
@dvargasfuertes dvargasfuertes deleted the apollo/tool-definition-and-default-risk branch May 25, 2026 11:57
dvargasfuertes pushed a commit that referenced this pull request May 25, 2026
Per Vargas's followup review comments on PR #31956:
  - Delete Tool.getDefinition()
  - Define Tool as extends LoadedTool (renamed from LoadedPluginTool)

The new type hierarchy:
  - ToolDefinition: author-facing plugin spec (all fields optional)
  - LoadedTool = Required<ToolDefinition> & { name: string }
  - Tool extends LoadedTool with category + ownership metadata

Migration touched every core/skill/plugin/MCP tool:
  - Class-style tools: getDefinition() method replaced with top-level
    input_schema field (hoisted from the closure return value).
  - Object-literal tools (ui-surface, apps, computer-use): same hoist.
  - registry.ts: getMcpToolDefinitions / getAllToolDefinitions no longer
    .map(t => t.getDefinition()) — Tool[] is structurally ToolDefinition[].
  - Plugin stamping in registerPluginTools: spreads loaded tool directly
    instead of destructuring input_schema and rebuilding via getDefinition.
  - Provider-safe wrapper: spreads the tool, overrides name; no longer
    needs to override getDefinition.

Special cases:
  - ask-question: hoisted optionItemsSchema to module-level OPTION_ITEMS_SCHEMA.
  - memory/register Remember+Recall: delegate to graphRemember/graphRecall
    definitions' input_schema directly.

Test fixtures (27 sites): arrow-closure getDefinition shapes converted to
top-level input_schema fields. Production code is now structurally fully
clean of getDefinition().

Co-authored-by: ApolloBot <apollobot@vellum.ai>
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