Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions assistant/src/__tests__/plugin-api-tool-definition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Shape tests for the public `ToolDefinition` author-facing tool spec.
*
* These tests don't exercise runtime behavior — they assert via
* `satisfies` that representative tool literals line up with the public
* interface. If a later PR breaks a field name or signature in
* `assistant/src/plugin-api/types.ts`, this file fails to type-check and
* the regression is caught at `tsc --noEmit` / `bun test` time.
*/

import { describe, expect, test } from "bun:test";

import {
RiskLevel,
type ToolContext,
type ToolDefinition,
type ToolExecutionResult,
} from "../plugin-api/index.js";

describe("ToolDefinition (public author-facing tool spec) ", () => {
test("a fully-populated literal satisfies the interface", () => {
const tool = {
description: "Greet the model in a fixed language.",
defaultRiskLevel: RiskLevel.Low,
input_schema: {
type: "object",
properties: {
language: { type: "string" },
},
required: ["language"],
additionalProperties: false,
},
async execute(
input: Record<string, unknown>,
_ctx: ToolContext,
): Promise<ToolExecutionResult> {
return {
content: `hello, ${String(input.language)} speaker`,
isError: false,
};
},
} as const satisfies ToolDefinition;

// `as const` propagates literal types and verifies type compatibility,
// but the runtime expectations below also smoke-check the structure
// for anyone reading the test without TS folded in.
expect(typeof tool.execute).toBe("function");
expect(tool.defaultRiskLevel).toBe(RiskLevel.Low);
});

test("every field is optional — empty literal satisfies the interface", () => {
const tool: ToolDefinition = {};
expect(tool).toEqual({});
});

test("every author-facing risk level is permitted", () => {
const low: ToolDefinition = { defaultRiskLevel: RiskLevel.Low };
const medium: ToolDefinition = { defaultRiskLevel: RiskLevel.Medium };
const high: ToolDefinition = { defaultRiskLevel: RiskLevel.High };

expect(low.defaultRiskLevel).toBe(RiskLevel.Low);
expect(medium.defaultRiskLevel).toBe(RiskLevel.Medium);
expect(high.defaultRiskLevel).toBe(RiskLevel.High);
});

test("execute receives the public ToolContext", async () => {
// Type-only assertion: the execute signature uses the public
// ToolContext (narrow base). A daemon-internal field added to the
// rich ToolContext that doesn't exist on the narrow one must not be
// accessible here. We can't test this at runtime — the assertion
// lives in `tsc --noEmit` over this file.
const tool: ToolDefinition = {
async execute(_input, ctx) {
// `ctx` is the public `ToolContext`. Touch
// commonly-needed fields to make sure they're present.
const _conversationId = ctx.conversationId;
const _workingDir = ctx.workingDir;
const _signal = ctx.signal;
return { content: "ok", isError: false };
},
};
const result = await tool.execute?.(
{},
{
conversationId: "conv-abc",
workingDir: "/tmp",
signal: new AbortController().signal,
} as ToolContext,
);
expect(result?.isError).toBe(false);
});
});
10 changes: 8 additions & 2 deletions assistant/src/__tests__/plugin-tool-contribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,18 @@ describe("registerPluginTools / unregisterPluginTools helpers", () => {
// ownerMcpServerId / ownerSkillBundled / ownerSkillVersionHash) so the
// stamped tool cannot leak across namespaces or spoof bundled-skill
// auto-allow.
const spoofed = makeFakeTool("pt_spoof", {
//
// The narrow `ToolDefinition` shape doesn't expose these ownership
// fields, so the cast through `unknown` simulates a hostile or
// transpiled artifact that arrives with spoofed fields baked in —
// the bootstrap-side defense is the second layer that must hold.
const spoofed = {
...makeFakeTool("pt_spoof"),
origin: "skill",
ownerSkillId: "some-other-skill",
ownerSkillBundled: true,
ownerSkillVersionHash: "deadbeef",
});
} as unknown as LoadedPluginTool;
registerPluginTools("my-plugin", [spoofed]);
const retrieved = getTool("pt_spoof");
expect(retrieved?.origin).toBe("plugin");
Expand Down
4 changes: 2 additions & 2 deletions assistant/src/daemon/meet-manifest-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,11 @@ function buildProxyTool(
supervisor: MeetHostSupervisor,
manifestHash: string,
): Tool {
const definition: ToolDefinition = {
const definition = {
name: entry.name,
description: entry.description,
input_schema: (entry.input_schema as object) ?? {},
};
} satisfies ToolDefinition & { name: string };
const risk = coerceRiskLevel(entry.risk, entry.name);
return {
name: entry.name,
Expand Down
4 changes: 2 additions & 2 deletions assistant/src/ipc/skill-routes/registries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,11 @@ export function __getActiveSessionCountForTesting(): number {
* exercised end-to-end.
*/
function buildProxyTool(manifest: ToolManifest): Tool {
const definition: ToolDefinition = {
const definition = {
name: manifest.name,
description: manifest.description,
input_schema: manifest.input_schema as object,
};
} satisfies ToolDefinition & { name: string };
// RiskLevel is a string enum whose values are "low" | "medium" | "high",
// matching the schema above exactly — the cast is a no-op at runtime.
return {
Expand Down
4 changes: 4 additions & 0 deletions assistant/src/plugin-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
* - {@link UserPromptSubmitContext} — passed to `user-prompt-submit` hook,
* fired immediately before the agent loop receives a user's prompt
* - {@link PluginLogger} — pino-compatible logger shape on the contexts
* - {@link ToolDefinition} — author-facing tool spec (default-export shape
* for both plugin tool files and workspace tool files)
* - {@link ToolContext} — passed to a plugin tool's `execute` method
* - {@link ToolExecutionResult} — return shape of a plugin tool's `execute`
*
Expand All @@ -41,6 +43,8 @@ export type {
PluginLogger,
PluginShutdownContext,
ToolContext,
ToolDefinition,
Comment thread
dvargasfuertes marked this conversation as resolved.
ToolExecutionResult,
UserPromptSubmitContext,
} from "./types.js";
export { RiskLevel } from "./types.js";
40 changes: 7 additions & 33 deletions assistant/src/plugin-api/types.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,17 @@
/**
* Public plugin-API types.
*
* This module is the entry point plugin authors land on when they import
* from `@vellumai/plugin-api`. The shapes here are the canonical public
* contract — anything exported is part of the surface that semver gates.
*
* ## Tool-execution types
*
* `ToolContext` and `ToolExecutionResult` are re-exports of the narrow,
* stable bases defined alongside their daemon-internal counterparts in
* `assistant/src/tools/types.ts`. The daemon-internal `ToolContext` /
* `ToolExecutionResult` (with CES, trust classification, lifecycle
* events, sensitive-output bindings, risk metadata, etc.) `extends`
* the public bases, so the runtime can hand plugins the full value
* without a manual cast and tsc enforces the structural relationship.
* Plugin tools see the narrow surface only — they MUST NOT set fields
* that belong to the daemon-internal extension.
*
* ## Hook contexts
*
* The init / shutdown hook contexts are owned by this module directly.
* They have no daemon-internal extension today (the daemon constructs
* and hands them straight through), so there's nothing to inherit from.
*
* ## Compatibility
*
* Adding fields to any public shape is non-breaking. Renaming or
* removing fields is breaking and gated on a major bump of
* `@vellumai/plugin-api`.
* Public plugin-API types — the canonical contract for
* `@vellumai/plugin-api`. Adding fields is non-breaking; renaming /
* removing is breaking and gated on a major bump.
*/

import type { Message } from "../providers/types.js";

// ─── Tool-execution types (re-exported from daemon source-of-truth) ──────────

export type {
PluginToolContext as ToolContext,
PluginToolExecutionResult as ToolExecutionResult,
ToolContext,
ToolDefinition,
ToolExecutionResult,
} from "../tools/types.js";
export { RiskLevel } from "../tools/types.js";

// ─── Logger ──────────────────────────────────────────────────────────────────

Expand Down
15 changes: 7 additions & 8 deletions assistant/src/plugins/external-plugin-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ import { z } from "zod";
import assistantPkg from "../../package.json" with { type: "json" };
import type {
LoadedPluginTool,
PluginTool,
RiskLevel,
ToolDefinition,
ToolExecutionResult,
} from "../tools/types.js";
import { getLogger } from "../util/logger.js";
Expand Down Expand Up @@ -142,12 +142,12 @@ export const PLUGIN_TOOL_DEFAULTS = Object.freeze({
});

/**
* Fill the four normally-required {@link PluginTool} fields with documented
* defaults when the author omitted them. Returns a {@link LoadedPluginTool}
* that is safe to register.
* Fill the four normally-required {@link ToolDefinition} fields with
* documented defaults when the author omitted them. Returns a
* {@link LoadedPluginTool} that is safe to register.
*/
function applyPluginToolDefaults(
tool: PluginTool,
tool: ToolDefinition,
name: string,
): LoadedPluginTool {
const description =
Expand All @@ -159,8 +159,7 @@ function applyPluginToolDefaults(
? tool.defaultRiskLevel
: PLUGIN_TOOL_DEFAULTS.defaultRiskLevel;
const input_schema =
tool.input_schema !== null &&
typeof tool.input_schema === "object"
tool.input_schema !== null && typeof tool.input_schema === "object"
? tool.input_schema
: PLUGIN_TOOL_DEFAULTS.input_schema;
const execute =
Expand Down Expand Up @@ -343,7 +342,7 @@ async function buildPluginFromDir(pluginDir: string): Promise<Plugin> {
for (const { name: toolName, path: toolPath } of listSurfaceDir(
join(pluginDir, "tools"),
)) {
const tool = await importDefault<PluginTool>(toolPath);
const tool = await importDefault<ToolDefinition>(toolPath);
if (tool === null || typeof tool !== "object") {
throw new Error(
`external plugin ${name}: ${toolPath} default export must be an object`,
Expand Down
7 changes: 4 additions & 3 deletions assistant/src/plugins/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,9 +1010,10 @@ export interface Injector {
/**
* Tool registration contributed by a plugin. Uses the narrow
* {@link LoadedPluginTool} shape. External plugin authors declare the
* nameless `PluginTool` file shape; the loader derives `name` from the
* `tools/<name>.ts` basename before storing it on `plugin.tools`. Authors
* also leave category / ownership metadata to the bootstrap, which stamps
* nameless `ToolDefinition` (from `@vellumai/plugin-api`) file shape;
* the loader derives `name` from the `tools/<name>.ts` basename before
* storing it on `plugin.tools`. Authors also leave category / ownership
* metadata to the bootstrap, which stamps
* `category: "plugin"`, `origin: "plugin"`, and
* `ownerPluginId: <plugin.name>` before handing the batch to
* `registerPluginTools`. The registration boundary synthesizes
Expand Down
Loading
Loading