diff --git a/assistant/src/__tests__/background-shell-bash.test.ts b/assistant/src/__tests__/background-shell-bash.test.ts index 76e0649fe49..db4ab8da6e4 100644 --- a/assistant/src/__tests__/background-shell-bash.test.ts +++ b/assistant/src/__tests__/background-shell-bash.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import type { WakeOptions } from "../runtime/agent-wake.js"; import type { BackgroundTool } from "../tools/background-tool-registry.js"; -import type { Tool } from "../tools/types.js"; // ── Mock modules ──────────────────────────────────────────────────────────── @@ -88,6 +87,8 @@ mock.module("../tools/background-tool-registry.js", () => ({ // ── Imports (after mocks) ─────────────────────────────────────────────────── +import { shellTool } from "../tools/terminal/shell.js"; + const baseContext = { workingDir: process.env.VELLUM_WORKSPACE_DIR ?? "/tmp", conversationId: "conv-bg-test", @@ -117,9 +118,7 @@ function waitForWake( } describe("bash tool background mode", () => { - let shellTool: Tool; - - beforeEach(async () => { + beforeEach(() => { mockWakeAgentForOpportunity.mockClear(); mockRegisterBackgroundTool.mockClear(); mockRemoveBackgroundTool.mockClear(); @@ -128,9 +127,6 @@ describe("bash tool background mode", () => { mockIsBackgroundToolLimitReached.mockClear(); mockIsBackgroundToolLimitReached.mockReturnValue(false); registeredTools.length = 0; - - const mod = await import("../tools/terminal/shell.js"); - shellTool = mod.shellTool; }); afterEach(() => { diff --git a/assistant/src/__tests__/computer-use-tools.test.ts b/assistant/src/__tests__/computer-use-tools.test.ts index 82e4ebf945e..aedc00cce57 100644 --- a/assistant/src/__tests__/computer-use-tools.test.ts +++ b/assistant/src/__tests__/computer-use-tools.test.ts @@ -23,7 +23,7 @@ interface JsonSchema { } /** Cast a tool definition's input_schema to a usable JSON Schema shape. */ -function schema(tool: { input_schema: object }): JsonSchema { +function schema(tool: { input_schema?: object }): JsonSchema { return tool.input_schema as JsonSchema; } @@ -53,7 +53,7 @@ describe("computer-use tool definitions", () => { test("all tools have descriptions", () => { for (const tool of allComputerUseTools) { - expect(tool.description.length).toBeGreaterThan(0); + expect(tool.description!.length).toBeGreaterThan(0); } }); }); diff --git a/assistant/src/__tests__/credential-execution-tools.test.ts b/assistant/src/__tests__/credential-execution-tools.test.ts index 1c3ffea14ea..aed9e4fdf18 100644 --- a/assistant/src/__tests__/credential-execution-tools.test.ts +++ b/assistant/src/__tests__/credential-execution-tools.test.ts @@ -5,7 +5,6 @@ import { makeAuthenticatedRequestTool } from "../tools/credential-execution/make import { manageSecureCommandTool } from "../tools/credential-execution/manage-secure-command-tool.js"; import { runAuthenticatedCommandTool } from "../tools/credential-execution/run-authenticated-command.js"; import { cesTools, getCesToolsIfEnabled } from "../tools/tool-manifest.js"; -import type { Tool } from "../tools/types.js"; // --------------------------------------------------------------------------- // Schema shape tests @@ -79,7 +78,7 @@ describe("CES tool schema shapes", () => { describe("CES tool manifest registration", () => { test("cesTools contains exactly three CES tools", () => { expect(cesTools).toHaveLength(3); - const names = cesTools.map((t: Tool) => t.name); + const names = cesTools.map((t) => t.name); expect(names).toContain("make_authenticated_request"); expect(names).toContain("run_authenticated_command"); expect(names).toContain("manage_secure_command_tool"); diff --git a/assistant/src/__tests__/host-file-edit-tool.test.ts b/assistant/src/__tests__/host-file-edit-tool.test.ts index 58cb2fcdb12..194434e7c29 100644 --- a/assistant/src/__tests__/host-file-edit-tool.test.ts +++ b/assistant/src/__tests__/host-file-edit-tool.test.ts @@ -12,7 +12,8 @@ let mockFileProxyRequestFn: ( input: HostFileInput, conversationId: string, signal?: AbortSignal, -) => Promise = () => Promise.resolve({ content: "", isError: false }); +) => Promise = () => + Promise.resolve({ content: "", isError: false }); mock.module("../daemon/host-file-proxy.js", () => ({ HostFileProxy: { @@ -43,7 +44,8 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }); } mockFileProxyAvailable = false; - mockFileProxyRequestFn = () => Promise.resolve({ content: "", isError: false }); + mockFileProxyRequestFn = () => + Promise.resolve({ content: "", isError: false }); }); describe("host_file_edit tool", () => { diff --git a/assistant/src/__tests__/host-file-read-tool.test.ts b/assistant/src/__tests__/host-file-read-tool.test.ts index 17f4726ecd9..d806b4b6f00 100644 --- a/assistant/src/__tests__/host-file-read-tool.test.ts +++ b/assistant/src/__tests__/host-file-read-tool.test.ts @@ -12,7 +12,8 @@ let mockFileProxyRequestFn: ( input: HostFileInput, conversationId: string, signal?: AbortSignal, -) => Promise = () => Promise.resolve({ content: "", isError: false }); +) => Promise = () => + Promise.resolve({ content: "", isError: false }); mock.module("../daemon/host-file-proxy.js", () => ({ HostFileProxy: { @@ -49,7 +50,8 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }); } mockFileProxyAvailable = false; - mockFileProxyRequestFn = () => Promise.resolve({ content: "", isError: false }); + mockFileProxyRequestFn = () => + Promise.resolve({ content: "", isError: false }); }); // Minimal valid JPEG: FF D8 FF E0 header diff --git a/assistant/src/__tests__/host-file-write-tool.test.ts b/assistant/src/__tests__/host-file-write-tool.test.ts index 25879f0ffef..369f515d8e4 100644 --- a/assistant/src/__tests__/host-file-write-tool.test.ts +++ b/assistant/src/__tests__/host-file-write-tool.test.ts @@ -12,7 +12,8 @@ let mockFileProxyRequestFn: ( input: HostFileInput, conversationId: string, signal?: AbortSignal, -) => Promise = () => Promise.resolve({ content: "", isError: false }); +) => Promise = () => + Promise.resolve({ content: "", isError: false }); mock.module("../daemon/host-file-proxy.js", () => ({ HostFileProxy: { @@ -43,7 +44,8 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }); } mockFileProxyAvailable = false; - mockFileProxyRequestFn = () => Promise.resolve({ content: "", isError: false }); + mockFileProxyRequestFn = () => + Promise.resolve({ content: "", isError: false }); }); describe("host_file_write tool", () => { @@ -202,7 +204,11 @@ describe("host_file_write tool", () => { }; await hostFileWriteTool.execute( - { path: "/host/output.txt", content: "hello", target_client_id: "client-x" }, + { + path: "/host/output.txt", + content: "hello", + target_client_id: "client-x", + }, makeContext(), ); diff --git a/assistant/src/__tests__/host-shell-tool.test.ts b/assistant/src/__tests__/host-shell-tool.test.ts index d52937f77b5..2c7c360fb3d 100644 --- a/assistant/src/__tests__/host-shell-tool.test.ts +++ b/assistant/src/__tests__/host-shell-tool.test.ts @@ -56,7 +56,13 @@ mock.module("../util/logger.js", () => ({ // Mock the host-bash-proxy singleton so proxy delegation tests can control it. let mockProxyAvailable = false; let mockProxyRequestFn: ( - input: { command: string; working_dir?: string; timeout_seconds?: number; env?: Record; targetClientId?: string }, + input: { + command: string; + working_dir?: string; + timeout_seconds?: number; + env?: Record; + targetClientId?: string; + }, conversationId: string, signal?: AbortSignal, ) => Promise = () => @@ -863,9 +869,8 @@ describe("host_bash — proxy delegation", () => { test("propagates VELLUM_UNTRUSTED_SHELL env to proxy under CES lockdown", async () => { // Enable CES shell lockdown via the override cache - const { setOverridesForTesting } = await import( - "./feature-flag-test-helpers.js" -); + const { setOverridesForTesting } = + await import("./feature-flag-test-helpers.js"); setOverridesForTesting({ "ces-shell-lockdown": true, }); diff --git a/assistant/src/__tests__/managed-skill-lifecycle.test.ts b/assistant/src/__tests__/managed-skill-lifecycle.test.ts index 460f3da73b8..9116b5f5f82 100644 --- a/assistant/src/__tests__/managed-skill-lifecycle.test.ts +++ b/assistant/src/__tests__/managed-skill-lifecycle.test.ts @@ -87,7 +87,7 @@ import { seedV2SkillEntries, } from "../memory/v2/skill-store.js"; import { executeDeleteManagedSkill } from "../tools/skills/delete-managed.js"; -import { SkillLoadTool } from "../tools/skills/load.js"; +import { skillLoadTool } from "../tools/skills/load.js"; import { executeScaffoldManagedSkill } from "../tools/skills/scaffold-managed.js"; import type { ToolContext } from "../tools/types.js"; @@ -136,9 +136,6 @@ Run the custom lifecycle verification procedure. expect(catalogSkill!.source).toBe("managed"); expect(catalogSkill!.displayName).toBe("E2E Custom Skill"); - const skillLoadTool = new (SkillLoadTool as any)() as InstanceType< - typeof SkillLoadTool - >; const loadResult = await skillLoadTool.execute( { skill: skillId }, makeContext(), @@ -280,10 +277,6 @@ Run the custom lifecycle verification procedure. test("scaffold → skill_load chain: literal tool execution", async () => { const ctx = makeContext(); - const skillLoadTool = new (SkillLoadTool as any)() as InstanceType< - typeof SkillLoadTool - >; - // Step 1: Scaffold a skill directly const scaffoldResult = await executeScaffoldManagedSkill( { diff --git a/assistant/src/__tests__/shell-observability.test.ts b/assistant/src/__tests__/shell-observability.test.ts index 3435c6a068f..f065a5f06b0 100644 --- a/assistant/src/__tests__/shell-observability.test.ts +++ b/assistant/src/__tests__/shell-observability.test.ts @@ -16,7 +16,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import type { WakeOptions } from "../runtime/agent-wake.js"; import type { BackgroundTool } from "../tools/background-tool-registry.js"; -import type { Tool } from "../tools/types.js"; // ── Mock modules ──────────────────────────────────────────────────────────── @@ -113,6 +112,15 @@ mock.module("../tools/background-tool-registry.js", () => ({ // ── Imports (after mocks) ─────────────────────────────────────────────────── +// `shellTool` is imported dynamically inside `beforeEach` so the logger +// mock above lands before shell.ts evaluates and captures its `getLogger` +// reference — static imports hoist past `mock.module()` and the test +// would see the real pino logger instead of the in-memory `logCalls` +// array. The shape type below mirrors the satisfies-narrowed export so +// `shellTool.execute(...)` keeps its required-execute typing without a +// `!` bang. +let shellTool: (typeof import("../tools/terminal/shell.js"))["shellTool"]; + const baseContext = { workingDir: process.env.VELLUM_WORKSPACE_DIR ?? "/tmp", conversationId: "conv-obs-test", @@ -159,8 +167,6 @@ const isKill = (reason: string) => (c: LogCall) => c.fields.reason === reason; describe("shell observability logs", () => { - let shellTool: Tool; - beforeEach(async () => { logCalls.length = 0; registeredTools.length = 0; diff --git a/assistant/src/__tests__/terminal-tools.test.ts b/assistant/src/__tests__/terminal-tools.test.ts index 55ccff1d82b..ca31d486788 100644 --- a/assistant/src/__tests__/terminal-tools.test.ts +++ b/assistant/src/__tests__/terminal-tools.test.ts @@ -1,8 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; - -import type { ShellOutputResult } from "../tools/shared/shell-output.js"; -import type { Tool } from "../tools/types.js"; +import { afterEach, describe, expect, mock, test } from "bun:test"; // ── Mock modules ──────────────────────────────────────────────────────────── @@ -64,6 +61,7 @@ mock.module("../tools/network/script-proxy/index.js", () => ({ // ── Imports (after mocks) ─────────────────────────────────────────────────── +import { formatShellOutput } from "../tools/shared/shell-output.js"; import { ALWAYS_INJECTED_ENV_VARS, buildSanitizedEnv, @@ -71,6 +69,7 @@ import { KATA_SAFE_ENV_VARS, SAFE_ENV_VARS, } from "../tools/terminal/safe-env.js"; +import { shellTool } from "../tools/terminal/shell.js"; // ═══════════════════════════════════════════════════════════════════════════ // Safe Environment — buildSanitizedEnv() @@ -210,13 +209,6 @@ describe("buildSanitizedEnv", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("Shell tool input validation", () => { - let shellTool: Tool; - - beforeEach(async () => { - const mod = await import("../tools/terminal/shell.js"); - shellTool = mod.shellTool; - }); - const baseContext = { workingDir: testTmpDir, conversationId: "test-conv-1", @@ -308,19 +300,6 @@ describe("Shell tool input validation", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("formatShellOutput", () => { - let formatShellOutput: ( - stdout: string, - stderr: string, - code: number | null, - timedOut: boolean, - timeoutSec: number, - ) => ShellOutputResult; - - beforeEach(async () => { - const mod = await import("../tools/shared/shell-output.js"); - formatShellOutput = mod.formatShellOutput; - }); - test("successful command with output", () => { const result = formatShellOutput("hello world", "", 0, false, 120); expect(result.content).toBe("hello world"); diff --git a/assistant/src/ipc/skill-routes/__tests__/registries.test.ts b/assistant/src/ipc/skill-routes/__tests__/registries.test.ts index ca3c87a08d0..95f2f70d44e 100644 --- a/assistant/src/ipc/skill-routes/__tests__/registries.test.ts +++ b/assistant/src/ipc/skill-routes/__tests__/registries.test.ts @@ -21,6 +21,7 @@ import { getTool, getToolOwner, } from "../../../tools/registry.js"; +import { RiskLevel } from "../../../tools/types.js"; import { __getActiveSessionCountForTesting, __resetActiveSessionsForTesting, @@ -85,7 +86,7 @@ describe("host.registries.register_tools", () => { }); }); - test("proxy execute throws when no supervisor is attached", async () => { + test("proxy execute surfaces an error result when no supervisor is attached", async () => { await registerToolsRoute.handler({ skillId: "stub-skill", tools: [ @@ -101,16 +102,19 @@ describe("host.registries.register_tools", () => { const installed = getTool("skill_stub_tool"); expect(installed).toBeDefined(); - await expect( - installed!.execute( - {}, - { - workingDir: "/tmp", - conversationId: "c", - trustClass: "guardian", - }, - ), - ).rejects.toThrow(/requires an attached MeetHostSupervisor/i); + // Skill tools arrive without an `execute` closure (closures don't cross + // IPC). `finalizeTool` synthesizes a no-op error result so unsupervised + // invocations surface a clear "not wired up" signal to the model. + const result = await installed!.execute( + {}, + { + workingDir: "/tmp", + conversationId: "c", + trustClass: "guardian", + }, + ); + expect(result.isError).toBe(true); + expect(result.content).toMatch(/no execute implementation/i); }); test("rejects empty tool list", async () => { @@ -119,13 +123,19 @@ describe("host.registries.register_tools", () => { ).rejects.toThrow(); }); - test("rejects missing required fields", async () => { - await expect( - registerToolsRoute.handler({ - skillId: "any-skill", - tools: [{ name: "missing_rest" }], - }), - ).rejects.toThrow(); + test("fills defaults for partial tool entries", async () => { + // Wire and author share one schema (`ToolDefinitionSchema`, all-optional) + // and the daemon runs `finalizeTool` on every incoming tool. So a + // partial entry doesn't reject — defaults fill in for missing fields. + const result = (await registerToolsRoute.handler({ + skillId: "partial-skill", + tools: [{ name: "partial_tool" }], + })) as { registered: string[] }; + expect(result.registered).toEqual(["partial_tool"]); + const installed = getTool("partial_tool"); + expect(installed).toBeDefined(); + expect(installed!.defaultRiskLevel).toBe(RiskLevel.Medium); + expect(installed!.executionTarget).toBe("sandbox"); }); test("rejects missing skillId", async () => { diff --git a/assistant/src/ipc/skill-routes/registries.ts b/assistant/src/ipc/skill-routes/registries.ts index 2b3e0c7d4de..90ad8b332f1 100644 --- a/assistant/src/ipc/skill-routes/registries.ts +++ b/assistant/src/ipc/skill-routes/registries.ts @@ -22,36 +22,15 @@ import { z } from "zod"; import type { MeetHostSupervisor } from "../../daemon/meet-host-supervisor.js"; import { registerShutdownHook } from "../../daemon/shutdown-registry.js"; import { registerSkillRoute } from "../../runtime/skill-route-registry.js"; -import { resolveExecutionTarget } from "../../tools/execution-target.js"; import { registerSkillTools } from "../../tools/registry.js"; -import type { ExecutionTarget, Tool } from "../../tools/types.js"; -import { RiskLevel } from "../../tools/types.js"; +import { finalizeTool } from "../../tools/tool-defaults.js"; +import { ToolDefinitionSchema } from "../../tools/types.js"; import { getLogger } from "../../util/logger.js"; import type { SkillIpcRoute } from "../skill-ipc-types.js"; import type { SkillIpcConnection } from "../skill-server.js"; const log = getLogger("skill-routes-registries"); -// ── Wire-level schemas ──────────────────────────────────────────────── - -/** - * Serialized tool manifest entry sent over IPC. Mirrors the subset of - * {@link Tool} a skill process can describe without carrying the tool's - * executable closure across the socket; the closure is synthesized - * daemon-side (see {@link buildProxyTool}) to forward invocations back - * over IPC. - */ -const ToolManifestSchema = z.object({ - name: z.string().min(1), - description: z.string(), - input_schema: z.record(z.string(), z.unknown()), - defaultRiskLevel: z.enum(["low", "medium", "high"]), - category: z.string().min(1), - executionTarget: z.enum(["sandbox", "host"]).optional(), -}); - -export type ToolManifest = z.infer; - // `skillId` lives at the params level rather than per-tool: a single // `register_tools` IPC frame is always one skill's batch, ownership flows // through `registerSkillTools(skillId, tools)` into the registry's @@ -61,7 +40,7 @@ export type ToolManifest = z.infer; // in-process on the assistant side. const RegisterToolsParams = z.object({ skillId: z.string().min(1), - tools: z.array(ToolManifestSchema).min(1), + tools: z.array(ToolDefinitionSchema).min(1), }); const RegisterSkillRouteParams = z.object({ @@ -170,39 +149,6 @@ export function __getActiveSessionCountForTesting(): number { return activeSessions.size; } -// ── Proxy-tool construction ─────────────────────────────────────────── - -/** - * Build a daemon-side {@link Tool} whose `execute` routes back to the - * remote skill over IPC. PR 28 replaces the stub body with a real - * `skill.dispatch_tool` round-trip; until then we keep a shape-complete - * proxy in the registry so the rest of the tool-manifest plumbing can be - * exercised end-to-end. - */ -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. - return { - name: manifest.name, - description: manifest.description, - input_schema: manifest.input_schema as object, - category: manifest.category, - defaultRiskLevel: manifest.defaultRiskLevel as RiskLevel, - executionTarget: resolveExecutionTarget({ - name: manifest.name, - executionTarget: manifest.executionTarget as ExecutionTarget | undefined, - }), - execute: async () => { - // Only reached when no supervisor is attached (tests/boot race); - // the supervisor short-circuit above replaces this with the - // manifest's dispatching execute closure on the production path. - throw new Error( - `Skill tool "${manifest.name}" invocation requires an attached MeetHostSupervisor`, - ); - }, - }; -} - // ── Handlers ────────────────────────────────────────────────────────── async function handleRegisterTools( @@ -212,6 +158,16 @@ async function handleRegisterTools( const { skillId, tools } = RegisterToolsParams.parse(params); const conn = connection as SkillIpcConnection | undefined; + // 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. + const proxies = tools.map((tool) => finalizeTool(tool, tool.name ?? "")); + // Supervisor short-circuit: when a supervisor is registered, the // manifest loader has already installed proxy tools at daemon boot. // Re-installing here would double-register and clobber the manifest's @@ -222,16 +178,15 @@ async function handleRegisterTools( if (conn) sessionSupervisor.setActiveConnection(conn); log.info( { - count: tools.length, - names: tools.map((t) => t.name), + count: proxies.length, + names: proxies.map((t) => t.name), ownerSkillId: skillId, }, "Supervisor active: skipping in-memory tool re-registration; manifest proxies serve dispatches", ); - return { registered: tools.map((t) => t.name) }; + return { registered: proxies.map((t) => t.name) }; } - const proxies = tools.map(buildProxyTool); // `registerExternalTools` is only consumed inside `initializeTools()` at // daemon boot; IPC children connect after boot, so route through // `registerSkillTools` into the live registry the agent-loop reads from. diff --git a/assistant/src/memory/graph/tools.ts b/assistant/src/memory/graph/tools.ts index 340d0fa967f..c83df7d534a 100644 --- a/assistant/src/memory/graph/tools.ts +++ b/assistant/src/memory/graph/tools.ts @@ -15,7 +15,7 @@ const RECALL_DEPTHS = ["fast", "standard", "deep"] as const; * Explicit local information search across memory, conversations, and * workspace files. */ -export const graphRecallDefinition: ToolDefinition = { +export const graphRecallDefinition = { name: "recall", description: 'Search local information the moment you feel uncertain. Use recall for memory, past conversations, and workspace files — before you guess, before you ask, before you hedge. Auto-injection is incomplete by design; it surfaces patterns, not the specifics you need to answer well. If you catch yourself reaching for "I think", "I believe", "if I remember", "didn\'t we", "last time" — that\'s the signal. Recall. If a turn references someone, a place, a decision, a document, or prior work you should be able to find locally — recall. Call it multiple times per conversation if the turn warrants it. Be specific in your query for best results.', @@ -51,7 +51,7 @@ export const graphRecallDefinition: ToolDefinition = { }, required: ["query"], }, -}; +} satisfies ToolDefinition; /** * `remember` tool description. The retrospective pass catches what isn't @@ -70,7 +70,7 @@ const REMEMBER_DESCRIPTION = * of the buffer into longer-form storage runs as a separate periodic job in * both modes. */ -export const graphRememberDefinition: ToolDefinition = { +export const graphRememberDefinition = { name: "remember", description: REMEMBER_DESCRIPTION, input_schema: { @@ -89,4 +89,4 @@ export const graphRememberDefinition: ToolDefinition = { }, required: ["content"], }, -}; +} satisfies ToolDefinition; diff --git a/assistant/src/memory/v2/sweep-job.ts b/assistant/src/memory/v2/sweep-job.ts index beee881bc4a..b3d3b919639 100644 --- a/assistant/src/memory/v2/sweep-job.ts +++ b/assistant/src/memory/v2/sweep-job.ts @@ -76,7 +76,7 @@ const MAX_BUFFER_CHARS = 16_000; // returns the tool input as `unknown`. The two must stay in sync. const SWEEP_TOOL_NAME = "emit_remember_entries"; -const SWEEP_TOOL: ToolDefinition = { +const SWEEP_TOOL = { name: SWEEP_TOOL_NAME, description: "Emit zero or more remember()-style entries the assistant should commit to long-term memory.", @@ -92,7 +92,7 @@ const SWEEP_TOOL: ToolDefinition = { }, required: ["entries"], }, -}; +} satisfies ToolDefinition; const SweepResultSchema = z.object({ entries: z.array(z.string()), diff --git a/assistant/src/messaging/style-analyzer.ts b/assistant/src/messaging/style-analyzer.ts index 3e2451b7bab..088958c3fb3 100644 --- a/assistant/src/messaging/style-analyzer.ts +++ b/assistant/src/messaging/style-analyzer.ts @@ -49,7 +49,7 @@ Also identify recurring contacts (people appearing in 3+ messages) and note how You MUST respond using the \`store_style_analysis\` tool. Do not respond with text.`; -const storeStyleAnalysisTool: ToolDefinition = { +const storeStyleAnalysisTool = { name: "store_style_analysis", description: "Store extracted writing style patterns and relationship observations", @@ -94,7 +94,7 @@ const storeStyleAnalysisTool: ToolDefinition = { }, required: ["style_patterns"], }, -}; +} satisfies ToolDefinition; /** * Build a text corpus from provider messages for LLM analysis. diff --git a/assistant/src/tools/apps/definitions.ts b/assistant/src/tools/apps/definitions.ts index 7b6058df426..130b9a61dc4 100644 --- a/assistant/src/tools/apps/definitions.ts +++ b/assistant/src/tools/apps/definitions.ts @@ -9,7 +9,11 @@ */ import { RiskLevel } from "../../permissions/types.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; // --------------------------------------------------------------------------- // Helpers @@ -40,7 +44,7 @@ function proxyExecute(toolName: string) { // app_open // --------------------------------------------------------------------------- -const appOpenTool: Tool = { +const appOpenTool = { name: "app_open", description: "Open a persistent app in a dynamic_page surface on the connected client.", @@ -66,10 +70,10 @@ const appOpenTool: Tool = { }, execute: proxyExecute("app_open"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // Proxy-only tools registered in the core daemon registry // --------------------------------------------------------------------------- -export const coreAppProxyTools: Tool[] = [appOpenTool]; +export const coreAppProxyTools: ToolDefinition[] = [appOpenTool]; diff --git a/assistant/src/tools/ask-question/ask-question-tool.test.ts b/assistant/src/tools/ask-question/ask-question-tool.test.ts index 58bb38c467f..2dc2d290e24 100644 --- a/assistant/src/tools/ask-question/ask-question-tool.test.ts +++ b/assistant/src/tools/ask-question/ask-question-tool.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { QuestionPromptResult } from "../../permissions/question-prompter.js"; import type { ToolContext } from "../types.js"; -import { AskQuestionTool } from "./ask-question-tool.js"; +import { askQuestionTool, createAskQuestionTool } from "./ask-question-tool.js"; type PromptParams = Parameters< import("../../permissions/question-prompter.js").QuestionPrompter["prompt"] @@ -18,12 +18,12 @@ function makeContext(overrides: Partial = {}): ToolContext { }; } -function makeToolWithStub(result: QuestionPromptResult): { - tool: AskQuestionTool; - calls: PromptParams[]; -} { +// Return type is inferred so the satisfies-narrowed shape of +// `createAskQuestionTool()` carries through — letting the test call +// `tool.execute(...)` without a `!` bang. +function makeToolWithStub(result: QuestionPromptResult) { const calls: PromptParams[] = []; - const tool = new AskQuestionTool(() => ({ + const tool = createAskQuestionTool(() => ({ async prompt(params: PromptParams) { calls.push(params); return result; @@ -49,9 +49,9 @@ const singleQ = { freeTextPlaceholder: validInput.freeTextPlaceholder, }; -describe("AskQuestionTool definition", () => { +describe("askQuestionTool definition", () => { test("exposes the expected schema shape and description language", () => { - const def = new AskQuestionTool(); + const def = askQuestionTool; expect(def.name).toBe("ask_question"); expect(def.description).toContain("free-text fallback is always added"); expect(def.description).toContain("do not"); @@ -461,9 +461,9 @@ describe("AskQuestionTool batched input", () => { }); }); -describe("AskQuestionTool definition (batched schema)", () => { +describe("askQuestionTool definition (batched schema)", () => { test("exposes `questions[]` shape, keeps legacy fields, omits per-question id", () => { - const def = new AskQuestionTool(); + const def = askQuestionTool; const schema = def.input_schema as unknown as { properties: Record< string, diff --git a/assistant/src/tools/ask-question/ask-question-tool.ts b/assistant/src/tools/ask-question/ask-question-tool.ts index 64e0b6d5664..8d2056b7d96 100644 --- a/assistant/src/tools/ask-question/ask-question-tool.ts +++ b/assistant/src/tools/ask-question/ask-question-tool.ts @@ -3,7 +3,11 @@ import { z } from "zod"; import { QuestionPrompter } from "../../permissions/question-prompter.js"; import { RiskLevel } from "../../permissions/types.js"; import { broadcastMessage } from "../../runtime/assistant-event-hub.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; // ── Input schema ──────────────────────────────────────────────────── // Runtime validation lives in Zod; the wire-level definition surfaced @@ -132,166 +136,167 @@ const OPTION_ITEMS_SCHEMA = { // ── Tool ──────────────────────────────────────────────────────────── -export class AskQuestionTool implements Tool { - name = "ask_question"; - description = DESCRIPTION; - category = "interaction"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; - input_schema = { - type: "object", - properties: { - // ── Recommended shape ───────────────────────────────────── - questions: { - type: "array", - minItems: 1, - maxItems: MAX_QUESTIONS_PER_BATCH, - description: `Recommended shape. 1–${MAX_QUESTIONS_PER_BATCH} clarifying questions to ask in a single turn. Use a batch when several independent ambiguities block progress; ask one at a time when they're sequentially dependent. Past ${MAX_QUESTIONS_PER_BATCH} questions you should be implementing, not asking.`, - items: { - type: "object", - properties: { - question: { - type: "string", - description: "The clarifying question to display.", - }, - description: { - type: "string", - description: - "Optional one-line context shown beneath the question.", - }, - options: { - type: "array", - minItems: 2, - maxItems: 4, - description: - "2–4 structured options. The UI always appends a free-text fallback slot, so do not include a 'something else' option here.", - items: OPTION_ITEMS_SCHEMA, - }, - freeTextPlaceholder: { - type: "string", - description: - "Optional placeholder text shown inside the free-text fallback input.", - }, +/** + * Build a fresh `ask_question` {@link ToolDefinition}. The default + * prompter factory wires the real `broadcastMessage` so the question + * reaches every connected client. Tests pass an override to swap in + * a stubbed prompter without monkey-patching the module — this is the + * factory-function replacement for the previous constructor injection + * point (`new AskQuestionTool(stub)`). + */ +export function createAskQuestionTool( + prompterFactory: () => Pick = () => + new QuestionPrompter({ broadcastMessage }), +) { + return { + name: "ask_question", + description: DESCRIPTION, + category: "interaction", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Low, + input_schema: { + type: "object", + properties: { + // ── Recommended shape ───────────────────────────────────── + questions: { + type: "array", + minItems: 1, + maxItems: MAX_QUESTIONS_PER_BATCH, + description: `Recommended shape. 1–${MAX_QUESTIONS_PER_BATCH} clarifying questions to ask in a single turn. Use a batch when several independent ambiguities block progress; ask one at a time when they're sequentially dependent. Past ${MAX_QUESTIONS_PER_BATCH} questions you should be implementing, not asking.`, + items: { + type: "object", + properties: { + question: { + type: "string", + description: "The clarifying question to display.", + }, + description: { + type: "string", + description: + "Optional one-line context shown beneath the question.", + }, + options: { + type: "array", + minItems: 2, + maxItems: 4, + description: + "2–4 structured options. The UI always appends a free-text fallback slot, so do not include a 'something else' option here.", + items: OPTION_ITEMS_SCHEMA, + }, + freeTextPlaceholder: { + type: "string", + description: + "Optional placeholder text shown inside the free-text fallback input.", }, - required: ["question", "options"], }, - }, - // ── Legacy single-question fields ───────────────────────── - // Kept optional so existing prompt caches and any single-question - // callers continue to work. New callers should use `questions`. - question: { - type: "string", - description: - "Legacy: the single clarifying question. Prefer `questions[]` for new code.", - }, - description: { - type: "string", - description: - "Legacy: optional one-line context shown beneath the question. Prefer `questions[].description`.", - }, - options: { - type: "array", - minItems: 2, - maxItems: 4, - description: - "Legacy: 2–4 structured options. Prefer `questions[].options`. The UI always appends a free-text fallback slot, so do not include a 'something else' option here.", - items: OPTION_ITEMS_SCHEMA, - }, - freeTextPlaceholder: { - type: "string", - description: - "Legacy: optional placeholder text for the free-text fallback input. Prefer `questions[].freeTextPlaceholder`.", + required: ["question", "options"], }, }, - // No top-level `required` — caller must supply either `questions` - // or the legacy flat trio (`question` + `options`). Enforced in Zod. - }; - - // Override hook for tests: lets a test replace the prompter factory - // without monkey-patching the module. Default factory wires the real - // broadcastMessage so the question reaches every connected client. - private prompterFactory: () => Pick; - - constructor( - prompterFactory: () => Pick = () => - new QuestionPrompter({ broadcastMessage }), - ) { - this.prompterFactory = prompterFactory; - } + // ── Legacy single-question fields ───────────────────────── + // Kept optional so existing prompt caches and any single-question + // callers continue to work. New callers should use `questions`. + question: { + type: "string", + description: + "Legacy: the single clarifying question. Prefer `questions[]` for new code.", + }, + description: { + type: "string", + description: + "Legacy: optional one-line context shown beneath the question. Prefer `questions[].description`.", + }, + options: { + type: "array", + minItems: 2, + maxItems: 4, + description: + "Legacy: 2–4 structured options. Prefer `questions[].options`. The UI always appends a free-text fallback slot, so do not include a 'something else' option here.", + items: OPTION_ITEMS_SCHEMA, + }, + freeTextPlaceholder: { + type: "string", + description: + "Legacy: optional placeholder text for the free-text fallback input. Prefer `questions[].freeTextPlaceholder`.", + }, + }, + // No top-level `required` — caller must supply either `questions` + // or the legacy flat trio (`question` + `options`). Enforced in Zod. + }, - async execute( - input: Record, - context: ToolContext, - ): Promise { - const parsed = InputSchema.safeParse(input); - if (!parsed.success) { - return { - content: `Invalid input: ${parsed.error.message}`, - isError: true, - }; - } + async execute( + input: Record, + context: ToolContext, + ): Promise { + const parsed = InputSchema.safeParse(input); + if (!parsed.success) { + return { + content: `Invalid input: ${parsed.error.message}`, + isError: true, + }; + } - // Normalize legacy flat input into a one-element `questions` batch so - // downstream code only has to deal with the batched shape. The refine - // above guarantees `question` and `options` are present whenever - // `questions` is absent. - const questions: SingleQuestion[] = parsed.data.questions ?? [ - { - question: parsed.data.question!, - description: parsed.data.description, - options: parsed.data.options!, - freeTextPlaceholder: parsed.data.freeTextPlaceholder, - }, - ]; + // Normalize legacy flat input into a one-element `questions` batch so + // downstream code only has to deal with the batched shape. The refine + // above guarantees `question` and `options` are present whenever + // `questions` is absent. + const questions: SingleQuestion[] = parsed.data.questions ?? [ + { + question: parsed.data.question!, + description: parsed.data.description, + options: parsed.data.options!, + freeTextPlaceholder: parsed.data.freeTextPlaceholder, + }, + ]; - const prompter = this.prompterFactory(); - const result = await prompter.prompt({ - conversationId: context.conversationId, - questions, - toolUseId: context.toolUseId, - signal: context.signal, - }); + const prompter = prompterFactory(); + const result = await prompter.prompt({ + conversationId: context.conversationId, + questions, + toolUseId: context.toolUseId, + signal: context.signal, + }); - // Format the aggregated transcript. Each line is keyed by the original - // question text (not the daemon-assigned id) — the LLM never sees those - // ids, and human-readable labels read better in the result content. - const lines = result.entries.map((entry, i) => { - const q = questions[i]!; - const prefix = `Question "${q.question}" →`; - if (entry.decision === "option") { - const chosen = q.options.find((o) => o.id === entry.optionId); - const label = chosen?.label ?? "(unknown)"; - return `${prefix} Option: ${entry.optionId} (${label})`; - } - if (entry.decision === "free_text") { - return `${prefix} Free text: ${entry.text ?? ""}`; - } - return `${prefix} Skipped`; - }); + // Format the aggregated transcript. Each line is keyed by the original + // question text (not the daemon-assigned id) — the LLM never sees those + // ids, and human-readable labels read better in the result content. + const lines = result.entries.map((entry, i) => { + const q = questions[i]!; + const prefix = `Question "${q.question}" →`; + if (entry.decision === "option") { + const chosen = q.options.find((o) => o.id === entry.optionId); + const label = chosen?.label ?? "(unknown)"; + return `${prefix} Option: ${entry.optionId} (${label})`; + } + if (entry.decision === "free_text") { + return `${prefix} Free text: ${entry.text ?? ""}`; + } + return `${prefix} Skipped`; + }); - switch (result.overall) { - case "completed": - return { content: lines.join("\n"), isError: false }; - case "closed": { - const summary = - "User closed the question card without answering. All questions skipped."; - return { - content: [summary, ...lines].join("\n"), - isError: false, - }; + switch (result.overall) { + case "completed": + return { content: lines.join("\n"), isError: false }; + case "closed": { + const summary = + "User closed the question card without answering. All questions skipped."; + return { + content: [summary, ...lines].join("\n"), + isError: false, + }; + } + case "timed_out": + return { + content: "User did not respond within timeout", + isError: true, + }; + case "aborted": + return { + content: "Question aborted", + isError: true, + }; } - case "timed_out": - return { - content: "User did not respond within timeout", - isError: true, - }; - case "aborted": - return { - content: "Question aborted", - isError: true, - }; - } - } + }, + } satisfies ToolDefinition; } -export const askQuestionTool = new AskQuestionTool(); +export const askQuestionTool = createAskQuestionTool(); diff --git a/assistant/src/tools/computer-use/definitions.ts b/assistant/src/tools/computer-use/definitions.ts index e100313951c..2eda6364d07 100644 --- a/assistant/src/tools/computer-use/definitions.ts +++ b/assistant/src/tools/computer-use/definitions.ts @@ -8,7 +8,11 @@ */ import { RiskLevel } from "../../permissions/types.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; // --------------------------------------------------------------------------- // Helpers @@ -39,7 +43,7 @@ function proxyExecute(toolName: string) { // click (unified - click_type selects single / double / right) // --------------------------------------------------------------------------- -export const computerUseClickTool: Tool = { +export const computerUseClickTool = { name: "computer_use_click", description: "Click an element on screen. Prefer element_id (from the accessibility tree) over x/y coordinates.", @@ -83,13 +87,13 @@ export const computerUseClickTool: Tool = { }, execute: proxyExecute("computer_use_click"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // type_text // --------------------------------------------------------------------------- -export const computerUseTypeTextTool: Tool = { +export const computerUseTypeTextTool = { name: "computer_use_type_text", description: "Type text at the current cursor position. First click a text field (by element_id) to focus it, then call this tool. If a field shows 'FOCUSED', skip the click.", @@ -118,13 +122,13 @@ export const computerUseTypeTextTool: Tool = { }, execute: proxyExecute("computer_use_type_text"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // key // --------------------------------------------------------------------------- -export const computerUseKeyTool: Tool = { +export const computerUseKeyTool = { name: "computer_use_key", description: "Press a key or keyboard shortcut. Supported: enter, tab, escape, backspace, delete, up, down, left, right, space, cmd+a, cmd+c, cmd+v, cmd+z, cmd+tab, cmd+w, shift+tab, option+tab", @@ -153,13 +157,13 @@ export const computerUseKeyTool: Tool = { }, execute: proxyExecute("computer_use_key"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // scroll // --------------------------------------------------------------------------- -export const computerUseScrollTool: Tool = { +export const computerUseScrollTool = { name: "computer_use_scroll", description: "Scroll within an element by its [ID], or at raw screen coordinates as fallback.", @@ -206,13 +210,13 @@ export const computerUseScrollTool: Tool = { }, execute: proxyExecute("computer_use_scroll"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // drag // --------------------------------------------------------------------------- -export const computerUseDragTool: Tool = { +export const computerUseDragTool = { name: "computer_use_drag", description: "Drag from one element or position to another. Use for moving files, resizing windows, rearranging items, or adjusting sliders.", @@ -264,13 +268,13 @@ export const computerUseDragTool: Tool = { }, execute: proxyExecute("computer_use_drag"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // wait // --------------------------------------------------------------------------- -export const computerUseWaitTool: Tool = { +export const computerUseWaitTool = { name: "computer_use_wait", description: "Wait for the UI to update", category: "computer-use", @@ -298,13 +302,13 @@ export const computerUseWaitTool: Tool = { }, execute: proxyExecute("computer_use_wait"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // open_app // --------------------------------------------------------------------------- -export const computerUseOpenAppTool: Tool = { +export const computerUseOpenAppTool = { name: "computer_use_open_app", description: "Open or switch to a macOS application by name. Preferred over cmd+tab for switching apps - more reliable and explicit.", @@ -335,13 +339,13 @@ export const computerUseOpenAppTool: Tool = { }, execute: proxyExecute("computer_use_open_app"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // run_applescript // --------------------------------------------------------------------------- -export const computerUseRunAppleScriptTool: Tool = { +export const computerUseRunAppleScriptTool = { name: "computer_use_run_applescript", description: "Run an AppleScript command. Prefer this over click/type when possible - it doesn't move the cursor or interrupt foreground activity. Never use 'do shell script' inside AppleScript (blocked for security).", @@ -371,13 +375,13 @@ export const computerUseRunAppleScriptTool: Tool = { }, execute: proxyExecute("computer_use_run_applescript"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // done // --------------------------------------------------------------------------- -export const computerUseDoneTool: Tool = { +export const computerUseDoneTool = { name: "computer_use_done", description: "Signal that the computer use task is complete. Provide a summary of what was accomplished. This ends the computer use session.", @@ -397,13 +401,13 @@ export const computerUseDoneTool: Tool = { }, execute: proxyExecute("computer_use_done"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // respond // --------------------------------------------------------------------------- -export const computerUseRespondTool: Tool = { +export const computerUseRespondTool = { name: "computer_use_respond", description: "Reply with a text answer instead of performing computer actions. Use this when you can answer directly without interacting with the screen.", @@ -427,13 +431,13 @@ export const computerUseRespondTool: Tool = { }, execute: proxyExecute("computer_use_respond"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // observe // --------------------------------------------------------------------------- -const computerUseObserveTool: Tool = { +const computerUseObserveTool = { name: "computer_use_observe", description: "Capture the current screen state. Returns the accessibility tree with [ID] element references and optionally a screenshot.\n\nThe accessibility tree shows interactive elements like [3] AXButton 'Save' or [17] AXTextField 'Search'. Use element_id to target these elements in subsequent actions - this is much more reliable than pixel coordinates.\n\nCall this before your first computer use action, or to check screen state without acting.", @@ -448,13 +452,13 @@ const computerUseObserveTool: Tool = { }, execute: proxyExecute("computer_use_observe"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // All tools exported as array for convenience // --------------------------------------------------------------------------- -export const allComputerUseTools: Tool[] = [ +export const allComputerUseTools: ToolDefinition[] = [ computerUseObserveTool, computerUseClickTool, computerUseTypeTextTool, diff --git a/assistant/src/tools/credential-execution/make-authenticated-request.ts b/assistant/src/tools/credential-execution/make-authenticated-request.ts index ba6254e642a..876127c34bd 100644 --- a/assistant/src/tools/credential-execution/make-authenticated-request.ts +++ b/assistant/src/tools/credential-execution/make-authenticated-request.ts @@ -10,61 +10,68 @@ * straight through to the CES RPC call with no transformation. */ -import { GrantProposalSchema, renderProposal } from "@vellumai/service-contracts/credential-rpc"; +import { + GrantProposalSchema, + renderProposal, +} from "@vellumai/service-contracts/credential-rpc"; import { RiskLevel } from "../../permissions/types.js"; import { getLogger } from "../../util/logger.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; const log = getLogger("ces-tool:make-authenticated-request"); -class MakeAuthenticatedRequestTool implements Tool { - name = "make_authenticated_request"; - description = - "Execute an authenticated HTTP request through CES. CES injects the credential and returns the response - the assistant never sees raw secrets."; - category = "credential-execution"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.High; +export const makeAuthenticatedRequestTool = { + name: "make_authenticated_request", + description: + "Execute an authenticated HTTP request through CES. CES injects the credential and returns the response - the assistant never sees raw secrets.", + category: "credential-execution", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.High, - input_schema = { + input_schema: { + type: "object", + properties: { + credentialHandle: { + type: "string", + description: + "CES credential handle to use for authentication (e.g. local_static:github/api_key).", + }, + method: { + type: "string", + description: "HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).", + }, + url: { + type: "string", + description: "Target URL for the request.", + }, + headers: { type: "object", - properties: { - credentialHandle: { - type: "string", - description: - "CES credential handle to use for authentication (e.g. local_static:github/api_key).", - }, - method: { - type: "string", - description: "HTTP method (GET, POST, PUT, DELETE, PATCH, etc.).", - }, - url: { - type: "string", - description: "Target URL for the request.", - }, - headers: { - type: "object", - additionalProperties: { type: "string" }, - description: - "Optional request headers. Credential headers are injected by CES - do not include secrets here.", - }, - body: { - description: - "Optional request body (string or JSON-serialisable object).", - }, - purpose: { - type: "string", - description: - "Human-readable purpose for this request, shown in audit logs and approval prompts.", - }, - grantId: { - type: "string", - description: - "Existing grant ID to consume, if the caller holds one from a prior approval.", - }, - }, - required: ["credentialHandle", "method", "url", "purpose"], - }; + additionalProperties: { type: "string" }, + description: + "Optional request headers. Credential headers are injected by CES - do not include secrets here.", + }, + body: { + description: + "Optional request body (string or JSON-serialisable object).", + }, + purpose: { + type: "string", + description: + "Human-readable purpose for this request, shown in audit logs and approval prompts.", + }, + grantId: { + type: "string", + description: + "Existing grant ID to consume, if the caller holds one from a prior approval.", + }, + }, + required: ["credentialHandle", "method", "url", "purpose"], + }, async execute( input: Record, @@ -186,7 +193,5 @@ class MakeAuthenticatedRequestTool implements Tool { isError: true, }; } - } -} - -export const makeAuthenticatedRequestTool = new MakeAuthenticatedRequestTool(); + }, +} satisfies ToolDefinition; diff --git a/assistant/src/tools/credential-execution/manage-secure-command-tool.ts b/assistant/src/tools/credential-execution/manage-secure-command-tool.ts index 1ece5c58d77..e35799a503c 100644 --- a/assistant/src/tools/credential-execution/manage-secure-command-tool.ts +++ b/assistant/src/tools/credential-execution/manage-secure-command-tool.ts @@ -19,11 +19,11 @@ import type { ManageSecureCommandTool } from "@vellumai/service-contracts/rpc"; import { RiskLevel } from "../../permissions/types.js"; import { getLogger } from "../../util/logger.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; const log = getLogger("ces-tool:manage-secure-command-tool"); -class ManageSecureCommandToolImpl implements Tool { +class ManageSecureCommandToolImpl implements ToolDefinition { name = "manage_secure_command_tool"; description = "Request installation, update, or removal of a secure command tool bundle. " + diff --git a/assistant/src/tools/credential-execution/run-authenticated-command.ts b/assistant/src/tools/credential-execution/run-authenticated-command.ts index a3fc1c226d1..5b8eb1bb679 100644 --- a/assistant/src/tools/credential-execution/run-authenticated-command.ts +++ b/assistant/src/tools/credential-execution/run-authenticated-command.ts @@ -10,90 +10,97 @@ * straight through to the CES RPC call with no transformation. */ -import { GrantProposalSchema, renderProposal } from "@vellumai/service-contracts/credential-rpc"; +import { + GrantProposalSchema, + renderProposal, +} from "@vellumai/service-contracts/credential-rpc"; import { RiskLevel } from "../../permissions/types.js"; import { getLogger } from "../../util/logger.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; const log = getLogger("ces-tool:run-authenticated-command"); -class RunAuthenticatedCommandTool implements Tool { - name = "run_authenticated_command"; - description = - "Execute a command with credential environment variables injected by CES. The command runs inside the CES sandbox - the assistant never sees raw secrets."; - category = "credential-execution"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.High; - - input_schema = { - type: "object", - properties: { - credentialHandle: { - type: "string", - description: - "CES credential handle to use for environment injection (e.g. local_static:aws/key).", - }, - command: { - type: "string", - description: - "Secure command reference in format '/ [argv...]'. Only manifest-driven secure commands are supported.", - }, - cwd: { - type: "string", - description: - "Optional path used for resolving workspace input/output staging, not as the actual execution working directory (CES always runs commands in the scratch directory).", - }, - purpose: { - type: "string", - description: - "Human-readable purpose for this command, shown in audit logs and approval prompts.", - }, - inputs: { - type: "array", - items: { - type: "object", - properties: { - workspacePath: { - type: "string", - description: - "Relative path within the assistant workspace to stage as a read-only input.", - }, - }, - required: ["workspacePath"], +export const runAuthenticatedCommandTool = { + name: "run_authenticated_command", + description: + "Execute a command with credential environment variables injected by CES. The command runs inside the CES sandbox - the assistant never sees raw secrets.", + category: "credential-execution", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.High, + + input_schema: { + type: "object", + properties: { + credentialHandle: { + type: "string", + description: + "CES credential handle to use for environment injection (e.g. local_static:aws/key).", + }, + command: { + type: "string", + description: + "Secure command reference in format '/ [argv...]'. Only manifest-driven secure commands are supported.", + }, + cwd: { + type: "string", + description: + "Optional path used for resolving workspace input/output staging, not as the actual execution working directory (CES always runs commands in the scratch directory).", + }, + purpose: { + type: "string", + description: + "Human-readable purpose for this command, shown in audit logs and approval prompts.", + }, + inputs: { + type: "array", + items: { + type: "object", + properties: { + workspacePath: { + type: "string", + description: + "Relative path within the assistant workspace to stage as a read-only input.", }, - description: - "Workspace files to stage as read-only inputs in the CES scratch directory before command execution.", }, - outputs: { - type: "array", - items: { - type: "object", - properties: { - scratchPath: { - type: "string", - description: - "Relative path within the scratch directory where the command writes output.", - }, - workspacePath: { - type: "string", - description: - "Relative path within the assistant workspace where the output is copied after execution.", - }, - }, - required: ["scratchPath", "workspacePath"], + required: ["workspacePath"], + }, + description: + "Workspace files to stage as read-only inputs in the CES scratch directory before command execution.", + }, + outputs: { + type: "array", + items: { + type: "object", + properties: { + scratchPath: { + type: "string", + description: + "Relative path within the scratch directory where the command writes output.", + }, + workspacePath: { + type: "string", + description: + "Relative path within the assistant workspace where the output is copied after execution.", }, - description: - "Workspace files to copy back from the CES scratch directory after command execution.", - }, - grantId: { - type: "string", - description: - "Existing grant ID to consume, if the caller holds one from a prior approval.", }, + required: ["scratchPath", "workspacePath"], }, - required: ["credentialHandle", "command", "purpose"], - }; + description: + "Workspace files to copy back from the CES scratch directory after command execution.", + }, + grantId: { + type: "string", + description: + "Existing grant ID to consume, if the caller holds one from a prior approval.", + }, + }, + required: ["credentialHandle", "command", "purpose"], + }, async execute( input: Record, @@ -249,7 +256,5 @@ class RunAuthenticatedCommandTool implements Tool { isError: true, }; } - } -} - -export const runAuthenticatedCommandTool = new RunAuthenticatedCommandTool(); + }, +} satisfies ToolDefinition; diff --git a/assistant/src/tools/credentials/vault.ts b/assistant/src/tools/credentials/vault.ts index 4cecfec746b..bfa1ba598c7 100644 --- a/assistant/src/tools/credentials/vault.ts +++ b/assistant/src/tools/credentials/vault.ts @@ -17,7 +17,11 @@ import { setSecureKeyAsync, } from "../../security/secure-keys.js"; import { getLogger } from "../../util/logger.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; import { credentialBroker } from "./broker.js"; import { assertMetadataWritable, @@ -69,115 +73,114 @@ function formatSlackChannelStatus(result: SlackChannelConfigResult): string { return ""; } -class CredentialStoreTool implements Tool { - name = "credential_store"; - description = - "Store, list, delete, or prompt for credentials in the secure vault"; - category = "credentials"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; - - input_schema = { - type: "object", - properties: { - action: { - type: "string", - enum: ["store", "list", "delete", "prompt"], - description: - 'The operation to perform. Use "prompt" to request a secret via secure UI - the value never enters the conversation.', - }, - service: { - type: "string", - description: "Service name, e.g. google, github", - }, - account: { - type: "string", - description: - "Account identifier (e.g. email address) to target a specific connection when multiple accounts are connected for the same service. If omitted, uses the most recently connected account.", - }, - field: { - type: "string", - description: "Field name, e.g. password, username, recovery_email", - }, - value: { - type: "string", - description: "The credential value (only for store action)", - }, - label: { - type: "string", - description: - 'Display label for the prompt UI (only for prompt action), e.g. "GitHub Personal Access Token"', - }, - description: { - type: "string", - description: - 'Optional context shown in the prompt UI (only for prompt action), e.g. "Needed to push changes"', - }, - placeholder: { - type: "string", - description: - 'Placeholder text for the input field (only for prompt action), e.g. "ghp_xxxxxxxxxxxx"', - }, - allowed_tools: { - type: "array", - items: { type: "string" }, - description: - 'Tools/capabilities allowed to use this credential (for store/prompt actions), e.g. ["assistant_browser_fill_credential"]. Empty = deny all.', - }, - allowed_domains: { - type: "array", - items: { type: "string" }, - description: - 'Domains where this credential may be used (for store/prompt actions), e.g. ["github.com"]. Empty = deny all.', - }, - usage_description: { - type: "string", - description: - 'Human-readable description of intended usage (for store/prompt actions), e.g. "GitHub login for pushing changes"', - }, - alias: { - type: "string", - description: - 'Human-friendly name for this credential (only for store action), e.g. "fal-primary"', - }, - injection_templates: { - type: "array", - items: { - type: "object", - properties: { - hostPattern: { - type: "string", - description: - 'Glob pattern for matching request hosts, e.g. "*.fal.ai"', - }, - injectionType: { - type: "string", - enum: ["header", "query"], - description: "Where to inject the credential value", - }, - headerName: { - type: "string", - description: 'Header name when injectionType is "header"', - }, - valuePrefix: { - type: "string", - description: - 'Prefix prepended to the secret value, e.g. "Key ", "Bearer "', - }, - queryParamName: { - type: "string", - description: - 'Query parameter name when injectionType is "query"', - }, - }, - required: ["hostPattern", "injectionType"], +export const credentialStoreTool = { + name: "credential_store", + description: + "Store, list, delete, or prompt for credentials in the secure vault", + category: "credentials", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Low, + + input_schema: { + type: "object", + properties: { + action: { + type: "string", + enum: ["store", "list", "delete", "prompt"], + description: + 'The operation to perform. Use "prompt" to request a secret via secure UI - the value never enters the conversation.', + }, + service: { + type: "string", + description: "Service name, e.g. google, github", + }, + account: { + type: "string", + description: + "Account identifier (e.g. email address) to target a specific connection when multiple accounts are connected for the same service. If omitted, uses the most recently connected account.", + }, + field: { + type: "string", + description: "Field name, e.g. password, username, recovery_email", + }, + value: { + type: "string", + description: "The credential value (only for store action)", + }, + label: { + type: "string", + description: + 'Display label for the prompt UI (only for prompt action), e.g. "GitHub Personal Access Token"', + }, + description: { + type: "string", + description: + 'Optional context shown in the prompt UI (only for prompt action), e.g. "Needed to push changes"', + }, + placeholder: { + type: "string", + description: + 'Placeholder text for the input field (only for prompt action), e.g. "ghp_xxxxxxxxxxxx"', + }, + allowed_tools: { + type: "array", + items: { type: "string" }, + description: + 'Tools/capabilities allowed to use this credential (for store/prompt actions), e.g. ["assistant_browser_fill_credential"]. Empty = deny all.', + }, + allowed_domains: { + type: "array", + items: { type: "string" }, + description: + 'Domains where this credential may be used (for store/prompt actions), e.g. ["github.com"]. Empty = deny all.', + }, + usage_description: { + type: "string", + description: + 'Human-readable description of intended usage (for store/prompt actions), e.g. "GitHub login for pushing changes"', + }, + alias: { + type: "string", + description: + 'Human-friendly name for this credential (only for store action), e.g. "fal-primary"', + }, + injection_templates: { + type: "array", + items: { + type: "object", + properties: { + hostPattern: { + type: "string", + description: + 'Glob pattern for matching request hosts, e.g. "*.fal.ai"', + }, + injectionType: { + type: "string", + enum: ["header", "query"], + description: "Where to inject the credential value", + }, + headerName: { + type: "string", + description: 'Header name when injectionType is "header"', + }, + valuePrefix: { + type: "string", + description: + 'Prefix prepended to the secret value, e.g. "Key ", "Bearer "', + }, + queryParamName: { + type: "string", + description: 'Query parameter name when injectionType is "query"', }, - description: - "Templates describing how to inject this credential into proxied requests (for store and prompt actions)", }, + required: ["hostPattern", "injectionType"], }, - required: ["action"], - }; + description: + "Templates describing how to inject this credential into proxied requests (for store and prompt actions)", + }, + }, + required: ["action"], + }, async execute( input: Record, @@ -816,7 +819,5 @@ class CredentialStoreTool implements Tool { default: return { content: `Error: unknown action "${action}"`, isError: true }; } - } -} - -export const credentialStoreTool = new CredentialStoreTool(); + }, +} satisfies ToolDefinition; diff --git a/assistant/src/tools/filesystem/edit.ts b/assistant/src/tools/filesystem/edit.ts index bcf4724a98b..03d6df17d4a 100644 --- a/assistant/src/tools/filesystem/edit.ts +++ b/assistant/src/tools/filesystem/edit.ts @@ -3,45 +3,49 @@ import { registerTool } from "../registry.js"; import { FileSystemOps } from "../shared/filesystem/file-ops-service.js"; import { formatEditDiff } from "../shared/filesystem/format-diff.js"; import { sandboxPolicy } from "../shared/filesystem/path-policy.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; - -class FileEditTool implements Tool { - name = "file_edit"; - description = - "Replace an exact string in a file on your own machine with a new string. Use this for surgical edits instead of rewriting entire files. Use host_file_edit for files on your guardian's device instead."; - category = "filesystem"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; - - input_schema = { - type: "object", - properties: { - path: { - type: "string", - description: - "The path to the file to edit (absolute or relative to working directory)", - }, - old_string: { - type: "string", - description: "The exact text to find in the file", - }, - new_string: { - type: "string", - description: "The replacement text", - }, - replace_all: { - type: "boolean", - description: - "Replace all occurrences of old_string instead of requiring a unique match (default: false)", - }, - activity: { - type: "string", - description: - "Brief non-technical explanation of what you are doing and why, shown as a status update.", - }, - }, - required: ["path", "old_string", "new_string", "activity"], - }; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; + +export const fileEditTool = { + name: "file_edit", + description: + "Replace an exact string in a file on your own machine with a new string. Use this for surgical edits instead of rewriting entire files. Use host_file_edit for files on your guardian's device instead.", + category: "filesystem", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Low, + + input_schema: { + type: "object", + properties: { + path: { + type: "string", + description: + "The path to the file to edit (absolute or relative to working directory)", + }, + old_string: { + type: "string", + description: "The exact text to find in the file", + }, + new_string: { + type: "string", + description: "The replacement text", + }, + replace_all: { + type: "boolean", + description: + "Replace all occurrences of old_string instead of requiring a unique match (default: false)", + }, + activity: { + type: "string", + description: + "Brief non-technical explanation of what you are doing and why, shown as a status update.", + }, + }, + required: ["path", "old_string", "new_string", "activity"], + }, async execute( input: Record, @@ -152,8 +156,7 @@ class FileEditTool implements Tool { isError: false, diff: { filePath, oldContent, newContent, isNewFile: false }, }; - } -} + }, +} satisfies ToolDefinition; -export const fileEditTool = new FileEditTool(); registerTool(fileEditTool); diff --git a/assistant/src/tools/filesystem/list.ts b/assistant/src/tools/filesystem/list.ts index 2b457eef46e..a9f9aa1e5ce 100644 --- a/assistant/src/tools/filesystem/list.ts +++ b/assistant/src/tools/filesystem/list.ts @@ -2,35 +2,39 @@ import { RiskLevel } from "../../permissions/types.js"; import { registerTool } from "../registry.js"; import { FileSystemOps } from "../shared/filesystem/file-ops-service.js"; import { sandboxPolicy } from "../shared/filesystem/path-policy.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; -class FileListTool implements Tool { - name = "file_list"; - description = - "List the contents of a directory on your own machine. Returns file and subdirectory names with type indicators and sizes."; - category = "filesystem"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; +export const fileListTool = { + name: "file_list", + description: + "List the contents of a directory on your own machine. Returns file and subdirectory names with type indicators and sizes.", + category: "filesystem", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Low, - input_schema = { - type: "object", - properties: { - path: { - type: "string", - description: "The directory path to list", - }, - glob: { - type: "string", - description: "Filter entries by glob pattern, e.g. '*.md'", - }, - activity: { - type: "string", - description: - "Brief non-technical explanation of what you are doing and why, shown as a status update.", - }, - }, - required: ["path", "activity"], - }; + input_schema: { + type: "object", + properties: { + path: { + type: "string", + description: "The directory path to list", + }, + glob: { + type: "string", + description: "Filter entries by glob pattern, e.g. '*.md'", + }, + activity: { + type: "string", + description: + "Brief non-technical explanation of what you are doing and why, shown as a status update.", + }, + }, + required: ["path", "activity"], + }, async execute( input: Record, @@ -80,8 +84,7 @@ class FileListTool implements Tool { } return { content: result.value.listing, isError: false }; - } -} + }, +} satisfies ToolDefinition; -export const fileListTool = new FileListTool(); registerTool(fileListTool); diff --git a/assistant/src/tools/filesystem/read.ts b/assistant/src/tools/filesystem/read.ts index 16c6ac7f251..110bda991bd 100644 --- a/assistant/src/tools/filesystem/read.ts +++ b/assistant/src/tools/filesystem/read.ts @@ -8,40 +8,44 @@ import { readImageFile, } from "../shared/filesystem/image-read.js"; import { sandboxPolicy } from "../shared/filesystem/path-policy.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; -class FileReadTool implements Tool { - name = "file_read"; - description = - "Read the contents of a file on your own machine. For image files (JPEG, PNG, GIF, WebP), returns the image for visual analysis. Use host_file_read for files on your guardian's device instead."; - category = "filesystem"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; +export const fileReadTool = { + name: "file_read", + description: + "Read the contents of a file on your own machine. For image files (JPEG, PNG, GIF, WebP), returns the image for visual analysis. Use host_file_read for files on your guardian's device instead.", + category: "filesystem", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Low, - input_schema = { - type: "object", - properties: { - path: { - type: "string", - description: - "The path to the file to read (absolute or relative to working directory)", - }, - offset: { - type: "number", - description: "Line number to start reading from (1-indexed)", - }, - limit: { - type: "number", - description: "Maximum number of lines to read", - }, - activity: { - type: "string", - description: - "Brief non-technical explanation of what you are doing and why, shown as a status update.", - }, - }, - required: ["path", "activity"], - }; + input_schema: { + type: "object", + properties: { + path: { + type: "string", + description: + "The path to the file to read (absolute or relative to working directory)", + }, + offset: { + type: "number", + description: "Line number to start reading from (1-indexed)", + }, + limit: { + type: "number", + description: "Maximum number of lines to read", + }, + activity: { + type: "string", + description: + "Brief non-technical explanation of what you are doing and why, shown as a status update.", + }, + }, + required: ["path", "activity"], + }, async execute( input: Record, @@ -105,8 +109,7 @@ class FileReadTool implements Tool { } return { content: result.value.content, isError: false }; - } -} + }, +} satisfies ToolDefinition; -export const fileReadTool = new FileReadTool(); registerTool(fileReadTool); diff --git a/assistant/src/tools/filesystem/write.ts b/assistant/src/tools/filesystem/write.ts index 0ac451bab13..621e7b521b1 100644 --- a/assistant/src/tools/filesystem/write.ts +++ b/assistant/src/tools/filesystem/write.ts @@ -9,7 +9,11 @@ import { registerTool } from "../registry.js"; import { FileSystemOps } from "../shared/filesystem/file-ops-service.js"; import { formatWriteSummary } from "../shared/filesystem/format-diff.js"; import { sandboxPolicy } from "../shared/filesystem/path-policy.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; const logger = getLogger("file-write"); @@ -29,34 +33,34 @@ function isInsidePkbRoot(absPath: string, pkbRoot: string): boolean { return normalized.startsWith(rootWithSep); } -class FileWriteTool implements Tool { - name = "file_write"; - description = - "Write content to a file on your own machine, creating it if it does not exist. Use host_file_write for files on your guardian's device instead."; - category = "filesystem"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; +export const fileWriteTool = { + name: "file_write", + description: + "Write content to a file on your own machine, creating it if it does not exist. Use host_file_write for files on your guardian's device instead.", + category: "filesystem", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Low, - input_schema = { - type: "object", - properties: { - path: { - type: "string", - description: - "The path to the file to write (absolute or relative to working directory)", - }, - content: { - type: "string", - description: "The content to write to the file", - }, - activity: { - type: "string", - description: - "Brief non-technical explanation of what you are doing and why, shown as a status update.", - }, - }, - required: ["path", "content", "activity"], - }; + input_schema: { + type: "object", + properties: { + path: { + type: "string", + description: + "The path to the file to write (absolute or relative to working directory)", + }, + content: { + type: "string", + description: "The content to write to the file", + }, + activity: { + type: "string", + description: + "Brief non-technical explanation of what you are doing and why, shown as a status update.", + }, + }, + required: ["path", "content", "activity"], + }, async execute( input: Record, @@ -144,8 +148,7 @@ class FileWriteTool implements Tool { isError: false, diff: { filePath, oldContent, newContent, isNewFile }, }; - } -} + }, +} satisfies ToolDefinition; -export const fileWriteTool = new FileWriteTool(); registerTool(fileWriteTool); diff --git a/assistant/src/tools/host-filesystem/edit.ts b/assistant/src/tools/host-filesystem/edit.ts index 45e89db1e37..224c2d94323 100644 --- a/assistant/src/tools/host-filesystem/edit.ts +++ b/assistant/src/tools/host-filesystem/edit.ts @@ -5,44 +5,48 @@ import { assistantEventHub } from "../../runtime/assistant-event-hub.js"; import { FileSystemOps } from "../shared/filesystem/file-ops-service.js"; import { formatEditDiff } from "../shared/filesystem/format-diff.js"; import { hostPolicy } from "../shared/filesystem/path-policy.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; - -class HostFileEditTool implements Tool { - name = "host_file_edit"; - description = - "Replace exact text in a file on your guardian's device with new text. For files on your own machine, use file_edit instead."; - category = "host-filesystem"; - executionTarget = "host" as const; - defaultRiskLevel = RiskLevel.Medium; - - input_schema = { - type: "object", - properties: { - path: { - type: "string", - description: "Absolute host path to the file to edit", - }, - old_string: { - type: "string", - description: "The exact text to find in the file", - }, - new_string: { - type: "string", - description: "The replacement text", - }, - replace_all: { - type: "boolean", - description: - "Replace all occurrences instead of requiring a unique match (default: false)", - }, - target_client_id: { - type: "string", - description: - "ID of the specific client to execute this on. Required when multiple clients support host_file; omit when only one is connected. Obtain IDs from `assistant clients list --capability host_file`.", - }, - }, - required: ["path", "old_string", "new_string"], - }; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; + +export const hostFileEditTool = { + name: "host_file_edit", + description: + "Replace exact text in a file on your guardian's device with new text. For files on your own machine, use file_edit instead.", + category: "host-filesystem", + executionTarget: "host", + defaultRiskLevel: RiskLevel.Medium, + + input_schema: { + type: "object", + properties: { + path: { + type: "string", + description: "Absolute host path to the file to edit", + }, + old_string: { + type: "string", + description: "The exact text to find in the file", + }, + new_string: { + type: "string", + description: "The replacement text", + }, + replace_all: { + type: "boolean", + description: + "Replace all occurrences instead of requiring a unique match (default: false)", + }, + target_client_id: { + type: "string", + description: + "ID of the specific client to execute this on. Required when multiple clients support host_file; omit when only one is connected. Obtain IDs from `assistant clients list --capability host_file`.", + }, + }, + required: ["path", "old_string", "new_string"], + }, async execute( input: Record, @@ -229,7 +233,5 @@ class HostFileEditTool implements Tool { isError: false, diff: { filePath, oldContent, newContent, isNewFile: false }, }; - } -} - -export const hostFileEditTool: Tool = new HostFileEditTool(); + }, +} satisfies ToolDefinition; diff --git a/assistant/src/tools/host-filesystem/read.ts b/assistant/src/tools/host-filesystem/read.ts index c73a6338be2..c409ab9acda 100644 --- a/assistant/src/tools/host-filesystem/read.ts +++ b/assistant/src/tools/host-filesystem/read.ts @@ -10,39 +10,43 @@ import { readImageFile, } from "../shared/filesystem/image-read.js"; import { hostPolicy } from "../shared/filesystem/path-policy.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; -class HostFileReadTool implements Tool { - name = "host_file_read"; - description = - "Read the contents of a file on your guardian's device, including images (JPEG, PNG, GIF, WebP). For files on your own machine, use file_read instead."; - category = "host-filesystem"; - executionTarget = "host" as const; - defaultRiskLevel = RiskLevel.Medium; +export const hostFileReadTool = { + name: "host_file_read", + description: + "Read the contents of a file on your guardian's device, including images (JPEG, PNG, GIF, WebP). For files on your own machine, use file_read instead.", + category: "host-filesystem", + executionTarget: "host", + defaultRiskLevel: RiskLevel.Medium, - input_schema = { - type: "object", - properties: { - path: { - type: "string", - description: "Absolute path to the host file to read", - }, - offset: { - type: "number", - description: "Line number to start reading from (1-indexed)", - }, - limit: { - type: "number", - description: "Maximum number of lines to read", - }, - target_client_id: { - type: "string", - description: - "ID of the specific client to execute this on. Required when multiple clients support host_file; omit when only one is connected. Obtain IDs from `assistant clients list --capability host_file`.", - }, - }, - required: ["path"], - }; + input_schema: { + type: "object", + properties: { + path: { + type: "string", + description: "Absolute path to the host file to read", + }, + offset: { + type: "number", + description: "Line number to start reading from (1-indexed)", + }, + limit: { + type: "number", + description: "Maximum number of lines to read", + }, + target_client_id: { + type: "string", + description: + "ID of the specific client to execute this on. Required when multiple clients support host_file; omit when only one is connected. Obtain IDs from `assistant clients list --capability host_file`.", + }, + }, + required: ["path"], + }, async execute( input: Record, @@ -183,7 +187,5 @@ class HostFileReadTool implements Tool { } return { content: result.value.content, isError: false }; - } -} - -export const hostFileReadTool: Tool = new HostFileReadTool(); + }, +} satisfies ToolDefinition; diff --git a/assistant/src/tools/host-filesystem/transfer.ts b/assistant/src/tools/host-filesystem/transfer.ts index af205739f1e..e241032629c 100644 --- a/assistant/src/tools/host-filesystem/transfer.ts +++ b/assistant/src/tools/host-filesystem/transfer.ts @@ -7,53 +7,61 @@ import { HostTransferProxy } from "../../daemon/host-transfer-proxy.js"; import { RiskLevel } from "../../permissions/types.js"; import { assistantEventHub } from "../../runtime/assistant-event-hub.js"; import { sandboxPolicy } from "../shared/filesystem/path-policy.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; -class HostFileTransferTool implements Tool { - name = "host_file_transfer"; - description = - "Copy a file between the assistant's workspace and the host machine. Set direction to 'to_host' to send a workspace file to the host, or 'to_sandbox' to pull a host file into the workspace. When multiple clients support host_file, specify which one to use with target_client_id."; - category = "host-filesystem"; - executionTarget = "host" as const; - defaultRiskLevel = RiskLevel.Medium; +export const hostFileTransferTool = { + name: "host_file_transfer", - input_schema = { - type: "object", - properties: { - source_path: { - type: "string", - description: - "Source file path. For to_host, a workspace path — relative paths resolve against the sandbox working directory; /workspace/... paths are also accepted. For to_sandbox, must be an absolute host path.", - }, - dest_path: { - type: "string", - description: - "Destination path. For to_host, must be an absolute host path. For to_sandbox, a workspace path — relative paths resolve against the sandbox working directory; /workspace/... paths are also accepted.", - }, - direction: { - type: "string", - enum: ["to_host", "to_sandbox"], - description: - "Transfer direction: 'to_host' sends a workspace file to the host, 'to_sandbox' pulls a host file into the workspace.", - }, - overwrite: { - type: "boolean", - description: - "Whether to overwrite the destination file if it already exists (default: false)", - }, - activity: { - type: "string", - description: - "Brief description of why the file is being transferred (for audit logging)", - }, - target_client_id: { - type: "string", - description: - "ID of the specific client to transfer files to/from. Required when multiple clients support host_file; omit when only one is connected. Obtain IDs from `assistant clients list --capability host_file`.", - }, - }, - required: ["source_path", "dest_path", "direction"], - }; + description: + "Copy a file between the assistant's workspace and the host machine. Set direction to 'to_host' to send a workspace file to the host, or 'to_sandbox' to pull a host file into the workspace. When multiple clients support host_file, specify which one to use with target_client_id.", + + category: "host-filesystem", + + executionTarget: "host", + + defaultRiskLevel: RiskLevel.Medium, + + input_schema: { + type: "object", + properties: { + source_path: { + type: "string", + description: + "Source file path. For to_host, a workspace path — relative paths resolve against the sandbox working directory; /workspace/... paths are also accepted. For to_sandbox, must be an absolute host path.", + }, + dest_path: { + type: "string", + description: + "Destination path. For to_host, must be an absolute host path. For to_sandbox, a workspace path — relative paths resolve against the sandbox working directory; /workspace/... paths are also accepted.", + }, + direction: { + type: "string", + enum: ["to_host", "to_sandbox"], + description: + "Transfer direction: 'to_host' sends a workspace file to the host, 'to_sandbox' pulls a host file into the workspace.", + }, + overwrite: { + type: "boolean", + description: + "Whether to overwrite the destination file if it already exists (default: false)", + }, + activity: { + type: "string", + description: + "Brief description of why the file is being transferred (for audit logging)", + }, + target_client_id: { + type: "string", + description: + "ID of the specific client to transfer files to/from. Required when multiple clients support host_file; omit when only one is connected. Obtain IDs from `assistant clients list --capability host_file`.", + }, + }, + required: ["source_path", "dest_path", "direction"], + }, async execute( input: Record, @@ -219,86 +227,91 @@ class HostFileTransferTool implements Tool { // target_client_id case is caught by the scoped guard at the top of // execute(); on macos a stale target_client_id is silently ignored // here, matching the read/write/edit pattern. - return this.executeLocal(resolvedSourcePath, resolvedDestPath, overwrite); + return executeLocal(resolvedSourcePath, resolvedDestPath, overwrite); + }, +} satisfies ToolDefinition; + +/** + * Local-mode filesystem copy. Module-level so the `host_file_transfer` + * tool can be authored as a plain {@link ToolDefinition} literal without + * losing access to this helper — the registry stores finalized literal + * references, so `this`-based method dispatch is no longer available + * on registered tools. + */ +async function executeLocal( + sourcePath: string, + destPath: string, + overwrite: boolean, +): Promise { + // Resolve symlinks on the source to ensure we read the real file. + let resolvedSource: string; + try { + resolvedSource = await realpath(sourcePath); + } catch { + return { + content: `Error: source file not found: ${sourcePath}`, + isError: true, + }; } - private async executeLocal( - sourcePath: string, - destPath: string, - overwrite: boolean, - ): Promise { - // Resolve symlinks on the source to ensure we read the real file. - let resolvedSource: string; - try { - resolvedSource = await realpath(sourcePath); - } catch { + // Verify the source is a regular file (not a directory). + try { + const stat = await lstat(resolvedSource); + if (stat.isDirectory()) { return { - content: `Error: source file not found: ${sourcePath}`, + content: `Error: source path is a directory, not a file: ${sourcePath}. To transfer a directory, archive it first (e.g. tar or zip) and transfer the archive.`, isError: true, }; } - - // Verify the source is a regular file (not a directory). - try { - const stat = await lstat(resolvedSource); - if (stat.isDirectory()) { - return { - content: `Error: source path is a directory, not a file: ${sourcePath}. To transfer a directory, archive it first (e.g. tar or zip) and transfer the archive.`, - isError: true, - }; - } - if (!stat.isFile()) { - return { - content: `Error: source path is not a regular file: ${sourcePath}`, - isError: true, - }; - } - } catch (err) { + if (!stat.isFile()) { return { - content: `Error: cannot stat source file: ${err instanceof Error ? err.message : String(err)}`, + content: `Error: source path is not a regular file: ${sourcePath}`, isError: true, }; } + } catch (err) { + return { + content: `Error: cannot stat source file: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } - // Ensure the destination parent directory exists. - try { - await mkdir(dirname(destPath), { recursive: true }); - } catch (err) { - return { - content: `Error: failed to create destination directory: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } + // Ensure the destination parent directory exists. + try { + await mkdir(dirname(destPath), { recursive: true }); + } catch (err) { + return { + content: `Error: failed to create destination directory: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } - // COPYFILE_EXCL makes the call fail atomically if dest exists, - // avoiding a TOCTOU race vs. a separate lstat check. - try { - const flags = overwrite ? 0 : constants.COPYFILE_EXCL; - await copyFile(resolvedSource, destPath, flags); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (!overwrite && msg.includes("EEXIST")) { - return { - content: `Error: destination file already exists: ${destPath}. Set overwrite to true to replace it.`, - isError: true, - }; - } - const hint = msg.includes("EACCES") - ? " (permission denied)" - : msg.includes("ENOSPC") - ? " (no space left on device)" - : ""; + // COPYFILE_EXCL makes the call fail atomically if dest exists, + // avoiding a TOCTOU race vs. a separate lstat check. + try { + const flags = overwrite ? 0 : constants.COPYFILE_EXCL; + await copyFile(resolvedSource, destPath, flags); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!overwrite && msg.includes("EEXIST")) { return { - content: `Error copying file${hint}: ${msg}`, + content: `Error: destination file already exists: ${destPath}. Set overwrite to true to replace it.`, isError: true, }; } - + const hint = msg.includes("EACCES") + ? " (permission denied)" + : msg.includes("ENOSPC") + ? " (no space left on device)" + : ""; return { - content: `Successfully copied ${sourcePath} to ${destPath}`, - isError: false, + content: `Error copying file${hint}: ${msg}`, + isError: true, }; } -} -export const hostFileTransferTool: Tool = new HostFileTransferTool(); + return { + content: `Successfully copied ${sourcePath} to ${destPath}`, + isError: false, + }; +} diff --git a/assistant/src/tools/host-filesystem/write.ts b/assistant/src/tools/host-filesystem/write.ts index 9657028053d..54f6d7c4170 100644 --- a/assistant/src/tools/host-filesystem/write.ts +++ b/assistant/src/tools/host-filesystem/write.ts @@ -5,35 +5,39 @@ import { assistantEventHub } from "../../runtime/assistant-event-hub.js"; import { FileSystemOps } from "../shared/filesystem/file-ops-service.js"; import { formatWriteSummary } from "../shared/filesystem/format-diff.js"; import { hostPolicy } from "../shared/filesystem/path-policy.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; -class HostFileWriteTool implements Tool { - name = "host_file_write"; - description = - "Write content to a file on your guardian's device, creating it if it does not exist. For files on your own machine, use file_write instead."; - category = "host-filesystem"; - executionTarget = "host" as const; - defaultRiskLevel = RiskLevel.Medium; +export const hostFileWriteTool = { + name: "host_file_write", + description: + "Write content to a file on your guardian's device, creating it if it does not exist. For files on your own machine, use file_write instead.", + category: "host-filesystem", + executionTarget: "host", + defaultRiskLevel: RiskLevel.Medium, - input_schema = { - type: "object", - properties: { - path: { - type: "string", - description: "Absolute host path to the file to write", - }, - content: { - type: "string", - description: "The content to write to the file", - }, - target_client_id: { - type: "string", - description: - "ID of the specific client to execute this on. Required when multiple clients support host_file; omit when only one is connected. Obtain IDs from `assistant clients list --capability host_file`.", - }, - }, - required: ["path", "content"], - }; + input_schema: { + type: "object", + properties: { + path: { + type: "string", + description: "Absolute host path to the file to write", + }, + content: { + type: "string", + description: "The content to write to the file", + }, + target_client_id: { + type: "string", + description: + "ID of the specific client to execute this on. Required when multiple clients support host_file; omit when only one is connected. Obtain IDs from `assistant clients list --capability host_file`.", + }, + }, + required: ["path", "content"], + }, async execute( input: Record, @@ -163,7 +167,5 @@ class HostFileWriteTool implements Tool { isError: false, diff: { filePath, oldContent, newContent, isNewFile }, }; - } -} - -export const hostFileWriteTool: Tool = new HostFileWriteTool(); + }, +} satisfies ToolDefinition; diff --git a/assistant/src/tools/host-terminal/host-shell.ts b/assistant/src/tools/host-terminal/host-shell.ts index 66814e9a74d..98c1c318232 100644 --- a/assistant/src/tools/host-terminal/host-shell.ts +++ b/assistant/src/tools/host-terminal/host-shell.ts @@ -37,7 +37,11 @@ import { } from "../background-tool-registry.js"; import { formatShellOutput } from "../shared/shell-output.js"; import { buildSanitizedEnv } from "../terminal/safe-env.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; const log = getLogger("host-shell-tool"); @@ -91,52 +95,52 @@ function buildHostBashProxyEnv( return env; } -class HostShellTool implements Tool { - name = "host_bash"; - description = - "LAST RESORT — Execute a shell command directly on the host machine. You MUST strongly prefer the regular `bash` tool for all commands. Only use `host_bash` when you are absolutely certain the command MUST run on the host machine and CANNOT run in the workspace (e.g., managing host-level system services, accessing host-only peripherals, or interacting with host paths outside the workspace). If in doubt, use `bash` instead. Approval-gated: each invocation must be explicitly approved. Do not use for commands that require injected credentials or secrets."; - category = "host-terminal"; - executionTarget = "host" as const; +export const hostShellTool = { + name: "host_bash", + description: + "LAST RESORT — Execute a shell command directly on the host machine. You MUST strongly prefer the regular `bash` tool for all commands. Only use `host_bash` when you are absolutely certain the command MUST run on the host machine and CANNOT run in the workspace (e.g., managing host-level system services, accessing host-only peripherals, or interacting with host paths outside the workspace). If in doubt, use `bash` instead. Approval-gated: each invocation must be explicitly approved. Do not use for commands that require injected credentials or secrets.", + category: "host-terminal", + executionTarget: "host", // host_bash is a weaker-tier escape hatch under CES lockdown. It remains // Medium risk by default but persistent approvals are disabled for // untrusted sessions (see execute()). - defaultRiskLevel = RiskLevel.Medium; - - input_schema = { - type: "object", - properties: { - command: { - type: "string", - description: "The host shell command to execute.", - }, - activity: { - type: "string", - description: - 'Brief non-technical explanation of what this command does and why, shown to a non-technical user in the permission prompt. Avoid jargon and technical terms. Good: "to check if a required program is installed on your computer". Bad: "to check if gcloud CLI is installed". Good: "to download a helper program". Bad: "to run npm install".', - }, - working_dir: { - type: "string", - description: - "Optional absolute host working directory (defaults to user home)", - }, - timeout_seconds: { - type: "number", - description: - "Optional timeout in seconds. Uses configured default and max limits.", - }, - background: { - type: "boolean", - description: - "Run the command in the background on the host machine. The tool returns immediately with a background tool ID. When the process exits, its output is delivered to the conversation as a wake.", - }, - target_client_id: { - type: "string", - description: - "ID of the specific client to execute this command on. Required when multiple clients support host_bash; omit when only one client is connected. Obtain IDs from `assistant clients list --capability host_bash`.", - }, - }, - required: ["command", "activity"], - }; + defaultRiskLevel: RiskLevel.Medium, + + input_schema: { + type: "object", + properties: { + command: { + type: "string", + description: "The host shell command to execute.", + }, + activity: { + type: "string", + description: + 'Brief non-technical explanation of what this command does and why, shown to a non-technical user in the permission prompt. Avoid jargon and technical terms. Good: "to check if a required program is installed on your computer". Bad: "to check if gcloud CLI is installed". Good: "to download a helper program". Bad: "to run npm install".', + }, + working_dir: { + type: "string", + description: + "Optional absolute host working directory (defaults to user home)", + }, + timeout_seconds: { + type: "number", + description: + "Optional timeout in seconds. Uses configured default and max limits.", + }, + background: { + type: "boolean", + description: + "Run the command in the background on the host machine. The tool returns immediately with a background tool ID. When the process exits, its output is delivered to the conversation as a wake.", + }, + target_client_id: { + type: "string", + description: + "ID of the specific client to execute this command on. Required when multiple clients support host_bash; omit when only one client is connected. Obtain IDs from `assistant clients list --capability host_bash`.", + }, + }, + required: ["command", "activity"], + }, async execute( input: Record, @@ -565,7 +569,5 @@ class HostShellTool implements Tool { }); }); }); - } -} - -export const hostShellTool: Tool = new HostShellTool(); + }, +} satisfies ToolDefinition; diff --git a/assistant/src/tools/memory/register.ts b/assistant/src/tools/memory/register.ts index ece503c5ea3..e1b0efe9ccd 100644 --- a/assistant/src/tools/memory/register.ts +++ b/assistant/src/tools/memory/register.ts @@ -11,17 +11,21 @@ import { } from "../../memory/graph/tools.js"; import { RiskLevel } from "../../permissions/types.js"; import { isUntrustedTrustClass } from "../../runtime/actor-trust-resolver.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; // ── remember ──────────────────────────────────────────────────────── -class RememberTool implements Tool { - name = "remember"; - description = graphRememberDefinition.description; - category = "memory"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; - input_schema = graphRememberDefinition.input_schema; +export const rememberTool = { + name: "remember", + description: graphRememberDefinition.description, + category: "memory", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Low, + input_schema: graphRememberDefinition.input_schema, async execute( input: Record, @@ -39,18 +43,18 @@ class RememberTool implements Tool { isError: !result.success, ...(typedInput.finish_turn === true ? { yieldToUser: true } : {}), }; - } -} + }, +} satisfies ToolDefinition; // ── recall ────────────────────────────────────────────────────────── -class RecallTool implements Tool { - name = "recall"; - description = graphRecallDefinition.description; - category = "memory"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; - input_schema = graphRecallDefinition.input_schema; +export const recallTool = { + name: "recall", + description: graphRecallDefinition.description, + category: "memory", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Low, + input_schema: graphRecallDefinition.input_schema, async execute( input: Record, @@ -73,10 +77,5 @@ class RecallTool implements Tool { }); return { content: result.content, isError: false }; - } -} - -// ── Exported tool instances ────────────────────────────────────────── - -export const rememberTool = new RememberTool(); -export const recallTool = new RecallTool(); + }, +} satisfies ToolDefinition; diff --git a/assistant/src/tools/network/web-fetch.ts b/assistant/src/tools/network/web-fetch.ts index 1b23b6d471b..5e2dd30bbbc 100644 --- a/assistant/src/tools/network/web-fetch.ts +++ b/assistant/src/tools/network/web-fetch.ts @@ -12,7 +12,11 @@ import { faviconUrlForDomain } from "../../util/favicon.js"; import { getLogger } from "../../util/logger.js"; import { safeStringSlice } from "../../util/unicode.js"; import { registerTool } from "../registry.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; import { extractDomain } from "./domain-normalize.js"; import { buildHostHeader, @@ -984,56 +988,55 @@ export async function executeWebFetch( } } -class WebFetchTool implements Tool { - name = "web_fetch"; - description = - "Fetch a webpage and return LLM-friendly extracted text with metadata. Use this after web_search when you need to read a specific result. To find pages on a site without guessing slugs, fetch /sitemap.xml first — it has ground-truth paths and works even when pages are JS-rendered."; - category = "network"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; - - input_schema = { - type: "object", - properties: { - url: { - type: "string", - description: - "The target webpage URL. If scheme is missing, https:// is assumed.", - }, - max_chars: { - type: "number", - description: `Maximum characters of content to return (1-${MAX_MAX_CHARS}, default ${DEFAULT_MAX_CHARS})`, - }, - start_index: { - type: "number", - description: - "Character index to start returning content from (default 0). Useful for paging large pages.", - }, - timeout_seconds: { - type: "number", - description: `Request timeout in seconds (1-${MAX_TIMEOUT_SECONDS}, default ${DEFAULT_TIMEOUT_SECONDS})`, - }, - raw: { - type: "boolean", - description: - "If true, return normalized raw response text instead of extracted plain text for HTML pages.", - }, - allow_private_network: { - type: "boolean", - description: - "If true, allows requests to localhost/private-network hosts. Disabled by default for SSRF safety.", - }, - }, - required: ["url"], - }; +export const webFetchTool = { + name: "web_fetch", + description: + "Fetch a webpage and return LLM-friendly extracted text with metadata. Use this after web_search when you need to read a specific result. To find pages on a site without guessing slugs, fetch /sitemap.xml first — it has ground-truth paths and works even when pages are JS-rendered.", + category: "network", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Low, + + input_schema: { + type: "object", + properties: { + url: { + type: "string", + description: + "The target webpage URL. If scheme is missing, https:// is assumed.", + }, + max_chars: { + type: "number", + description: `Maximum characters of content to return (1-${MAX_MAX_CHARS}, default ${DEFAULT_MAX_CHARS})`, + }, + start_index: { + type: "number", + description: + "Character index to start returning content from (default 0). Useful for paging large pages.", + }, + timeout_seconds: { + type: "number", + description: `Request timeout in seconds (1-${MAX_TIMEOUT_SECONDS}, default ${DEFAULT_TIMEOUT_SECONDS})`, + }, + raw: { + type: "boolean", + description: + "If true, return normalized raw response text instead of extracted plain text for HTML pages.", + }, + allow_private_network: { + type: "boolean", + description: + "If true, allows requests to localhost/private-network hosts. Disabled by default for SSRF safety.", + }, + }, + required: ["url"], + }, async execute( input: Record, context: ToolContext, ): Promise { return executeWebFetch(input, { signal: context.signal }); - } -} + }, +} satisfies ToolDefinition; -export const webFetchTool = new WebFetchTool(); registerTool(webFetchTool); diff --git a/assistant/src/tools/network/web-search.ts b/assistant/src/tools/network/web-search.ts index 8adbda4a245..9dc63cdd336 100644 --- a/assistant/src/tools/network/web-search.ts +++ b/assistant/src/tools/network/web-search.ts @@ -15,7 +15,11 @@ import { sleep, } from "../../util/retry.js"; import { registerTool } from "../registry.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; import { extractDomain } from "./domain-normalize.js"; import type { ManagedSearchProxyResult } from "./managed-search-proxy.js"; @@ -769,14 +773,14 @@ const WEB_SEARCH_FALLBACK_ORDER: readonly WebSearchProvider[] = Object.values( .sort((a, b) => a.fallbackOrder - b.fallbackOrder) .map((adapter) => adapter.id); -class WebSearchTool implements Tool { - name = "web_search"; - description = - "Search the web and return results. Useful for looking up current information, documentation, or anything the assistant doesn't know."; - category = "network"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; - input_schema = { +export const webSearchTool = { + name: "web_search", + description: + "Search the web and return results. Useful for looking up current information, documentation, or anything the assistant doesn't know.", + category: "network", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Low, + input_schema: { type: "object", properties: { query: { @@ -800,7 +804,7 @@ class WebSearchTool implements Tool { }, }, required: ["query"], - }; + }, async execute( input: Record, @@ -902,8 +906,7 @@ class WebSearchTool implements Tool { `Web search failed: ${msg}`, ); } - } -} + }, +} satisfies ToolDefinition; -export const webSearchTool = new WebSearchTool(); registerTool(webSearchTool); diff --git a/assistant/src/tools/registry.ts b/assistant/src/tools/registry.ts index 7889a817ee5..48d365d3a22 100644 --- a/assistant/src/tools/registry.ts +++ b/assistant/src/tools/registry.ts @@ -1,4 +1,3 @@ -import type { ToolDefinition } from "../providers/types.js"; import { getLogger } from "../util/logger.js"; import { coreAppProxyTools } from "./apps/definitions.js"; import { registerAppTools } from "./apps/registry.js"; @@ -9,7 +8,8 @@ import { hostFileWriteTool } from "./host-filesystem/write.js"; import { hostShellTool } from "./host-terminal/host-shell.js"; import { toProviderSafeToolName } from "./provider-tool-name.js"; import { registerSystemTools } from "./system/register.js"; -import type { OwnerInfo, Tool } from "./types.js"; +import { finalizeTool } from "./tool-defaults.js"; +import type { OwnerInfo, Tool, ToolDefinition } from "./types.js"; import { allUiSurfaceTools } from "./ui-surface/definitions.js"; import { registerUiSurfaceTools } from "./ui-surface/registry.js"; @@ -130,14 +130,34 @@ function withProviderSafeToolName(tool: Tool): Tool { }; } -export function registerTool(tool: Tool): void { - const existing = tools.get(tool.name); +/** + * Memoize `finalizeTool(definition, name)` by the definition reference so + * idempotent re-registration (test reset helpers, module re-imports) stays a + * silent no-op — the same `ToolDefinition` always finalizes to the same `Tool` + * instance, and the existing `existing === tool` short-circuit below keeps + * working. + */ +const finalizedByDefinition = new WeakMap(); + +export function registerTool(definition: ToolDefinition): void { + const name = definition.name; + if (typeof name !== "string" || name.length === 0) { + throw new Error( + "registerTool: tool.name is required — set it on the literal or finalize through `finalizeTool(def, name)` first", + ); + } + let tool = finalizedByDefinition.get(definition); + if (!tool) { + tool = finalizeTool(definition, name); + finalizedByDefinition.set(definition, tool); + } + const existing = tools.get(name); if (existing) { - if (existing === tool) return; // same object, skip - log.warn({ name: tool.name }, "Tool already registered, overwriting"); + if (existing === tool) return; // same definition re-registered, skip + log.warn({ name }, "Tool already registered, overwriting"); } - tools.set(tool.name, tool); - log.info({ name: tool.name, category: tool.category }, "Tool registered"); + tools.set(name, tool); + log.info({ name, category: tool.category }, "Tool registered"); } export function getTool(name: string): Tool | undefined { @@ -423,7 +443,7 @@ export function unregisterAllMcpTools(): void { * Used by the session resolver to dynamically pick up MCP tools that * were registered after session creation (e.g. via `vellum mcp reload`). */ -export function getMcpToolDefinitions(): ToolDefinition[] { +export function getMcpToolDefinitions(): Tool[] { return Array.from(tools.values()).filter( (t) => ownersByName.get(t.name)?.kind === "mcp", ); @@ -445,7 +465,7 @@ export function getSkillRefCount(skillId: string): number { return skillRefCount.get(skillId) ?? 0; } -export function getAllToolDefinitions(): ToolDefinition[] { +export function getAllToolDefinitions(): Tool[] { // Exclude skill-origin tools - they are managed by the session-level // skill projection system (projectSkillTools) and must not leak into // the base tool list, which is shared across sessions via the global @@ -522,14 +542,17 @@ export async function initializeTools(): Promise { // registered external skill tool). This handles ESM cache hits where // eager-module tools are already in the registry before init ran. if (!coreToolsSnapshot) { + // Core tool literals always set `name` (verified by `registerTool` — + // it throws on missing name). The `!` assertions reflect that + // invariant at the iteration sites. const manifestToolNames = new Set([ ...eagerModuleToolNames, - ...explicitTools.map((t: Tool) => t.name), + ...explicitTools.map((t) => t.name!), ...extEntries.map(({ tool }) => tool.name), - ...hostTools.map((t: Tool) => t.name), - ...cesTools.map((t: Tool) => t.name), - ...allUiSurfaceTools.map((t: Tool) => t.name), - ...coreAppProxyTools.map((t: Tool) => t.name), + ...hostTools.map((t) => t.name!), + ...cesTools.map((t) => t.name!), + ...allUiSurfaceTools.map((t) => t.name!), + ...coreAppProxyTools.map((t) => t.name!), ]); coreToolsSnapshot = new Map(); diff --git a/assistant/src/tools/skills/execute.ts b/assistant/src/tools/skills/execute.ts index 3bd4b38557f..0c5adc112bf 100644 --- a/assistant/src/tools/skills/execute.ts +++ b/assistant/src/tools/skills/execute.ts @@ -1,36 +1,40 @@ import { RiskLevel } from "../../permissions/types.js"; import { registerTool } from "../registry.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; -class SkillExecuteTool implements Tool { - name = "skill_execute"; - description = - "Execute a tool provided by a loaded skill. Use this instead of calling skill tools directly. The skill's instructions (from skill_load) describe available tools and their parameters. For browser automation, use the `assistant browser` CLI commands instead."; - category = "skills"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; +export const skillExecuteTool = { + name: "skill_execute", + description: + "Execute a tool provided by a loaded skill. Use this instead of calling skill tools directly. The skill's instructions (from skill_load) describe available tools and their parameters. For browser automation, use the `assistant browser` CLI commands instead.", + category: "skills", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Low, - input_schema = { + input_schema: { + type: "object", + properties: { + tool: { + type: "string", + description: + "The skill tool name to execute (e.g. 'task_create', 'deploy_run')", + }, + input: { type: "object", - properties: { - tool: { - type: "string", - description: - "The skill tool name to execute (e.g. 'task_create', 'deploy_run')", - }, - input: { - type: "object", - description: - "Tool-specific parameters as documented in the skill's instructions", - }, - activity: { - type: "string", - description: - "Brief non-technical explanation of what you are doing and why, shown as a progress update.", - }, - }, - required: ["tool", "input", "activity"], - }; + description: + "Tool-specific parameters as documented in the skill's instructions", + }, + activity: { + type: "string", + description: + "Brief non-technical explanation of what you are doing and why, shown as a progress update.", + }, + }, + required: ["tool", "input", "activity"], + }, async execute( _input: Record, @@ -41,8 +45,7 @@ class SkillExecuteTool implements Tool { "skill_execute should be intercepted at session level. If you see this error, the session dispatch is not configured.", isError: true, }; - } -} + }, +} satisfies ToolDefinition; -export const skillExecuteTool = new SkillExecuteTool(); registerTool(skillExecuteTool); diff --git a/assistant/src/tools/skills/load.ts b/assistant/src/tools/skills/load.ts index 9c5623adbb9..b93daa88477 100644 --- a/assistant/src/tools/skills/load.ts +++ b/assistant/src/tools/skills/load.ts @@ -26,7 +26,11 @@ import { computeSkillVersionHash } from "../../skills/version-hash.js"; import { getLogger } from "../../util/logger.js"; import { getWorkspaceDirDisplay } from "../../util/platform.js"; import { registerTool } from "../registry.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; /** Skill sources eligible for inline command expansion in v1. */ const INLINE_COMMAND_ELIGIBLE_SOURCES = new Set([ @@ -120,24 +124,28 @@ function formatToolSchemas( return lines.join("\n").trimEnd(); } -export class SkillLoadTool implements Tool { - name = "skill_load"; - description = - "Load full instructions for a skill. Works for both bundled skills (listed in the catalog) and custom workspace skills."; - category = "skills"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; - - input_schema = { - type: "object", - properties: { - skill: { - type: "string", - description: "The skill id or skill name to load.", - }, - }, - required: ["skill"], - }; +export const skillLoadTool = { + name: "skill_load", + + description: + "Load full instructions for a skill. Works for both bundled skills (listed in the catalog) and custom workspace skills.", + + category: "skills", + + executionTarget: "sandbox", + + defaultRiskLevel: RiskLevel.Low, + + input_schema: { + type: "object", + properties: { + skill: { + type: "string", + description: "The skill id or skill name to load.", + }, + }, + required: ["skill"], + }, async execute( input: Record, @@ -516,8 +524,6 @@ export class SkillLoadTool implements Tool { ].join("\n"), isError: false, }; - } -} - -export const skillLoadTool = new SkillLoadTool(); + }, +} satisfies ToolDefinition; registerTool(skillLoadTool); diff --git a/assistant/src/tools/subagent/notify-parent.ts b/assistant/src/tools/subagent/notify-parent.ts index bda67c24c94..05c50153f10 100644 --- a/assistant/src/tools/subagent/notify-parent.ts +++ b/assistant/src/tools/subagent/notify-parent.ts @@ -1,7 +1,11 @@ import { RiskLevel } from "../../permissions/types.js"; import { getSubagentManager } from "../../subagent/index.js"; import { registerTool } from "../registry.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; export async function executeSubagentNotifyParent( input: Record, @@ -31,43 +35,42 @@ export async function executeSubagentNotifyParent( }; } -class NotifyParentTool implements Tool { - name = "notify_parent"; - description = - "Send a notification to the parent conversation. Use this for important findings, when you're blocked, or when you have preliminary results the parent should know about. Do not overuse — notify for significant findings, not after every tool call."; - category = "orchestration"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; +export const notifyParentTool = { + name: "notify_parent", + description: + "Send a notification to the parent conversation. Use this for important findings, when you're blocked, or when you have preliminary results the parent should know about. Do not overuse — notify for significant findings, not after every tool call.", + category: "orchestration", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Low, - input_schema = { - type: "object", - properties: { - message: { - type: "string", - description: "The notification content for the parent.", - }, - urgency: { - type: "string", - enum: ["info", "important", "blocked"], - description: - "'info' for progress updates, 'important' for key findings, 'blocked' when you need guidance.", - }, - activity: { - type: "string", - description: - "Brief non-technical explanation of what you are doing and why, shown as a status update.", - }, - }, - required: ["message", "activity"], - }; + input_schema: { + type: "object", + properties: { + message: { + type: "string", + description: "The notification content for the parent.", + }, + urgency: { + type: "string", + enum: ["info", "important", "blocked"], + description: + "'info' for progress updates, 'important' for key findings, 'blocked' when you need guidance.", + }, + activity: { + type: "string", + description: + "Brief non-technical explanation of what you are doing and why, shown as a status update.", + }, + }, + required: ["message", "activity"], + }, async execute( input: Record, context: ToolContext, ): Promise { return executeSubagentNotifyParent(input, context); - } -} + }, +} satisfies ToolDefinition; -export const notifyParentTool = new NotifyParentTool(); registerTool(notifyParentTool); diff --git a/assistant/src/tools/system/request-permission.ts b/assistant/src/tools/system/request-permission.ts index f1423ac0e7f..96145fb3c7b 100644 --- a/assistant/src/tools/system/request-permission.ts +++ b/assistant/src/tools/system/request-permission.ts @@ -1,6 +1,10 @@ import { RiskLevel } from "../../permissions/types.js"; import { registerTool } from "../registry.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; const PERMISSION_TYPES = [ "full_disk_access", @@ -49,32 +53,32 @@ const FRIENDLY_NAMES: Record = { camera: "Camera", }; -class RequestSystemPermissionTool implements Tool { - name = "request_system_permission"; - description = +export const requestSystemPermissionTool = { + name: "request_system_permission", + description: "Request a macOS system permission via System Settings. " + "Use when a tool fails with a permission/access error (e.g. 'Operation not permitted', 'EACCES', sandbox denial). " + - "Do not explain how to open System Settings manually - this tool handles it with a clickable button."; - category = "system"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.High; + "Do not explain how to open System Settings manually - this tool handles it with a clickable button.", + category: "system", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.High, - input_schema = { - type: "object", - properties: { - permission_type: { - type: "string", - enum: [...PERMISSION_TYPES], - description: "The macOS system permission to request", - }, - activity: { - type: "string", - description: - "Short explanation of why this permission is needed (shown in the prompt)", - }, - }, - required: ["permission_type", "activity"], - }; + input_schema: { + type: "object", + properties: { + permission_type: { + type: "string", + enum: [...PERMISSION_TYPES], + description: "The macOS system permission to request", + }, + activity: { + type: "string", + description: + "Short explanation of why this permission is needed (shown in the prompt)", + }, + }, + required: ["permission_type", "activity"], + }, async execute( input: Record, @@ -102,8 +106,7 @@ class RequestSystemPermissionTool implements Tool { ].join("\n"), isError: false, }; - } -} + }, +} satisfies ToolDefinition; -export const requestSystemPermissionTool = new RequestSystemPermissionTool(); registerTool(requestSystemPermissionTool); diff --git a/assistant/src/tools/terminal/shell.ts b/assistant/src/tools/terminal/shell.ts index 56cda88dbd5..58536d76f1d 100644 --- a/assistant/src/tools/terminal/shell.ts +++ b/assistant/src/tools/terminal/shell.ts @@ -27,8 +27,8 @@ import { registerTool } from "../registry.js"; import { formatShellOutput } from "../shared/shell-output.js"; import type { ProxyEnvVars, - Tool, ToolContext, + ToolDefinition, ToolExecutionResult, } from "../types.js"; import { buildSanitizedEnv } from "./safe-env.js"; @@ -44,14 +44,14 @@ function buildCredentialRefTrace( const log = getLogger("shell-tool"); -class ShellTool implements Tool { - name = "bash"; - description = "Execute a shell command on the local machine"; - category = "terminal"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Medium; +export const shellTool = { + name: "bash", + description: "Execute a shell command on the local machine", + category: "terminal", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Medium, - input_schema = { + input_schema: { type: "object", properties: { command: { @@ -87,7 +87,7 @@ class ShellTool implements Tool { }, }, required: ["command", "activity"], - }; + }, async execute( input: Record, @@ -558,8 +558,8 @@ class ShellTool implements Tool { }); return result; - } -} + }, +} satisfies ToolDefinition; /** * Structured teardown log. Pairs with the `"Executing shell command"` @@ -652,5 +652,4 @@ function buildKillTree( }; } -export const shellTool: Tool = new ShellTool(); registerTool(shellTool); diff --git a/assistant/src/tools/tool-defaults.ts b/assistant/src/tools/tool-defaults.ts index 01b04269336..4c0df52a7a2 100644 --- a/assistant/src/tools/tool-defaults.ts +++ b/assistant/src/tools/tool-defaults.ts @@ -7,7 +7,7 @@ * accepts loose `ToolDefinition` objects from authors must run them * through `finalizeTool` before handing the result to a `registerXxxTools` * call. The registry types make this a hard rule: every registered tool - * is a `Tool` (`Required & { name }`). + * is a `Tool` (`Required`). */ import type { @@ -55,7 +55,8 @@ export const TOOL_DEFAULTS = Object.freeze({ /** * Fill the five normally-required `ToolDefinition` fields with documented * defaults when the author omitted them, attach the registration-time - * `name`, and return a `Tool` that is safe to hand to a + * `name` (preferring an explicit override on the literal over the + * file-derived default), and return a `Tool` that is safe to hand to a * `registerXxxTools` call. * * The default `execute` returns an error result so the model sees a clear @@ -65,8 +66,9 @@ export const TOOL_DEFAULTS = Object.freeze({ */ export function finalizeTool( tool: ToolDefinition, - name: string, + defaultName: string, ): Tool { + const name = tool.name ?? defaultName; const description = typeof tool.description === "string" ? tool.description diff --git a/assistant/src/tools/tool-manifest.ts b/assistant/src/tools/tool-manifest.ts index 1acf27a298c..caace237ce2 100644 --- a/assistant/src/tools/tool-manifest.ts +++ b/assistant/src/tools/tool-manifest.ts @@ -28,7 +28,7 @@ import { skillLoadTool } from "./skills/load.js"; import { notifyParentTool } from "./subagent/notify-parent.js"; import { requestSystemPermissionTool } from "./system/request-permission.js"; import { shellTool } from "./terminal/shell.js"; -import type { Tool } from "./types.js"; +import type { ToolDefinition } from "./types.js"; // ── Eager side-effect modules ─────────────────────────────────────── // These static imports trigger top-level `registerTool()` side effects on @@ -73,7 +73,7 @@ export const eagerModuleToolNames: string[] = [ // This includes both previously-eager tools (referenced here so they survive // a test registry reset) and tools that have always been explicit. -export const explicitTools: Tool[] = [ +export const explicitTools: ToolDefinition[] = [ // Previously-eager tools - kept here so initializeTools() can re-register // them after __resetRegistryForTesting() clears the registry (ESM caching // prevents their side-effect registrations from re-running). @@ -108,7 +108,7 @@ export const explicitTools: Tool[] = [ // initializeTools() in registry.ts can conditionally include them. /** All CES tools - stable references for the manifest snapshot. */ -export const cesTools: Tool[] = [ +export const cesTools: ToolDefinition[] = [ makeAuthenticatedRequestTool, runAuthenticatedCommandTool, manageSecureCommandTool, @@ -119,7 +119,7 @@ export const cesTools: Tool[] = [ * Returns an empty array when the flag is disabled so callers can * unconditionally iterate the result. */ -export function getCesToolsIfEnabled(): Tool[] { +export function getCesToolsIfEnabled(): ToolDefinition[] { try { const config = getConfig(); if (isCesToolsEnabled(config)) { diff --git a/assistant/src/tools/types.ts b/assistant/src/tools/types.ts index 75c0db7b4e2..c79d14829b7 100644 --- a/assistant/src/tools/types.ts +++ b/assistant/src/tools/types.ts @@ -3,13 +3,14 @@ import type { DiffInfo, ExecutionTarget, ProxyApprovalCallback, - RiskLevel, SensitiveOutputBinding, ToolExecutionErrorEvent, ToolExecutionStartEvent, ToolPermissionDeniedEvent, ToolPermissionPromptEvent, } from "@vellumai/skill-host-contracts"; +import { RiskLevel } from "@vellumai/skill-host-contracts"; +import { z } from "zod"; import type { InterfaceId } from "../channels/types.js"; import type { CesClient } from "../credential-execution/client.js"; @@ -317,32 +318,70 @@ export interface ToolContext { } /** - * Author-facing tool spec — re-exported from `@vellumai/plugin-api`. - * Loaders fill documented defaults for omitted fields via `finalizeTool` - * in `tool-defaults.ts`. + * Schema describing the serializable shape of a {@link ToolDefinition}. + * All fields are optional — loaders fill documented defaults for omitted + * fields via `finalizeTool` in `tool-defaults.ts`. The IPC layer parses + * incoming skill tools against this same schema and re-finalizes them + * locally, so author shape and wire shape are one schema. + * + * `execute` is intentionally absent from the schema (closures cannot + * cross IPC). It is added back as a TypeScript overlay on + * {@link ToolDefinition} so author literals can still set it. */ -export interface ToolDefinition { +export const ToolDefinitionSchema = z.object({ + /** + * Name the model sees when calling this tool. Loaders default to the + * source file basename (e.g. `tools/read.ts` → `read`) when omitted, + * so the literal only needs to set this when overriding the + * file-derived name. + */ + name: z.string().min(1).optional(), /** Human-readable description shown to the model in the tool catalog. */ - description?: string; + description: z.string().optional(), + /** JSON schema describing the tool's input arguments. */ + input_schema: z.record(z.string(), z.unknown()).optional(), /** Author-asserted risk band — low / medium / high. Drives default permission gating. */ - defaultRiskLevel?: RiskLevel; + defaultRiskLevel: z.enum(RiskLevel).optional(), + /** Tool category used for Slack channel `allowedToolCategories` enforcement. */ + category: z.string().min(1).optional(), + /** Where the tool runs — sandbox (assistant container) or host (guardian device via proxy). Resolved by `resolveExecutionTarget` if omitted. */ + executionTarget: z.enum(["sandbox", "host"]).optional(), +}); + +/** + * Author-facing tool spec — re-exported from `@vellumai/plugin-api`. + * Loaders fill documented defaults for omitted fields via `finalizeTool` + * in `tool-defaults.ts`. Type is `z.infer` + * (serializable fields) plus overlays: + * - `input_schema` is widened from `Record` (the + * parsed wire shape) to `object`, so authors can assign a typed + * JSON-schema literal without `as Record<...>` gymnastics. + * - `execute` is optional because some `ToolDefinition` instances are + * schema-only (e.g. {@link ../memory/graph/tools.graphRememberDefinition}, + * {@link ../messaging/style-analyzer.storeStyleAnalysisTool}, + * {@link ../memory/v2/sweep-job.SWEEP_TOOL}) — handed to providers as + * a function-calling schema without ever being registered for + * execution. Closures also can't cross IPC, so the wire path drops + * it and `finalizeTool` synthesizes a no-op error closure on arrival. + * Tool sources use `satisfies ToolDefinition` (not `: ToolDefinition`) + * so the inferred export type preserves `execute` as required at + * call sites that statically import the literal. + */ +export type ToolDefinition = Omit< + z.infer, + "input_schema" +> & { /** JSON schema describing the tool's input arguments. */ input_schema?: object; - /** Where the tool runs — sandbox (assistant container) or host (guardian device via proxy). Resolved by `resolveExecutionTarget` if omitted. */ - executionTarget?: ExecutionTarget; /** Implementation invoked when the model calls the tool. */ execute?: ( input: Record, context: ToolContext, ) => Promise; - /** Tool category used for Slack channel `allowedToolCategories` enforcement. */ - category?: string; -} +}; /** Tool after the loader has derived its name and filled defaults. */ -export type Tool = Required & { - name: string; -}; +export type Tool = Required; /** The kind of extension that owns a tool. Core tools have no owner. */ export type OwnerKind = "skill" | "mcp" | "plugin"; diff --git a/assistant/src/tools/ui-surface/definitions.ts b/assistant/src/tools/ui-surface/definitions.ts index d06393d8a9a..e4649c70596 100644 --- a/assistant/src/tools/ui-surface/definitions.ts +++ b/assistant/src/tools/ui-surface/definitions.ts @@ -8,7 +8,11 @@ */ import { RiskLevel } from "../../permissions/types.js"; -import type { Tool, ToolContext, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; // --------------------------------------------------------------------------- // Helpers @@ -39,7 +43,7 @@ function proxyExecute(toolName: string) { // ui_show // --------------------------------------------------------------------------- -export const uiShowTool: Tool = { +export const uiShowTool = { name: "ui_show", description: "Surface structured data or UI in the conversation. For long-form writing use the document skill; for interactive apps use the app-builder skill.\n\n" + @@ -121,13 +125,13 @@ export const uiShowTool: Tool = { }, execute: proxyExecute("ui_show"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // ui_update // --------------------------------------------------------------------------- -const uiUpdateTool: Tool = { +const uiUpdateTool = { name: "ui_update", description: "Update an existing surface's data. The provided data object is merged into the surface's current data.\n" + @@ -152,13 +156,13 @@ const uiUpdateTool: Tool = { }, execute: proxyExecute("ui_update"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // ui_dismiss // --------------------------------------------------------------------------- -const uiDismissTool: Tool = { +const uiDismissTool = { name: "ui_dismiss", description: "Dismiss a currently displayed surface.", category: "ui-surface", @@ -177,9 +181,9 @@ const uiDismissTool: Tool = { }, execute: proxyExecute("ui_dismiss"), -}; +} satisfies ToolDefinition; -export const allUiSurfaceTools: Tool[] = [ +export const allUiSurfaceTools: ToolDefinition[] = [ uiShowTool, uiUpdateTool, uiDismissTool,