From c1b601da92ee3cb996a93b2ac1fa0f79710e3cdc Mon Sep 17 00:00:00 2001 From: "vellum-apollo-bot[bot]" <242025090+vellum-apollo-bot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 21:54:31 +0000 Subject: [PATCH 01/10] refactor(tools): move name to ToolDefinition, delete ToolManifest, retype core tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the ToolDefinition refactor cycle started by #32362 (executionMode) and #32472 (category) — every tool author writes a `ToolDefinition` literal, the registry finalizes it into a `Tool`. ## name → ToolDefinition (PR #32472 follow-up review comment) `name?: string` is now an optional field on `ToolDefinition` with a JSDoc note that loaders default it to the source file basename (`tools/read.ts` → `read`). `Tool` collapses from `Required & { name: string }` to bare `Required` since name is now part of the base shape. `finalizeTool(tool, defaultName)` now prefers `tool.name` over the file-derived default, so a literal that overrides the file name still wins. ## Delete ToolManifest The Zod-derived `ToolManifest` type in `ipc/skill-routes/registries.ts` was a near-duplicate of `ToolDefinition` minus `execute` (functions don't cross IPC). Replaced with `ToolDefinition` directly; renamed the schema to `WireToolDefinitionSchema` to reflect that it's the wire form of the canonical type. `buildProxyTool(definition: ToolDefinition)` uses `!` assertions on fields the Zod schema guarantees are set. ## Core tools as ToolDefinition Every core tool literal — 22 `class XxxTool implements ToolDefinition` sites plus 14 `: ToolDefinition = { ... }` object literals across `apps/`, `ui-surface/`, `computer-use/` definitions — now uses the relaxed authoring contract. Aggregate lists (`explicitTools`, `cesTools`, `coreAppProxyTools`, `allUiSurfaceTools`) are typed `ToolDefinition[]`. `registerTool` widens to accept `ToolDefinition` and finalizes internally via a `WeakMap` so idempotent re-registration (test reset helpers, ESM module re-imports) stays the same silent no-op the old `existing === tool` check provided. Throws if `tool.name` is missing — the file-basename default is a loader concern, not a registry concern. ## Verified locally - `eslint src/tools src/ipc/skill-routes/registries.ts` — clean. - `bun test plugin-bootstrap plugin-tool-contribution plugin-types registry skill-tool-manifest tool-executor tool-approval-handler conversation-skill-tools conversation-app-control-instantiation skill-projection-feature-flag` — 263/263 pass. - Broader `bun test src/tools/` reproduces a 410/489 pass baseline identical to `origin/main` HEAD — no new regressions. --- assistant/src/ipc/skill-routes/registries.ts | 47 ++++++++++-------- assistant/src/tools/apps/definitions.ts | 6 +-- .../tools/ask-question/ask-question-tool.ts | 4 +- .../src/tools/computer-use/definitions.ts | 26 +++++----- .../make-authenticated-request.ts | 4 +- .../manage-secure-command-tool.ts | 4 +- .../run-authenticated-command.ts | 4 +- assistant/src/tools/credentials/vault.ts | 4 +- assistant/src/tools/filesystem/edit.ts | 4 +- assistant/src/tools/filesystem/list.ts | 4 +- assistant/src/tools/filesystem/read.ts | 4 +- assistant/src/tools/filesystem/write.ts | 4 +- assistant/src/tools/host-filesystem/edit.ts | 6 +-- assistant/src/tools/host-filesystem/read.ts | 6 +-- .../src/tools/host-filesystem/transfer.ts | 6 +-- assistant/src/tools/host-filesystem/write.ts | 6 +-- .../src/tools/host-terminal/host-shell.ts | 6 +-- assistant/src/tools/memory/register.ts | 6 +-- assistant/src/tools/network/web-fetch.ts | 4 +- assistant/src/tools/network/web-search.ts | 4 +- assistant/src/tools/registry.ts | 49 ++++++++++++++----- assistant/src/tools/skills/execute.ts | 4 +- assistant/src/tools/skills/load.ts | 4 +- assistant/src/tools/subagent/notify-parent.ts | 4 +- .../src/tools/system/request-permission.ts | 4 +- assistant/src/tools/terminal/shell.ts | 6 +-- assistant/src/tools/tool-defaults.ts | 8 +-- assistant/src/tools/tool-manifest.ts | 8 +-- assistant/src/tools/types.ts | 11 +++-- assistant/src/tools/ui-surface/definitions.ts | 10 ++-- 30 files changed, 152 insertions(+), 115 deletions(-) diff --git a/assistant/src/ipc/skill-routes/registries.ts b/assistant/src/ipc/skill-routes/registries.ts index 2b3e0c7d4de..d7b6a2b30d1 100644 --- a/assistant/src/ipc/skill-routes/registries.ts +++ b/assistant/src/ipc/skill-routes/registries.ts @@ -24,7 +24,11 @@ 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 type { + ExecutionTarget, + Tool, + ToolDefinition, +} from "../../tools/types.js"; import { RiskLevel } from "../../tools/types.js"; import { getLogger } from "../../util/logger.js"; import type { SkillIpcRoute } from "../skill-ipc-types.js"; @@ -35,13 +39,13 @@ 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. + * Wire form of a {@link ToolDefinition} sent over IPC by a skill process. + * Identical structurally to {@link ToolDefinition} except `execute` is + * dropped (a closure cannot cross the socket) — the daemon synthesizes + * an `execute` that forwards invocations back over IPC; see + * {@link buildProxyTool}. */ -const ToolManifestSchema = z.object({ +const WireToolDefinitionSchema = z.object({ name: z.string().min(1), description: z.string(), input_schema: z.record(z.string(), z.unknown()), @@ -50,8 +54,6 @@ const ToolManifestSchema = z.object({ 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 +63,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(WireToolDefinitionSchema).min(1), }); const RegisterSkillRouteParams = z.object({ @@ -179,25 +181,30 @@ export function __getActiveSessionCountForTesting(): number { * proxy in the registry so the rest of the tool-manifest plumbing can be * exercised end-to-end. */ -function buildProxyTool(manifest: ToolManifest): Tool { +function buildProxyTool(definition: ToolDefinition): Tool { + // The Zod schema (`WireToolDefinitionSchema`) requires name, description, + // input_schema, defaultRiskLevel, and category — `definition` arrives via + // that parse, so the `!` assertions reflect the runtime invariant while + // matching the relaxed `ToolDefinition` type contract. // RiskLevel is a string enum whose values are "low" | "medium" | "high", // matching the schema above exactly — the cast is a no-op at runtime. + const name = definition.name!; return { - name: manifest.name, - description: manifest.description, - input_schema: manifest.input_schema as object, - category: manifest.category, - defaultRiskLevel: manifest.defaultRiskLevel as RiskLevel, + name, + description: definition.description!, + input_schema: definition.input_schema as object, + category: definition.category!, + defaultRiskLevel: definition.defaultRiskLevel as RiskLevel, executionTarget: resolveExecutionTarget({ - name: manifest.name, - executionTarget: manifest.executionTarget as ExecutionTarget | undefined, + name, + executionTarget: definition.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. + // definition's dispatching execute closure on the production path. throw new Error( - `Skill tool "${manifest.name}" invocation requires an attached MeetHostSupervisor`, + `Skill tool "${name}" invocation requires an attached MeetHostSupervisor`, ); }, }; diff --git a/assistant/src/tools/apps/definitions.ts b/assistant/src/tools/apps/definitions.ts index 7b6058df426..52a9b9f30a4 100644 --- a/assistant/src/tools/apps/definitions.ts +++ b/assistant/src/tools/apps/definitions.ts @@ -9,7 +9,7 @@ */ 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 +40,7 @@ function proxyExecute(toolName: string) { // app_open // --------------------------------------------------------------------------- -const appOpenTool: Tool = { +const appOpenTool: ToolDefinition = { name: "app_open", description: "Open a persistent app in a dynamic_page surface on the connected client.", @@ -72,4 +72,4 @@ const appOpenTool: Tool = { // 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.ts b/assistant/src/tools/ask-question/ask-question-tool.ts index 64e0b6d5664..409a90ecf1f 100644 --- a/assistant/src/tools/ask-question/ask-question-tool.ts +++ b/assistant/src/tools/ask-question/ask-question-tool.ts @@ -3,7 +3,7 @@ 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,7 +132,7 @@ const OPTION_ITEMS_SCHEMA = { // ── Tool ──────────────────────────────────────────────────────────── -export class AskQuestionTool implements Tool { +export class AskQuestionTool implements ToolDefinition { name = "ask_question"; description = DESCRIPTION; category = "interaction"; diff --git a/assistant/src/tools/computer-use/definitions.ts b/assistant/src/tools/computer-use/definitions.ts index e100313951c..593adac0975 100644 --- a/assistant/src/tools/computer-use/definitions.ts +++ b/assistant/src/tools/computer-use/definitions.ts @@ -8,7 +8,7 @@ */ 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 +39,7 @@ function proxyExecute(toolName: string) { // click (unified - click_type selects single / double / right) // --------------------------------------------------------------------------- -export const computerUseClickTool: Tool = { +export const computerUseClickTool: ToolDefinition = { name: "computer_use_click", description: "Click an element on screen. Prefer element_id (from the accessibility tree) over x/y coordinates.", @@ -89,7 +89,7 @@ export const computerUseClickTool: Tool = { // type_text // --------------------------------------------------------------------------- -export const computerUseTypeTextTool: Tool = { +export const computerUseTypeTextTool: ToolDefinition = { 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.", @@ -124,7 +124,7 @@ export const computerUseTypeTextTool: Tool = { // key // --------------------------------------------------------------------------- -export const computerUseKeyTool: Tool = { +export const computerUseKeyTool: ToolDefinition = { 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", @@ -159,7 +159,7 @@ export const computerUseKeyTool: Tool = { // scroll // --------------------------------------------------------------------------- -export const computerUseScrollTool: Tool = { +export const computerUseScrollTool: ToolDefinition = { name: "computer_use_scroll", description: "Scroll within an element by its [ID], or at raw screen coordinates as fallback.", @@ -212,7 +212,7 @@ export const computerUseScrollTool: Tool = { // drag // --------------------------------------------------------------------------- -export const computerUseDragTool: Tool = { +export const computerUseDragTool: ToolDefinition = { name: "computer_use_drag", description: "Drag from one element or position to another. Use for moving files, resizing windows, rearranging items, or adjusting sliders.", @@ -270,7 +270,7 @@ export const computerUseDragTool: Tool = { // wait // --------------------------------------------------------------------------- -export const computerUseWaitTool: Tool = { +export const computerUseWaitTool: ToolDefinition = { name: "computer_use_wait", description: "Wait for the UI to update", category: "computer-use", @@ -304,7 +304,7 @@ export const computerUseWaitTool: Tool = { // open_app // --------------------------------------------------------------------------- -export const computerUseOpenAppTool: Tool = { +export const computerUseOpenAppTool: ToolDefinition = { 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.", @@ -341,7 +341,7 @@ export const computerUseOpenAppTool: Tool = { // run_applescript // --------------------------------------------------------------------------- -export const computerUseRunAppleScriptTool: Tool = { +export const computerUseRunAppleScriptTool: ToolDefinition = { 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).", @@ -377,7 +377,7 @@ export const computerUseRunAppleScriptTool: Tool = { // done // --------------------------------------------------------------------------- -export const computerUseDoneTool: Tool = { +export const computerUseDoneTool: ToolDefinition = { 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.", @@ -403,7 +403,7 @@ export const computerUseDoneTool: Tool = { // respond // --------------------------------------------------------------------------- -export const computerUseRespondTool: Tool = { +export const computerUseRespondTool: ToolDefinition = { 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.", @@ -433,7 +433,7 @@ export const computerUseRespondTool: Tool = { // observe // --------------------------------------------------------------------------- -const computerUseObserveTool: Tool = { +const computerUseObserveTool: ToolDefinition = { 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.", @@ -454,7 +454,7 @@ const computerUseObserveTool: Tool = { // 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..ace5bdc3cb3 100644 --- a/assistant/src/tools/credential-execution/make-authenticated-request.ts +++ b/assistant/src/tools/credential-execution/make-authenticated-request.ts @@ -14,11 +14,11 @@ import { GrantProposalSchema, renderProposal } from "@vellumai/service-contracts 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 { +class MakeAuthenticatedRequestTool implements ToolDefinition { 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."; 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..ea20dd579de 100644 --- a/assistant/src/tools/credential-execution/run-authenticated-command.ts +++ b/assistant/src/tools/credential-execution/run-authenticated-command.ts @@ -14,11 +14,11 @@ import { GrantProposalSchema, renderProposal } from "@vellumai/service-contracts 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 { +class RunAuthenticatedCommandTool implements ToolDefinition { 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."; diff --git a/assistant/src/tools/credentials/vault.ts b/assistant/src/tools/credentials/vault.ts index 4cecfec746b..9e024ace9f1 100644 --- a/assistant/src/tools/credentials/vault.ts +++ b/assistant/src/tools/credentials/vault.ts @@ -17,7 +17,7 @@ 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,7 +69,7 @@ function formatSlackChannelStatus(result: SlackChannelConfigResult): string { return ""; } -class CredentialStoreTool implements Tool { +class CredentialStoreTool implements ToolDefinition { name = "credential_store"; description = "Store, list, delete, or prompt for credentials in the secure vault"; diff --git a/assistant/src/tools/filesystem/edit.ts b/assistant/src/tools/filesystem/edit.ts index bcf4724a98b..5fc82fa61bc 100644 --- a/assistant/src/tools/filesystem/edit.ts +++ b/assistant/src/tools/filesystem/edit.ts @@ -3,9 +3,9 @@ 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"; +import type { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; -class FileEditTool implements Tool { +class FileEditTool implements ToolDefinition { 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."; diff --git a/assistant/src/tools/filesystem/list.ts b/assistant/src/tools/filesystem/list.ts index 2b457eef46e..4cd8a65f920 100644 --- a/assistant/src/tools/filesystem/list.ts +++ b/assistant/src/tools/filesystem/list.ts @@ -2,9 +2,9 @@ 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 { +class FileListTool implements ToolDefinition { name = "file_list"; description = "List the contents of a directory on your own machine. Returns file and subdirectory names with type indicators and sizes."; diff --git a/assistant/src/tools/filesystem/read.ts b/assistant/src/tools/filesystem/read.ts index 16c6ac7f251..790026645aa 100644 --- a/assistant/src/tools/filesystem/read.ts +++ b/assistant/src/tools/filesystem/read.ts @@ -8,9 +8,9 @@ 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 { +class FileReadTool implements ToolDefinition { 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."; diff --git a/assistant/src/tools/filesystem/write.ts b/assistant/src/tools/filesystem/write.ts index 0ac451bab13..2de7d403db8 100644 --- a/assistant/src/tools/filesystem/write.ts +++ b/assistant/src/tools/filesystem/write.ts @@ -9,7 +9,7 @@ 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,7 +29,7 @@ function isInsidePkbRoot(absPath: string, pkbRoot: string): boolean { return normalized.startsWith(rootWithSep); } -class FileWriteTool implements Tool { +class FileWriteTool implements ToolDefinition { 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."; diff --git a/assistant/src/tools/host-filesystem/edit.ts b/assistant/src/tools/host-filesystem/edit.ts index 45e89db1e37..0c0a919d878 100644 --- a/assistant/src/tools/host-filesystem/edit.ts +++ b/assistant/src/tools/host-filesystem/edit.ts @@ -5,9 +5,9 @@ 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"; +import type { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; -class HostFileEditTool implements Tool { +class HostFileEditTool implements ToolDefinition { 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."; @@ -232,4 +232,4 @@ class HostFileEditTool implements Tool { } } -export const hostFileEditTool: Tool = new HostFileEditTool(); +export const hostFileEditTool: ToolDefinition = new HostFileEditTool(); diff --git a/assistant/src/tools/host-filesystem/read.ts b/assistant/src/tools/host-filesystem/read.ts index c73a6338be2..5752e144d62 100644 --- a/assistant/src/tools/host-filesystem/read.ts +++ b/assistant/src/tools/host-filesystem/read.ts @@ -10,9 +10,9 @@ 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 { +class HostFileReadTool implements ToolDefinition { 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."; @@ -186,4 +186,4 @@ class HostFileReadTool implements Tool { } } -export const hostFileReadTool: Tool = new HostFileReadTool(); +export const hostFileReadTool: ToolDefinition = new HostFileReadTool(); diff --git a/assistant/src/tools/host-filesystem/transfer.ts b/assistant/src/tools/host-filesystem/transfer.ts index af205739f1e..bbbad192845 100644 --- a/assistant/src/tools/host-filesystem/transfer.ts +++ b/assistant/src/tools/host-filesystem/transfer.ts @@ -7,9 +7,9 @@ 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 { +class HostFileTransferTool implements ToolDefinition { 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."; @@ -301,4 +301,4 @@ class HostFileTransferTool implements Tool { } } -export const hostFileTransferTool: Tool = new HostFileTransferTool(); +export const hostFileTransferTool: ToolDefinition = new HostFileTransferTool(); diff --git a/assistant/src/tools/host-filesystem/write.ts b/assistant/src/tools/host-filesystem/write.ts index 9657028053d..68093b7f117 100644 --- a/assistant/src/tools/host-filesystem/write.ts +++ b/assistant/src/tools/host-filesystem/write.ts @@ -5,9 +5,9 @@ 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 { +class HostFileWriteTool implements ToolDefinition { 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."; @@ -166,4 +166,4 @@ class HostFileWriteTool implements Tool { } } -export const hostFileWriteTool: Tool = new HostFileWriteTool(); +export const hostFileWriteTool: ToolDefinition = new HostFileWriteTool(); diff --git a/assistant/src/tools/host-terminal/host-shell.ts b/assistant/src/tools/host-terminal/host-shell.ts index 66814e9a74d..2740f26ed09 100644 --- a/assistant/src/tools/host-terminal/host-shell.ts +++ b/assistant/src/tools/host-terminal/host-shell.ts @@ -37,7 +37,7 @@ 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,7 +91,7 @@ function buildHostBashProxyEnv( return env; } -class HostShellTool implements Tool { +class HostShellTool implements ToolDefinition { 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."; @@ -568,4 +568,4 @@ class HostShellTool implements Tool { } } -export const hostShellTool: Tool = new HostShellTool(); +export const hostShellTool: ToolDefinition = new HostShellTool(); diff --git a/assistant/src/tools/memory/register.ts b/assistant/src/tools/memory/register.ts index ece503c5ea3..12fed2c25d6 100644 --- a/assistant/src/tools/memory/register.ts +++ b/assistant/src/tools/memory/register.ts @@ -11,11 +11,11 @@ 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 { +class RememberTool implements ToolDefinition { name = "remember"; description = graphRememberDefinition.description; category = "memory"; @@ -44,7 +44,7 @@ class RememberTool implements Tool { // ── recall ────────────────────────────────────────────────────────── -class RecallTool implements Tool { +class RecallTool implements ToolDefinition { name = "recall"; description = graphRecallDefinition.description; category = "memory"; diff --git a/assistant/src/tools/network/web-fetch.ts b/assistant/src/tools/network/web-fetch.ts index 1b23b6d471b..7b024f5a6af 100644 --- a/assistant/src/tools/network/web-fetch.ts +++ b/assistant/src/tools/network/web-fetch.ts @@ -12,7 +12,7 @@ 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,7 +984,7 @@ export async function executeWebFetch( } } -class WebFetchTool implements Tool { +class WebFetchTool implements ToolDefinition { 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."; diff --git a/assistant/src/tools/network/web-search.ts b/assistant/src/tools/network/web-search.ts index 8adbda4a245..3bca2c53a59 100644 --- a/assistant/src/tools/network/web-search.ts +++ b/assistant/src/tools/network/web-search.ts @@ -15,7 +15,7 @@ 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,7 +769,7 @@ const WEB_SEARCH_FALLBACK_ORDER: readonly WebSearchProvider[] = Object.values( .sort((a, b) => a.fallbackOrder - b.fallbackOrder) .map((adapter) => adapter.id); -class WebSearchTool implements Tool { +class WebSearchTool implements ToolDefinition { name = "web_search"; description = "Search the web and return results. Useful for looking up current information, documentation, or anything the assistant doesn't know."; diff --git a/assistant/src/tools/registry.ts b/assistant/src/tools/registry.ts index 7889a817ee5..cebc5409be9 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 { @@ -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..86c8b66e858 100644 --- a/assistant/src/tools/skills/execute.ts +++ b/assistant/src/tools/skills/execute.ts @@ -1,8 +1,8 @@ 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 { +class SkillExecuteTool implements ToolDefinition { 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."; diff --git a/assistant/src/tools/skills/load.ts b/assistant/src/tools/skills/load.ts index 9c5623adbb9..05a4542824f 100644 --- a/assistant/src/tools/skills/load.ts +++ b/assistant/src/tools/skills/load.ts @@ -26,7 +26,7 @@ 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,7 +120,7 @@ function formatToolSchemas( return lines.join("\n").trimEnd(); } -export class SkillLoadTool implements Tool { +export class SkillLoadTool implements ToolDefinition { name = "skill_load"; description = "Load full instructions for a skill. Works for both bundled skills (listed in the catalog) and custom workspace skills."; diff --git a/assistant/src/tools/subagent/notify-parent.ts b/assistant/src/tools/subagent/notify-parent.ts index bda67c24c94..68e00586c04 100644 --- a/assistant/src/tools/subagent/notify-parent.ts +++ b/assistant/src/tools/subagent/notify-parent.ts @@ -1,7 +1,7 @@ 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,7 +31,7 @@ export async function executeSubagentNotifyParent( }; } -class NotifyParentTool implements Tool { +class NotifyParentTool implements ToolDefinition { 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."; diff --git a/assistant/src/tools/system/request-permission.ts b/assistant/src/tools/system/request-permission.ts index f1423ac0e7f..24563dec446 100644 --- a/assistant/src/tools/system/request-permission.ts +++ b/assistant/src/tools/system/request-permission.ts @@ -1,6 +1,6 @@ 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,7 +49,7 @@ const FRIENDLY_NAMES: Record = { camera: "Camera", }; -class RequestSystemPermissionTool implements Tool { +class RequestSystemPermissionTool implements ToolDefinition { name = "request_system_permission"; description = "Request a macOS system permission via System Settings. " + diff --git a/assistant/src/tools/terminal/shell.ts b/assistant/src/tools/terminal/shell.ts index 56cda88dbd5..2b3de966df4 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,7 +44,7 @@ function buildCredentialRefTrace( const log = getLogger("shell-tool"); -class ShellTool implements Tool { +class ShellTool implements ToolDefinition { name = "bash"; description = "Execute a shell command on the local machine"; category = "terminal"; @@ -652,5 +652,5 @@ function buildKillTree( }; } -export const shellTool: Tool = new ShellTool(); +export const shellTool: ToolDefinition = 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..a64b55d3404 100644 --- a/assistant/src/tools/types.ts +++ b/assistant/src/tools/types.ts @@ -337,12 +337,17 @@ export interface ToolDefinition { ) => Promise; /** Tool category used for Slack channel `allowedToolCategories` enforcement. */ category?: string; + /** + * 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?: 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..01b927def95 100644 --- a/assistant/src/tools/ui-surface/definitions.ts +++ b/assistant/src/tools/ui-surface/definitions.ts @@ -8,7 +8,7 @@ */ 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 +39,7 @@ function proxyExecute(toolName: string) { // ui_show // --------------------------------------------------------------------------- -export const uiShowTool: Tool = { +export const uiShowTool: ToolDefinition = { 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" + @@ -127,7 +127,7 @@ export const uiShowTool: Tool = { // ui_update // --------------------------------------------------------------------------- -const uiUpdateTool: Tool = { +const uiUpdateTool: ToolDefinition = { name: "ui_update", description: "Update an existing surface's data. The provided data object is merged into the surface's current data.\n" + @@ -158,7 +158,7 @@ const uiUpdateTool: Tool = { // ui_dismiss // --------------------------------------------------------------------------- -const uiDismissTool: Tool = { +const uiDismissTool: ToolDefinition = { name: "ui_dismiss", description: "Dismiss a currently displayed surface.", category: "ui-surface", @@ -179,7 +179,7 @@ const uiDismissTool: Tool = { execute: proxyExecute("ui_dismiss"), }; -export const allUiSurfaceTools: Tool[] = [ +export const allUiSurfaceTools: ToolDefinition[] = [ uiShowTool, uiUpdateTool, uiDismissTool, From 94419564ae55fd1b1d829a528cdba3fe491d191d Mon Sep 17 00:00:00 2001 From: "vellum-apollo-bot[bot]" <242025090+vellum-apollo-bot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 22:22:07 +0000 Subject: [PATCH 02/10] fix(tools): typecheck after ToolDefinition widening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #32472 widened `ToolDefinition` (made name/description/etc. optional). This commit fixes the cascade of typecheck failures in core-tool tests + two production-side mismatches. ## Registry returns Tool[], not ToolDefinition[] `getAllToolDefinitions()` and `getMcpToolDefinitions()` previously returned `ToolDefinition[]`. At runtime they always return finalized `Tool` objects (full Required); the loose typing was lying. Tightening to `Tool[]` fixes: - `conversation.ts` and `conversation-tool-setup.ts` `.name` reads that became `string | undefined` under the widened type. - Cross-package assignability to `@vellumai/skill-host-contracts`'s stricter `ToolDefinition` (which requires name/description/input_schema). - `btw-routes.ts` passing the result to `runBtwSidechain` whose `tools` parameter is the contracts-package shape. ## buildProxyTool takes Zod-inferred wire shape `buildProxyTool(definition: ToolDefinition)` was being passed elements from `z.array(WireToolDefinitionSchema)`. The Zod-inferred type has strict field types (e.g. `defaultRiskLevel: "low"|"medium"|"high"`) that don't match assistant's enum-typed `ToolDefinition` even though they're structurally equivalent. Switching the parameter to `z.infer` aligns the types and drops the `!` assertions / `as` casts that the prior version needed. ## Tests: ! on .execute() calls and tool.description 13 test files invoke `.execute()` directly on imported tool exports typed as `ToolDefinition`. Added non-null assertions matching the runtime invariant — these tools are class instances or fully-specified literals where every field is implemented. Also: `computer-use-tools.test.ts` `schema()` helper now accepts `input_schema?: object` instead of requiring it; `background-shell-bash` and `terminal-tools` local `shellTool: Tool` declarations changed to `shellTool: ToolDefinition` matching the module export type; `credential-execution-tools` drops an explicit `(t: Tool)` callback annotation that tripped the variance check. --- .../__tests__/background-shell-bash.test.ts | 14 ++-- .../background-shell-host-bash.test.ts | 26 +++--- .../src/__tests__/computer-use-tools.test.ts | 10 +-- .../credential-execution-tools.test.ts | 4 +- .../src/__tests__/disk-pressure-tools.test.ts | 4 +- .../src/__tests__/host-file-edit-tool.test.ts | 28 +++---- .../src/__tests__/host-file-read-tool.test.ts | 32 ++++---- .../__tests__/host-file-write-tool.test.ts | 24 +++--- .../src/__tests__/host-shell-tool.test.ts | 82 +++++++++---------- .../__tests__/shell-credential-ref.test.ts | 18 ++-- .../__tests__/shell-tool-proxy-mode.test.ts | 10 +-- .../src/__tests__/terminal-tools.test.ts | 18 ++-- .../tool-execution-abort-cleanup.test.ts | 10 +-- assistant/src/ipc/skill-routes/registries.ts | 23 +++--- .../src/tools/host-filesystem/edit.test.ts | 10 +-- .../src/tools/host-filesystem/read.test.ts | 10 +-- .../tools/host-filesystem/transfer.test.ts | 28 +++---- .../src/tools/host-filesystem/write.test.ts | 10 +-- assistant/src/tools/registry.ts | 4 +- 19 files changed, 181 insertions(+), 184 deletions(-) diff --git a/assistant/src/__tests__/background-shell-bash.test.ts b/assistant/src/__tests__/background-shell-bash.test.ts index 76e0649fe49..dc440ccddc4 100644 --- a/assistant/src/__tests__/background-shell-bash.test.ts +++ b/assistant/src/__tests__/background-shell-bash.test.ts @@ -2,7 +2,7 @@ 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"; +import type { ToolDefinition } from "../tools/types.js"; // ── Mock modules ──────────────────────────────────────────────────────────── @@ -117,7 +117,7 @@ function waitForWake( } describe("bash tool background mode", () => { - let shellTool: Tool; + let shellTool: ToolDefinition; beforeEach(async () => { mockWakeAgentForOpportunity.mockClear(); @@ -138,7 +138,7 @@ describe("bash tool background mode", () => { }); test("background: true returns immediately with backgrounded payload", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo hello", activity: "test", background: true }, baseContext, ); @@ -153,7 +153,7 @@ describe("bash tool background mode", () => { }); test("background process registers in the background tool registry", async () => { - await shellTool.execute( + await shellTool.execute!( { command: "echo hello", activity: "test", background: true }, baseContext, ); @@ -172,7 +172,7 @@ describe("bash tool background mode", () => { }); test("background process completion triggers wakeAgentForOpportunity with stdout", async () => { - await shellTool.execute( + await shellTool.execute!( { command: "echo bg_output_12345", activity: "test", background: true }, baseContext, ); @@ -192,7 +192,7 @@ describe("bash tool background mode", () => { }); test("failing background process delivers an error hint via wake", async () => { - await shellTool.execute( + await shellTool.execute!( { command: "exit 1", activity: "test", background: true }, baseContext, ); @@ -213,7 +213,7 @@ describe("bash tool background mode", () => { }); test("foreground mode still works when background is not set", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo foreground_test_789", activity: "test" }, baseContext, ); diff --git a/assistant/src/__tests__/background-shell-host-bash.test.ts b/assistant/src/__tests__/background-shell-host-bash.test.ts index e9dcb5985de..f3f76a5868f 100644 --- a/assistant/src/__tests__/background-shell-host-bash.test.ts +++ b/assistant/src/__tests__/background-shell-host-bash.test.ts @@ -189,7 +189,7 @@ describe("host_bash background mode — proxy path", () => { const ctx = makeContext({}); - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo bg-proxy", background: true }, ctx, ); @@ -209,7 +209,7 @@ describe("host_bash background mode — proxy path", () => { const ctx = makeContext({}); - await hostShellTool.execute( + await hostShellTool.execute!( { command: "echo bg-proxy", background: true }, ctx, ); @@ -232,7 +232,7 @@ describe("host_bash background mode — proxy path", () => { const ctx = makeContext({}); - await hostShellTool.execute( + await hostShellTool.execute!( { command: "echo bg-proxy", background: true }, ctx, ); @@ -261,7 +261,7 @@ describe("host_bash background mode — proxy path", () => { const ctx = makeContext({}); - await hostShellTool.execute( + await hostShellTool.execute!( { command: "bad-command", background: true }, ctx, ); @@ -283,7 +283,7 @@ describe("host_bash background mode — proxy path", () => { const ctx = makeContext({}); - await hostShellTool.execute( + await hostShellTool.execute!( { command: "echo fail", background: true }, ctx, ); @@ -307,7 +307,7 @@ describe("host_bash background mode — proxy path", () => { const ctx = makeContext({}); - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo bg-proxy", background: true }, ctx, ); @@ -330,7 +330,7 @@ describe("host_bash background mode — direct execution path", () => { test("returns immediately with backgrounded response", async () => { const ctx = makeContext(); - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo bg-local", background: true }, ctx, ); @@ -344,7 +344,7 @@ describe("host_bash background mode — direct execution path", () => { test("registers background tool in the registry", async () => { const ctx = makeContext(); - await hostShellTool.execute( + await hostShellTool.execute!( { command: "echo bg-local", background: true }, ctx, ); @@ -362,7 +362,7 @@ describe("host_bash background mode — direct execution path", () => { test("calls wakeAgentForOpportunity on process exit", async () => { const ctx = makeContext(); - await hostShellTool.execute( + await hostShellTool.execute!( { command: "echo bg-local", background: true }, ctx, ); @@ -388,7 +388,7 @@ describe("host_bash background mode — direct execution path", () => { test("calls wakeAgentForOpportunity with error hint on non-zero exit", async () => { const ctx = makeContext(); - await hostShellTool.execute({ command: "false", background: true }, ctx); + await hostShellTool.execute!({ command: "false", background: true }, ctx); expect(latestChild).toBeDefined(); @@ -407,7 +407,7 @@ describe("host_bash background mode — direct execution path", () => { test("calls wakeAgentForOpportunity on spawn error", async () => { const ctx = makeContext(); - await hostShellTool.execute( + await hostShellTool.execute!( { command: "echo bg-error", background: true }, ctx, ); @@ -429,7 +429,7 @@ describe("host_bash background mode — direct execution path", () => { test("removes background tool from registry on process exit", async () => { const ctx = makeContext(); - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo bg-local", background: true }, ctx, ); @@ -447,7 +447,7 @@ describe("host_bash background mode — direct execution path", () => { test("removes background tool from registry on spawn error", async () => { const ctx = makeContext(); - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo bg-error", background: true }, ctx, ); diff --git a/assistant/src/__tests__/computer-use-tools.test.ts b/assistant/src/__tests__/computer-use-tools.test.ts index 82e4ebf945e..3773581a97c 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); } }); }); @@ -89,7 +89,7 @@ describe("computer_use_click (unified)", () => { }); test("execute returns isError when no proxy resolver is configured", async () => { - const result = await computerUseClickTool.execute({}, ctx); + const result = await computerUseClickTool.execute!({}, ctx); expect(result.isError).toBe(true); expect(result.content).toMatch(/No proxy resolver/); }); @@ -104,7 +104,7 @@ describe("computer_use_type_text", () => { }); test("execute returns isError when no proxy resolver is configured", async () => { - const result = await computerUseTypeTextTool.execute({}, ctx); + const result = await computerUseTypeTextTool.execute!({}, ctx); expect(result.isError).toBe(true); expect(result.content).toMatch(/No proxy resolver/); }); @@ -119,7 +119,7 @@ describe("computer_use_key", () => { }); test("execute returns isError when no proxy resolver is configured", async () => { - const result = await computerUseKeyTool.execute({}, ctx); + const result = await computerUseKeyTool.execute!({}, ctx); expect(result.isError).toBe(true); expect(result.content).toMatch(/No proxy resolver/); }); diff --git a/assistant/src/__tests__/credential-execution-tools.test.ts b/assistant/src/__tests__/credential-execution-tools.test.ts index 1c3ffea14ea..c6d4d9b398a 100644 --- a/assistant/src/__tests__/credential-execution-tools.test.ts +++ b/assistant/src/__tests__/credential-execution-tools.test.ts @@ -5,7 +5,7 @@ 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 +79,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__/disk-pressure-tools.test.ts b/assistant/src/__tests__/disk-pressure-tools.test.ts index 3c0bb56f9a7..8222c684d93 100644 --- a/assistant/src/__tests__/disk-pressure-tools.test.ts +++ b/assistant/src/__tests__/disk-pressure-tools.test.ts @@ -233,7 +233,7 @@ describe("disk pressure cleanup tool restrictions", () => { }); test("background shell modes are blocked during cleanup mode", async () => { - const shellResult = await shellTool.execute( + const shellResult = await shellTool.execute!( { command: "sleep 100", activity: "check disk usage", @@ -252,7 +252,7 @@ describe("disk pressure cleanup tool restrictions", () => { "background shell commands are not available", ); - const hostResult = await hostShellTool.execute( + const hostResult = await hostShellTool.execute!( { command: "sleep 100", activity: "check disk usage", diff --git a/assistant/src/__tests__/host-file-edit-tool.test.ts b/assistant/src/__tests__/host-file-edit-tool.test.ts index 58cb2fcdb12..750c96f770c 100644 --- a/assistant/src/__tests__/host-file-edit-tool.test.ts +++ b/assistant/src/__tests__/host-file-edit-tool.test.ts @@ -48,7 +48,7 @@ afterEach(() => { describe("host_file_edit tool", () => { test("rejects relative paths", async () => { - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: "relative.txt", old_string: "a", @@ -66,7 +66,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "hello world\n"); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: filePath, old_string: "hello world", @@ -86,7 +86,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "x\ny\nx\n"); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: filePath, old_string: "x", @@ -108,7 +108,7 @@ describe("host_file_edit tool", () => { // Content has a typo-level difference from oldString writeFileSync(filePath, "function fooo() {\n return 1;\n}\n"); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: filePath, old_string: "function foo() {\n return 1;\n}", @@ -128,7 +128,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "repeat\nrepeat\n"); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: filePath, old_string: "repeat", @@ -142,7 +142,7 @@ describe("host_file_edit tool", () => { }); test("rejects missing path parameter", async () => { - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { old_string: "a", new_string: "b", @@ -159,7 +159,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "content\n"); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: filePath, old_string: 42, @@ -177,7 +177,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "content\n"); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: filePath, old_string: "content", @@ -195,7 +195,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "content\n"); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: filePath, old_string: "", @@ -213,7 +213,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "content\n"); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: filePath, old_string: "content", @@ -232,7 +232,7 @@ describe("host_file_edit tool", () => { testDirs.push(dir); const filePath = join(dir, "missing.txt"); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: filePath, old_string: "a", @@ -250,7 +250,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "before\n"); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: filePath, old_string: "before", @@ -274,7 +274,7 @@ describe("host_file_edit tool", () => { // File has tab indentation writeFileSync(filePath, "function foo() {\n\treturn 1;\n}\n"); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: filePath, // old_string uses spaces instead of tabs — should whitespace-normalize @@ -301,7 +301,7 @@ describe("host_file_edit tool", () => { return { content: "proxied edit", isError: false }; }; - await hostFileEditTool.execute( + await hostFileEditTool.execute!( { path: "/host/file.txt", old_string: "old", diff --git a/assistant/src/__tests__/host-file-read-tool.test.ts b/assistant/src/__tests__/host-file-read-tool.test.ts index 17f4726ecd9..59c6f37fa05 100644 --- a/assistant/src/__tests__/host-file-read-tool.test.ts +++ b/assistant/src/__tests__/host-file-read-tool.test.ts @@ -66,7 +66,7 @@ const PNG_HEADER = Buffer.from([ describe("host_file_read tool", () => { test("rejects relative paths", async () => { - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: "relative.txt" }, makeContext(), ); @@ -80,7 +80,7 @@ describe("host_file_read tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "first\nsecond\nthird\n"); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: filePath, offset: 2, limit: 2 }, makeContext(), ); @@ -91,7 +91,7 @@ describe("host_file_read tool", () => { test("returns error when file does not exist", async () => { const filePath = join(tmpdir(), `host-file-read-missing-${Date.now()}.txt`); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: filePath }, makeContext(), ); @@ -105,7 +105,7 @@ describe("host_file_read tool", () => { const nestedDir = join(dir, "nested"); mkdirSync(nestedDir, { recursive: true }); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: nestedDir }, makeContext(), ); @@ -114,13 +114,13 @@ describe("host_file_read tool", () => { }); test("rejects missing path parameter", async () => { - const result = await hostFileReadTool.execute({}, makeContext()); + const result = await hostFileReadTool.execute!({}, makeContext()); expect(result.isError).toBe(true); expect(result.content).toContain("path is required"); }); test("rejects non-string path", async () => { - const result = await hostFileReadTool.execute({ path: 42 }, makeContext()); + const result = await hostFileReadTool.execute!({ path: 42 }, makeContext()); expect(result.isError).toBe(true); expect(result.content).toContain("path is required and must be a string"); }); @@ -131,7 +131,7 @@ describe("host_file_read tool", () => { const filePath = join(dir, "full.txt"); writeFileSync(filePath, "line1\nline2\nline3\n"); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: filePath }, makeContext(), ); @@ -147,7 +147,7 @@ describe("host_file_read tool", () => { const filePath = join(dir, "empty.txt"); writeFileSync(filePath, ""); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: filePath }, makeContext(), ); @@ -160,7 +160,7 @@ describe("host_file_read tool", () => { const filePath = join(dir, "lines.txt"); writeFileSync(filePath, "a\nb\nc\nd\ne\n"); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: filePath, offset: 3, limit: 1 }, makeContext(), ); @@ -179,7 +179,7 @@ describe("host_file_read tool", () => { const { symlinkSync } = await import("node:fs"); symlinkSync(realFile, linkFile); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: linkFile }, makeContext(), ); @@ -217,7 +217,7 @@ describe("host_file_read image support", () => { ...makeContext(), }; - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: "/host/screenshot.png" }, proxyContext, ); @@ -243,7 +243,7 @@ describe("host_file_read image support", () => { const filePath = join(dir, "screenshot.png"); writeFileSync(filePath, PNG_HEADER); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: filePath }, makeContext(), ); @@ -263,7 +263,7 @@ describe("host_file_read image support", () => { const filePath = join(dir, "photo.jpg"); writeFileSync(filePath, JPEG_HEADER); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: filePath }, makeContext(), ); @@ -280,7 +280,7 @@ describe("host_file_read image support", () => { test("returns error for non-existent image path", async () => { const filePath = join(tmpdir(), `host-file-read-missing-${Date.now()}.png`); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: filePath }, makeContext(), ); @@ -294,7 +294,7 @@ describe("host_file_read image support", () => { const filePath = join(dir, "notes.txt"); writeFileSync(filePath, "hello world\nsecond line\n"); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: filePath }, makeContext(), ); @@ -313,7 +313,7 @@ describe("host_file_read image support", () => { return { content: "proxied", isError: false }; }; - await hostFileReadTool.execute( + await hostFileReadTool.execute!( { path: "/host/notes.txt", target_client_id: "client-x" }, makeContext(), ); diff --git a/assistant/src/__tests__/host-file-write-tool.test.ts b/assistant/src/__tests__/host-file-write-tool.test.ts index 25879f0ffef..a30e3946674 100644 --- a/assistant/src/__tests__/host-file-write-tool.test.ts +++ b/assistant/src/__tests__/host-file-write-tool.test.ts @@ -48,7 +48,7 @@ afterEach(() => { describe("host_file_write tool", () => { test("rejects relative paths", async () => { - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: "relative.txt", content: "hi" }, makeContext(), ); @@ -61,7 +61,7 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "out.txt"); - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: filePath, content: 42 }, makeContext(), ); @@ -76,7 +76,7 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "nested", "new.txt"); - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: filePath, content: "new content" }, makeContext(), ); @@ -97,11 +97,11 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "existing.txt"); - await hostFileWriteTool.execute( + await hostFileWriteTool.execute!( { path: filePath, content: "old" }, makeContext(), ); - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: filePath, content: "updated" }, makeContext(), ); @@ -117,7 +117,7 @@ describe("host_file_write tool", () => { }); test("rejects missing path parameter", async () => { - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { content: "data" }, makeContext(), ); @@ -126,7 +126,7 @@ describe("host_file_write tool", () => { }); test("rejects non-string path", async () => { - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: 123, content: "data" }, makeContext(), ); @@ -139,7 +139,7 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "msg-check.txt"); - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: filePath, content: "check" }, makeContext(), ); @@ -152,7 +152,7 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "lines.txt"); - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: filePath, content: "line1\nline2\nline3", @@ -170,7 +170,7 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "empty.txt"); - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: filePath, content: "" }, makeContext(), ); @@ -184,7 +184,7 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "a", "b", "c", "deep.txt"); - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: filePath, content: "deep" }, makeContext(), ); @@ -201,7 +201,7 @@ describe("host_file_write tool", () => { return { content: "proxied write", isError: false }; }; - await hostFileWriteTool.execute( + await hostFileWriteTool.execute!( { 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..5669d52d654 100644 --- a/assistant/src/__tests__/host-shell-tool.test.ts +++ b/assistant/src/__tests__/host-shell-tool.test.ts @@ -96,7 +96,7 @@ afterEach(() => { describe("host_bash tool", () => { test("rejects relative working_dir", async () => { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "pwd", working_dir: "relative/path", @@ -112,7 +112,7 @@ describe("host_bash tool", () => { const dir = mkdtempSync(join(tmpdir(), "host-shell-test-")); testDirs.push(dir); - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "pwd", working_dir: dir, @@ -125,7 +125,7 @@ describe("host_bash tool", () => { }); test("returns error for non-zero exit commands", async () => { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "exit 12" }, makeContext(), ); @@ -137,7 +137,7 @@ describe("host_bash tool", () => { const dir = mkdtempSync(join(tmpdir(), "host-shell-nosandbox-")); testDirs.push(dir); - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo isolation-test", working_dir: dir, @@ -155,7 +155,7 @@ describe("host_bash tool", () => { spawnCalls.length = 0; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo hello", working_dir: dir, @@ -182,7 +182,7 @@ describe("host_bash — baseline: no sandbox isolation", () => { spawnCalls.length = 0; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo baseline", working_dir: dir, @@ -203,7 +203,7 @@ describe("host_bash — baseline: no sandbox isolation", () => { spawnCalls.length = 0; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo no-native-sandbox", working_dir: dir, @@ -222,7 +222,7 @@ describe("host_bash — baseline: no sandbox isolation", () => { spawnCalls.length = 0; - await hostShellTool.execute( + await hostShellTool.execute!( { command: "ls -la /tmp", working_dir: dir, @@ -242,7 +242,7 @@ describe("host_bash — baseline: no sandbox isolation", () => { spawnCalls.length = 0; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo sandbox-enabled-irrelevant", working_dir: dir, @@ -296,7 +296,7 @@ describe("host_bash — regression: no proxied-mode additions", () => { // Pass network_mode as if the model hallucinated the parameter — // host_bash must ignore it and run the command normally. - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo should-work", working_dir: dir, @@ -319,7 +319,7 @@ describe("host_bash — regression: no proxied-mode additions", () => { spawnCalls.length = 0; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo creds-ignored", working_dir: dir, @@ -352,7 +352,7 @@ describe("host_bash — regression: no proxied-mode additions", () => { describe("host_bash — input validation", () => { test("rejects null bytes in command", async () => { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo \0evil", }, @@ -364,7 +364,7 @@ describe("host_bash — input validation", () => { }); test("rejects null bytes in working_dir", async () => { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo test", working_dir: "/tmp/\0evil", @@ -377,7 +377,7 @@ describe("host_bash — input validation", () => { }); test("rejects empty command", async () => { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "", }, @@ -389,7 +389,7 @@ describe("host_bash — input validation", () => { }); test("rejects non-string command", async () => { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: 42, }, @@ -403,7 +403,7 @@ describe("host_bash — input validation", () => { }); test("rejects non-string working_dir", async () => { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo test", working_dir: 123, @@ -423,7 +423,7 @@ describe("host_bash — input validation", () => { describe("host_bash — environment setup", () => { test("defaults working_dir to user home when not provided", async () => { const { homedir } = await import("node:os"); - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "pwd", }, @@ -438,7 +438,7 @@ describe("host_bash — environment setup", () => { const { homedir } = await import("node:os"); const home = homedir(); - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: 'echo "$PATH"', }, @@ -457,7 +457,7 @@ describe("host_bash — environment setup", () => { process.env[varName] = "should-not-appear"; try { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "env", }, @@ -477,7 +477,7 @@ describe("host_bash — environment setup", () => { }); test("includes safe env vars like HOME and TERM", async () => { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: 'echo "HOME=$HOME"', }, @@ -493,7 +493,7 @@ describe("host_bash — environment setup", () => { const originalGatewayPort = process.env.GATEWAY_PORT; process.env.GATEWAY_PORT = "9000"; try { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: 'echo "$INTERNAL_GATEWAY_BASE_URL"', }, @@ -517,7 +517,7 @@ describe("host_bash — environment setup", () => { describe("host_bash — timeout handling", () => { test("respects custom timeout_seconds", async () => { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "sleep 5", timeout_seconds: 1, @@ -531,7 +531,7 @@ describe("host_bash — timeout handling", () => { test("clamps timeout to at least 1 second", async () => { // A timeout_seconds of 0 should be clamped to 1 - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo fast", timeout_seconds: 0, @@ -546,7 +546,7 @@ describe("host_bash — timeout handling", () => { test("clamps timeout to max configured value", async () => { // Request a timeout larger than the configured max (600) - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo capped", timeout_seconds: 9999, @@ -571,7 +571,7 @@ describe("host_bash — streaming and cancellation", () => { onOutput: (chunk: string) => chunks.push(chunk), }; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo streamed-output", }, @@ -589,7 +589,7 @@ describe("host_bash — streaming and cancellation", () => { onOutput: (chunk: string) => chunks.push(chunk), }; - await hostShellTool.execute( + await hostShellTool.execute!( { command: "echo stderr-data >&2", }, @@ -603,7 +603,7 @@ describe("host_bash — streaming and cancellation", () => { const ac = new AbortController(); // Start a long-running command then abort it quickly - const promise = hostShellTool.execute( + const promise = hostShellTool.execute!( { command: "sleep 30", }, @@ -623,7 +623,7 @@ describe("host_bash — streaming and cancellation", () => { const ac = new AbortController(); ac.abort(); - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "sleep 30", }, @@ -640,7 +640,7 @@ describe("host_bash — streaming and cancellation", () => { describe("host_bash — spawn error handling", () => { test("reports error when working_dir does not exist", async () => { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo test", working_dir: "/nonexistent/path/that/does/not/exist", @@ -653,7 +653,7 @@ describe("host_bash — spawn error handling", () => { }); test("captures both stdout and stderr in output", async () => { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo out && echo err >&2", }, @@ -665,7 +665,7 @@ describe("host_bash — spawn error handling", () => { }); test("returns completed marker for successful empty output", async () => { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "true", }, @@ -677,7 +677,7 @@ describe("host_bash — spawn error handling", () => { }); test("injects __CONVERSATION_ID for local host_bash execution", async () => { - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: 'echo "$__CONVERSATION_ID"', }, @@ -754,7 +754,7 @@ describe("host_bash — proxy delegation", () => { }; spawnCalls.length = 0; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo hello", working_dir: "/tmp", timeout_seconds: 30 }, ctx, ); @@ -780,7 +780,7 @@ describe("host_bash — proxy delegation", () => { ...makeContext(), }; - const result = await hostShellTool.execute({ command: "echo \0evil" }, ctx); + const result = await hostShellTool.execute!({ command: "echo \0evil" }, ctx); expect(result.isError).toBe(true); expect(result.content).toContain("null bytes"); @@ -799,7 +799,7 @@ describe("host_bash — proxy delegation", () => { ...makeContext(), }; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo test", working_dir: "relative/path" }, ctx, ); @@ -819,7 +819,7 @@ describe("host_bash — proxy delegation", () => { }; spawnCalls.length = 0; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo local-fallback", working_dir: dir }, ctx, ); @@ -834,7 +834,7 @@ describe("host_bash — proxy delegation", () => { // mockProxyAvailable defaults to false — simulates client disconnecting // after tool definitions were built (targetClientId already resolved). spawnCalls.length = 0; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo should-not-run", target_client_id: "client-mac-abc123" }, { ...makeContext(), transportInterface: "web" }, ); @@ -851,7 +851,7 @@ describe("host_bash — proxy delegation", () => { testDirs.push(dir); spawnCalls.length = 0; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo no-proxy", working_dir: dir }, makeContext(), ); @@ -888,7 +888,7 @@ describe("host_bash — proxy delegation", () => { trustClass: "trusted_contact", // untrusted actor }; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo lockdown" }, ctx, ); @@ -923,7 +923,7 @@ describe("host_bash — proxy delegation", () => { trustClass: "guardian", // trusted actor — no lockdown }; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo no-lockdown" }, ctx, ); @@ -961,7 +961,7 @@ describe("host_bash — proxy delegation", () => { trustClass: "guardian", // trusted actor — no lockdown }; - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "assistant browser status --json" }, ctx, ); diff --git a/assistant/src/__tests__/shell-credential-ref.test.ts b/assistant/src/__tests__/shell-credential-ref.test.ts index c451f03561e..20de5f73495 100644 --- a/assistant/src/__tests__/shell-credential-ref.test.ts +++ b/assistant/src/__tests__/shell-credential-ref.test.ts @@ -104,7 +104,7 @@ describe("shell tool credential ref resolution", () => { ], }); - await shellTool.execute( + await shellTool.execute!( { command: "echo hello", reason: "test", @@ -125,7 +125,7 @@ describe("shell tool credential ref resolution", () => { allowedTools: ["bash"], }); - await shellTool.execute( + await shellTool.execute!( { command: "echo hello", reason: "test", @@ -141,7 +141,7 @@ describe("shell tool credential ref resolution", () => { }); test("unknown ref fails fast before spawning", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo hello", reason: "test", @@ -163,7 +163,7 @@ describe("shell tool credential ref resolution", () => { allowedTools: ["bash"], }); - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo hello", reason: "test", @@ -184,7 +184,7 @@ describe("shell tool credential ref resolution", () => { allowedTools: ["bash"], }); - await shellTool.execute( + await shellTool.execute!( { command: "echo hello", reason: "test", @@ -202,7 +202,7 @@ describe("shell tool credential ref resolution", () => { test("non-proxied mode passes refs through without resolution", async () => { // In non-proxied mode, credential_ids are ignored for proxy but still collected - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo hello", reason: "test", @@ -230,7 +230,7 @@ describe("shell tool credential ref resolution", () => { ], }); - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "curl https://api.vercel.com/v1/projects", activity: "test", @@ -260,7 +260,7 @@ describe("shell tool credential ref resolution", () => { ], }); - await shellTool.execute( + await shellTool.execute!( { command: "echo deploy", activity: "test", @@ -284,7 +284,7 @@ describe("shell tool credential ref resolution", () => { allowedTools: ["publish_page"], }); - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo mixed", activity: "test", diff --git a/assistant/src/__tests__/shell-tool-proxy-mode.test.ts b/assistant/src/__tests__/shell-tool-proxy-mode.test.ts index dec4d5b95c0..b71ecdfef27 100644 --- a/assistant/src/__tests__/shell-tool-proxy-mode.test.ts +++ b/assistant/src/__tests__/shell-tool-proxy-mode.test.ts @@ -179,7 +179,7 @@ afterEach(() => { describe("shell tool proxy mode", () => { test("default mode does not inject proxy env vars", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo hello" }, makeContext(), ); @@ -193,7 +193,7 @@ describe("shell tool proxy mode", () => { }); test("network_mode=off does not inject proxy env vars", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo hello", network_mode: "off" }, makeContext(), ); @@ -206,7 +206,7 @@ describe("shell tool proxy mode", () => { }); test("network_mode=proxied creates session and injects proxy env", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo proxied", network_mode: "proxied", @@ -246,7 +246,7 @@ describe("shell tool proxy mode", () => { conversationId: "test-conv", }; - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo reuse", network_mode: "proxied" }, makeContext(), ); @@ -266,7 +266,7 @@ describe("shell tool proxy mode", () => { }); test("safe env vars are preserved alongside proxy vars", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo env-merge", network_mode: "proxied", diff --git a/assistant/src/__tests__/terminal-tools.test.ts b/assistant/src/__tests__/terminal-tools.test.ts index 55ccff1d82b..88977ff6e37 100644 --- a/assistant/src/__tests__/terminal-tools.test.ts +++ b/assistant/src/__tests__/terminal-tools.test.ts @@ -2,7 +2,7 @@ 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 type { ToolDefinition } from "../tools/types.js"; // ── Mock modules ──────────────────────────────────────────────────────────── @@ -210,7 +210,7 @@ describe("buildSanitizedEnv", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("Shell tool input validation", () => { - let shellTool: Tool; + let shellTool: ToolDefinition; beforeEach(async () => { const mod = await import("../tools/terminal/shell.js"); @@ -225,7 +225,7 @@ describe("Shell tool input validation", () => { }; test("rejects empty command", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "", reason: "test" }, baseContext, ); @@ -234,7 +234,7 @@ describe("Shell tool input validation", () => { }); test("rejects non-string command", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: 123, reason: "test" }, baseContext, ); @@ -243,7 +243,7 @@ describe("Shell tool input validation", () => { }); test("rejects command with null bytes", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo hello\0world", reason: "test" }, baseContext, ); @@ -252,13 +252,13 @@ describe("Shell tool input validation", () => { }); test("rejects missing command", async () => { - const result = await shellTool.execute({ reason: "test" }, baseContext); + const result = await shellTool.execute!({ reason: "test" }, baseContext); expect(result.isError).toBe(true); expect(result.content).toContain("command is required"); }); test("executes simple command successfully", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo test_output_12345", reason: "testing" }, baseContext, ); @@ -267,7 +267,7 @@ describe("Shell tool input validation", () => { }); test("returns error for failed command", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "false", reason: "testing failure" }, baseContext, ); @@ -279,7 +279,7 @@ describe("Shell tool input validation", () => { // Verify by checking that the proxy session is never started — the // observable effect of network_mode defaulting to 'off'. proxyGetOrStartSession.mockClear(); - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo network_default", reason: "testing" }, baseContext, ); diff --git a/assistant/src/__tests__/tool-execution-abort-cleanup.test.ts b/assistant/src/__tests__/tool-execution-abort-cleanup.test.ts index 6002ac24bb6..4c65509dd89 100644 --- a/assistant/src/__tests__/tool-execution-abort-cleanup.test.ts +++ b/assistant/src/__tests__/tool-execution-abort-cleanup.test.ts @@ -112,7 +112,7 @@ describe("shell tool — process cleanup on AbortSignal", () => { const dir = makeTempDir(); const ac = new AbortController(); - const promise = hostShellTool.execute( + const promise = hostShellTool.execute!( { command: "sleep 30", reason: "test" }, makeToolContext(dir, ac.signal), ); @@ -134,7 +134,7 @@ describe("shell tool — process cleanup on AbortSignal", () => { const ac = new AbortController(); ac.abort(); // pre-aborted - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "sleep 30", reason: "test" }, makeToolContext(dir, ac.signal), ); @@ -148,7 +148,7 @@ describe("shell tool — process cleanup on AbortSignal", () => { const dir = makeTempDir(); const ac = new AbortController(); - const result = await hostShellTool.execute( + const result = await hostShellTool.execute!( { command: "echo completed", reason: "test" }, makeToolContext(dir, ac.signal), ); @@ -165,7 +165,7 @@ describe("shell tool — process cleanup on AbortSignal", () => { const dir = makeTempDir(); const ac = new AbortController(); - await hostShellTool.execute( + await hostShellTool.execute!( { command: "echo done", reason: "test" }, makeToolContext(dir, ac.signal), ); @@ -184,7 +184,7 @@ describe("shell tool — process cleanup on AbortSignal", () => { const ac = new AbortController(); const chunks: string[] = []; - const promise = hostShellTool.execute( + const promise = hostShellTool.execute!( { command: "for i in 1 2 3 4 5; do echo $i; sleep 2; done", reason: "test", diff --git a/assistant/src/ipc/skill-routes/registries.ts b/assistant/src/ipc/skill-routes/registries.ts index d7b6a2b30d1..35bc7d83af1 100644 --- a/assistant/src/ipc/skill-routes/registries.ts +++ b/assistant/src/ipc/skill-routes/registries.ts @@ -24,11 +24,7 @@ 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, - ToolDefinition, -} from "../../tools/types.js"; +import type { Tool } from "../../tools/types.js"; import { RiskLevel } from "../../tools/types.js"; import { getLogger } from "../../util/logger.js"; import type { SkillIpcRoute } from "../skill-ipc-types.js"; @@ -181,23 +177,24 @@ export function __getActiveSessionCountForTesting(): number { * proxy in the registry so the rest of the tool-manifest plumbing can be * exercised end-to-end. */ -function buildProxyTool(definition: ToolDefinition): Tool { +type WireToolDefinition = z.infer; + +function buildProxyTool(definition: WireToolDefinition): Tool { // The Zod schema (`WireToolDefinitionSchema`) requires name, description, // input_schema, defaultRiskLevel, and category — `definition` arrives via - // that parse, so the `!` assertions reflect the runtime invariant while - // matching the relaxed `ToolDefinition` type contract. + // that parse, so every field below is guaranteed present at runtime. // RiskLevel is a string enum whose values are "low" | "medium" | "high", // matching the schema above exactly — the cast is a no-op at runtime. - const name = definition.name!; + const { name } = definition; return { name, - description: definition.description!, - input_schema: definition.input_schema as object, - category: definition.category!, + description: definition.description, + input_schema: definition.input_schema, + category: definition.category, defaultRiskLevel: definition.defaultRiskLevel as RiskLevel, executionTarget: resolveExecutionTarget({ name, - executionTarget: definition.executionTarget as ExecutionTarget | undefined, + executionTarget: definition.executionTarget, }), execute: async () => { // Only reached when no supervisor is attached (tests/boot race); diff --git a/assistant/src/tools/host-filesystem/edit.test.ts b/assistant/src/tools/host-filesystem/edit.test.ts index 3b4e3768b0d..74b9b21b63e 100644 --- a/assistant/src/tools/host-filesystem/edit.test.ts +++ b/assistant/src/tools/host-filesystem/edit.test.ts @@ -61,7 +61,7 @@ function makeContext( describe("host_file_edit cross-client guards", () => { test("returns 'no client' error on web transport when proxy unavailable and no targetClientId", async () => { const workingDir = makeTempDir(); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: "/some/host/path.txt", old_string: "foo", @@ -77,7 +77,7 @@ describe("host_file_edit cross-client guards", () => { test("returns 'specified client disconnected' error when targetClientId set but proxy unavailable on web", async () => { const workingDir = makeTempDir(); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: "/some/host/path.txt", old_string: "foo", @@ -94,7 +94,7 @@ describe("host_file_edit cross-client guards", () => { test("falls through to local fs on macos transport when proxy unavailable", async () => { const workingDir = makeTempDir(); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: "/nonexistent/x.txt", old_string: "foo", @@ -110,7 +110,7 @@ describe("host_file_edit cross-client guards", () => { test("does NOT reject on macos transport with a stale target_client_id when proxy unavailable (regression: P2 fix)", async () => { const workingDir = makeTempDir(); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: "/nonexistent/x.txt", old_string: "foo", @@ -131,7 +131,7 @@ describe("host_file_edit cross-client guards", () => { test("rejects when target_client_id is set but transport metadata is missing (legacy/backwards-compat path)", async () => { const workingDir = makeTempDir(); - const result = await hostFileEditTool.execute( + const result = await hostFileEditTool.execute!( { path: "/some/host/path.txt", old_string: "foo", diff --git a/assistant/src/tools/host-filesystem/read.test.ts b/assistant/src/tools/host-filesystem/read.test.ts index 67f64171fd1..574cce2e82b 100644 --- a/assistant/src/tools/host-filesystem/read.test.ts +++ b/assistant/src/tools/host-filesystem/read.test.ts @@ -61,7 +61,7 @@ function makeContext( describe("host_file_read cross-client guards", () => { test("returns 'no client' error on web transport when proxy unavailable and no targetClientId", async () => { const workingDir = makeTempDir(); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: "/some/host/path.txt" }, makeContext(workingDir, "web"), ); @@ -73,7 +73,7 @@ describe("host_file_read cross-client guards", () => { test("returns 'specified client disconnected' error when targetClientId set but proxy unavailable on web", async () => { const workingDir = makeTempDir(); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: "/some/host/path.txt", target_client_id: "abc-123" }, makeContext(workingDir, "web"), ); @@ -85,7 +85,7 @@ describe("host_file_read cross-client guards", () => { test("falls through to local fs on macos transport when proxy unavailable and path is non-image", async () => { const workingDir = makeTempDir(); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: "/nonexistent/x.txt" }, makeContext(workingDir, "macos"), ); @@ -97,7 +97,7 @@ describe("host_file_read cross-client guards", () => { test("does NOT reject on macos transport with a stale target_client_id when proxy unavailable (regression: P2 fix)", async () => { const workingDir = makeTempDir(); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: "/nonexistent/x.txt", target_client_id: "stale-mac" }, makeContext(workingDir, "macos"), ); @@ -113,7 +113,7 @@ describe("host_file_read cross-client guards", () => { test("rejects when target_client_id is set but transport metadata is missing (legacy/backwards-compat path)", async () => { const workingDir = makeTempDir(); - const result = await hostFileReadTool.execute( + const result = await hostFileReadTool.execute!( { path: "/some/host/path.txt", target_client_id: "abc-123" }, // transportInterface intentionally undefined (legacy callers). makeContext(workingDir, undefined), diff --git a/assistant/src/tools/host-filesystem/transfer.test.ts b/assistant/src/tools/host-filesystem/transfer.test.ts index 51139cbf1fb..a2392478df8 100644 --- a/assistant/src/tools/host-filesystem/transfer.test.ts +++ b/assistant/src/tools/host-filesystem/transfer.test.ts @@ -105,7 +105,7 @@ describe("host_file_transfer local mode", () => { const srcFile = join(srcDir, "source.md"); writeFileSync(srcFile, "hello world"); - const result = await hostFileTransferTool.execute( + const result = await hostFileTransferTool.execute!( { source_path: srcFile, dest_path: "scratch/out.md", @@ -127,7 +127,7 @@ describe("host_file_transfer local mode", () => { const destFile = join(workingDir, "out.md"); - const result = await hostFileTransferTool.execute( + const result = await hostFileTransferTool.execute!( { source_path: srcFile, dest_path: destFile, @@ -146,7 +146,7 @@ describe("host_file_transfer local mode", () => { const srcFile = join(srcDir, "source.txt"); writeFileSync(srcFile, "content"); - const result = await hostFileTransferTool.execute( + const result = await hostFileTransferTool.execute!( { source_path: srcFile, dest_path: "../../etc/shadow", @@ -166,7 +166,7 @@ describe("host_file_transfer local mode", () => { const srcFile = join(srcDir, "source.txt"); writeFileSync(srcFile, "content"); - const result = await hostFileTransferTool.execute( + const result = await hostFileTransferTool.execute!( { source_path: srcFile, dest_path: "/workspace/out.md", @@ -193,7 +193,7 @@ describe("host_file_transfer local mode to_host", () => { const destDir = makeTempDir(); const destFile = join(destDir, "report.pdf"); - const result = await hostFileTransferTool.execute( + const result = await hostFileTransferTool.execute!( { source_path: "report.pdf", dest_path: destFile, @@ -211,7 +211,7 @@ describe("host_file_transfer local mode to_host", () => { const destDir = makeTempDir(); const destFile = join(destDir, "out.txt"); - const result = await hostFileTransferTool.execute( + const result = await hostFileTransferTool.execute!( { source_path: "../../etc/passwd", dest_path: destFile, @@ -231,7 +231,7 @@ describe("host_file_transfer local mode to_host", () => { const destDir = makeTempDir(); const destFile = join(destDir, "data.txt"); - const result = await hostFileTransferTool.execute( + const result = await hostFileTransferTool.execute!( { source_path: "/workspace/data.txt", dest_path: destFile, @@ -257,7 +257,7 @@ describe("host_file_transfer managed mode", () => { const srcFile = join(srcDir, "source.txt"); writeFileSync(srcFile, "content"); - await hostFileTransferTool.execute( + await hostFileTransferTool.execute!( { source_path: srcFile, dest_path: "relative/file.txt", @@ -277,7 +277,7 @@ describe("host_file_transfer managed mode", () => { const workingDir = makeTempDir(); writeFileSync(join(workingDir, "doc.md"), "content"); - await hostFileTransferTool.execute( + await hostFileTransferTool.execute!( { source_path: "doc.md", dest_path: "/Users/someone/Desktop/doc.md", @@ -297,7 +297,7 @@ describe("host_file_transfer managed mode", () => { const srcFile = join(srcDir, "source.txt"); writeFileSync(srcFile, "content"); - const result = await hostFileTransferTool.execute( + const result = await hostFileTransferTool.execute!( { source_path: srcFile, dest_path: "/etc/passwd", @@ -324,7 +324,7 @@ describe("host_file_transfer cross-client guards", () => { const srcFile = join(srcDir, "source.txt"); writeFileSync(srcFile, "content"); - const result = await hostFileTransferTool.execute( + const result = await hostFileTransferTool.execute!( { source_path: srcFile, dest_path: "out.txt", @@ -346,7 +346,7 @@ describe("host_file_transfer cross-client guards", () => { const srcFile = join(srcDir, "source.txt"); writeFileSync(srcFile, "content"); - const result = await hostFileTransferTool.execute( + const result = await hostFileTransferTool.execute!( { source_path: srcFile, dest_path: "out.txt", @@ -370,7 +370,7 @@ describe("host_file_transfer cross-client guards", () => { writeFileSync(srcFile, "content"); const destFile = join(workingDir, "should-not-exist.txt"); - const result = await hostFileTransferTool.execute( + const result = await hostFileTransferTool.execute!( { source_path: srcFile, dest_path: destFile, @@ -399,7 +399,7 @@ describe("host_file_transfer cross-client guards", () => { writeFileSync(srcFile, "content"); const destFile = join(workingDir, "stale-target.txt"); - const result = await hostFileTransferTool.execute( + const result = await hostFileTransferTool.execute!( { source_path: srcFile, dest_path: destFile, diff --git a/assistant/src/tools/host-filesystem/write.test.ts b/assistant/src/tools/host-filesystem/write.test.ts index 8e5863135ba..a1bece55818 100644 --- a/assistant/src/tools/host-filesystem/write.test.ts +++ b/assistant/src/tools/host-filesystem/write.test.ts @@ -61,7 +61,7 @@ function makeContext( describe("host_file_write cross-client guards", () => { test("returns 'no client' error on web transport when proxy unavailable and no targetClientId", async () => { const workingDir = makeTempDir(); - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: "/some/host/path.txt", content: "hello" }, makeContext(workingDir, "web"), ); @@ -73,7 +73,7 @@ describe("host_file_write cross-client guards", () => { test("returns 'specified client disconnected' error when targetClientId set but proxy unavailable on web", async () => { const workingDir = makeTempDir(); - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: "/some/host/path.txt", content: "hello", @@ -90,7 +90,7 @@ describe("host_file_write cross-client guards", () => { test("falls through to local fs on macos transport when proxy unavailable", async () => { const workingDir = makeTempDir(); const destFile = join(workingDir, "out.txt"); - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: destFile, content: "hello" }, makeContext(workingDir, "macos"), ); @@ -102,7 +102,7 @@ describe("host_file_write cross-client guards", () => { test("does NOT reject on macos transport with a stale target_client_id when proxy unavailable (regression: P2 fix)", async () => { const workingDir = makeTempDir(); const destFile = join(workingDir, "stale-target.txt"); - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: destFile, content: "hello", target_client_id: "stale-mac" }, makeContext(workingDir, "macos"), ); @@ -118,7 +118,7 @@ describe("host_file_write cross-client guards", () => { test("rejects when target_client_id is set but transport metadata is missing (legacy/backwards-compat path)", async () => { const workingDir = makeTempDir(); const destFile = join(workingDir, "should-not-exist.txt"); - const result = await hostFileWriteTool.execute( + const result = await hostFileWriteTool.execute!( { path: destFile, content: "hello", target_client_id: "abc-123" }, // transportInterface intentionally undefined (legacy callers). makeContext(workingDir, undefined), diff --git a/assistant/src/tools/registry.ts b/assistant/src/tools/registry.ts index cebc5409be9..48d365d3a22 100644 --- a/assistant/src/tools/registry.ts +++ b/assistant/src/tools/registry.ts @@ -443,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", ); @@ -465,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 From ee1d51b069e0a6bde6c744ad77f80939a81a0f31 Mon Sep 17 00:00:00 2001 From: "vellum-apollo-bot[bot]" <242025090+vellum-apollo-bot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 22:26:03 +0000 Subject: [PATCH 03/10] fix(tools): update shell-observability test to widened ToolDefinition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #32595 ('Observability for subprocess orphans') merged into main after this branch opened. It introduced shell-observability.test.ts using `let shellTool: Tool` + `shellTool.execute(...)` — the same pattern fad576a335 fixed in the other shell tests. CI's typecheck on the merge commit catches this; rebasing surfaces the file so the fix goes in-branch. Same mechanical fix: `Tool` → `ToolDefinition` for the variable type, `execute!` on the four call sites. --- assistant/src/__tests__/shell-observability.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/assistant/src/__tests__/shell-observability.test.ts b/assistant/src/__tests__/shell-observability.test.ts index 3435c6a068f..14a0d77b0ae 100644 --- a/assistant/src/__tests__/shell-observability.test.ts +++ b/assistant/src/__tests__/shell-observability.test.ts @@ -16,7 +16,7 @@ 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"; +import type { ToolDefinition } from "../tools/types.js"; // ── Mock modules ──────────────────────────────────────────────────────────── @@ -159,7 +159,7 @@ const isKill = (reason: string) => (c: LogCall) => c.fields.reason === reason; describe("shell observability logs", () => { - let shellTool: Tool; + let shellTool: ToolDefinition; beforeEach(async () => { logCalls.length = 0; @@ -174,7 +174,7 @@ describe("shell observability logs", () => { }); test("foreground exit emits structured 'Shell command exited' info log", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "echo obs-foreground", activity: "test" }, baseContext, ); @@ -191,7 +191,7 @@ describe("shell observability logs", () => { }); test("foreground timeout emits killTree warn + exit log with timedOut=true", async () => { - const result = await shellTool.execute( + const result = await shellTool.execute!( { command: "sleep 30", activity: "test", timeout_seconds: 1 }, baseContext, ); @@ -210,7 +210,7 @@ describe("shell observability logs", () => { }, 10_000); test("background mode emits an exit log with mode='background' and the bg invocationId", async () => { - await shellTool.execute( + await shellTool.execute!( { command: "echo bg-obs", activity: "test", background: true }, baseContext, ); @@ -225,7 +225,7 @@ describe("shell observability logs", () => { test("aborted foreground command emits killTree warn with reason='abort'", async () => { const controller = new AbortController(); - const execPromise = shellTool.execute( + const execPromise = shellTool.execute!( { command: "sleep 30", activity: "test" }, { ...baseContext, signal: controller.signal }, ); From 867810963ca2530dab74360c22d54a7f8f8d6e43 Mon Sep 17 00:00:00 2001 From: "vellum-apollo-bot[bot]" <242025090+vellum-apollo-bot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 16:50:19 +0000 Subject: [PATCH 04/10] refactor(tools): convert class-based tools to ToolDefinition literals + consolidate schemas Replaces all 22 class-based tools with plain object literals typed as `ToolDefinition`, addressing PR review feedback. Class+instance indirection is gone; `finalizeTool` no longer has anything to do with prototypes. Schema consolidation: - `ToolDefinitionSchema` (tools/types.ts) is the single source of truth. `WireToolDefinitionSchema = ToolDefinitionSchema.required({...})` with `executionTarget` optional. - `ipc/skill-routes/registries.ts` imports the canonical schema; the local duplicate and `RiskLevel` cast are removed (`z.enum(RiskLevel)` infers as the native enum). Refactors: - ask-question: constructor-injected `prompterFactory` becomes a `createAskQuestionTool()` factory; default export `askQuestionTool`. Test file updated to import the literal + factory. - host-filesystem/transfer: `executeLocal` lifted from a private method to a module-level function (registry stores finalized literal references, so `this`-based dispatch is unavailable). - 20 other tools converted to literals (zero `this.` usage). Tests: - ask-question 23/23, host-filesystem 29/29, memory 14/14, network web-search 36/36 pass in isolation. Broad-batch failures are pre-existing Bun mock-leakage: baseline at merge-base shows 167 fail / 537 tests; this branch shows 79 fail / 489 tests under the same conditions. --- assistant/src/ipc/skill-routes/registries.ts | 30 +- .../ask-question/ask-question-tool.test.ts | 58 ++-- .../tools/ask-question/ask-question-tool.ts | 309 +++++++++--------- .../make-authenticated-request.ts | 107 +++--- .../run-authenticated-command.ts | 159 ++++----- assistant/src/tools/credentials/vault.ts | 223 ++++++------- assistant/src/tools/filesystem/edit.ts | 87 ++--- assistant/src/tools/filesystem/list.ts | 63 ++-- assistant/src/tools/filesystem/read.ts | 73 +++-- assistant/src/tools/filesystem/write.ts | 65 ++-- assistant/src/tools/host-filesystem/edit.ts | 86 ++--- assistant/src/tools/host-filesystem/read.ts | 72 ++-- .../src/tools/host-filesystem/transfer.ts | 229 +++++++------ assistant/src/tools/host-filesystem/write.ts | 64 ++-- .../src/tools/host-terminal/host-shell.ts | 98 +++--- assistant/src/tools/memory/register.ts | 47 ++- assistant/src/tools/network/web-fetch.ts | 95 +++--- assistant/src/tools/network/web-search.ts | 29 +- assistant/src/tools/skills/execute.ts | 65 ++-- assistant/src/tools/skills/load.ts | 52 +-- assistant/src/tools/subagent/notify-parent.ts | 67 ++-- .../src/tools/system/request-permission.ts | 57 ++-- assistant/src/tools/terminal/shell.ts | 23 +- assistant/src/tools/types.ts | 76 +++-- 24 files changed, 1163 insertions(+), 1071 deletions(-) diff --git a/assistant/src/ipc/skill-routes/registries.ts b/assistant/src/ipc/skill-routes/registries.ts index 35bc7d83af1..d31f5fc1c74 100644 --- a/assistant/src/ipc/skill-routes/registries.ts +++ b/assistant/src/ipc/skill-routes/registries.ts @@ -25,7 +25,7 @@ import { registerSkillRoute } from "../../runtime/skill-route-registry.js"; import { resolveExecutionTarget } from "../../tools/execution-target.js"; import { registerSkillTools } from "../../tools/registry.js"; import type { Tool } from "../../tools/types.js"; -import { RiskLevel } from "../../tools/types.js"; +import { WireToolDefinitionSchema } 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"; @@ -33,22 +33,12 @@ import type { SkillIpcConnection } from "../skill-server.js"; const log = getLogger("skill-routes-registries"); // ── Wire-level schemas ──────────────────────────────────────────────── - -/** - * Wire form of a {@link ToolDefinition} sent over IPC by a skill process. - * Identical structurally to {@link ToolDefinition} except `execute` is - * dropped (a closure cannot cross the socket) — the daemon synthesizes - * an `execute` that forwards invocations back over IPC; see - * {@link buildProxyTool}. - */ -const WireToolDefinitionSchema = 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(), -}); +// +// `WireToolDefinitionSchema` is the single source of truth for the wire +// form of a tool. It lives in `tools/types.ts` alongside the loose +// `ToolDefinitionSchema` that `ToolDefinition` is inferred from, so both +// the in-process author shape and the IPC wire shape derive from one +// schema declaration. // `skillId` lives at the params level rather than per-tool: a single // `register_tools` IPC frame is always one skill's batch, ownership flows @@ -183,15 +173,15 @@ function buildProxyTool(definition: WireToolDefinition): Tool { // The Zod schema (`WireToolDefinitionSchema`) requires name, description, // input_schema, defaultRiskLevel, and category — `definition` arrives via // that parse, so every field below is guaranteed present at runtime. - // RiskLevel is a string enum whose values are "low" | "medium" | "high", - // matching the schema above exactly — the cast is a no-op at runtime. + // `defaultRiskLevel` is `z.enum(RiskLevel)` so the inferred type IS + // `RiskLevel` (the native enum), no cast needed. const { name } = definition; return { name, description: definition.description, input_schema: definition.input_schema, category: definition.category, - defaultRiskLevel: definition.defaultRiskLevel as RiskLevel, + defaultRiskLevel: definition.defaultRiskLevel, executionTarget: resolveExecutionTarget({ name, executionTarget: definition.executionTarget, 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..912ea45db9d 100644 --- a/assistant/src/tools/ask-question/ask-question-tool.test.ts +++ b/assistant/src/tools/ask-question/ask-question-tool.test.ts @@ -1,8 +1,8 @@ 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 type { ToolContext, ToolDefinition } from "../types.js"; +import { askQuestionTool, createAskQuestionTool } from "./ask-question-tool.js"; type PromptParams = Parameters< import("../../permissions/question-prompter.js").QuestionPrompter["prompt"] @@ -19,11 +19,11 @@ function makeContext(overrides: Partial = {}): ToolContext { } function makeToolWithStub(result: QuestionPromptResult): { - tool: AskQuestionTool; + tool: ToolDefinition; calls: PromptParams[]; } { 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"); @@ -102,7 +102,7 @@ describe("AskQuestionTool.execute", () => { singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute(validInput, makeContext()); + const result = await tool.execute!(validInput, makeContext()); expect(calls).toHaveLength(1); expect(calls[0]?.conversationId).toBe("conv-1"); @@ -125,7 +125,7 @@ describe("AskQuestionTool.execute", () => { const { tool } = makeToolWithStub( singleCompleted({ decision: "option", optionId: "b" }), ); - const result = await tool.execute(validInput, makeContext()); + const result = await tool.execute!(validInput, makeContext()); expect(result.content).toBe( `Question "${validInput.question}" → Option: b (Banana)`, ); @@ -136,7 +136,7 @@ describe("AskQuestionTool.execute", () => { const { tool } = makeToolWithStub( singleCompleted({ decision: "option", optionId: "ghost" }), ); - const result = await tool.execute(validInput, makeContext()); + const result = await tool.execute!(validInput, makeContext()); expect(result.content).toBe( `Question "${validInput.question}" → Option: ghost ((unknown))`, ); @@ -147,7 +147,7 @@ describe("AskQuestionTool.execute", () => { const { tool } = makeToolWithStub( singleCompleted({ decision: "free_text", text: "Cherry" }), ); - const result = await tool.execute(validInput, makeContext()); + const result = await tool.execute!(validInput, makeContext()); expect(result.content).toBe( `Question "${validInput.question}" → Free text: Cherry`, ); @@ -156,7 +156,7 @@ describe("AskQuestionTool.execute", () => { test("formats skipped result", async () => { const { tool } = makeToolWithStub(singleCompleted({ decision: "skipped" })); - const result = await tool.execute(validInput, makeContext()); + const result = await tool.execute!(validInput, makeContext()); expect(result.content).toBe(`Question "${validInput.question}" → Skipped`); expect(result.isError).toBe(false); }); @@ -166,7 +166,7 @@ describe("AskQuestionTool.execute", () => { entries: [{ questionId: "q1", decision: "timed_out" }], overall: "timed_out", }); - const result = await tool.execute(validInput, makeContext()); + const result = await tool.execute!(validInput, makeContext()); expect(result.isError).toBe(true); expect(result.content).toBe("User did not respond within timeout"); }); @@ -176,7 +176,7 @@ describe("AskQuestionTool.execute", () => { entries: [{ questionId: "q1", decision: "skipped" }], overall: "aborted", }); - const result = await tool.execute(validInput, makeContext()); + const result = await tool.execute!(validInput, makeContext()); expect(result.isError).toBe(true); expect(result.content).toBe("Question aborted"); }); @@ -185,7 +185,7 @@ describe("AskQuestionTool.execute", () => { const { tool, calls } = makeToolWithStub( singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute( + const result = await tool.execute!( { ...validInput, options: [{ id: "a", label: "Apple" }] }, makeContext(), ); @@ -198,7 +198,7 @@ describe("AskQuestionTool.execute", () => { const { tool, calls } = makeToolWithStub( singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute( + const result = await tool.execute!( { ...validInput, options: [ @@ -219,7 +219,7 @@ describe("AskQuestionTool.execute", () => { const { tool, calls } = makeToolWithStub( singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute( + const result = await tool.execute!( { ...validInput, question: "" }, makeContext(), ); @@ -232,7 +232,7 @@ describe("AskQuestionTool.execute", () => { singleCompleted({ decision: "option", optionId: "a" }), ); const ac = new AbortController(); - await tool.execute(validInput, makeContext({ signal: ac.signal })); + await tool.execute!(validInput, makeContext({ signal: ac.signal })); expect(calls[0]?.signal).toBe(ac.signal); }); }); @@ -245,7 +245,7 @@ describe("AskQuestionTool batched input", () => { singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute(validInput, makeContext()); + const result = await tool.execute!(validInput, makeContext()); expect(calls).toHaveLength(1); expect(calls[0]?.questions).toHaveLength(1); @@ -259,7 +259,7 @@ describe("AskQuestionTool batched input", () => { singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute({ questions: [singleQ] }, makeContext()); + const result = await tool.execute!({ questions: [singleQ] }, makeContext()); expect(calls).toHaveLength(1); expect(calls[0]?.questions).toHaveLength(1); @@ -298,7 +298,7 @@ describe("AskQuestionTool batched input", () => { overall: "completed", }); - const result = await tool.execute( + const result = await tool.execute!( { questions: [singleQ, q2, q3] }, makeContext(), ); @@ -345,7 +345,7 @@ describe("AskQuestionTool batched input", () => { overall: "completed", }); - const result = await tool.execute( + const result = await tool.execute!( { questions: [singleQ, q2, q3] }, makeContext(), ); @@ -376,7 +376,7 @@ describe("AskQuestionTool batched input", () => { overall: "closed", }); - const result = await tool.execute( + const result = await tool.execute!( { questions: [singleQ, q2] }, makeContext(), ); @@ -404,7 +404,7 @@ describe("AskQuestionTool batched input", () => { }); const five = [singleQ, singleQ, singleQ, singleQ, singleQ]; - const result = await tool.execute({ questions: five }, makeContext()); + const result = await tool.execute!({ questions: five }, makeContext()); expect(result.isError).toBe(false); expect(calls).toHaveLength(1); @@ -417,7 +417,7 @@ describe("AskQuestionTool batched input", () => { ); const six = [singleQ, singleQ, singleQ, singleQ, singleQ, singleQ]; - const result = await tool.execute({ questions: six }, makeContext()); + const result = await tool.execute!({ questions: six }, makeContext()); expect(result.isError).toBe(true); expect(result.content.toLowerCase()).toContain("invalid input"); @@ -429,7 +429,7 @@ describe("AskQuestionTool batched input", () => { singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute({ questions: [] }, makeContext()); + const result = await tool.execute!({ questions: [] }, makeContext()); expect(result.isError).toBe(true); expect(result.content.toLowerCase()).toContain("invalid input"); @@ -441,7 +441,7 @@ describe("AskQuestionTool batched input", () => { singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute({}, makeContext()); + const result = await tool.execute!({}, makeContext()); expect(result.isError).toBe(true); expect(result.content.toLowerCase()).toContain("invalid input"); @@ -453,7 +453,7 @@ describe("AskQuestionTool batched input", () => { singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute({ question: "Hi?" }, makeContext()); + const result = await tool.execute!({ question: "Hi?" }, makeContext()); expect(result.isError).toBe(true); expect(result.content.toLowerCase()).toContain("invalid input"); @@ -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 409a90ecf1f..6a7f2b1344f 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 { ToolContext, ToolDefinition, 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 ToolDefinition { - 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 }), +): ToolDefinition { + 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, - }; - } - } + }, + }; } -export const askQuestionTool = new AskQuestionTool(); +export const askQuestionTool: ToolDefinition = createAskQuestionTool(); diff --git a/assistant/src/tools/credential-execution/make-authenticated-request.ts b/assistant/src/tools/credential-execution/make-authenticated-request.ts index ace5bdc3cb3..ea1b1645fa0 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 { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; const log = getLogger("ces-tool:make-authenticated-request"); -class MakeAuthenticatedRequestTool implements ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { isError: true, }; } - } -} - -export const makeAuthenticatedRequestTool = new MakeAuthenticatedRequestTool(); + }, +}; diff --git a/assistant/src/tools/credential-execution/run-authenticated-command.ts b/assistant/src/tools/credential-execution/run-authenticated-command.ts index ea20dd579de..cdd042fa77d 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 { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; const log = getLogger("ces-tool:run-authenticated-command"); -class RunAuthenticatedCommandTool implements ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { isError: true, }; } - } -} - -export const runAuthenticatedCommandTool = new RunAuthenticatedCommandTool(); + }, +}; diff --git a/assistant/src/tools/credentials/vault.ts b/assistant/src/tools/credentials/vault.ts index 9e024ace9f1..74eb5402459 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 { ToolContext, ToolDefinition, 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 ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { default: return { content: `Error: unknown action "${action}"`, isError: true }; } - } -} - -export const credentialStoreTool = new CredentialStoreTool(); + }, +}; diff --git a/assistant/src/tools/filesystem/edit.ts b/assistant/src/tools/filesystem/edit.ts index 5fc82fa61bc..25074a94e7e 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 { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; - -class FileEditTool implements ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { isError: false, diff: { filePath, oldContent, newContent, isNewFile: false }, }; - } -} + }, +}; -export const fileEditTool = new FileEditTool(); registerTool(fileEditTool); diff --git a/assistant/src/tools/filesystem/list.ts b/assistant/src/tools/filesystem/list.ts index 4cd8a65f920..3d0d7d8195e 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 { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; -class FileListTool implements ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { } return { content: result.value.listing, isError: false }; - } -} + }, +}; -export const fileListTool = new FileListTool(); registerTool(fileListTool); diff --git a/assistant/src/tools/filesystem/read.ts b/assistant/src/tools/filesystem/read.ts index 790026645aa..89aaddf5f63 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 { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; -class FileReadTool implements ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { } return { content: result.value.content, isError: false }; - } -} + }, +}; -export const fileReadTool = new FileReadTool(); registerTool(fileReadTool); diff --git a/assistant/src/tools/filesystem/write.ts b/assistant/src/tools/filesystem/write.ts index 2de7d403db8..2703b938fa8 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 { ToolContext, ToolDefinition, 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 ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { isError: false, diff: { filePath, oldContent, newContent, isNewFile }, }; - } -} + }, +}; -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 0c0a919d878..8dea10866bd 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 { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; - -class HostFileEditTool implements ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { isError: false, diff: { filePath, oldContent, newContent, isNewFile: false }, }; - } -} - -export const hostFileEditTool: ToolDefinition = new HostFileEditTool(); + }, +}; diff --git a/assistant/src/tools/host-filesystem/read.ts b/assistant/src/tools/host-filesystem/read.ts index 5752e144d62..af7c82aa414 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 { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; -class HostFileReadTool implements ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { } return { content: result.value.content, isError: false }; - } -} - -export const hostFileReadTool: ToolDefinition = new HostFileReadTool(); + }, +}; diff --git a/assistant/src/tools/host-filesystem/transfer.ts b/assistant/src/tools/host-filesystem/transfer.ts index bbbad192845..d4d49b20bf3 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 { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; -class HostFileTransferTool implements ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { // 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); + }, +}; + +/** + * 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: ToolDefinition = 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 68093b7f117..4bc5b7b6735 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 { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; -class HostFileWriteTool implements ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { isError: false, diff: { filePath, oldContent, newContent, isNewFile }, }; - } -} - -export const hostFileWriteTool: ToolDefinition = new HostFileWriteTool(); + }, +}; diff --git a/assistant/src/tools/host-terminal/host-shell.ts b/assistant/src/tools/host-terminal/host-shell.ts index 2740f26ed09..108731c0ea9 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 { ToolContext, ToolDefinition, 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 ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { }); }); }); - } -} - -export const hostShellTool: ToolDefinition = new HostShellTool(); + }, +}; diff --git a/assistant/src/tools/memory/register.ts b/assistant/src/tools/memory/register.ts index 12fed2c25d6..e9d074a2a5d 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 { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; // ── remember ──────────────────────────────────────────────────────── -class RememberTool implements ToolDefinition { - name = "remember"; - description = graphRememberDefinition.description; - category = "memory"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; - input_schema = graphRememberDefinition.input_schema; +export const rememberTool: ToolDefinition = { + 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 ToolDefinition { isError: !result.success, ...(typedInput.finish_turn === true ? { yieldToUser: true } : {}), }; - } -} + }, +}; // ── recall ────────────────────────────────────────────────────────── -class RecallTool implements ToolDefinition { - name = "recall"; - description = graphRecallDefinition.description; - category = "memory"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Low; - input_schema = graphRecallDefinition.input_schema; +export const recallTool: ToolDefinition = { + 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 ToolDefinition { }); return { content: result.content, isError: false }; - } -} - -// ── Exported tool instances ────────────────────────────────────────── - -export const rememberTool = new RememberTool(); -export const recallTool = new RecallTool(); + }, +}; diff --git a/assistant/src/tools/network/web-fetch.ts b/assistant/src/tools/network/web-fetch.ts index 7b024f5a6af..b2b2f627303 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 { ToolContext, ToolDefinition, 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 ToolDefinition { - 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: ToolDefinition = { + 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 }); - } -} + }, +}; -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 3bca2c53a59..816fe9ab18d 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 { ToolContext, ToolDefinition, 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 ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { }, }, required: ["query"], - }; + }, async execute( input: Record, @@ -902,8 +906,7 @@ class WebSearchTool implements ToolDefinition { `Web search failed: ${msg}`, ); } - } -} + }, +}; -export const webSearchTool = new WebSearchTool(); registerTool(webSearchTool); diff --git a/assistant/src/tools/skills/execute.ts b/assistant/src/tools/skills/execute.ts index 86c8b66e858..edf15edd0ca 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 { ToolContext, ToolDefinition, ToolExecutionResult } from "../types.js"; +import type { + ToolContext, + ToolDefinition, + ToolExecutionResult, +} from "../types.js"; -class SkillExecuteTool implements ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { "skill_execute should be intercepted at session level. If you see this error, the session dispatch is not configured.", isError: true, }; - } -} + }, +}; -export const skillExecuteTool = new SkillExecuteTool(); registerTool(skillExecuteTool); diff --git a/assistant/src/tools/skills/load.ts b/assistant/src/tools/skills/load.ts index 05a4542824f..5bdf38fe832 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 { ToolContext, ToolDefinition, 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 ToolDefinition { - 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: ToolDefinition = { + 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 ToolDefinition { ].join("\n"), isError: false, }; - } -} - -export const skillLoadTool = new SkillLoadTool(); + }, +}; registerTool(skillLoadTool); diff --git a/assistant/src/tools/subagent/notify-parent.ts b/assistant/src/tools/subagent/notify-parent.ts index 68e00586c04..b834af8cdab 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 { ToolContext, ToolDefinition, 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 ToolDefinition { - 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: ToolDefinition = { + 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); - } -} + }, +}; -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 24563dec446..5d1def217d4 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 { ToolContext, ToolDefinition, 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 ToolDefinition { - name = "request_system_permission"; - description = +export const requestSystemPermissionTool: ToolDefinition = { + 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 ToolDefinition { ].join("\n"), isError: false, }; - } -} + }, +}; -export const requestSystemPermissionTool = new RequestSystemPermissionTool(); registerTool(requestSystemPermissionTool); diff --git a/assistant/src/tools/terminal/shell.ts b/assistant/src/tools/terminal/shell.ts index 2b3de966df4..f3d8d6e0d75 100644 --- a/assistant/src/tools/terminal/shell.ts +++ b/assistant/src/tools/terminal/shell.ts @@ -44,14 +44,14 @@ function buildCredentialRefTrace( const log = getLogger("shell-tool"); -class ShellTool implements ToolDefinition { - name = "bash"; - description = "Execute a shell command on the local machine"; - category = "terminal"; - executionTarget = "sandbox" as const; - defaultRiskLevel = RiskLevel.Medium; - - input_schema = { +export const shellTool: ToolDefinition = { + name: "bash", + description: "Execute a shell command on the local machine", + category: "terminal", + executionTarget: "sandbox", + defaultRiskLevel: RiskLevel.Medium, + + input_schema: { type: "object", properties: { command: { @@ -87,7 +87,7 @@ class ShellTool implements ToolDefinition { }, }, required: ["command", "activity"], - }; + }, async execute( input: Record, @@ -558,8 +558,8 @@ class ShellTool implements ToolDefinition { }); return result; - } -} + }, +}; /** * Structured teardown log. Pairs with the `"Executing shell command"` @@ -652,5 +652,4 @@ function buildKillTree( }; } -export const shellTool: ToolDefinition = new ShellTool(); registerTool(shellTool); diff --git a/assistant/src/tools/types.ts b/assistant/src/tools/types.ts index a64b55d3404..79c3322eaaf 100644 --- a/assistant/src/tools/types.ts +++ b/assistant/src/tools/types.ts @@ -1,15 +1,15 @@ import type { ApprovalRequired } from "@vellumai/service-contracts/credential-rpc"; 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,34 +317,68 @@ 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 derives + * a stricter wire schema (`WireToolDefinitionSchema`) where the + * skill-finalized fields become required. + * + * `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; - /** Author-asserted risk band — low / medium / high. Drives default permission gating. */ - defaultRiskLevel?: RiskLevel; + description: z.string().optional(), /** JSON schema describing the tool's input arguments. */ - input_schema?: object; + input_schema: z.record(z.string(), z.unknown()).optional(), + /** Author-asserted risk band — low / medium / high. Drives default permission gating. */ + 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?: ExecutionTarget; + executionTarget: z.enum(["sandbox", "host"]).optional(), +}); + +/** + * Wire form of a {@link ToolDefinition} sent over IPC by a skill process. + * Skills run `finalizeTool` locally before sending, so name, description, + * input_schema, defaultRiskLevel, and category are required on arrival; + * `executionTarget` stays optional because the daemon resolves it via + * `resolveExecutionTarget`. `execute` is dropped — closures cannot cross + * the socket, so {@link buildProxyTool} synthesizes one that forwards + * invocations back over IPC. + */ +export const WireToolDefinitionSchema = ToolDefinitionSchema.required({ + name: true, + description: true, + input_schema: true, + defaultRiskLevel: true, + category: true, +}); + +/** + * 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 an `execute` overlay (non-serializable + * closure) so a single schema is the source of truth for both the + * in-process author shape and the IPC wire shape. + */ +export type ToolDefinition = z.infer & { /** Implementation invoked when the model calls the tool. */ execute?: ( input: Record, context: ToolContext, ) => Promise; - /** Tool category used for Slack channel `allowedToolCategories` enforcement. */ - category?: string; - /** - * 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?: string; -} +}; /** Tool after the loader has derived its name and filled defaults. */ export type Tool = Required; From 2b26dca47195cab0a79ed57d0d8a26c0fa8496a6 Mon Sep 17 00:00:00 2001 From: "vellum-apollo-bot[bot]" <242025090+vellum-apollo-bot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 17:07:07 +0000 Subject: [PATCH 05/10] fix(tools): make ToolDefinition.execute required + widen input_schema to object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI typecheck caught three regressions from the schema-driven type: 1. `ExecutionTarget` type import was removed by accident while restructuring the import block. Restored. 2. `ToolDefinition.execute` was inferred as optional (because the schema doesn't include execute), so every test calling `tool.execute(...)` failed with TS18048. Changed the type overlay to require execute — every in-process tool literal has one, and the wire schema still parses without it (closures can't cross IPC; `buildProxyTool` synthesizes execute on arrival). 3. `input_schema` was inferred as `Record` from `z.record(z.string(), z.unknown())`, which is stricter than the prior `object` interface field. Factories and tests assigning typed JSON-schema objects (`as object`) failed. Widened the overlay to `input_schema?: object` so authors can keep using JSON schema literals; wire form still parses to `Record` via `WireToolDefinitionSchema`. Also updated `managed-skill-lifecycle.test.ts` to use the literal `skillLoadTool` import instead of the (removed) `SkillLoadTool` class. --- .../__tests__/managed-skill-lifecycle.test.ts | 9 +------ assistant/src/tools/types.ts | 25 +++++++++++++++---- 2 files changed, 21 insertions(+), 13 deletions(-) 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/tools/types.ts b/assistant/src/tools/types.ts index 79c3322eaaf..457f0ebb84e 100644 --- a/assistant/src/tools/types.ts +++ b/assistant/src/tools/types.ts @@ -1,6 +1,7 @@ import type { ApprovalRequired } from "@vellumai/service-contracts/credential-rpc"; import type { DiffInfo, + ExecutionTarget, ProxyApprovalCallback, SensitiveOutputBinding, ToolExecutionErrorEvent, @@ -368,13 +369,27 @@ export const WireToolDefinitionSchema = ToolDefinitionSchema.required({ * 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 an `execute` overlay (non-serializable - * closure) so a single schema is the source of truth for both the - * in-process author shape and the IPC wire shape. + * (serializable fields) plus overlays for `input_schema` and `execute` + * — both required at author time, but represented differently from the + * wire schema: + * - `input_schema` is widened from `Record` (the + * parsed wire shape) to `object`, so authors can assign a typed + * JSON-schema literal (e.g. `as const`) without `as Record<...>` + * gymnastics. The wire form still parses to `Record` via {@link WireToolDefinitionSchema}. + * - `execute` is required at the in-process layer (every tool a + * literal author writes has one) but absent from the schema + * (closures cannot cross IPC; `buildProxyTool` synthesizes one on + * the daemon side). */ -export type ToolDefinition = z.infer & { +export type ToolDefinition = Omit< + z.infer, + "input_schema" +> & { + /** JSON schema describing the tool's input arguments. */ + input_schema?: object; /** Implementation invoked when the model calls the tool. */ - execute?: ( + execute: ( input: Record, context: ToolContext, ) => Promise; From a1d2a45de8d1e6365d58b2eae99d939ca0828523 Mon Sep 17 00:00:00 2001 From: "vellum-apollo-bot[bot]" <242025090+vellum-apollo-bot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 17:18:31 +0000 Subject: [PATCH 06/10] fix(tools): keep execute optional on ToolDefinition, bang test call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverting the previous `execute: required` change — there are schema-only ToolDefinitions in the codebase (`graphRememberDefinition`, `storeStyleAnalysisTool`, `SWEEP_TOOL`) that are handed to providers as function-calling schemas without ever being registered for execution. Requiring execute on the type would break those legitimate use cases, plus the plugin-api contract test that asserts an empty literal satisfies `ToolDefinition`. Instead: keep `execute` optional, and add the `!` bang to test call sites (mirroring the existing pattern in `host-filesystem/*.test.ts`). Sed-replaced 111 call sites across 8 test files. Affected tools: credentialStoreTool, fileListTool, makeAuthenticatedRequestTool, recallTool, rememberTool, runAuthenticatedCommandTool, webSearchTool. Also added a comment in types.ts documenting why execute is optional and the schema-only ToolDefinition pattern, with pointers to the three existing instances. --- .../credential-execution-tools.test.ts | 5 +- .../__tests__/credential-security-e2e.test.ts | 16 ++-- .../__tests__/credential-vault-unit.test.ts | 60 ++++++------ .../src/__tests__/credential-vault.test.ts | 96 +++++++++---------- .../src/__tests__/file-list-tool.test.ts | 6 +- ...rovider-platform-proxy-integration.test.ts | 2 +- .../src/__tests__/secret-onetime-send.test.ts | 8 +- assistant/src/tools/memory/register.test.ts | 24 ++--- assistant/src/tools/types.ts | 24 ++--- 9 files changed, 121 insertions(+), 120 deletions(-) diff --git a/assistant/src/__tests__/credential-execution-tools.test.ts b/assistant/src/__tests__/credential-execution-tools.test.ts index c6d4d9b398a..3e4accebd33 100644 --- a/assistant/src/__tests__/credential-execution-tools.test.ts +++ b/assistant/src/__tests__/credential-execution-tools.test.ts @@ -6,7 +6,6 @@ import { manageSecureCommandTool } from "../tools/credential-execution/manage-se import { runAuthenticatedCommandTool } from "../tools/credential-execution/run-authenticated-command.js"; import { cesTools, getCesToolsIfEnabled } from "../tools/tool-manifest.js"; - // --------------------------------------------------------------------------- // Schema shape tests // --------------------------------------------------------------------------- @@ -170,7 +169,7 @@ describe("CES tool execution without client", () => { }; test("make_authenticated_request fails gracefully when CES client is absent", async () => { - const result = await makeAuthenticatedRequestTool.execute( + const result = await makeAuthenticatedRequestTool.execute!( { credentialHandle: "local_static:test/key", method: "GET", @@ -184,7 +183,7 @@ describe("CES tool execution without client", () => { }); test("run_authenticated_command fails gracefully when CES client is absent", async () => { - const result = await runAuthenticatedCommandTool.execute( + const result = await runAuthenticatedCommandTool.execute!( { credentialHandle: "local_static:test/key", command: "echo hello", diff --git a/assistant/src/__tests__/credential-security-e2e.test.ts b/assistant/src/__tests__/credential-security-e2e.test.ts index fef2e09885d..3bce9d0937b 100644 --- a/assistant/src/__tests__/credential-security-e2e.test.ts +++ b/assistant/src/__tests__/credential-security-e2e.test.ts @@ -152,7 +152,7 @@ describe("E2E: secure store and list lifecycle", () => { }); test("store persists credential and returns metadata-only confirmation", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "github", @@ -183,7 +183,7 @@ describe("E2E: secure store and list lifecycle", () => { field: "access_key", }); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "list" }, makeContext(), ); @@ -198,7 +198,7 @@ describe("E2E: secure store and list lifecycle", () => { test("delete removes credential from credential store", async () => { storedKeys.set("credential/github/token", "secret1"); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "delete", service: "github", field: "token" }, makeContext(), ); @@ -225,7 +225,7 @@ describe("E2E: one-time send override", () => { delivery: "transient_send" as const, }), }); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "svc", field: "key", label: "Key" }, ctx, ); @@ -242,7 +242,7 @@ describe("E2E: one-time send override", () => { delivery: "transient_send" as const, }), }); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "svc", field: "key", label: "Key" }, ctx, ); @@ -262,7 +262,7 @@ describe("E2E: one-time send override", () => { delivery: "store" as const, }), }); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "svc", field: "key", label: "Key" }, ctx, ); @@ -337,7 +337,7 @@ describe("E2E: cross-cutting secret leak prevention", () => { test("store output never contains the stored value", async () => { const secret = ["sk", "-proj-", "abc123xyz"].join(""); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "openai", field: "api_key", value: secret }, makeContext(), ); @@ -353,7 +353,7 @@ describe("E2E: cross-cutting secret leak prevention", () => { delivery: "transient_send" as const, }), }); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "svc", field: "key", label: "Key" }, ctx, ); diff --git a/assistant/src/__tests__/credential-vault-unit.test.ts b/assistant/src/__tests__/credential-vault-unit.test.ts index d2722bf82e4..4b9b3c9ee9d 100644 --- a/assistant/src/__tests__/credential-vault-unit.test.ts +++ b/assistant/src/__tests__/credential-vault-unit.test.ts @@ -516,7 +516,7 @@ describe("credential_store tool — unknown action", () => { }); test("returns error for unknown action", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "unknown_action" }, _ctx, ); @@ -546,7 +546,7 @@ describe("credential_store tool — prompt action", () => { }); test("returns error when requestSecret is not available", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "svc", field: "key", label: "API Key" }, _ctx, // no requestSecret ); @@ -555,7 +555,7 @@ describe("credential_store tool — prompt action", () => { }); test("returns error when service is missing for prompt", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", field: "key" }, _ctx, ); @@ -564,7 +564,7 @@ describe("credential_store tool — prompt action", () => { }); test("returns error when field is missing for prompt", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "svc" }, _ctx, ); @@ -580,7 +580,7 @@ describe("credential_store tool — prompt action", () => { delivery: "store" as const, }), }; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "svc", field: "key", label: "Test" }, ctxWithPrompt, ); @@ -596,7 +596,7 @@ describe("credential_store tool — prompt action", () => { delivery: "store" as const, }), }; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "test-prompt", @@ -623,7 +623,7 @@ describe("credential_store tool — prompt action", () => { delivery: "store" as const, }), }; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "github", @@ -656,7 +656,7 @@ describe("credential_store tool — prompt action", () => { }), }; - const appResult = await credentialStoreTool.execute( + const appResult = await credentialStoreTool.execute!( { action: "prompt", service: "slack_channel", @@ -670,7 +670,7 @@ describe("credential_store tool — prompt action", () => { expect(slackChannelConfigCalls).toEqual([{ appToken: "xapp-test-token" }]); expect(appResult.content).toContain("connection incomplete"); - const botResult = await credentialStoreTool.execute( + const botResult = await credentialStoreTool.execute!( { action: "prompt", service: "slack_channel", @@ -699,7 +699,7 @@ describe("credential_store tool — prompt action", () => { }), }; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "slack_channel", @@ -724,7 +724,7 @@ describe("credential_store tool — prompt action", () => { }), }; - const appResult = await credentialStoreTool.execute( + const appResult = await credentialStoreTool.execute!( { action: "prompt", service: "slack_channel", @@ -735,7 +735,7 @@ describe("credential_store tool — prompt action", () => { ); expect(appResult.isError).toBe(false); - const botResult = await credentialStoreTool.execute( + const botResult = await credentialStoreTool.execute!( { action: "prompt", service: "slack_channel", @@ -761,7 +761,7 @@ describe("credential_store tool — prompt action", () => { }), }; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "slack_channel", @@ -795,7 +795,7 @@ describe("credential_store tool — prompt action", () => { }), }; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "slack_channel", @@ -822,7 +822,7 @@ describe("credential_store tool — prompt action", () => { ..._ctx, requestSecret: async () => ({ value: "val", delivery: "store" as const }), }; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "svc", @@ -857,7 +857,7 @@ describe("credential_store tool — slack_channel store routing", () => { }); test("store with user_token routes to setSlackChannelConfig as third positional arg", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "slack_channel", @@ -883,7 +883,7 @@ describe("credential_store tool — slack_channel store routing", () => { }); test("store with user_token surfaces handler rejection for malformed token", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "slack_channel", @@ -906,7 +906,7 @@ describe("credential_store tool — slack_channel store routing", () => { }); test("store with bot_token still routes via first positional arg", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "slack_channel", @@ -927,7 +927,7 @@ describe("credential_store tool — slack_channel store routing", () => { }); test("store with app_token still routes via second positional arg", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "slack_channel", @@ -968,7 +968,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("rejects alias that is not a string", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "svc", @@ -983,7 +983,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("rejects injection_templates that is not an array", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "svc", @@ -998,7 +998,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("rejects template with invalid injectionType", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "svc", @@ -1017,7 +1017,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("rejects template with empty hostPattern", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "svc", @@ -1038,7 +1038,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("rejects template with non-string valuePrefix", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "svc", @@ -1060,7 +1060,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("reports multiple template errors at once", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "svc", @@ -1079,7 +1079,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("delete removes both secret and metadata", async () => { - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "del-test", @@ -1098,7 +1098,7 @@ describe("credential_store tool — store validation edge cases", () => { expect(getCredentialMetadata("del-test", "key")).toBeDefined(); // Delete - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "delete", service: "del-test", @@ -1154,7 +1154,7 @@ describe("credential_store tool — slack_channel delete routing", () => { upsertCredentialMetadata("slack_channel", "user_token", {}); manualConnectionStore["slack_channel"] = "active"; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "delete", service: "slack_channel", @@ -1202,7 +1202,7 @@ describe("credential_store tool — slack_channel delete routing", () => { upsertCredentialMetadata("slack_channel", "app_token", {}); manualConnectionStore["slack_channel"] = "active"; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "delete", service: "slack_channel", @@ -1238,7 +1238,7 @@ describe("credential_store tool — slack_channel delete routing", () => { upsertCredentialMetadata("slack_channel", "app_token", {}); manualConnectionStore["slack_channel"] = "active"; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "delete", service: "slack_channel", diff --git a/assistant/src/__tests__/credential-vault.test.ts b/assistant/src/__tests__/credential-vault.test.ts index fc690950998..7c6175e604c 100644 --- a/assistant/src/__tests__/credential-vault.test.ts +++ b/assistant/src/__tests__/credential-vault.test.ts @@ -229,7 +229,7 @@ async function executeVault( } case "list": - return credentialStoreTool.execute({ action: "list" }, _ctx); + return credentialStoreTool.execute!({ action: "list" }, _ctx); case "delete": { const service = input.service as string | undefined; @@ -356,7 +356,7 @@ describe("credential_store tool", () => { }); test("store success includes credential_id via credentialStoreTool", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "test-cred-id", @@ -380,7 +380,7 @@ describe("credential_store tool", () => { // ----------------------------------------------------------------------- describe("list action", () => { test("lists stored credentials with credential_id, service, field", async () => { - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "gmail", @@ -389,7 +389,7 @@ describe("credential_store tool", () => { }, _ctx, ); - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "github", @@ -399,7 +399,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "list" }, _ctx, ); @@ -427,7 +427,7 @@ describe("credential_store tool", () => { }); test("list output includes alias when set", async () => { - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "fal", @@ -438,7 +438,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "list" }, _ctx, ); @@ -451,7 +451,7 @@ describe("credential_store tool", () => { }); test("list output includes template summary with host patterns", async () => { - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "fal", @@ -474,7 +474,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "list" }, _ctx, ); @@ -493,7 +493,7 @@ describe("credential_store tool", () => { test("list does not include credential values", async () => { const testValue = "test-dummy-value-for-list"; - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "test", @@ -503,7 +503,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "list" }, _ctx, ); @@ -519,7 +519,7 @@ describe("credential_store tool", () => { }); test("returns empty array when no credentials exist", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "list" }, _ctx, ); @@ -528,7 +528,7 @@ describe("credential_store tool", () => { }); test("lists multiple credentials", async () => { - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "gmail", @@ -537,7 +537,7 @@ describe("credential_store tool", () => { }, _ctx, ); - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "github", @@ -547,7 +547,7 @@ describe("credential_store tool", () => { }, _ctx, ); - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "fal", @@ -565,7 +565,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "list" }, _ctx, ); @@ -591,7 +591,7 @@ describe("credential_store tool", () => { test("works with metadata store fallback when listing secrets", async () => { // Store a credential first (on encrypted backend) - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "keychain-test", @@ -601,7 +601,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "list" }, _ctx, ); @@ -622,7 +622,7 @@ describe("credential_store tool", () => { "utf-8", ); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "list" }, _ctx, ); @@ -632,7 +632,7 @@ describe("credential_store tool", () => { test("excludes metadata entries whose secret was deleted from secure storage", async () => { // Store two credentials so both metadata and secrets exist - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "svc-a", @@ -641,7 +641,7 @@ describe("credential_store tool", () => { }, _ctx, ); - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "svc-b", @@ -655,7 +655,7 @@ describe("credential_store tool", () => { // a divergence where metadata write failed after secret deletion) await deleteSecureKeyAsync(credentialKey("svc-a", "key")); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "list" }, _ctx, ); @@ -668,7 +668,7 @@ describe("credential_store tool", () => { test("recovers from corrupt secure storage by resetting and returning empty list", async () => { // Store a credential so metadata exists - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "svc-x", @@ -682,7 +682,7 @@ describe("credential_store tool", () => { // backing up the corrupt file and creating a fresh store writeFileSync(STORE_PATH, "not-valid-json!!!", "utf-8"); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "list" }, _ctx, ); @@ -742,7 +742,7 @@ describe("credential_store tool", () => { test("delete also disconnects OAuth connection for the service", async () => { // Store a credential via the real tool so metadata exists - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "google", @@ -760,7 +760,7 @@ describe("credential_store tool", () => { expiresAt: Date.now() + 3600_000, }); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "delete", service: "google", @@ -805,7 +805,7 @@ describe("credential_store tool", () => { }); test("store with policy fields persists metadata", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "github", @@ -826,7 +826,7 @@ describe("credential_store tool", () => { }); test("store without policy fields defaults to empty arrays", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "slack", @@ -843,7 +843,7 @@ describe("credential_store tool", () => { }); test("store rejects invalid policy input", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "test", @@ -858,7 +858,7 @@ describe("credential_store tool", () => { }); test("list action entries do not expose policy metadata", async () => { - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "myservice", @@ -871,7 +871,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "list" }, _ctx, ); @@ -896,7 +896,7 @@ describe("credential_store tool", () => { // ----------------------------------------------------------------------- describe("alias and injection template fields", () => { test("store with valid alias and templates persists metadata", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "fal", @@ -926,7 +926,7 @@ describe("credential_store tool", () => { }); test("store with alias only (no templates)", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "openai", @@ -944,7 +944,7 @@ describe("credential_store tool", () => { }); test("store with templates only (no alias)", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "replicate", @@ -970,7 +970,7 @@ describe("credential_store tool", () => { }); test("rejects template missing headerName for header type", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "fal", @@ -991,7 +991,7 @@ describe("credential_store tool", () => { }); test("rejects template missing queryParamName for query type", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "mapbox", @@ -1012,7 +1012,7 @@ describe("credential_store tool", () => { }); test("round-trip: store then list shows the credential", async () => { - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "anthropic", @@ -1030,7 +1030,7 @@ describe("credential_store tool", () => { _ctx, ); - const listResult = await credentialStoreTool.execute( + const listResult = await credentialStoreTool.execute!( { action: "list" }, _ctx, ); @@ -1050,7 +1050,7 @@ describe("credential_store tool", () => { }); test("update alias on existing credential", async () => { - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "fal", @@ -1065,7 +1065,7 @@ describe("credential_store tool", () => { expect(metadata!.alias).toBe("fal-old"); // Re-store same credential with updated alias - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "fal", @@ -1081,7 +1081,7 @@ describe("credential_store tool", () => { }); test("store with query injection template", async () => { - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "store", service: "mapbox", @@ -1112,7 +1112,7 @@ describe("credential_store tool", () => { // ----------------------------------------------------------------------- describe("multi-key same-service storage", () => { test("stores two credentials with same service but different aliases", async () => { - const result1 = await credentialStoreTool.execute( + const result1 = await credentialStoreTool.execute!( { action: "store", service: "openai", @@ -1124,7 +1124,7 @@ describe("credential_store tool", () => { ); expect(result1.isError).toBe(false); - const result2 = await credentialStoreTool.execute( + const result2 = await credentialStoreTool.execute!( { action: "store", service: "openai", @@ -1146,7 +1146,7 @@ describe("credential_store tool", () => { }); test("listing shows both same-service credentials independently", async () => { - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "openai", @@ -1156,7 +1156,7 @@ describe("credential_store tool", () => { }, _ctx, ); - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "openai", @@ -1167,7 +1167,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "list" }, _ctx, ); @@ -1186,7 +1186,7 @@ describe("credential_store tool", () => { }); test("each same-service credential has its own credential_id", async () => { - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "openai", @@ -1196,7 +1196,7 @@ describe("credential_store tool", () => { }, _ctx, ); - await credentialStoreTool.execute( + await credentialStoreTool.execute!( { action: "store", service: "openai", diff --git a/assistant/src/__tests__/file-list-tool.test.ts b/assistant/src/__tests__/file-list-tool.test.ts index 8840477c508..059087dec98 100644 --- a/assistant/src/__tests__/file-list-tool.test.ts +++ b/assistant/src/__tests__/file-list-tool.test.ts @@ -141,7 +141,7 @@ describe("FileListTool", () => { writeFileSync(join(dir, "README.md"), "# Hello"); writeFileSync(join(dir, "index.ts"), "export {}"); - const result = await fileListTool.execute( + const result = await fileListTool.execute!( { path: dir, activity: "listing test dir" }, makeToolContext(dir), ); @@ -157,7 +157,7 @@ describe("FileListTool", () => { test("execute() returns error for invalid path input", async () => { const dir = makeTempDir(); - const result = await fileListTool.execute( + const result = await fileListTool.execute!( { path: 123, activity: "test" }, makeToolContext(dir), ); @@ -167,7 +167,7 @@ describe("FileListTool", () => { test("execute() returns error for nonexistent directory", async () => { const dir = makeTempDir(); - const result = await fileListTool.execute( + const result = await fileListTool.execute!( { path: join(dir, "nope"), activity: "test" }, makeToolContext(dir), ); diff --git a/assistant/src/__tests__/provider-platform-proxy-integration.test.ts b/assistant/src/__tests__/provider-platform-proxy-integration.test.ts index bed1dfb359d..c4a2c8d82d4 100644 --- a/assistant/src/__tests__/provider-platform-proxy-integration.test.ts +++ b/assistant/src/__tests__/provider-platform-proxy-integration.test.ts @@ -600,7 +600,7 @@ describe("managed proxy integration — managed web search routing", () => { }); const { webSearchTool } = await import("../tools/network/web-search.js"); - const result = await webSearchTool.execute( + const result = await webSearchTool.execute!( { query: "managed kimi query", count: 1, offset: 2, freshness: "pw" }, { conversationId: "conv-123", diff --git a/assistant/src/__tests__/secret-onetime-send.test.ts b/assistant/src/__tests__/secret-onetime-send.test.ts index f7fd41c4a4a..1e9d1ebdab3 100644 --- a/assistant/src/__tests__/secret-onetime-send.test.ts +++ b/assistant/src/__tests__/secret-onetime-send.test.ts @@ -110,7 +110,7 @@ describe("one-time send override", () => { }), }; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "svc", field: "key", label: "Key" }, context, ); @@ -132,7 +132,7 @@ describe("one-time send override", () => { }), }; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "svc", field: "key", label: "Key" }, context, ); @@ -151,7 +151,7 @@ describe("one-time send override", () => { requestSecret: async () => ({ value: "v1", delivery: "store" as const }), }; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "svc", field: "key", label: "Key" }, context, ); @@ -173,7 +173,7 @@ describe("one-time send override", () => { }), }; - const result = await credentialStoreTool.execute( + const result = await credentialStoreTool.execute!( { action: "prompt", service: "svc", field: "key", label: "Key" }, context, ); diff --git a/assistant/src/tools/memory/register.test.ts b/assistant/src/tools/memory/register.test.ts index ad73e35e43d..a6df2c8b656 100644 --- a/assistant/src/tools/memory/register.test.ts +++ b/assistant/src/tools/memory/register.test.ts @@ -148,7 +148,7 @@ describe("recallTool.execute", () => { }); test("allows guardian recall to invoke the agentic runner", async () => { - const result = await recallTool.execute( + const result = await recallTool.execute!( { query: "guardian recall" }, makeContext({ trustClass: "guardian" }), ); @@ -164,7 +164,7 @@ describe("recallTool.execute", () => { test.each(["trusted_contact", "unknown"] as const)( "blocks %s recall before invoking the agentic runner", async (trustClass) => { - const result = await recallTool.execute( + const result = await recallTool.execute!( { query: "sensitive local search", sources: ["workspace"] }, makeContext({ trustClass }), ); @@ -176,7 +176,7 @@ describe("recallTool.execute", () => { ); test("passes source filtering input through to agentic recall", async () => { - const result = await recallTool.execute( + const result = await recallTool.execute!( { query: "release notes", sources: ["memory", "workspace"], @@ -202,7 +202,7 @@ describe("recallTool.execute", () => { test("returns deterministic fallback content directly", async () => { recallContent = "Found evidence:\n\n- [workspace] fallback note"; - const result = await recallTool.execute( + const result = await recallTool.execute!( { query: "fallback search", sources: ["workspace"], depth: "fast" }, makeContext(), ); @@ -216,7 +216,7 @@ describe("recallTool.execute", () => { test("propagates tool context", async () => { const controller = new AbortController(); - await recallTool.execute( + await recallTool.execute!( { query: "context propagation" }, makeContext({ workingDir: "/workspace/project", @@ -237,7 +237,7 @@ describe("recallTool.execute", () => { describe("rememberTool.execute — finish_turn", () => { test("omits yieldToUser when finish_turn is not provided", async () => { - const result = await rememberTool.execute( + const result = await rememberTool.execute!( { content: "no finish_turn provided" }, makeContext(), ); @@ -246,7 +246,7 @@ describe("rememberTool.execute — finish_turn", () => { }); test("omits yieldToUser when finish_turn is false", async () => { - const result = await rememberTool.execute( + const result = await rememberTool.execute!( { content: "finish_turn=false", finish_turn: false }, makeContext(), ); @@ -255,7 +255,7 @@ describe("rememberTool.execute — finish_turn", () => { }); test("sets yieldToUser=true when finish_turn is true", async () => { - const result = await rememberTool.execute( + const result = await rememberTool.execute!( { content: "finish_turn=true", finish_turn: true }, makeContext(), ); @@ -264,7 +264,7 @@ describe("rememberTool.execute — finish_turn", () => { }); test("sets yieldToUser=true even when the write fails (empty content)", async () => { - const result = await rememberTool.execute( + const result = await rememberTool.execute!( { content: "", finish_turn: true }, makeContext(), ); @@ -280,7 +280,7 @@ describe("rememberTool.execute — PKB re-index enqueue", () => { }); test("enqueues re-index jobs for both buffer and daily archive paths", async () => { - const result = await rememberTool.execute( + const result = await rememberTool.execute!( { content: "index me please" }, makeContext(), ); @@ -311,7 +311,7 @@ describe("rememberTool.execute — PKB re-index enqueue", () => { }); test("does not enqueue when content is empty (write was skipped)", async () => { - const result = await rememberTool.execute( + const result = await rememberTool.execute!( { content: " " }, makeContext(), ); @@ -322,7 +322,7 @@ describe("rememberTool.execute — PKB re-index enqueue", () => { test("thrown enqueue does not surface; remember still writes files", async () => { enqueueShouldThrow = true; - const result = await rememberTool.execute( + const result = await rememberTool.execute!( { content: "enqueue will throw" }, makeContext(), ); diff --git a/assistant/src/tools/types.ts b/assistant/src/tools/types.ts index 457f0ebb84e..f9d2cf56907 100644 --- a/assistant/src/tools/types.ts +++ b/assistant/src/tools/types.ts @@ -369,18 +369,20 @@ export const WireToolDefinitionSchema = ToolDefinitionSchema.required({ * 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 for `input_schema` and `execute` - * — both required at author time, but represented differently from the - * wire schema: + * (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 (e.g. `as const`) without `as Record<...>` - * gymnastics. The wire form still parses to `Record` via {@link WireToolDefinitionSchema}. - * - `execute` is required at the in-process layer (every tool a - * literal author writes has one) but absent from the schema - * (closures cannot cross IPC; `buildProxyTool` synthesizes one on - * the daemon side). + * JSON-schema literal without `as Record<...>` gymnastics. The + * wire form still parses to `Record` via + * {@link WireToolDefinitionSchema}. + * - `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 `buildProxyTool` synthesizes a new one on arrival. + * Callers that invoke `execute` should treat it as `tool.execute!(...)`. */ export type ToolDefinition = Omit< z.infer, @@ -389,7 +391,7 @@ export type ToolDefinition = Omit< /** JSON schema describing the tool's input arguments. */ input_schema?: object; /** Implementation invoked when the model calls the tool. */ - execute: ( + execute?: ( input: Record, context: ToolContext, ) => Promise; From 0f4b563de53273f2f7f577ff2639b4cc9b888eb1 Mon Sep 17 00:00:00 2001 From: "vellum-apollo-bot[bot]" <242025090+vellum-apollo-bot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 17:23:02 +0000 Subject: [PATCH 07/10] fix(tools): bang skillLoadTool.execute in managed-skill-lifecycle test --- assistant/src/__tests__/managed-skill-lifecycle.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assistant/src/__tests__/managed-skill-lifecycle.test.ts b/assistant/src/__tests__/managed-skill-lifecycle.test.ts index 9116b5f5f82..9125aa1861a 100644 --- a/assistant/src/__tests__/managed-skill-lifecycle.test.ts +++ b/assistant/src/__tests__/managed-skill-lifecycle.test.ts @@ -136,7 +136,7 @@ Run the custom lifecycle verification procedure. expect(catalogSkill!.source).toBe("managed"); expect(catalogSkill!.displayName).toBe("E2E Custom Skill"); - const loadResult = await skillLoadTool.execute( + const loadResult = await skillLoadTool.execute!( { skill: skillId }, makeContext(), ); @@ -294,7 +294,7 @@ Run the custom lifecycle verification procedure. expect(scaffoldData.created).toBe(true); // Step 2: Call skill_load tool to load the created skill - const loadResult = await skillLoadTool.execute( + const loadResult = await skillLoadTool.execute!( { skill: "chain-test" }, ctx, ); @@ -314,7 +314,7 @@ Run the custom lifecycle verification procedure. expect(deleteResult.isError).not.toBe(true); // Step 4: Verify skill_load returns error for deleted skill - const loadAfterDelete = await skillLoadTool.execute( + const loadAfterDelete = await skillLoadTool.execute!( { skill: "chain-test" }, ctx, ); From cdb986ab137eae2ea9ca5d7ceb451e3d519965dc Mon Sep 17 00:00:00 2001 From: "vellum-apollo-bot[bot]" <242025090+vellum-apollo-bot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 18:28:24 +0000 Subject: [PATCH 08/10] refactor(tools): consolidate to one schema + finalizeTool at IPC boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 feedback on PR #32631: kill the dual-schema design. types.ts: - Delete WireToolDefinitionSchema and its doc block. - ToolDefinitionSchema is the single source of truth — all fields optional, both author and wire paths share it. - Update ToolDefinition's doc to reflect the new contract and note the `satisfies ToolDefinition` pattern. registries.ts (skill-routes): - Drop the Wire-level-schemas comment block. - Use ToolDefinitionSchema in z.array(). - Delete buildProxyTool + WireToolDefinition type. - Replace tools.map(buildProxyTool) with tools.map((t) => finalizeTool(t, t.name ?? "")) — finalizeTool already synthesizes a no-op error closure for the no-supervisor path. Tool sources (28 files: terminal/shell, host-terminal/host-shell, host-filesystem/*, filesystem/*, credentials/vault, credential-execution/*, memory/register, memory/graph/tools, memory/v2/sweep-job, messaging/style-analyzer, ui-surface, computer-use/definitions, network/{web-fetch,web-search}, system/request-permission, subagent/notify-parent, skills/execute, apps/definitions): - Switch from `: ToolDefinition = { ... }` to `... } satisfies ToolDefinition` so the inferred per-export type preserves `execute` as required at call sites that statically import the literal. The schema-only definitions (graphRememberDefinition, storeStyleAnalysisTool, SWEEP_TOOL) intentionally keep `execute` absent — ToolDefinition itself still has it optional. Test files (27 files): - Drop the `.execute!(` bang now that satisfies preserves the required type at the call site. - Pattern A refactor: replace inline `await import("../tools/...")` in beforeEach with module-scope static imports for background-shell-bash and terminal-tools. shell-observability keeps a dynamic import because the test mocks `../util/logger.js` and shell.ts captures the logger at module-eval time — static import there would hoist past mock.module() and the test would see the real logger. Type-narrow via `typeof import(...)["shellTool"]` so no bang is needed. Intentional reverts: - openai-responses-provider.test.ts and plugin-api-tool-definition.test.ts keep `: ToolDefinition = { ... }` — they assert type shape (including the empty-literal contract test) rather than calling .execute, so the widened type is the right tool there. registries.test.ts: - "rejects missing required fields" → "fills defaults for partial tool entries": partial entries no longer reject (one all-optional schema + finalizeTool fills defaults). - "proxy execute throws when no supervisor is attached" → "proxy execute surfaces an error result when no supervisor is attached": finalizeTool synthesizes a no-op error closure now, not a throw. --- .../__tests__/background-shell-bash.test.ts | 20 ++-- .../background-shell-host-bash.test.ts | 26 ++--- .../src/__tests__/computer-use-tools.test.ts | 6 +- .../credential-execution-tools.test.ts | 4 +- .../__tests__/credential-security-e2e.test.ts | 16 ++-- .../__tests__/credential-vault-unit.test.ts | 60 ++++++------ .../src/__tests__/credential-vault.test.ts | 96 +++++++++---------- .../src/__tests__/disk-pressure-tools.test.ts | 4 +- .../src/__tests__/file-list-tool.test.ts | 6 +- .../src/__tests__/host-file-edit-tool.test.ts | 34 +++---- .../src/__tests__/host-file-read-tool.test.ts | 38 ++++---- .../__tests__/host-file-write-tool.test.ts | 36 ++++--- .../src/__tests__/host-shell-tool.test.ts | 95 +++++++++--------- .../__tests__/managed-skill-lifecycle.test.ts | 6 +- ...rovider-platform-proxy-integration.test.ts | 2 +- .../src/__tests__/secret-onetime-send.test.ts | 8 +- .../__tests__/shell-credential-ref.test.ts | 18 ++-- .../src/__tests__/shell-observability.test.ts | 20 ++-- .../__tests__/shell-tool-proxy-mode.test.ts | 10 +- .../src/__tests__/terminal-tools.test.ts | 41 ++------ .../tool-execution-abort-cleanup.test.ts | 10 +- .../skill-routes/__tests__/registries.test.ts | 45 +++++---- assistant/src/ipc/skill-routes/registries.ts | 62 ++---------- assistant/src/memory/graph/tools.ts | 8 +- assistant/src/memory/v2/sweep-job.ts | 4 +- assistant/src/messaging/style-analyzer.ts | 4 +- assistant/src/tools/apps/definitions.ts | 10 +- .../ask-question/ask-question-tool.test.ts | 42 ++++---- .../src/tools/computer-use/definitions.ts | 50 +++++----- .../make-authenticated-request.ts | 4 +- .../run-authenticated-command.ts | 4 +- assistant/src/tools/credentials/vault.ts | 4 +- assistant/src/tools/filesystem/edit.ts | 4 +- assistant/src/tools/filesystem/list.ts | 4 +- assistant/src/tools/filesystem/read.ts | 4 +- assistant/src/tools/filesystem/write.ts | 4 +- .../src/tools/host-filesystem/edit.test.ts | 10 +- assistant/src/tools/host-filesystem/edit.ts | 4 +- .../src/tools/host-filesystem/read.test.ts | 10 +- assistant/src/tools/host-filesystem/read.ts | 4 +- .../tools/host-filesystem/transfer.test.ts | 28 +++--- .../src/tools/host-filesystem/transfer.ts | 4 +- .../src/tools/host-filesystem/write.test.ts | 10 +- assistant/src/tools/host-filesystem/write.ts | 4 +- .../src/tools/host-terminal/host-shell.ts | 4 +- assistant/src/tools/memory/register.test.ts | 24 ++--- assistant/src/tools/memory/register.ts | 8 +- assistant/src/tools/network/web-fetch.ts | 4 +- assistant/src/tools/network/web-search.ts | 4 +- assistant/src/tools/skills/execute.ts | 4 +- assistant/src/tools/subagent/notify-parent.ts | 4 +- .../src/tools/system/request-permission.ts | 4 +- assistant/src/tools/terminal/shell.ts | 4 +- assistant/src/tools/types.ts | 43 +++------ assistant/src/tools/ui-surface/definitions.ts | 18 ++-- 55 files changed, 481 insertions(+), 523 deletions(-) diff --git a/assistant/src/__tests__/background-shell-bash.test.ts b/assistant/src/__tests__/background-shell-bash.test.ts index dc440ccddc4..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 { ToolDefinition } 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: ToolDefinition; - - 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(() => { @@ -138,7 +134,7 @@ describe("bash tool background mode", () => { }); test("background: true returns immediately with backgrounded payload", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo hello", activity: "test", background: true }, baseContext, ); @@ -153,7 +149,7 @@ describe("bash tool background mode", () => { }); test("background process registers in the background tool registry", async () => { - await shellTool.execute!( + await shellTool.execute( { command: "echo hello", activity: "test", background: true }, baseContext, ); @@ -172,7 +168,7 @@ describe("bash tool background mode", () => { }); test("background process completion triggers wakeAgentForOpportunity with stdout", async () => { - await shellTool.execute!( + await shellTool.execute( { command: "echo bg_output_12345", activity: "test", background: true }, baseContext, ); @@ -192,7 +188,7 @@ describe("bash tool background mode", () => { }); test("failing background process delivers an error hint via wake", async () => { - await shellTool.execute!( + await shellTool.execute( { command: "exit 1", activity: "test", background: true }, baseContext, ); @@ -213,7 +209,7 @@ describe("bash tool background mode", () => { }); test("foreground mode still works when background is not set", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo foreground_test_789", activity: "test" }, baseContext, ); diff --git a/assistant/src/__tests__/background-shell-host-bash.test.ts b/assistant/src/__tests__/background-shell-host-bash.test.ts index f3f76a5868f..e9dcb5985de 100644 --- a/assistant/src/__tests__/background-shell-host-bash.test.ts +++ b/assistant/src/__tests__/background-shell-host-bash.test.ts @@ -189,7 +189,7 @@ describe("host_bash background mode — proxy path", () => { const ctx = makeContext({}); - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo bg-proxy", background: true }, ctx, ); @@ -209,7 +209,7 @@ describe("host_bash background mode — proxy path", () => { const ctx = makeContext({}); - await hostShellTool.execute!( + await hostShellTool.execute( { command: "echo bg-proxy", background: true }, ctx, ); @@ -232,7 +232,7 @@ describe("host_bash background mode — proxy path", () => { const ctx = makeContext({}); - await hostShellTool.execute!( + await hostShellTool.execute( { command: "echo bg-proxy", background: true }, ctx, ); @@ -261,7 +261,7 @@ describe("host_bash background mode — proxy path", () => { const ctx = makeContext({}); - await hostShellTool.execute!( + await hostShellTool.execute( { command: "bad-command", background: true }, ctx, ); @@ -283,7 +283,7 @@ describe("host_bash background mode — proxy path", () => { const ctx = makeContext({}); - await hostShellTool.execute!( + await hostShellTool.execute( { command: "echo fail", background: true }, ctx, ); @@ -307,7 +307,7 @@ describe("host_bash background mode — proxy path", () => { const ctx = makeContext({}); - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo bg-proxy", background: true }, ctx, ); @@ -330,7 +330,7 @@ describe("host_bash background mode — direct execution path", () => { test("returns immediately with backgrounded response", async () => { const ctx = makeContext(); - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo bg-local", background: true }, ctx, ); @@ -344,7 +344,7 @@ describe("host_bash background mode — direct execution path", () => { test("registers background tool in the registry", async () => { const ctx = makeContext(); - await hostShellTool.execute!( + await hostShellTool.execute( { command: "echo bg-local", background: true }, ctx, ); @@ -362,7 +362,7 @@ describe("host_bash background mode — direct execution path", () => { test("calls wakeAgentForOpportunity on process exit", async () => { const ctx = makeContext(); - await hostShellTool.execute!( + await hostShellTool.execute( { command: "echo bg-local", background: true }, ctx, ); @@ -388,7 +388,7 @@ describe("host_bash background mode — direct execution path", () => { test("calls wakeAgentForOpportunity with error hint on non-zero exit", async () => { const ctx = makeContext(); - await hostShellTool.execute!({ command: "false", background: true }, ctx); + await hostShellTool.execute({ command: "false", background: true }, ctx); expect(latestChild).toBeDefined(); @@ -407,7 +407,7 @@ describe("host_bash background mode — direct execution path", () => { test("calls wakeAgentForOpportunity on spawn error", async () => { const ctx = makeContext(); - await hostShellTool.execute!( + await hostShellTool.execute( { command: "echo bg-error", background: true }, ctx, ); @@ -429,7 +429,7 @@ describe("host_bash background mode — direct execution path", () => { test("removes background tool from registry on process exit", async () => { const ctx = makeContext(); - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo bg-local", background: true }, ctx, ); @@ -447,7 +447,7 @@ describe("host_bash background mode — direct execution path", () => { test("removes background tool from registry on spawn error", async () => { const ctx = makeContext(); - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo bg-error", background: true }, ctx, ); diff --git a/assistant/src/__tests__/computer-use-tools.test.ts b/assistant/src/__tests__/computer-use-tools.test.ts index 3773581a97c..aedc00cce57 100644 --- a/assistant/src/__tests__/computer-use-tools.test.ts +++ b/assistant/src/__tests__/computer-use-tools.test.ts @@ -89,7 +89,7 @@ describe("computer_use_click (unified)", () => { }); test("execute returns isError when no proxy resolver is configured", async () => { - const result = await computerUseClickTool.execute!({}, ctx); + const result = await computerUseClickTool.execute({}, ctx); expect(result.isError).toBe(true); expect(result.content).toMatch(/No proxy resolver/); }); @@ -104,7 +104,7 @@ describe("computer_use_type_text", () => { }); test("execute returns isError when no proxy resolver is configured", async () => { - const result = await computerUseTypeTextTool.execute!({}, ctx); + const result = await computerUseTypeTextTool.execute({}, ctx); expect(result.isError).toBe(true); expect(result.content).toMatch(/No proxy resolver/); }); @@ -119,7 +119,7 @@ describe("computer_use_key", () => { }); test("execute returns isError when no proxy resolver is configured", async () => { - const result = await computerUseKeyTool.execute!({}, ctx); + const result = await computerUseKeyTool.execute({}, ctx); expect(result.isError).toBe(true); expect(result.content).toMatch(/No proxy resolver/); }); diff --git a/assistant/src/__tests__/credential-execution-tools.test.ts b/assistant/src/__tests__/credential-execution-tools.test.ts index 3e4accebd33..aed9e4fdf18 100644 --- a/assistant/src/__tests__/credential-execution-tools.test.ts +++ b/assistant/src/__tests__/credential-execution-tools.test.ts @@ -169,7 +169,7 @@ describe("CES tool execution without client", () => { }; test("make_authenticated_request fails gracefully when CES client is absent", async () => { - const result = await makeAuthenticatedRequestTool.execute!( + const result = await makeAuthenticatedRequestTool.execute( { credentialHandle: "local_static:test/key", method: "GET", @@ -183,7 +183,7 @@ describe("CES tool execution without client", () => { }); test("run_authenticated_command fails gracefully when CES client is absent", async () => { - const result = await runAuthenticatedCommandTool.execute!( + const result = await runAuthenticatedCommandTool.execute( { credentialHandle: "local_static:test/key", command: "echo hello", diff --git a/assistant/src/__tests__/credential-security-e2e.test.ts b/assistant/src/__tests__/credential-security-e2e.test.ts index 3bce9d0937b..fef2e09885d 100644 --- a/assistant/src/__tests__/credential-security-e2e.test.ts +++ b/assistant/src/__tests__/credential-security-e2e.test.ts @@ -152,7 +152,7 @@ describe("E2E: secure store and list lifecycle", () => { }); test("store persists credential and returns metadata-only confirmation", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "github", @@ -183,7 +183,7 @@ describe("E2E: secure store and list lifecycle", () => { field: "access_key", }); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "list" }, makeContext(), ); @@ -198,7 +198,7 @@ describe("E2E: secure store and list lifecycle", () => { test("delete removes credential from credential store", async () => { storedKeys.set("credential/github/token", "secret1"); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "delete", service: "github", field: "token" }, makeContext(), ); @@ -225,7 +225,7 @@ describe("E2E: one-time send override", () => { delivery: "transient_send" as const, }), }); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "svc", field: "key", label: "Key" }, ctx, ); @@ -242,7 +242,7 @@ describe("E2E: one-time send override", () => { delivery: "transient_send" as const, }), }); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "svc", field: "key", label: "Key" }, ctx, ); @@ -262,7 +262,7 @@ describe("E2E: one-time send override", () => { delivery: "store" as const, }), }); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "svc", field: "key", label: "Key" }, ctx, ); @@ -337,7 +337,7 @@ describe("E2E: cross-cutting secret leak prevention", () => { test("store output never contains the stored value", async () => { const secret = ["sk", "-proj-", "abc123xyz"].join(""); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "openai", field: "api_key", value: secret }, makeContext(), ); @@ -353,7 +353,7 @@ describe("E2E: cross-cutting secret leak prevention", () => { delivery: "transient_send" as const, }), }); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "svc", field: "key", label: "Key" }, ctx, ); diff --git a/assistant/src/__tests__/credential-vault-unit.test.ts b/assistant/src/__tests__/credential-vault-unit.test.ts index 4b9b3c9ee9d..d2722bf82e4 100644 --- a/assistant/src/__tests__/credential-vault-unit.test.ts +++ b/assistant/src/__tests__/credential-vault-unit.test.ts @@ -516,7 +516,7 @@ describe("credential_store tool — unknown action", () => { }); test("returns error for unknown action", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "unknown_action" }, _ctx, ); @@ -546,7 +546,7 @@ describe("credential_store tool — prompt action", () => { }); test("returns error when requestSecret is not available", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "svc", field: "key", label: "API Key" }, _ctx, // no requestSecret ); @@ -555,7 +555,7 @@ describe("credential_store tool — prompt action", () => { }); test("returns error when service is missing for prompt", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", field: "key" }, _ctx, ); @@ -564,7 +564,7 @@ describe("credential_store tool — prompt action", () => { }); test("returns error when field is missing for prompt", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "svc" }, _ctx, ); @@ -580,7 +580,7 @@ describe("credential_store tool — prompt action", () => { delivery: "store" as const, }), }; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "svc", field: "key", label: "Test" }, ctxWithPrompt, ); @@ -596,7 +596,7 @@ describe("credential_store tool — prompt action", () => { delivery: "store" as const, }), }; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "test-prompt", @@ -623,7 +623,7 @@ describe("credential_store tool — prompt action", () => { delivery: "store" as const, }), }; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "github", @@ -656,7 +656,7 @@ describe("credential_store tool — prompt action", () => { }), }; - const appResult = await credentialStoreTool.execute!( + const appResult = await credentialStoreTool.execute( { action: "prompt", service: "slack_channel", @@ -670,7 +670,7 @@ describe("credential_store tool — prompt action", () => { expect(slackChannelConfigCalls).toEqual([{ appToken: "xapp-test-token" }]); expect(appResult.content).toContain("connection incomplete"); - const botResult = await credentialStoreTool.execute!( + const botResult = await credentialStoreTool.execute( { action: "prompt", service: "slack_channel", @@ -699,7 +699,7 @@ describe("credential_store tool — prompt action", () => { }), }; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "slack_channel", @@ -724,7 +724,7 @@ describe("credential_store tool — prompt action", () => { }), }; - const appResult = await credentialStoreTool.execute!( + const appResult = await credentialStoreTool.execute( { action: "prompt", service: "slack_channel", @@ -735,7 +735,7 @@ describe("credential_store tool — prompt action", () => { ); expect(appResult.isError).toBe(false); - const botResult = await credentialStoreTool.execute!( + const botResult = await credentialStoreTool.execute( { action: "prompt", service: "slack_channel", @@ -761,7 +761,7 @@ describe("credential_store tool — prompt action", () => { }), }; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "slack_channel", @@ -795,7 +795,7 @@ describe("credential_store tool — prompt action", () => { }), }; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "slack_channel", @@ -822,7 +822,7 @@ describe("credential_store tool — prompt action", () => { ..._ctx, requestSecret: async () => ({ value: "val", delivery: "store" as const }), }; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "svc", @@ -857,7 +857,7 @@ describe("credential_store tool — slack_channel store routing", () => { }); test("store with user_token routes to setSlackChannelConfig as third positional arg", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "slack_channel", @@ -883,7 +883,7 @@ describe("credential_store tool — slack_channel store routing", () => { }); test("store with user_token surfaces handler rejection for malformed token", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "slack_channel", @@ -906,7 +906,7 @@ describe("credential_store tool — slack_channel store routing", () => { }); test("store with bot_token still routes via first positional arg", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "slack_channel", @@ -927,7 +927,7 @@ describe("credential_store tool — slack_channel store routing", () => { }); test("store with app_token still routes via second positional arg", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "slack_channel", @@ -968,7 +968,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("rejects alias that is not a string", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "svc", @@ -983,7 +983,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("rejects injection_templates that is not an array", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "svc", @@ -998,7 +998,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("rejects template with invalid injectionType", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "svc", @@ -1017,7 +1017,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("rejects template with empty hostPattern", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "svc", @@ -1038,7 +1038,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("rejects template with non-string valuePrefix", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "svc", @@ -1060,7 +1060,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("reports multiple template errors at once", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "svc", @@ -1079,7 +1079,7 @@ describe("credential_store tool — store validation edge cases", () => { }); test("delete removes both secret and metadata", async () => { - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "del-test", @@ -1098,7 +1098,7 @@ describe("credential_store tool — store validation edge cases", () => { expect(getCredentialMetadata("del-test", "key")).toBeDefined(); // Delete - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "delete", service: "del-test", @@ -1154,7 +1154,7 @@ describe("credential_store tool — slack_channel delete routing", () => { upsertCredentialMetadata("slack_channel", "user_token", {}); manualConnectionStore["slack_channel"] = "active"; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "delete", service: "slack_channel", @@ -1202,7 +1202,7 @@ describe("credential_store tool — slack_channel delete routing", () => { upsertCredentialMetadata("slack_channel", "app_token", {}); manualConnectionStore["slack_channel"] = "active"; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "delete", service: "slack_channel", @@ -1238,7 +1238,7 @@ describe("credential_store tool — slack_channel delete routing", () => { upsertCredentialMetadata("slack_channel", "app_token", {}); manualConnectionStore["slack_channel"] = "active"; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "delete", service: "slack_channel", diff --git a/assistant/src/__tests__/credential-vault.test.ts b/assistant/src/__tests__/credential-vault.test.ts index 7c6175e604c..fc690950998 100644 --- a/assistant/src/__tests__/credential-vault.test.ts +++ b/assistant/src/__tests__/credential-vault.test.ts @@ -229,7 +229,7 @@ async function executeVault( } case "list": - return credentialStoreTool.execute!({ action: "list" }, _ctx); + return credentialStoreTool.execute({ action: "list" }, _ctx); case "delete": { const service = input.service as string | undefined; @@ -356,7 +356,7 @@ describe("credential_store tool", () => { }); test("store success includes credential_id via credentialStoreTool", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "test-cred-id", @@ -380,7 +380,7 @@ describe("credential_store tool", () => { // ----------------------------------------------------------------------- describe("list action", () => { test("lists stored credentials with credential_id, service, field", async () => { - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "gmail", @@ -389,7 +389,7 @@ describe("credential_store tool", () => { }, _ctx, ); - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "github", @@ -399,7 +399,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "list" }, _ctx, ); @@ -427,7 +427,7 @@ describe("credential_store tool", () => { }); test("list output includes alias when set", async () => { - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "fal", @@ -438,7 +438,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "list" }, _ctx, ); @@ -451,7 +451,7 @@ describe("credential_store tool", () => { }); test("list output includes template summary with host patterns", async () => { - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "fal", @@ -474,7 +474,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "list" }, _ctx, ); @@ -493,7 +493,7 @@ describe("credential_store tool", () => { test("list does not include credential values", async () => { const testValue = "test-dummy-value-for-list"; - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "test", @@ -503,7 +503,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "list" }, _ctx, ); @@ -519,7 +519,7 @@ describe("credential_store tool", () => { }); test("returns empty array when no credentials exist", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "list" }, _ctx, ); @@ -528,7 +528,7 @@ describe("credential_store tool", () => { }); test("lists multiple credentials", async () => { - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "gmail", @@ -537,7 +537,7 @@ describe("credential_store tool", () => { }, _ctx, ); - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "github", @@ -547,7 +547,7 @@ describe("credential_store tool", () => { }, _ctx, ); - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "fal", @@ -565,7 +565,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "list" }, _ctx, ); @@ -591,7 +591,7 @@ describe("credential_store tool", () => { test("works with metadata store fallback when listing secrets", async () => { // Store a credential first (on encrypted backend) - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "keychain-test", @@ -601,7 +601,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "list" }, _ctx, ); @@ -622,7 +622,7 @@ describe("credential_store tool", () => { "utf-8", ); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "list" }, _ctx, ); @@ -632,7 +632,7 @@ describe("credential_store tool", () => { test("excludes metadata entries whose secret was deleted from secure storage", async () => { // Store two credentials so both metadata and secrets exist - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "svc-a", @@ -641,7 +641,7 @@ describe("credential_store tool", () => { }, _ctx, ); - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "svc-b", @@ -655,7 +655,7 @@ describe("credential_store tool", () => { // a divergence where metadata write failed after secret deletion) await deleteSecureKeyAsync(credentialKey("svc-a", "key")); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "list" }, _ctx, ); @@ -668,7 +668,7 @@ describe("credential_store tool", () => { test("recovers from corrupt secure storage by resetting and returning empty list", async () => { // Store a credential so metadata exists - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "svc-x", @@ -682,7 +682,7 @@ describe("credential_store tool", () => { // backing up the corrupt file and creating a fresh store writeFileSync(STORE_PATH, "not-valid-json!!!", "utf-8"); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "list" }, _ctx, ); @@ -742,7 +742,7 @@ describe("credential_store tool", () => { test("delete also disconnects OAuth connection for the service", async () => { // Store a credential via the real tool so metadata exists - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "google", @@ -760,7 +760,7 @@ describe("credential_store tool", () => { expiresAt: Date.now() + 3600_000, }); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "delete", service: "google", @@ -805,7 +805,7 @@ describe("credential_store tool", () => { }); test("store with policy fields persists metadata", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "github", @@ -826,7 +826,7 @@ describe("credential_store tool", () => { }); test("store without policy fields defaults to empty arrays", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "slack", @@ -843,7 +843,7 @@ describe("credential_store tool", () => { }); test("store rejects invalid policy input", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "test", @@ -858,7 +858,7 @@ describe("credential_store tool", () => { }); test("list action entries do not expose policy metadata", async () => { - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "myservice", @@ -871,7 +871,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "list" }, _ctx, ); @@ -896,7 +896,7 @@ describe("credential_store tool", () => { // ----------------------------------------------------------------------- describe("alias and injection template fields", () => { test("store with valid alias and templates persists metadata", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "fal", @@ -926,7 +926,7 @@ describe("credential_store tool", () => { }); test("store with alias only (no templates)", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "openai", @@ -944,7 +944,7 @@ describe("credential_store tool", () => { }); test("store with templates only (no alias)", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "replicate", @@ -970,7 +970,7 @@ describe("credential_store tool", () => { }); test("rejects template missing headerName for header type", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "fal", @@ -991,7 +991,7 @@ describe("credential_store tool", () => { }); test("rejects template missing queryParamName for query type", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "mapbox", @@ -1012,7 +1012,7 @@ describe("credential_store tool", () => { }); test("round-trip: store then list shows the credential", async () => { - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "anthropic", @@ -1030,7 +1030,7 @@ describe("credential_store tool", () => { _ctx, ); - const listResult = await credentialStoreTool.execute!( + const listResult = await credentialStoreTool.execute( { action: "list" }, _ctx, ); @@ -1050,7 +1050,7 @@ describe("credential_store tool", () => { }); test("update alias on existing credential", async () => { - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "fal", @@ -1065,7 +1065,7 @@ describe("credential_store tool", () => { expect(metadata!.alias).toBe("fal-old"); // Re-store same credential with updated alias - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "fal", @@ -1081,7 +1081,7 @@ describe("credential_store tool", () => { }); test("store with query injection template", async () => { - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "store", service: "mapbox", @@ -1112,7 +1112,7 @@ describe("credential_store tool", () => { // ----------------------------------------------------------------------- describe("multi-key same-service storage", () => { test("stores two credentials with same service but different aliases", async () => { - const result1 = await credentialStoreTool.execute!( + const result1 = await credentialStoreTool.execute( { action: "store", service: "openai", @@ -1124,7 +1124,7 @@ describe("credential_store tool", () => { ); expect(result1.isError).toBe(false); - const result2 = await credentialStoreTool.execute!( + const result2 = await credentialStoreTool.execute( { action: "store", service: "openai", @@ -1146,7 +1146,7 @@ describe("credential_store tool", () => { }); test("listing shows both same-service credentials independently", async () => { - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "openai", @@ -1156,7 +1156,7 @@ describe("credential_store tool", () => { }, _ctx, ); - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "openai", @@ -1167,7 +1167,7 @@ describe("credential_store tool", () => { _ctx, ); - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "list" }, _ctx, ); @@ -1186,7 +1186,7 @@ describe("credential_store tool", () => { }); test("each same-service credential has its own credential_id", async () => { - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "openai", @@ -1196,7 +1196,7 @@ describe("credential_store tool", () => { }, _ctx, ); - await credentialStoreTool.execute!( + await credentialStoreTool.execute( { action: "store", service: "openai", diff --git a/assistant/src/__tests__/disk-pressure-tools.test.ts b/assistant/src/__tests__/disk-pressure-tools.test.ts index 8222c684d93..3c0bb56f9a7 100644 --- a/assistant/src/__tests__/disk-pressure-tools.test.ts +++ b/assistant/src/__tests__/disk-pressure-tools.test.ts @@ -233,7 +233,7 @@ describe("disk pressure cleanup tool restrictions", () => { }); test("background shell modes are blocked during cleanup mode", async () => { - const shellResult = await shellTool.execute!( + const shellResult = await shellTool.execute( { command: "sleep 100", activity: "check disk usage", @@ -252,7 +252,7 @@ describe("disk pressure cleanup tool restrictions", () => { "background shell commands are not available", ); - const hostResult = await hostShellTool.execute!( + const hostResult = await hostShellTool.execute( { command: "sleep 100", activity: "check disk usage", diff --git a/assistant/src/__tests__/file-list-tool.test.ts b/assistant/src/__tests__/file-list-tool.test.ts index 059087dec98..8840477c508 100644 --- a/assistant/src/__tests__/file-list-tool.test.ts +++ b/assistant/src/__tests__/file-list-tool.test.ts @@ -141,7 +141,7 @@ describe("FileListTool", () => { writeFileSync(join(dir, "README.md"), "# Hello"); writeFileSync(join(dir, "index.ts"), "export {}"); - const result = await fileListTool.execute!( + const result = await fileListTool.execute( { path: dir, activity: "listing test dir" }, makeToolContext(dir), ); @@ -157,7 +157,7 @@ describe("FileListTool", () => { test("execute() returns error for invalid path input", async () => { const dir = makeTempDir(); - const result = await fileListTool.execute!( + const result = await fileListTool.execute( { path: 123, activity: "test" }, makeToolContext(dir), ); @@ -167,7 +167,7 @@ describe("FileListTool", () => { test("execute() returns error for nonexistent directory", async () => { const dir = makeTempDir(); - const result = await fileListTool.execute!( + const result = await fileListTool.execute( { path: join(dir, "nope"), activity: "test" }, makeToolContext(dir), ); diff --git a/assistant/src/__tests__/host-file-edit-tool.test.ts b/assistant/src/__tests__/host-file-edit-tool.test.ts index 750c96f770c..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,12 +44,13 @@ 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", () => { test("rejects relative paths", async () => { - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: "relative.txt", old_string: "a", @@ -66,7 +68,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "hello world\n"); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: filePath, old_string: "hello world", @@ -86,7 +88,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "x\ny\nx\n"); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: filePath, old_string: "x", @@ -108,7 +110,7 @@ describe("host_file_edit tool", () => { // Content has a typo-level difference from oldString writeFileSync(filePath, "function fooo() {\n return 1;\n}\n"); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: filePath, old_string: "function foo() {\n return 1;\n}", @@ -128,7 +130,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "repeat\nrepeat\n"); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: filePath, old_string: "repeat", @@ -142,7 +144,7 @@ describe("host_file_edit tool", () => { }); test("rejects missing path parameter", async () => { - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { old_string: "a", new_string: "b", @@ -159,7 +161,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "content\n"); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: filePath, old_string: 42, @@ -177,7 +179,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "content\n"); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: filePath, old_string: "content", @@ -195,7 +197,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "content\n"); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: filePath, old_string: "", @@ -213,7 +215,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "content\n"); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: filePath, old_string: "content", @@ -232,7 +234,7 @@ describe("host_file_edit tool", () => { testDirs.push(dir); const filePath = join(dir, "missing.txt"); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: filePath, old_string: "a", @@ -250,7 +252,7 @@ describe("host_file_edit tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "before\n"); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: filePath, old_string: "before", @@ -274,7 +276,7 @@ describe("host_file_edit tool", () => { // File has tab indentation writeFileSync(filePath, "function foo() {\n\treturn 1;\n}\n"); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: filePath, // old_string uses spaces instead of tabs — should whitespace-normalize @@ -301,7 +303,7 @@ describe("host_file_edit tool", () => { return { content: "proxied edit", isError: false }; }; - await hostFileEditTool.execute!( + await hostFileEditTool.execute( { path: "/host/file.txt", old_string: "old", diff --git a/assistant/src/__tests__/host-file-read-tool.test.ts b/assistant/src/__tests__/host-file-read-tool.test.ts index 59c6f37fa05..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 @@ -66,7 +68,7 @@ const PNG_HEADER = Buffer.from([ describe("host_file_read tool", () => { test("rejects relative paths", async () => { - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: "relative.txt" }, makeContext(), ); @@ -80,7 +82,7 @@ describe("host_file_read tool", () => { const filePath = join(dir, "sample.txt"); writeFileSync(filePath, "first\nsecond\nthird\n"); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: filePath, offset: 2, limit: 2 }, makeContext(), ); @@ -91,7 +93,7 @@ describe("host_file_read tool", () => { test("returns error when file does not exist", async () => { const filePath = join(tmpdir(), `host-file-read-missing-${Date.now()}.txt`); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: filePath }, makeContext(), ); @@ -105,7 +107,7 @@ describe("host_file_read tool", () => { const nestedDir = join(dir, "nested"); mkdirSync(nestedDir, { recursive: true }); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: nestedDir }, makeContext(), ); @@ -114,13 +116,13 @@ describe("host_file_read tool", () => { }); test("rejects missing path parameter", async () => { - const result = await hostFileReadTool.execute!({}, makeContext()); + const result = await hostFileReadTool.execute({}, makeContext()); expect(result.isError).toBe(true); expect(result.content).toContain("path is required"); }); test("rejects non-string path", async () => { - const result = await hostFileReadTool.execute!({ path: 42 }, makeContext()); + const result = await hostFileReadTool.execute({ path: 42 }, makeContext()); expect(result.isError).toBe(true); expect(result.content).toContain("path is required and must be a string"); }); @@ -131,7 +133,7 @@ describe("host_file_read tool", () => { const filePath = join(dir, "full.txt"); writeFileSync(filePath, "line1\nline2\nline3\n"); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: filePath }, makeContext(), ); @@ -147,7 +149,7 @@ describe("host_file_read tool", () => { const filePath = join(dir, "empty.txt"); writeFileSync(filePath, ""); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: filePath }, makeContext(), ); @@ -160,7 +162,7 @@ describe("host_file_read tool", () => { const filePath = join(dir, "lines.txt"); writeFileSync(filePath, "a\nb\nc\nd\ne\n"); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: filePath, offset: 3, limit: 1 }, makeContext(), ); @@ -179,7 +181,7 @@ describe("host_file_read tool", () => { const { symlinkSync } = await import("node:fs"); symlinkSync(realFile, linkFile); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: linkFile }, makeContext(), ); @@ -217,7 +219,7 @@ describe("host_file_read image support", () => { ...makeContext(), }; - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: "/host/screenshot.png" }, proxyContext, ); @@ -243,7 +245,7 @@ describe("host_file_read image support", () => { const filePath = join(dir, "screenshot.png"); writeFileSync(filePath, PNG_HEADER); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: filePath }, makeContext(), ); @@ -263,7 +265,7 @@ describe("host_file_read image support", () => { const filePath = join(dir, "photo.jpg"); writeFileSync(filePath, JPEG_HEADER); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: filePath }, makeContext(), ); @@ -280,7 +282,7 @@ describe("host_file_read image support", () => { test("returns error for non-existent image path", async () => { const filePath = join(tmpdir(), `host-file-read-missing-${Date.now()}.png`); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: filePath }, makeContext(), ); @@ -294,7 +296,7 @@ describe("host_file_read image support", () => { const filePath = join(dir, "notes.txt"); writeFileSync(filePath, "hello world\nsecond line\n"); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: filePath }, makeContext(), ); @@ -313,7 +315,7 @@ describe("host_file_read image support", () => { return { content: "proxied", isError: false }; }; - await hostFileReadTool.execute!( + await hostFileReadTool.execute( { path: "/host/notes.txt", target_client_id: "client-x" }, makeContext(), ); diff --git a/assistant/src/__tests__/host-file-write-tool.test.ts b/assistant/src/__tests__/host-file-write-tool.test.ts index a30e3946674..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,12 +44,13 @@ 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", () => { test("rejects relative paths", async () => { - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: "relative.txt", content: "hi" }, makeContext(), ); @@ -61,7 +63,7 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "out.txt"); - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: filePath, content: 42 }, makeContext(), ); @@ -76,7 +78,7 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "nested", "new.txt"); - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: filePath, content: "new content" }, makeContext(), ); @@ -97,11 +99,11 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "existing.txt"); - await hostFileWriteTool.execute!( + await hostFileWriteTool.execute( { path: filePath, content: "old" }, makeContext(), ); - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: filePath, content: "updated" }, makeContext(), ); @@ -117,7 +119,7 @@ describe("host_file_write tool", () => { }); test("rejects missing path parameter", async () => { - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { content: "data" }, makeContext(), ); @@ -126,7 +128,7 @@ describe("host_file_write tool", () => { }); test("rejects non-string path", async () => { - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: 123, content: "data" }, makeContext(), ); @@ -139,7 +141,7 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "msg-check.txt"); - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: filePath, content: "check" }, makeContext(), ); @@ -152,7 +154,7 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "lines.txt"); - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: filePath, content: "line1\nline2\nline3", @@ -170,7 +172,7 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "empty.txt"); - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: filePath, content: "" }, makeContext(), ); @@ -184,7 +186,7 @@ describe("host_file_write tool", () => { testDirs.push(dir); const filePath = join(dir, "a", "b", "c", "deep.txt"); - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: filePath, content: "deep" }, makeContext(), ); @@ -201,8 +203,12 @@ describe("host_file_write tool", () => { return { content: "proxied write", isError: false }; }; - await hostFileWriteTool.execute!( - { path: "/host/output.txt", content: "hello", target_client_id: "client-x" }, + await hostFileWriteTool.execute( + { + 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 5669d52d654..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 = () => @@ -96,7 +102,7 @@ afterEach(() => { describe("host_bash tool", () => { test("rejects relative working_dir", async () => { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "pwd", working_dir: "relative/path", @@ -112,7 +118,7 @@ describe("host_bash tool", () => { const dir = mkdtempSync(join(tmpdir(), "host-shell-test-")); testDirs.push(dir); - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "pwd", working_dir: dir, @@ -125,7 +131,7 @@ describe("host_bash tool", () => { }); test("returns error for non-zero exit commands", async () => { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "exit 12" }, makeContext(), ); @@ -137,7 +143,7 @@ describe("host_bash tool", () => { const dir = mkdtempSync(join(tmpdir(), "host-shell-nosandbox-")); testDirs.push(dir); - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo isolation-test", working_dir: dir, @@ -155,7 +161,7 @@ describe("host_bash tool", () => { spawnCalls.length = 0; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo hello", working_dir: dir, @@ -182,7 +188,7 @@ describe("host_bash — baseline: no sandbox isolation", () => { spawnCalls.length = 0; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo baseline", working_dir: dir, @@ -203,7 +209,7 @@ describe("host_bash — baseline: no sandbox isolation", () => { spawnCalls.length = 0; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo no-native-sandbox", working_dir: dir, @@ -222,7 +228,7 @@ describe("host_bash — baseline: no sandbox isolation", () => { spawnCalls.length = 0; - await hostShellTool.execute!( + await hostShellTool.execute( { command: "ls -la /tmp", working_dir: dir, @@ -242,7 +248,7 @@ describe("host_bash — baseline: no sandbox isolation", () => { spawnCalls.length = 0; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo sandbox-enabled-irrelevant", working_dir: dir, @@ -296,7 +302,7 @@ describe("host_bash — regression: no proxied-mode additions", () => { // Pass network_mode as if the model hallucinated the parameter — // host_bash must ignore it and run the command normally. - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo should-work", working_dir: dir, @@ -319,7 +325,7 @@ describe("host_bash — regression: no proxied-mode additions", () => { spawnCalls.length = 0; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo creds-ignored", working_dir: dir, @@ -352,7 +358,7 @@ describe("host_bash — regression: no proxied-mode additions", () => { describe("host_bash — input validation", () => { test("rejects null bytes in command", async () => { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo \0evil", }, @@ -364,7 +370,7 @@ describe("host_bash — input validation", () => { }); test("rejects null bytes in working_dir", async () => { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo test", working_dir: "/tmp/\0evil", @@ -377,7 +383,7 @@ describe("host_bash — input validation", () => { }); test("rejects empty command", async () => { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "", }, @@ -389,7 +395,7 @@ describe("host_bash — input validation", () => { }); test("rejects non-string command", async () => { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: 42, }, @@ -403,7 +409,7 @@ describe("host_bash — input validation", () => { }); test("rejects non-string working_dir", async () => { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo test", working_dir: 123, @@ -423,7 +429,7 @@ describe("host_bash — input validation", () => { describe("host_bash — environment setup", () => { test("defaults working_dir to user home when not provided", async () => { const { homedir } = await import("node:os"); - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "pwd", }, @@ -438,7 +444,7 @@ describe("host_bash — environment setup", () => { const { homedir } = await import("node:os"); const home = homedir(); - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: 'echo "$PATH"', }, @@ -457,7 +463,7 @@ describe("host_bash — environment setup", () => { process.env[varName] = "should-not-appear"; try { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "env", }, @@ -477,7 +483,7 @@ describe("host_bash — environment setup", () => { }); test("includes safe env vars like HOME and TERM", async () => { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: 'echo "HOME=$HOME"', }, @@ -493,7 +499,7 @@ describe("host_bash — environment setup", () => { const originalGatewayPort = process.env.GATEWAY_PORT; process.env.GATEWAY_PORT = "9000"; try { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: 'echo "$INTERNAL_GATEWAY_BASE_URL"', }, @@ -517,7 +523,7 @@ describe("host_bash — environment setup", () => { describe("host_bash — timeout handling", () => { test("respects custom timeout_seconds", async () => { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "sleep 5", timeout_seconds: 1, @@ -531,7 +537,7 @@ describe("host_bash — timeout handling", () => { test("clamps timeout to at least 1 second", async () => { // A timeout_seconds of 0 should be clamped to 1 - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo fast", timeout_seconds: 0, @@ -546,7 +552,7 @@ describe("host_bash — timeout handling", () => { test("clamps timeout to max configured value", async () => { // Request a timeout larger than the configured max (600) - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo capped", timeout_seconds: 9999, @@ -571,7 +577,7 @@ describe("host_bash — streaming and cancellation", () => { onOutput: (chunk: string) => chunks.push(chunk), }; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo streamed-output", }, @@ -589,7 +595,7 @@ describe("host_bash — streaming and cancellation", () => { onOutput: (chunk: string) => chunks.push(chunk), }; - await hostShellTool.execute!( + await hostShellTool.execute( { command: "echo stderr-data >&2", }, @@ -603,7 +609,7 @@ describe("host_bash — streaming and cancellation", () => { const ac = new AbortController(); // Start a long-running command then abort it quickly - const promise = hostShellTool.execute!( + const promise = hostShellTool.execute( { command: "sleep 30", }, @@ -623,7 +629,7 @@ describe("host_bash — streaming and cancellation", () => { const ac = new AbortController(); ac.abort(); - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "sleep 30", }, @@ -640,7 +646,7 @@ describe("host_bash — streaming and cancellation", () => { describe("host_bash — spawn error handling", () => { test("reports error when working_dir does not exist", async () => { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo test", working_dir: "/nonexistent/path/that/does/not/exist", @@ -653,7 +659,7 @@ describe("host_bash — spawn error handling", () => { }); test("captures both stdout and stderr in output", async () => { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo out && echo err >&2", }, @@ -665,7 +671,7 @@ describe("host_bash — spawn error handling", () => { }); test("returns completed marker for successful empty output", async () => { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "true", }, @@ -677,7 +683,7 @@ describe("host_bash — spawn error handling", () => { }); test("injects __CONVERSATION_ID for local host_bash execution", async () => { - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: 'echo "$__CONVERSATION_ID"', }, @@ -754,7 +760,7 @@ describe("host_bash — proxy delegation", () => { }; spawnCalls.length = 0; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo hello", working_dir: "/tmp", timeout_seconds: 30 }, ctx, ); @@ -780,7 +786,7 @@ describe("host_bash — proxy delegation", () => { ...makeContext(), }; - const result = await hostShellTool.execute!({ command: "echo \0evil" }, ctx); + const result = await hostShellTool.execute({ command: "echo \0evil" }, ctx); expect(result.isError).toBe(true); expect(result.content).toContain("null bytes"); @@ -799,7 +805,7 @@ describe("host_bash — proxy delegation", () => { ...makeContext(), }; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo test", working_dir: "relative/path" }, ctx, ); @@ -819,7 +825,7 @@ describe("host_bash — proxy delegation", () => { }; spawnCalls.length = 0; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo local-fallback", working_dir: dir }, ctx, ); @@ -834,7 +840,7 @@ describe("host_bash — proxy delegation", () => { // mockProxyAvailable defaults to false — simulates client disconnecting // after tool definitions were built (targetClientId already resolved). spawnCalls.length = 0; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo should-not-run", target_client_id: "client-mac-abc123" }, { ...makeContext(), transportInterface: "web" }, ); @@ -851,7 +857,7 @@ describe("host_bash — proxy delegation", () => { testDirs.push(dir); spawnCalls.length = 0; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo no-proxy", working_dir: dir }, makeContext(), ); @@ -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, }); @@ -888,7 +893,7 @@ describe("host_bash — proxy delegation", () => { trustClass: "trusted_contact", // untrusted actor }; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo lockdown" }, ctx, ); @@ -923,7 +928,7 @@ describe("host_bash — proxy delegation", () => { trustClass: "guardian", // trusted actor — no lockdown }; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo no-lockdown" }, ctx, ); @@ -961,7 +966,7 @@ describe("host_bash — proxy delegation", () => { trustClass: "guardian", // trusted actor — no lockdown }; - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "assistant browser status --json" }, ctx, ); diff --git a/assistant/src/__tests__/managed-skill-lifecycle.test.ts b/assistant/src/__tests__/managed-skill-lifecycle.test.ts index 9125aa1861a..9116b5f5f82 100644 --- a/assistant/src/__tests__/managed-skill-lifecycle.test.ts +++ b/assistant/src/__tests__/managed-skill-lifecycle.test.ts @@ -136,7 +136,7 @@ Run the custom lifecycle verification procedure. expect(catalogSkill!.source).toBe("managed"); expect(catalogSkill!.displayName).toBe("E2E Custom Skill"); - const loadResult = await skillLoadTool.execute!( + const loadResult = await skillLoadTool.execute( { skill: skillId }, makeContext(), ); @@ -294,7 +294,7 @@ Run the custom lifecycle verification procedure. expect(scaffoldData.created).toBe(true); // Step 2: Call skill_load tool to load the created skill - const loadResult = await skillLoadTool.execute!( + const loadResult = await skillLoadTool.execute( { skill: "chain-test" }, ctx, ); @@ -314,7 +314,7 @@ Run the custom lifecycle verification procedure. expect(deleteResult.isError).not.toBe(true); // Step 4: Verify skill_load returns error for deleted skill - const loadAfterDelete = await skillLoadTool.execute!( + const loadAfterDelete = await skillLoadTool.execute( { skill: "chain-test" }, ctx, ); diff --git a/assistant/src/__tests__/provider-platform-proxy-integration.test.ts b/assistant/src/__tests__/provider-platform-proxy-integration.test.ts index c4a2c8d82d4..bed1dfb359d 100644 --- a/assistant/src/__tests__/provider-platform-proxy-integration.test.ts +++ b/assistant/src/__tests__/provider-platform-proxy-integration.test.ts @@ -600,7 +600,7 @@ describe("managed proxy integration — managed web search routing", () => { }); const { webSearchTool } = await import("../tools/network/web-search.js"); - const result = await webSearchTool.execute!( + const result = await webSearchTool.execute( { query: "managed kimi query", count: 1, offset: 2, freshness: "pw" }, { conversationId: "conv-123", diff --git a/assistant/src/__tests__/secret-onetime-send.test.ts b/assistant/src/__tests__/secret-onetime-send.test.ts index 1e9d1ebdab3..f7fd41c4a4a 100644 --- a/assistant/src/__tests__/secret-onetime-send.test.ts +++ b/assistant/src/__tests__/secret-onetime-send.test.ts @@ -110,7 +110,7 @@ describe("one-time send override", () => { }), }; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "svc", field: "key", label: "Key" }, context, ); @@ -132,7 +132,7 @@ describe("one-time send override", () => { }), }; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "svc", field: "key", label: "Key" }, context, ); @@ -151,7 +151,7 @@ describe("one-time send override", () => { requestSecret: async () => ({ value: "v1", delivery: "store" as const }), }; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "svc", field: "key", label: "Key" }, context, ); @@ -173,7 +173,7 @@ describe("one-time send override", () => { }), }; - const result = await credentialStoreTool.execute!( + const result = await credentialStoreTool.execute( { action: "prompt", service: "svc", field: "key", label: "Key" }, context, ); diff --git a/assistant/src/__tests__/shell-credential-ref.test.ts b/assistant/src/__tests__/shell-credential-ref.test.ts index 20de5f73495..c451f03561e 100644 --- a/assistant/src/__tests__/shell-credential-ref.test.ts +++ b/assistant/src/__tests__/shell-credential-ref.test.ts @@ -104,7 +104,7 @@ describe("shell tool credential ref resolution", () => { ], }); - await shellTool.execute!( + await shellTool.execute( { command: "echo hello", reason: "test", @@ -125,7 +125,7 @@ describe("shell tool credential ref resolution", () => { allowedTools: ["bash"], }); - await shellTool.execute!( + await shellTool.execute( { command: "echo hello", reason: "test", @@ -141,7 +141,7 @@ describe("shell tool credential ref resolution", () => { }); test("unknown ref fails fast before spawning", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo hello", reason: "test", @@ -163,7 +163,7 @@ describe("shell tool credential ref resolution", () => { allowedTools: ["bash"], }); - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo hello", reason: "test", @@ -184,7 +184,7 @@ describe("shell tool credential ref resolution", () => { allowedTools: ["bash"], }); - await shellTool.execute!( + await shellTool.execute( { command: "echo hello", reason: "test", @@ -202,7 +202,7 @@ describe("shell tool credential ref resolution", () => { test("non-proxied mode passes refs through without resolution", async () => { // In non-proxied mode, credential_ids are ignored for proxy but still collected - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo hello", reason: "test", @@ -230,7 +230,7 @@ describe("shell tool credential ref resolution", () => { ], }); - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "curl https://api.vercel.com/v1/projects", activity: "test", @@ -260,7 +260,7 @@ describe("shell tool credential ref resolution", () => { ], }); - await shellTool.execute!( + await shellTool.execute( { command: "echo deploy", activity: "test", @@ -284,7 +284,7 @@ describe("shell tool credential ref resolution", () => { allowedTools: ["publish_page"], }); - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo mixed", activity: "test", diff --git a/assistant/src/__tests__/shell-observability.test.ts b/assistant/src/__tests__/shell-observability.test.ts index 14a0d77b0ae..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 { ToolDefinition } 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: ToolDefinition; - beforeEach(async () => { logCalls.length = 0; registeredTools.length = 0; @@ -174,7 +180,7 @@ describe("shell observability logs", () => { }); test("foreground exit emits structured 'Shell command exited' info log", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo obs-foreground", activity: "test" }, baseContext, ); @@ -191,7 +197,7 @@ describe("shell observability logs", () => { }); test("foreground timeout emits killTree warn + exit log with timedOut=true", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "sleep 30", activity: "test", timeout_seconds: 1 }, baseContext, ); @@ -210,7 +216,7 @@ describe("shell observability logs", () => { }, 10_000); test("background mode emits an exit log with mode='background' and the bg invocationId", async () => { - await shellTool.execute!( + await shellTool.execute( { command: "echo bg-obs", activity: "test", background: true }, baseContext, ); @@ -225,7 +231,7 @@ describe("shell observability logs", () => { test("aborted foreground command emits killTree warn with reason='abort'", async () => { const controller = new AbortController(); - const execPromise = shellTool.execute!( + const execPromise = shellTool.execute( { command: "sleep 30", activity: "test" }, { ...baseContext, signal: controller.signal }, ); diff --git a/assistant/src/__tests__/shell-tool-proxy-mode.test.ts b/assistant/src/__tests__/shell-tool-proxy-mode.test.ts index b71ecdfef27..dec4d5b95c0 100644 --- a/assistant/src/__tests__/shell-tool-proxy-mode.test.ts +++ b/assistant/src/__tests__/shell-tool-proxy-mode.test.ts @@ -179,7 +179,7 @@ afterEach(() => { describe("shell tool proxy mode", () => { test("default mode does not inject proxy env vars", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo hello" }, makeContext(), ); @@ -193,7 +193,7 @@ describe("shell tool proxy mode", () => { }); test("network_mode=off does not inject proxy env vars", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo hello", network_mode: "off" }, makeContext(), ); @@ -206,7 +206,7 @@ describe("shell tool proxy mode", () => { }); test("network_mode=proxied creates session and injects proxy env", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo proxied", network_mode: "proxied", @@ -246,7 +246,7 @@ describe("shell tool proxy mode", () => { conversationId: "test-conv", }; - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo reuse", network_mode: "proxied" }, makeContext(), ); @@ -266,7 +266,7 @@ describe("shell tool proxy mode", () => { }); test("safe env vars are preserved alongside proxy vars", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo env-merge", network_mode: "proxied", diff --git a/assistant/src/__tests__/terminal-tools.test.ts b/assistant/src/__tests__/terminal-tools.test.ts index 88977ff6e37..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 { ToolDefinition } 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: ToolDefinition; - - beforeEach(async () => { - const mod = await import("../tools/terminal/shell.js"); - shellTool = mod.shellTool; - }); - const baseContext = { workingDir: testTmpDir, conversationId: "test-conv-1", @@ -225,7 +217,7 @@ describe("Shell tool input validation", () => { }; test("rejects empty command", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "", reason: "test" }, baseContext, ); @@ -234,7 +226,7 @@ describe("Shell tool input validation", () => { }); test("rejects non-string command", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: 123, reason: "test" }, baseContext, ); @@ -243,7 +235,7 @@ describe("Shell tool input validation", () => { }); test("rejects command with null bytes", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo hello\0world", reason: "test" }, baseContext, ); @@ -252,13 +244,13 @@ describe("Shell tool input validation", () => { }); test("rejects missing command", async () => { - const result = await shellTool.execute!({ reason: "test" }, baseContext); + const result = await shellTool.execute({ reason: "test" }, baseContext); expect(result.isError).toBe(true); expect(result.content).toContain("command is required"); }); test("executes simple command successfully", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo test_output_12345", reason: "testing" }, baseContext, ); @@ -267,7 +259,7 @@ describe("Shell tool input validation", () => { }); test("returns error for failed command", async () => { - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "false", reason: "testing failure" }, baseContext, ); @@ -279,7 +271,7 @@ describe("Shell tool input validation", () => { // Verify by checking that the proxy session is never started — the // observable effect of network_mode defaulting to 'off'. proxyGetOrStartSession.mockClear(); - const result = await shellTool.execute!( + const result = await shellTool.execute( { command: "echo network_default", reason: "testing" }, baseContext, ); @@ -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/__tests__/tool-execution-abort-cleanup.test.ts b/assistant/src/__tests__/tool-execution-abort-cleanup.test.ts index 4c65509dd89..6002ac24bb6 100644 --- a/assistant/src/__tests__/tool-execution-abort-cleanup.test.ts +++ b/assistant/src/__tests__/tool-execution-abort-cleanup.test.ts @@ -112,7 +112,7 @@ describe("shell tool — process cleanup on AbortSignal", () => { const dir = makeTempDir(); const ac = new AbortController(); - const promise = hostShellTool.execute!( + const promise = hostShellTool.execute( { command: "sleep 30", reason: "test" }, makeToolContext(dir, ac.signal), ); @@ -134,7 +134,7 @@ describe("shell tool — process cleanup on AbortSignal", () => { const ac = new AbortController(); ac.abort(); // pre-aborted - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "sleep 30", reason: "test" }, makeToolContext(dir, ac.signal), ); @@ -148,7 +148,7 @@ describe("shell tool — process cleanup on AbortSignal", () => { const dir = makeTempDir(); const ac = new AbortController(); - const result = await hostShellTool.execute!( + const result = await hostShellTool.execute( { command: "echo completed", reason: "test" }, makeToolContext(dir, ac.signal), ); @@ -165,7 +165,7 @@ describe("shell tool — process cleanup on AbortSignal", () => { const dir = makeTempDir(); const ac = new AbortController(); - await hostShellTool.execute!( + await hostShellTool.execute( { command: "echo done", reason: "test" }, makeToolContext(dir, ac.signal), ); @@ -184,7 +184,7 @@ describe("shell tool — process cleanup on AbortSignal", () => { const ac = new AbortController(); const chunks: string[] = []; - const promise = hostShellTool.execute!( + const promise = hostShellTool.execute( { command: "for i in 1 2 3 4 5; do echo $i; sleep 2; done", reason: "test", diff --git a/assistant/src/ipc/skill-routes/__tests__/registries.test.ts b/assistant/src/ipc/skill-routes/__tests__/registries.test.ts index ca3c87a08d0..001b596d464 100644 --- a/assistant/src/ipc/skill-routes/__tests__/registries.test.ts +++ b/assistant/src/ipc/skill-routes/__tests__/registries.test.ts @@ -85,7 +85,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 +101,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 +122,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("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 d31f5fc1c74..9f359bab46f 100644 --- a/assistant/src/ipc/skill-routes/registries.ts +++ b/assistant/src/ipc/skill-routes/registries.ts @@ -22,24 +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 { Tool } from "../../tools/types.js"; -import { WireToolDefinitionSchema } 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 ──────────────────────────────────────────────── -// -// `WireToolDefinitionSchema` is the single source of truth for the wire -// form of a tool. It lives in `tools/types.ts` alongside the loose -// `ToolDefinitionSchema` that `ToolDefinition` is inferred from, so both -// the in-process author shape and the IPC wire shape derive from one -// schema declaration. - // `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 @@ -49,7 +40,7 @@ const log = getLogger("skill-routes-registries"); // in-process on the assistant side. const RegisterToolsParams = z.object({ skillId: z.string().min(1), - tools: z.array(WireToolDefinitionSchema).min(1), + tools: z.array(ToolDefinitionSchema).min(1), }); const RegisterSkillRouteParams = z.object({ @@ -158,45 +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. - */ -type WireToolDefinition = z.infer; - -function buildProxyTool(definition: WireToolDefinition): Tool { - // The Zod schema (`WireToolDefinitionSchema`) requires name, description, - // input_schema, defaultRiskLevel, and category — `definition` arrives via - // that parse, so every field below is guaranteed present at runtime. - // `defaultRiskLevel` is `z.enum(RiskLevel)` so the inferred type IS - // `RiskLevel` (the native enum), no cast needed. - const { name } = definition; - return { - name, - description: definition.description, - input_schema: definition.input_schema, - category: definition.category, - defaultRiskLevel: definition.defaultRiskLevel, - executionTarget: resolveExecutionTarget({ - name, - executionTarget: definition.executionTarget, - }), - execute: async () => { - // Only reached when no supervisor is attached (tests/boot race); - // the supervisor short-circuit above replaces this with the - // definition's dispatching execute closure on the production path. - throw new Error( - `Skill tool "${name}" invocation requires an attached MeetHostSupervisor`, - ); - }, - }; -} - // ── Handlers ────────────────────────────────────────────────────────── async function handleRegisterTools( @@ -225,7 +177,13 @@ async function handleRegisterTools( return { registered: tools.map((t) => t.name) }; } - const proxies = tools.map(buildProxyTool); + // Skills run `finalizeTool` locally before sending, so name is set — + // `?? ""` is a defensive default that will fail in registerSkillTools + // with a clear error if a skill ever forgets. The execute closure + // arrives as a no-op error closure from `finalizeTool`; the production + // path replaces it via the supervisor short-circuit above, so this + // fallback is only reached in tests / boot race. + const proxies = tools.map((tool) => finalizeTool(tool, tool.name ?? "")); // `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 52a9b9f30a4..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 { ToolContext, ToolDefinition, 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: ToolDefinition = { +const appOpenTool = { name: "app_open", description: "Open a persistent app in a dynamic_page surface on the connected client.", @@ -66,7 +70,7 @@ const appOpenTool: ToolDefinition = { }, execute: proxyExecute("app_open"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // Proxy-only tools registered in the core daemon registry 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 912ea45db9d..8246d136f78 100644 --- a/assistant/src/tools/ask-question/ask-question-tool.test.ts +++ b/assistant/src/tools/ask-question/ask-question-tool.test.ts @@ -102,7 +102,7 @@ describe("AskQuestionTool.execute", () => { singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute!(validInput, makeContext()); + const result = await tool.execute(validInput, makeContext()); expect(calls).toHaveLength(1); expect(calls[0]?.conversationId).toBe("conv-1"); @@ -125,7 +125,7 @@ describe("AskQuestionTool.execute", () => { const { tool } = makeToolWithStub( singleCompleted({ decision: "option", optionId: "b" }), ); - const result = await tool.execute!(validInput, makeContext()); + const result = await tool.execute(validInput, makeContext()); expect(result.content).toBe( `Question "${validInput.question}" → Option: b (Banana)`, ); @@ -136,7 +136,7 @@ describe("AskQuestionTool.execute", () => { const { tool } = makeToolWithStub( singleCompleted({ decision: "option", optionId: "ghost" }), ); - const result = await tool.execute!(validInput, makeContext()); + const result = await tool.execute(validInput, makeContext()); expect(result.content).toBe( `Question "${validInput.question}" → Option: ghost ((unknown))`, ); @@ -147,7 +147,7 @@ describe("AskQuestionTool.execute", () => { const { tool } = makeToolWithStub( singleCompleted({ decision: "free_text", text: "Cherry" }), ); - const result = await tool.execute!(validInput, makeContext()); + const result = await tool.execute(validInput, makeContext()); expect(result.content).toBe( `Question "${validInput.question}" → Free text: Cherry`, ); @@ -156,7 +156,7 @@ describe("AskQuestionTool.execute", () => { test("formats skipped result", async () => { const { tool } = makeToolWithStub(singleCompleted({ decision: "skipped" })); - const result = await tool.execute!(validInput, makeContext()); + const result = await tool.execute(validInput, makeContext()); expect(result.content).toBe(`Question "${validInput.question}" → Skipped`); expect(result.isError).toBe(false); }); @@ -166,7 +166,7 @@ describe("AskQuestionTool.execute", () => { entries: [{ questionId: "q1", decision: "timed_out" }], overall: "timed_out", }); - const result = await tool.execute!(validInput, makeContext()); + const result = await tool.execute(validInput, makeContext()); expect(result.isError).toBe(true); expect(result.content).toBe("User did not respond within timeout"); }); @@ -176,7 +176,7 @@ describe("AskQuestionTool.execute", () => { entries: [{ questionId: "q1", decision: "skipped" }], overall: "aborted", }); - const result = await tool.execute!(validInput, makeContext()); + const result = await tool.execute(validInput, makeContext()); expect(result.isError).toBe(true); expect(result.content).toBe("Question aborted"); }); @@ -185,7 +185,7 @@ describe("AskQuestionTool.execute", () => { const { tool, calls } = makeToolWithStub( singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute!( + const result = await tool.execute( { ...validInput, options: [{ id: "a", label: "Apple" }] }, makeContext(), ); @@ -198,7 +198,7 @@ describe("AskQuestionTool.execute", () => { const { tool, calls } = makeToolWithStub( singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute!( + const result = await tool.execute( { ...validInput, options: [ @@ -219,7 +219,7 @@ describe("AskQuestionTool.execute", () => { const { tool, calls } = makeToolWithStub( singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute!( + const result = await tool.execute( { ...validInput, question: "" }, makeContext(), ); @@ -232,7 +232,7 @@ describe("AskQuestionTool.execute", () => { singleCompleted({ decision: "option", optionId: "a" }), ); const ac = new AbortController(); - await tool.execute!(validInput, makeContext({ signal: ac.signal })); + await tool.execute(validInput, makeContext({ signal: ac.signal })); expect(calls[0]?.signal).toBe(ac.signal); }); }); @@ -245,7 +245,7 @@ describe("AskQuestionTool batched input", () => { singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute!(validInput, makeContext()); + const result = await tool.execute(validInput, makeContext()); expect(calls).toHaveLength(1); expect(calls[0]?.questions).toHaveLength(1); @@ -259,7 +259,7 @@ describe("AskQuestionTool batched input", () => { singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute!({ questions: [singleQ] }, makeContext()); + const result = await tool.execute({ questions: [singleQ] }, makeContext()); expect(calls).toHaveLength(1); expect(calls[0]?.questions).toHaveLength(1); @@ -298,7 +298,7 @@ describe("AskQuestionTool batched input", () => { overall: "completed", }); - const result = await tool.execute!( + const result = await tool.execute( { questions: [singleQ, q2, q3] }, makeContext(), ); @@ -345,7 +345,7 @@ describe("AskQuestionTool batched input", () => { overall: "completed", }); - const result = await tool.execute!( + const result = await tool.execute( { questions: [singleQ, q2, q3] }, makeContext(), ); @@ -376,7 +376,7 @@ describe("AskQuestionTool batched input", () => { overall: "closed", }); - const result = await tool.execute!( + const result = await tool.execute( { questions: [singleQ, q2] }, makeContext(), ); @@ -404,7 +404,7 @@ describe("AskQuestionTool batched input", () => { }); const five = [singleQ, singleQ, singleQ, singleQ, singleQ]; - const result = await tool.execute!({ questions: five }, makeContext()); + const result = await tool.execute({ questions: five }, makeContext()); expect(result.isError).toBe(false); expect(calls).toHaveLength(1); @@ -417,7 +417,7 @@ describe("AskQuestionTool batched input", () => { ); const six = [singleQ, singleQ, singleQ, singleQ, singleQ, singleQ]; - const result = await tool.execute!({ questions: six }, makeContext()); + const result = await tool.execute({ questions: six }, makeContext()); expect(result.isError).toBe(true); expect(result.content.toLowerCase()).toContain("invalid input"); @@ -429,7 +429,7 @@ describe("AskQuestionTool batched input", () => { singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute!({ questions: [] }, makeContext()); + const result = await tool.execute({ questions: [] }, makeContext()); expect(result.isError).toBe(true); expect(result.content.toLowerCase()).toContain("invalid input"); @@ -441,7 +441,7 @@ describe("AskQuestionTool batched input", () => { singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute!({}, makeContext()); + const result = await tool.execute({}, makeContext()); expect(result.isError).toBe(true); expect(result.content.toLowerCase()).toContain("invalid input"); @@ -453,7 +453,7 @@ describe("AskQuestionTool batched input", () => { singleCompleted({ decision: "option", optionId: "a" }), ); - const result = await tool.execute!({ question: "Hi?" }, makeContext()); + const result = await tool.execute({ question: "Hi?" }, makeContext()); expect(result.isError).toBe(true); expect(result.content.toLowerCase()).toContain("invalid input"); diff --git a/assistant/src/tools/computer-use/definitions.ts b/assistant/src/tools/computer-use/definitions.ts index 593adac0975..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 { ToolContext, ToolDefinition, 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: ToolDefinition = { +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: ToolDefinition = { }, execute: proxyExecute("computer_use_click"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // type_text // --------------------------------------------------------------------------- -export const computerUseTypeTextTool: ToolDefinition = { +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: ToolDefinition = { }, execute: proxyExecute("computer_use_type_text"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // key // --------------------------------------------------------------------------- -export const computerUseKeyTool: ToolDefinition = { +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: ToolDefinition = { }, execute: proxyExecute("computer_use_key"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // scroll // --------------------------------------------------------------------------- -export const computerUseScrollTool: ToolDefinition = { +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: ToolDefinition = { }, execute: proxyExecute("computer_use_scroll"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // drag // --------------------------------------------------------------------------- -export const computerUseDragTool: ToolDefinition = { +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: ToolDefinition = { }, execute: proxyExecute("computer_use_drag"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // wait // --------------------------------------------------------------------------- -export const computerUseWaitTool: ToolDefinition = { +export const computerUseWaitTool = { name: "computer_use_wait", description: "Wait for the UI to update", category: "computer-use", @@ -298,13 +302,13 @@ export const computerUseWaitTool: ToolDefinition = { }, execute: proxyExecute("computer_use_wait"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // open_app // --------------------------------------------------------------------------- -export const computerUseOpenAppTool: ToolDefinition = { +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: ToolDefinition = { }, execute: proxyExecute("computer_use_open_app"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // run_applescript // --------------------------------------------------------------------------- -export const computerUseRunAppleScriptTool: ToolDefinition = { +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: ToolDefinition = { }, execute: proxyExecute("computer_use_run_applescript"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // done // --------------------------------------------------------------------------- -export const computerUseDoneTool: ToolDefinition = { +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: ToolDefinition = { }, execute: proxyExecute("computer_use_done"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // respond // --------------------------------------------------------------------------- -export const computerUseRespondTool: ToolDefinition = { +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: ToolDefinition = { }, execute: proxyExecute("computer_use_respond"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // observe // --------------------------------------------------------------------------- -const computerUseObserveTool: ToolDefinition = { +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,7 +452,7 @@ const computerUseObserveTool: ToolDefinition = { }, execute: proxyExecute("computer_use_observe"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // All tools exported as array for convenience diff --git a/assistant/src/tools/credential-execution/make-authenticated-request.ts b/assistant/src/tools/credential-execution/make-authenticated-request.ts index ea1b1645fa0..876127c34bd 100644 --- a/assistant/src/tools/credential-execution/make-authenticated-request.ts +++ b/assistant/src/tools/credential-execution/make-authenticated-request.ts @@ -25,7 +25,7 @@ import type { const log = getLogger("ces-tool:make-authenticated-request"); -export const makeAuthenticatedRequestTool: ToolDefinition = { +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.", @@ -194,4 +194,4 @@ export const makeAuthenticatedRequestTool: ToolDefinition = { }; } }, -}; +} satisfies ToolDefinition; diff --git a/assistant/src/tools/credential-execution/run-authenticated-command.ts b/assistant/src/tools/credential-execution/run-authenticated-command.ts index cdd042fa77d..5b8eb1bb679 100644 --- a/assistant/src/tools/credential-execution/run-authenticated-command.ts +++ b/assistant/src/tools/credential-execution/run-authenticated-command.ts @@ -25,7 +25,7 @@ import type { const log = getLogger("ces-tool:run-authenticated-command"); -export const runAuthenticatedCommandTool: ToolDefinition = { +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.", @@ -257,4 +257,4 @@ export const runAuthenticatedCommandTool: ToolDefinition = { }; } }, -}; +} satisfies ToolDefinition; diff --git a/assistant/src/tools/credentials/vault.ts b/assistant/src/tools/credentials/vault.ts index 74eb5402459..bfa1ba598c7 100644 --- a/assistant/src/tools/credentials/vault.ts +++ b/assistant/src/tools/credentials/vault.ts @@ -73,7 +73,7 @@ function formatSlackChannelStatus(result: SlackChannelConfigResult): string { return ""; } -export const credentialStoreTool: ToolDefinition = { +export const credentialStoreTool = { name: "credential_store", description: "Store, list, delete, or prompt for credentials in the secure vault", @@ -820,4 +820,4 @@ export const credentialStoreTool: ToolDefinition = { return { content: `Error: unknown action "${action}"`, isError: true }; } }, -}; +} satisfies ToolDefinition; diff --git a/assistant/src/tools/filesystem/edit.ts b/assistant/src/tools/filesystem/edit.ts index 25074a94e7e..03d6df17d4a 100644 --- a/assistant/src/tools/filesystem/edit.ts +++ b/assistant/src/tools/filesystem/edit.ts @@ -9,7 +9,7 @@ import type { ToolExecutionResult, } from "../types.js"; -export const fileEditTool: ToolDefinition = { +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.", @@ -157,6 +157,6 @@ export const fileEditTool: ToolDefinition = { diff: { filePath, oldContent, newContent, isNewFile: false }, }; }, -}; +} satisfies ToolDefinition; registerTool(fileEditTool); diff --git a/assistant/src/tools/filesystem/list.ts b/assistant/src/tools/filesystem/list.ts index 3d0d7d8195e..a9f9aa1e5ce 100644 --- a/assistant/src/tools/filesystem/list.ts +++ b/assistant/src/tools/filesystem/list.ts @@ -8,7 +8,7 @@ import type { ToolExecutionResult, } from "../types.js"; -export const fileListTool: ToolDefinition = { +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.", @@ -85,6 +85,6 @@ export const fileListTool: ToolDefinition = { return { content: result.value.listing, isError: false }; }, -}; +} satisfies ToolDefinition; registerTool(fileListTool); diff --git a/assistant/src/tools/filesystem/read.ts b/assistant/src/tools/filesystem/read.ts index 89aaddf5f63..110bda991bd 100644 --- a/assistant/src/tools/filesystem/read.ts +++ b/assistant/src/tools/filesystem/read.ts @@ -14,7 +14,7 @@ import type { ToolExecutionResult, } from "../types.js"; -export const fileReadTool: ToolDefinition = { +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.", @@ -110,6 +110,6 @@ export const fileReadTool: ToolDefinition = { return { content: result.value.content, isError: false }; }, -}; +} satisfies ToolDefinition; registerTool(fileReadTool); diff --git a/assistant/src/tools/filesystem/write.ts b/assistant/src/tools/filesystem/write.ts index 2703b938fa8..621e7b521b1 100644 --- a/assistant/src/tools/filesystem/write.ts +++ b/assistant/src/tools/filesystem/write.ts @@ -33,7 +33,7 @@ function isInsidePkbRoot(absPath: string, pkbRoot: string): boolean { return normalized.startsWith(rootWithSep); } -export const fileWriteTool: ToolDefinition = { +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.", @@ -149,6 +149,6 @@ export const fileWriteTool: ToolDefinition = { diff: { filePath, oldContent, newContent, isNewFile }, }; }, -}; +} satisfies ToolDefinition; registerTool(fileWriteTool); diff --git a/assistant/src/tools/host-filesystem/edit.test.ts b/assistant/src/tools/host-filesystem/edit.test.ts index 74b9b21b63e..3b4e3768b0d 100644 --- a/assistant/src/tools/host-filesystem/edit.test.ts +++ b/assistant/src/tools/host-filesystem/edit.test.ts @@ -61,7 +61,7 @@ function makeContext( describe("host_file_edit cross-client guards", () => { test("returns 'no client' error on web transport when proxy unavailable and no targetClientId", async () => { const workingDir = makeTempDir(); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: "/some/host/path.txt", old_string: "foo", @@ -77,7 +77,7 @@ describe("host_file_edit cross-client guards", () => { test("returns 'specified client disconnected' error when targetClientId set but proxy unavailable on web", async () => { const workingDir = makeTempDir(); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: "/some/host/path.txt", old_string: "foo", @@ -94,7 +94,7 @@ describe("host_file_edit cross-client guards", () => { test("falls through to local fs on macos transport when proxy unavailable", async () => { const workingDir = makeTempDir(); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: "/nonexistent/x.txt", old_string: "foo", @@ -110,7 +110,7 @@ describe("host_file_edit cross-client guards", () => { test("does NOT reject on macos transport with a stale target_client_id when proxy unavailable (regression: P2 fix)", async () => { const workingDir = makeTempDir(); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: "/nonexistent/x.txt", old_string: "foo", @@ -131,7 +131,7 @@ describe("host_file_edit cross-client guards", () => { test("rejects when target_client_id is set but transport metadata is missing (legacy/backwards-compat path)", async () => { const workingDir = makeTempDir(); - const result = await hostFileEditTool.execute!( + const result = await hostFileEditTool.execute( { path: "/some/host/path.txt", old_string: "foo", diff --git a/assistant/src/tools/host-filesystem/edit.ts b/assistant/src/tools/host-filesystem/edit.ts index 8dea10866bd..224c2d94323 100644 --- a/assistant/src/tools/host-filesystem/edit.ts +++ b/assistant/src/tools/host-filesystem/edit.ts @@ -11,7 +11,7 @@ import type { ToolExecutionResult, } from "../types.js"; -export const hostFileEditTool: ToolDefinition = { +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.", @@ -234,4 +234,4 @@ export const hostFileEditTool: ToolDefinition = { diff: { filePath, oldContent, newContent, isNewFile: false }, }; }, -}; +} satisfies ToolDefinition; diff --git a/assistant/src/tools/host-filesystem/read.test.ts b/assistant/src/tools/host-filesystem/read.test.ts index 574cce2e82b..67f64171fd1 100644 --- a/assistant/src/tools/host-filesystem/read.test.ts +++ b/assistant/src/tools/host-filesystem/read.test.ts @@ -61,7 +61,7 @@ function makeContext( describe("host_file_read cross-client guards", () => { test("returns 'no client' error on web transport when proxy unavailable and no targetClientId", async () => { const workingDir = makeTempDir(); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: "/some/host/path.txt" }, makeContext(workingDir, "web"), ); @@ -73,7 +73,7 @@ describe("host_file_read cross-client guards", () => { test("returns 'specified client disconnected' error when targetClientId set but proxy unavailable on web", async () => { const workingDir = makeTempDir(); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: "/some/host/path.txt", target_client_id: "abc-123" }, makeContext(workingDir, "web"), ); @@ -85,7 +85,7 @@ describe("host_file_read cross-client guards", () => { test("falls through to local fs on macos transport when proxy unavailable and path is non-image", async () => { const workingDir = makeTempDir(); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: "/nonexistent/x.txt" }, makeContext(workingDir, "macos"), ); @@ -97,7 +97,7 @@ describe("host_file_read cross-client guards", () => { test("does NOT reject on macos transport with a stale target_client_id when proxy unavailable (regression: P2 fix)", async () => { const workingDir = makeTempDir(); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: "/nonexistent/x.txt", target_client_id: "stale-mac" }, makeContext(workingDir, "macos"), ); @@ -113,7 +113,7 @@ describe("host_file_read cross-client guards", () => { test("rejects when target_client_id is set but transport metadata is missing (legacy/backwards-compat path)", async () => { const workingDir = makeTempDir(); - const result = await hostFileReadTool.execute!( + const result = await hostFileReadTool.execute( { path: "/some/host/path.txt", target_client_id: "abc-123" }, // transportInterface intentionally undefined (legacy callers). makeContext(workingDir, undefined), diff --git a/assistant/src/tools/host-filesystem/read.ts b/assistant/src/tools/host-filesystem/read.ts index af7c82aa414..c409ab9acda 100644 --- a/assistant/src/tools/host-filesystem/read.ts +++ b/assistant/src/tools/host-filesystem/read.ts @@ -16,7 +16,7 @@ import type { ToolExecutionResult, } from "../types.js"; -export const hostFileReadTool: ToolDefinition = { +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.", @@ -188,4 +188,4 @@ export const hostFileReadTool: ToolDefinition = { return { content: result.value.content, isError: false }; }, -}; +} satisfies ToolDefinition; diff --git a/assistant/src/tools/host-filesystem/transfer.test.ts b/assistant/src/tools/host-filesystem/transfer.test.ts index a2392478df8..51139cbf1fb 100644 --- a/assistant/src/tools/host-filesystem/transfer.test.ts +++ b/assistant/src/tools/host-filesystem/transfer.test.ts @@ -105,7 +105,7 @@ describe("host_file_transfer local mode", () => { const srcFile = join(srcDir, "source.md"); writeFileSync(srcFile, "hello world"); - const result = await hostFileTransferTool.execute!( + const result = await hostFileTransferTool.execute( { source_path: srcFile, dest_path: "scratch/out.md", @@ -127,7 +127,7 @@ describe("host_file_transfer local mode", () => { const destFile = join(workingDir, "out.md"); - const result = await hostFileTransferTool.execute!( + const result = await hostFileTransferTool.execute( { source_path: srcFile, dest_path: destFile, @@ -146,7 +146,7 @@ describe("host_file_transfer local mode", () => { const srcFile = join(srcDir, "source.txt"); writeFileSync(srcFile, "content"); - const result = await hostFileTransferTool.execute!( + const result = await hostFileTransferTool.execute( { source_path: srcFile, dest_path: "../../etc/shadow", @@ -166,7 +166,7 @@ describe("host_file_transfer local mode", () => { const srcFile = join(srcDir, "source.txt"); writeFileSync(srcFile, "content"); - const result = await hostFileTransferTool.execute!( + const result = await hostFileTransferTool.execute( { source_path: srcFile, dest_path: "/workspace/out.md", @@ -193,7 +193,7 @@ describe("host_file_transfer local mode to_host", () => { const destDir = makeTempDir(); const destFile = join(destDir, "report.pdf"); - const result = await hostFileTransferTool.execute!( + const result = await hostFileTransferTool.execute( { source_path: "report.pdf", dest_path: destFile, @@ -211,7 +211,7 @@ describe("host_file_transfer local mode to_host", () => { const destDir = makeTempDir(); const destFile = join(destDir, "out.txt"); - const result = await hostFileTransferTool.execute!( + const result = await hostFileTransferTool.execute( { source_path: "../../etc/passwd", dest_path: destFile, @@ -231,7 +231,7 @@ describe("host_file_transfer local mode to_host", () => { const destDir = makeTempDir(); const destFile = join(destDir, "data.txt"); - const result = await hostFileTransferTool.execute!( + const result = await hostFileTransferTool.execute( { source_path: "/workspace/data.txt", dest_path: destFile, @@ -257,7 +257,7 @@ describe("host_file_transfer managed mode", () => { const srcFile = join(srcDir, "source.txt"); writeFileSync(srcFile, "content"); - await hostFileTransferTool.execute!( + await hostFileTransferTool.execute( { source_path: srcFile, dest_path: "relative/file.txt", @@ -277,7 +277,7 @@ describe("host_file_transfer managed mode", () => { const workingDir = makeTempDir(); writeFileSync(join(workingDir, "doc.md"), "content"); - await hostFileTransferTool.execute!( + await hostFileTransferTool.execute( { source_path: "doc.md", dest_path: "/Users/someone/Desktop/doc.md", @@ -297,7 +297,7 @@ describe("host_file_transfer managed mode", () => { const srcFile = join(srcDir, "source.txt"); writeFileSync(srcFile, "content"); - const result = await hostFileTransferTool.execute!( + const result = await hostFileTransferTool.execute( { source_path: srcFile, dest_path: "/etc/passwd", @@ -324,7 +324,7 @@ describe("host_file_transfer cross-client guards", () => { const srcFile = join(srcDir, "source.txt"); writeFileSync(srcFile, "content"); - const result = await hostFileTransferTool.execute!( + const result = await hostFileTransferTool.execute( { source_path: srcFile, dest_path: "out.txt", @@ -346,7 +346,7 @@ describe("host_file_transfer cross-client guards", () => { const srcFile = join(srcDir, "source.txt"); writeFileSync(srcFile, "content"); - const result = await hostFileTransferTool.execute!( + const result = await hostFileTransferTool.execute( { source_path: srcFile, dest_path: "out.txt", @@ -370,7 +370,7 @@ describe("host_file_transfer cross-client guards", () => { writeFileSync(srcFile, "content"); const destFile = join(workingDir, "should-not-exist.txt"); - const result = await hostFileTransferTool.execute!( + const result = await hostFileTransferTool.execute( { source_path: srcFile, dest_path: destFile, @@ -399,7 +399,7 @@ describe("host_file_transfer cross-client guards", () => { writeFileSync(srcFile, "content"); const destFile = join(workingDir, "stale-target.txt"); - const result = await hostFileTransferTool.execute!( + const result = await hostFileTransferTool.execute( { source_path: srcFile, dest_path: destFile, diff --git a/assistant/src/tools/host-filesystem/transfer.ts b/assistant/src/tools/host-filesystem/transfer.ts index d4d49b20bf3..e241032629c 100644 --- a/assistant/src/tools/host-filesystem/transfer.ts +++ b/assistant/src/tools/host-filesystem/transfer.ts @@ -13,7 +13,7 @@ import type { ToolExecutionResult, } from "../types.js"; -export const hostFileTransferTool: ToolDefinition = { +export const hostFileTransferTool = { name: "host_file_transfer", description: @@ -229,7 +229,7 @@ export const hostFileTransferTool: ToolDefinition = { // here, matching the read/write/edit pattern. return executeLocal(resolvedSourcePath, resolvedDestPath, overwrite); }, -}; +} satisfies ToolDefinition; /** * Local-mode filesystem copy. Module-level so the `host_file_transfer` diff --git a/assistant/src/tools/host-filesystem/write.test.ts b/assistant/src/tools/host-filesystem/write.test.ts index a1bece55818..8e5863135ba 100644 --- a/assistant/src/tools/host-filesystem/write.test.ts +++ b/assistant/src/tools/host-filesystem/write.test.ts @@ -61,7 +61,7 @@ function makeContext( describe("host_file_write cross-client guards", () => { test("returns 'no client' error on web transport when proxy unavailable and no targetClientId", async () => { const workingDir = makeTempDir(); - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: "/some/host/path.txt", content: "hello" }, makeContext(workingDir, "web"), ); @@ -73,7 +73,7 @@ describe("host_file_write cross-client guards", () => { test("returns 'specified client disconnected' error when targetClientId set but proxy unavailable on web", async () => { const workingDir = makeTempDir(); - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: "/some/host/path.txt", content: "hello", @@ -90,7 +90,7 @@ describe("host_file_write cross-client guards", () => { test("falls through to local fs on macos transport when proxy unavailable", async () => { const workingDir = makeTempDir(); const destFile = join(workingDir, "out.txt"); - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: destFile, content: "hello" }, makeContext(workingDir, "macos"), ); @@ -102,7 +102,7 @@ describe("host_file_write cross-client guards", () => { test("does NOT reject on macos transport with a stale target_client_id when proxy unavailable (regression: P2 fix)", async () => { const workingDir = makeTempDir(); const destFile = join(workingDir, "stale-target.txt"); - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: destFile, content: "hello", target_client_id: "stale-mac" }, makeContext(workingDir, "macos"), ); @@ -118,7 +118,7 @@ describe("host_file_write cross-client guards", () => { test("rejects when target_client_id is set but transport metadata is missing (legacy/backwards-compat path)", async () => { const workingDir = makeTempDir(); const destFile = join(workingDir, "should-not-exist.txt"); - const result = await hostFileWriteTool.execute!( + const result = await hostFileWriteTool.execute( { path: destFile, content: "hello", target_client_id: "abc-123" }, // transportInterface intentionally undefined (legacy callers). makeContext(workingDir, undefined), diff --git a/assistant/src/tools/host-filesystem/write.ts b/assistant/src/tools/host-filesystem/write.ts index 4bc5b7b6735..54f6d7c4170 100644 --- a/assistant/src/tools/host-filesystem/write.ts +++ b/assistant/src/tools/host-filesystem/write.ts @@ -11,7 +11,7 @@ import type { ToolExecutionResult, } from "../types.js"; -export const hostFileWriteTool: ToolDefinition = { +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.", @@ -168,4 +168,4 @@ export const hostFileWriteTool: ToolDefinition = { diff: { filePath, oldContent, newContent, isNewFile }, }; }, -}; +} satisfies ToolDefinition; diff --git a/assistant/src/tools/host-terminal/host-shell.ts b/assistant/src/tools/host-terminal/host-shell.ts index 108731c0ea9..98c1c318232 100644 --- a/assistant/src/tools/host-terminal/host-shell.ts +++ b/assistant/src/tools/host-terminal/host-shell.ts @@ -95,7 +95,7 @@ function buildHostBashProxyEnv( return env; } -export const hostShellTool: ToolDefinition = { +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.", @@ -570,4 +570,4 @@ export const hostShellTool: ToolDefinition = { }); }); }, -}; +} satisfies ToolDefinition; diff --git a/assistant/src/tools/memory/register.test.ts b/assistant/src/tools/memory/register.test.ts index a6df2c8b656..ad73e35e43d 100644 --- a/assistant/src/tools/memory/register.test.ts +++ b/assistant/src/tools/memory/register.test.ts @@ -148,7 +148,7 @@ describe("recallTool.execute", () => { }); test("allows guardian recall to invoke the agentic runner", async () => { - const result = await recallTool.execute!( + const result = await recallTool.execute( { query: "guardian recall" }, makeContext({ trustClass: "guardian" }), ); @@ -164,7 +164,7 @@ describe("recallTool.execute", () => { test.each(["trusted_contact", "unknown"] as const)( "blocks %s recall before invoking the agentic runner", async (trustClass) => { - const result = await recallTool.execute!( + const result = await recallTool.execute( { query: "sensitive local search", sources: ["workspace"] }, makeContext({ trustClass }), ); @@ -176,7 +176,7 @@ describe("recallTool.execute", () => { ); test("passes source filtering input through to agentic recall", async () => { - const result = await recallTool.execute!( + const result = await recallTool.execute( { query: "release notes", sources: ["memory", "workspace"], @@ -202,7 +202,7 @@ describe("recallTool.execute", () => { test("returns deterministic fallback content directly", async () => { recallContent = "Found evidence:\n\n- [workspace] fallback note"; - const result = await recallTool.execute!( + const result = await recallTool.execute( { query: "fallback search", sources: ["workspace"], depth: "fast" }, makeContext(), ); @@ -216,7 +216,7 @@ describe("recallTool.execute", () => { test("propagates tool context", async () => { const controller = new AbortController(); - await recallTool.execute!( + await recallTool.execute( { query: "context propagation" }, makeContext({ workingDir: "/workspace/project", @@ -237,7 +237,7 @@ describe("recallTool.execute", () => { describe("rememberTool.execute — finish_turn", () => { test("omits yieldToUser when finish_turn is not provided", async () => { - const result = await rememberTool.execute!( + const result = await rememberTool.execute( { content: "no finish_turn provided" }, makeContext(), ); @@ -246,7 +246,7 @@ describe("rememberTool.execute — finish_turn", () => { }); test("omits yieldToUser when finish_turn is false", async () => { - const result = await rememberTool.execute!( + const result = await rememberTool.execute( { content: "finish_turn=false", finish_turn: false }, makeContext(), ); @@ -255,7 +255,7 @@ describe("rememberTool.execute — finish_turn", () => { }); test("sets yieldToUser=true when finish_turn is true", async () => { - const result = await rememberTool.execute!( + const result = await rememberTool.execute( { content: "finish_turn=true", finish_turn: true }, makeContext(), ); @@ -264,7 +264,7 @@ describe("rememberTool.execute — finish_turn", () => { }); test("sets yieldToUser=true even when the write fails (empty content)", async () => { - const result = await rememberTool.execute!( + const result = await rememberTool.execute( { content: "", finish_turn: true }, makeContext(), ); @@ -280,7 +280,7 @@ describe("rememberTool.execute — PKB re-index enqueue", () => { }); test("enqueues re-index jobs for both buffer and daily archive paths", async () => { - const result = await rememberTool.execute!( + const result = await rememberTool.execute( { content: "index me please" }, makeContext(), ); @@ -311,7 +311,7 @@ describe("rememberTool.execute — PKB re-index enqueue", () => { }); test("does not enqueue when content is empty (write was skipped)", async () => { - const result = await rememberTool.execute!( + const result = await rememberTool.execute( { content: " " }, makeContext(), ); @@ -322,7 +322,7 @@ describe("rememberTool.execute — PKB re-index enqueue", () => { test("thrown enqueue does not surface; remember still writes files", async () => { enqueueShouldThrow = true; - const result = await rememberTool.execute!( + const result = await rememberTool.execute( { content: "enqueue will throw" }, makeContext(), ); diff --git a/assistant/src/tools/memory/register.ts b/assistant/src/tools/memory/register.ts index e9d074a2a5d..e1b0efe9ccd 100644 --- a/assistant/src/tools/memory/register.ts +++ b/assistant/src/tools/memory/register.ts @@ -19,7 +19,7 @@ import type { // ── remember ──────────────────────────────────────────────────────── -export const rememberTool: ToolDefinition = { +export const rememberTool = { name: "remember", description: graphRememberDefinition.description, category: "memory", @@ -44,11 +44,11 @@ export const rememberTool: ToolDefinition = { ...(typedInput.finish_turn === true ? { yieldToUser: true } : {}), }; }, -}; +} satisfies ToolDefinition; // ── recall ────────────────────────────────────────────────────────── -export const recallTool: ToolDefinition = { +export const recallTool = { name: "recall", description: graphRecallDefinition.description, category: "memory", @@ -78,4 +78,4 @@ export const recallTool: ToolDefinition = { return { content: result.content, isError: false }; }, -}; +} satisfies ToolDefinition; diff --git a/assistant/src/tools/network/web-fetch.ts b/assistant/src/tools/network/web-fetch.ts index b2b2f627303..5e2dd30bbbc 100644 --- a/assistant/src/tools/network/web-fetch.ts +++ b/assistant/src/tools/network/web-fetch.ts @@ -988,7 +988,7 @@ export async function executeWebFetch( } } -export const webFetchTool: ToolDefinition = { +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.", @@ -1037,6 +1037,6 @@ export const webFetchTool: ToolDefinition = { ): Promise { return executeWebFetch(input, { signal: context.signal }); }, -}; +} satisfies ToolDefinition; registerTool(webFetchTool); diff --git a/assistant/src/tools/network/web-search.ts b/assistant/src/tools/network/web-search.ts index 816fe9ab18d..9dc63cdd336 100644 --- a/assistant/src/tools/network/web-search.ts +++ b/assistant/src/tools/network/web-search.ts @@ -773,7 +773,7 @@ const WEB_SEARCH_FALLBACK_ORDER: readonly WebSearchProvider[] = Object.values( .sort((a, b) => a.fallbackOrder - b.fallbackOrder) .map((adapter) => adapter.id); -export const webSearchTool: ToolDefinition = { +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.", @@ -907,6 +907,6 @@ export const webSearchTool: ToolDefinition = { ); } }, -}; +} satisfies ToolDefinition; registerTool(webSearchTool); diff --git a/assistant/src/tools/skills/execute.ts b/assistant/src/tools/skills/execute.ts index edf15edd0ca..0c5adc112bf 100644 --- a/assistant/src/tools/skills/execute.ts +++ b/assistant/src/tools/skills/execute.ts @@ -6,7 +6,7 @@ import type { ToolExecutionResult, } from "../types.js"; -export const skillExecuteTool: ToolDefinition = { +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.", @@ -46,6 +46,6 @@ export const skillExecuteTool: ToolDefinition = { isError: true, }; }, -}; +} satisfies ToolDefinition; registerTool(skillExecuteTool); diff --git a/assistant/src/tools/subagent/notify-parent.ts b/assistant/src/tools/subagent/notify-parent.ts index b834af8cdab..05c50153f10 100644 --- a/assistant/src/tools/subagent/notify-parent.ts +++ b/assistant/src/tools/subagent/notify-parent.ts @@ -35,7 +35,7 @@ export async function executeSubagentNotifyParent( }; } -export const notifyParentTool: ToolDefinition = { +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.", @@ -71,6 +71,6 @@ export const notifyParentTool: ToolDefinition = { ): Promise { return executeSubagentNotifyParent(input, context); }, -}; +} satisfies ToolDefinition; registerTool(notifyParentTool); diff --git a/assistant/src/tools/system/request-permission.ts b/assistant/src/tools/system/request-permission.ts index 5d1def217d4..96145fb3c7b 100644 --- a/assistant/src/tools/system/request-permission.ts +++ b/assistant/src/tools/system/request-permission.ts @@ -53,7 +53,7 @@ const FRIENDLY_NAMES: Record = { camera: "Camera", }; -export const requestSystemPermissionTool: ToolDefinition = { +export const requestSystemPermissionTool = { name: "request_system_permission", description: "Request a macOS system permission via System Settings. " + @@ -107,6 +107,6 @@ export const requestSystemPermissionTool: ToolDefinition = { isError: false, }; }, -}; +} satisfies ToolDefinition; registerTool(requestSystemPermissionTool); diff --git a/assistant/src/tools/terminal/shell.ts b/assistant/src/tools/terminal/shell.ts index f3d8d6e0d75..58536d76f1d 100644 --- a/assistant/src/tools/terminal/shell.ts +++ b/assistant/src/tools/terminal/shell.ts @@ -44,7 +44,7 @@ function buildCredentialRefTrace( const log = getLogger("shell-tool"); -export const shellTool: ToolDefinition = { +export const shellTool = { name: "bash", description: "Execute a shell command on the local machine", category: "terminal", @@ -559,7 +559,7 @@ export const shellTool: ToolDefinition = { return result; }, -}; +} satisfies ToolDefinition; /** * Structured teardown log. Pairs with the `"Executing shell command"` diff --git a/assistant/src/tools/types.ts b/assistant/src/tools/types.ts index f9d2cf56907..c79d14829b7 100644 --- a/assistant/src/tools/types.ts +++ b/assistant/src/tools/types.ts @@ -320,9 +320,9 @@ export interface ToolContext { /** * 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 derives - * a stricter wire schema (`WireToolDefinitionSchema`) where the - * skill-finalized fields become required. + * 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 @@ -348,23 +348,6 @@ export const ToolDefinitionSchema = z.object({ executionTarget: z.enum(["sandbox", "host"]).optional(), }); -/** - * Wire form of a {@link ToolDefinition} sent over IPC by a skill process. - * Skills run `finalizeTool` locally before sending, so name, description, - * input_schema, defaultRiskLevel, and category are required on arrival; - * `executionTarget` stays optional because the daemon resolves it via - * `resolveExecutionTarget`. `execute` is dropped — closures cannot cross - * the socket, so {@link buildProxyTool} synthesizes one that forwards - * invocations back over IPC. - */ -export const WireToolDefinitionSchema = ToolDefinitionSchema.required({ - name: true, - description: true, - input_schema: true, - defaultRiskLevel: true, - category: true, -}); - /** * Author-facing tool spec — re-exported from `@vellumai/plugin-api`. * Loaders fill documented defaults for omitted fields via `finalizeTool` @@ -372,17 +355,17 @@ export const WireToolDefinitionSchema = ToolDefinitionSchema.required({ * (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. The - * wire form still parses to `Record` via - * {@link WireToolDefinitionSchema}. - * - `execute` is optional because some `ToolDefinition` instances - * are schema-only (e.g. {@link ../memory/graph/tools.graphRememberDefinition}, + * 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 `buildProxyTool` synthesizes a new one on arrival. - * Callers that invoke `execute` should treat it as `tool.execute!(...)`. + * {@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, diff --git a/assistant/src/tools/ui-surface/definitions.ts b/assistant/src/tools/ui-surface/definitions.ts index 01b927def95..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 { ToolContext, ToolDefinition, 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: ToolDefinition = { +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: ToolDefinition = { }, execute: proxyExecute("ui_show"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // ui_update // --------------------------------------------------------------------------- -const uiUpdateTool: ToolDefinition = { +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: ToolDefinition = { }, execute: proxyExecute("ui_update"), -}; +} satisfies ToolDefinition; // --------------------------------------------------------------------------- // ui_dismiss // --------------------------------------------------------------------------- -const uiDismissTool: ToolDefinition = { +const uiDismissTool = { name: "ui_dismiss", description: "Dismiss a currently displayed surface.", category: "ui-surface", @@ -177,7 +181,7 @@ const uiDismissTool: ToolDefinition = { }, execute: proxyExecute("ui_dismiss"), -}; +} satisfies ToolDefinition; export const allUiSurfaceTools: ToolDefinition[] = [ uiShowTool, From 9ab5f40d1b51cd9e46c41e8a55b1d4a8f6dbd0cc Mon Sep 17 00:00:00 2001 From: "vellum-apollo-bot[bot]" <242025090+vellum-apollo-bot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 18:42:02 +0000 Subject: [PATCH 09/10] fix(tools): convert skillLoadTool + ask-question to satisfies, finalize before supervisor short-circuit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 CI follow-up — three type-check failures from PR #32631 round 2: 1. `skillLoadTool` (skills/load.ts) and `askQuestionTool` (ask-question/ask-question-tool.ts) source files were missed in the first satisfies pass: - `skills/load.ts` contains a regex literal (`/!`[^`]*`/g`) inside the execute body. The AST converter at scratch/satisfies-converter.ts doesn't track regex literals, so its brace matcher walked through the backticks inside the character class, lost depth tracking, and silently emitted "no spots found". Converted manually. - `ask-question/ask-question-tool.ts` wraps a factory call (`createAskQuestionTool()` returns ToolDefinition). The cleanest fix is to drop the factory's explicit return annotation and apply `satisfies ToolDefinition` inside the returned literal so the inferred return type carries the narrow shape through the export. 2. `ipc/skill-routes/registries.ts` supervisor short-circuit log + return read `tools.map((t) => t.name)` where `t.name` is `string | undefined` per the all-optional schema. Pulled `finalizeTool` ahead of the supervisor branch so both paths see a `Tool[]` (name guaranteed). 3. `ipc/skill-routes/__tests__/registries.test.ts` "fills defaults" assertion compared `installed.defaultRiskLevel` (typed `RiskLevel`) against the string literal `"medium"` — strict enum compare needs `RiskLevel.Medium`. --- .../skill-routes/__tests__/registries.test.ts | 3 ++- assistant/src/ipc/skill-routes/registries.ts | 23 +++++++++++-------- .../tools/ask-question/ask-question-tool.ts | 6 ++--- assistant/src/tools/skills/load.ts | 4 ++-- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/assistant/src/ipc/skill-routes/__tests__/registries.test.ts b/assistant/src/ipc/skill-routes/__tests__/registries.test.ts index 001b596d464..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, @@ -133,7 +134,7 @@ describe("host.registries.register_tools", () => { expect(result.registered).toEqual(["partial_tool"]); const installed = getTool("partial_tool"); expect(installed).toBeDefined(); - expect(installed!.defaultRiskLevel).toBe("medium"); + expect(installed!.defaultRiskLevel).toBe(RiskLevel.Medium); expect(installed!.executionTarget).toBe("sandbox"); }); diff --git a/assistant/src/ipc/skill-routes/registries.ts b/assistant/src/ipc/skill-routes/registries.ts index 9f359bab46f..90ad8b332f1 100644 --- a/assistant/src/ipc/skill-routes/registries.ts +++ b/assistant/src/ipc/skill-routes/registries.ts @@ -158,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 @@ -168,22 +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) }; } - // Skills run `finalizeTool` locally before sending, so name is set — - // `?? ""` is a defensive default that will fail in registerSkillTools - // with a clear error if a skill ever forgets. The execute closure - // arrives as a no-op error closure from `finalizeTool`; the production - // path replaces it via the supervisor short-circuit above, so this - // fallback is only reached in tests / boot race. - const proxies = tools.map((tool) => finalizeTool(tool, tool.name ?? "")); // `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/tools/ask-question/ask-question-tool.ts b/assistant/src/tools/ask-question/ask-question-tool.ts index 6a7f2b1344f..8d2056b7d96 100644 --- a/assistant/src/tools/ask-question/ask-question-tool.ts +++ b/assistant/src/tools/ask-question/ask-question-tool.ts @@ -147,7 +147,7 @@ const OPTION_ITEMS_SCHEMA = { export function createAskQuestionTool( prompterFactory: () => Pick = () => new QuestionPrompter({ broadcastMessage }), -): ToolDefinition { +) { return { name: "ask_question", description: DESCRIPTION, @@ -296,7 +296,7 @@ export function createAskQuestionTool( }; } }, - }; + } satisfies ToolDefinition; } -export const askQuestionTool: ToolDefinition = createAskQuestionTool(); +export const askQuestionTool = createAskQuestionTool(); diff --git a/assistant/src/tools/skills/load.ts b/assistant/src/tools/skills/load.ts index 5bdf38fe832..b93daa88477 100644 --- a/assistant/src/tools/skills/load.ts +++ b/assistant/src/tools/skills/load.ts @@ -124,7 +124,7 @@ function formatToolSchemas( return lines.join("\n").trimEnd(); } -export const skillLoadTool: ToolDefinition = { +export const skillLoadTool = { name: "skill_load", description: @@ -525,5 +525,5 @@ export const skillLoadTool: ToolDefinition = { isError: false, }; }, -}; +} satisfies ToolDefinition; registerTool(skillLoadTool); From 1ed662ca0fa64ae74f583fc063684679add26b88 Mon Sep 17 00:00:00 2001 From: "vellum-apollo-bot[bot]" <242025090+vellum-apollo-bot[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 18:45:14 +0000 Subject: [PATCH 10/10] fix(tests): drop widening return type from makeToolWithStub in ask-question test The helper had `tool: ToolDefinition` in its explicit return type, which collapses the satisfies-narrowed shape returned by `createAskQuestionTool()` back to the public author-facing shape (execute optional). Remove the explicit return type so callers see the inferred narrow shape and can invoke `tool.execute(...)` without a `!` bang. Also drop the now-unused `ToolDefinition` type import. --- .../src/tools/ask-question/ask-question-tool.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 8246d136f78..2dc2d290e24 100644 --- a/assistant/src/tools/ask-question/ask-question-tool.test.ts +++ b/assistant/src/tools/ask-question/ask-question-tool.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { QuestionPromptResult } from "../../permissions/question-prompter.js"; -import type { ToolContext, ToolDefinition } from "../types.js"; +import type { ToolContext } from "../types.js"; import { askQuestionTool, createAskQuestionTool } from "./ask-question-tool.js"; type PromptParams = Parameters< @@ -18,10 +18,10 @@ function makeContext(overrides: Partial = {}): ToolContext { }; } -function makeToolWithStub(result: QuestionPromptResult): { - tool: ToolDefinition; - 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 = createAskQuestionTool(() => ({ async prompt(params: PromptParams) {