From 16bc0d023cb0e95e211f4b8c99d6e5829036ed15 Mon Sep 17 00:00:00 2001
From: cte
Date: Sun, 14 Dec 2025 10:44:32 -0800
Subject: [PATCH 01/18] Add `@roo-code/core`
---
packages/core/eslint.config.mjs | 4 +
packages/core/package.json | 24 ++
.../__tests__/custom-tool-registry.spec.ts | 372 ++++++++++++++++++
.../custom-tools/__tests__/fixtures/cached.ts | 9 +
.../__tests__/fixtures/invalid.ts | 4 +
.../custom-tools/__tests__/fixtures/legacy.ts | 9 +
.../custom-tools/__tests__/fixtures/mixed.ts | 15 +
.../custom-tools/__tests__/fixtures/multi.ts | 17 +
.../custom-tools/__tests__/fixtures/simple.ts | 9 +
.../src/custom-tools/custom-tool-registry.ts | 333 ++++++++++++++++
packages/core/src/custom-tools/index.ts | 1 +
packages/core/src/index.ts | 1 +
packages/core/tsconfig.json | 9 +
packages/core/vitest.config.ts | 9 +
pnpm-lock.yaml | 27 +-
15 files changed, 842 insertions(+), 1 deletion(-)
create mode 100644 packages/core/eslint.config.mjs
create mode 100644 packages/core/package.json
create mode 100644 packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
create mode 100644 packages/core/src/custom-tools/__tests__/fixtures/cached.ts
create mode 100644 packages/core/src/custom-tools/__tests__/fixtures/invalid.ts
create mode 100644 packages/core/src/custom-tools/__tests__/fixtures/legacy.ts
create mode 100644 packages/core/src/custom-tools/__tests__/fixtures/mixed.ts
create mode 100644 packages/core/src/custom-tools/__tests__/fixtures/multi.ts
create mode 100644 packages/core/src/custom-tools/__tests__/fixtures/simple.ts
create mode 100644 packages/core/src/custom-tools/custom-tool-registry.ts
create mode 100644 packages/core/src/custom-tools/index.ts
create mode 100644 packages/core/src/index.ts
create mode 100644 packages/core/tsconfig.json
create mode 100644 packages/core/vitest.config.ts
diff --git a/packages/core/eslint.config.mjs b/packages/core/eslint.config.mjs
new file mode 100644
index 00000000000..694bf736642
--- /dev/null
+++ b/packages/core/eslint.config.mjs
@@ -0,0 +1,4 @@
+import { config } from "@roo-code/config-eslint/base"
+
+/** @type {import("eslint").Linter.Config} */
+export default [...config]
diff --git a/packages/core/package.json b/packages/core/package.json
new file mode 100644
index 00000000000..f14c6d2d589
--- /dev/null
+++ b/packages/core/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@roo-code/core",
+ "description": "Platform agnostic core functionality for Roo Code.",
+ "version": "0.0.0",
+ "type": "module",
+ "exports": "./src/index.ts",
+ "scripts": {
+ "lint": "eslint src --ext=ts --max-warnings=0",
+ "check-types": "tsc --noEmit",
+ "test": "vitest run",
+ "clean": "rimraf .turbo"
+ },
+ "dependencies": {
+ "@roo-code/types": "workspace:^",
+ "esbuild": "^0.25.0",
+ "zod": "^3.25.61"
+ },
+ "devDependencies": {
+ "@roo-code/config-eslint": "workspace:^",
+ "@roo-code/config-typescript": "workspace:^",
+ "@types/node": "^24.1.0",
+ "vitest": "^3.2.3"
+ }
+}
diff --git a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
new file mode 100644
index 00000000000..56bb5e7fce3
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
@@ -0,0 +1,372 @@
+import { z } from "zod"
+import path from "path"
+import { fileURLToPath } from "url"
+
+import {
+ type ToolContext,
+ type ToolDefinition,
+ CustomToolRegistry,
+ ToolDefinitionSchema,
+ isZodSchema,
+} from "../custom-tool-registry.js"
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+
+const TEST_FIXTURES_DIR = path.join(__dirname, "fixtures")
+
+const testContext: ToolContext = {
+ sessionID: "test-session",
+ messageID: "test-message",
+ agent: "test-agent",
+}
+
+describe("CustomToolRegistry", () => {
+ let registry: CustomToolRegistry
+
+ beforeEach(() => {
+ registry = new CustomToolRegistry()
+ })
+
+ describe("isZodSchema", () => {
+ it("should return true for Zod schemas", () => {
+ expect(isZodSchema(z.string())).toBe(true)
+ expect(isZodSchema(z.number())).toBe(true)
+ expect(isZodSchema(z.object({ foo: z.string() }))).toBe(true)
+ expect(isZodSchema(z.array(z.number()))).toBe(true)
+ })
+
+ it("should return false for non-Zod values", () => {
+ expect(isZodSchema(null)).toBe(false)
+ expect(isZodSchema(undefined)).toBe(false)
+ expect(isZodSchema("string")).toBe(false)
+ expect(isZodSchema(123)).toBe(false)
+ expect(isZodSchema({})).toBe(false)
+ expect(isZodSchema({ _def: "not an object" })).toBe(false)
+ expect(isZodSchema([])).toBe(false)
+ })
+ })
+
+ describe("ToolDefinitionSchema", () => {
+ it("should validate a correct tool definition", () => {
+ const validTool = {
+ description: "A valid tool",
+ parameters: z.object({ name: z.string() }),
+ execute: async () => "result",
+ }
+
+ const result = ToolDefinitionSchema.safeParse(validTool)
+ expect(result.success).toBe(true)
+ })
+
+ it("should reject empty description", () => {
+ const invalidTool = {
+ description: "",
+ parameters: z.object({}),
+ execute: async () => "result",
+ }
+
+ const result = ToolDefinitionSchema.safeParse(invalidTool)
+ expect(result.success).toBe(false)
+ })
+
+ it("should reject non-Zod parameters", () => {
+ const invalidTool = {
+ description: "Tool with bad params",
+ parameters: { foo: "bar" },
+ execute: async () => "result",
+ }
+
+ const result = ToolDefinitionSchema.safeParse(invalidTool)
+ expect(result.success).toBe(false)
+ })
+
+ it("should allow missing parameters", () => {
+ const toolWithoutParams = {
+ description: "Tool without parameters",
+ execute: async () => "result",
+ }
+
+ const result = ToolDefinitionSchema.safeParse(toolWithoutParams)
+ expect(result.success).toBe(true)
+ })
+ })
+
+ describe("register", () => {
+ it("should register a valid tool", () => {
+ const tool: ToolDefinition = {
+ description: "Test tool",
+ parameters: z.object({ input: z.string() }),
+ execute: async (args) => `Processed: ${(args as { input: string }).input}`,
+ }
+
+ registry.register("test_tool", tool)
+
+ expect(registry.has("test_tool")).toBe(true)
+ expect(registry.size).toBe(1)
+ })
+
+ it("should throw for invalid tool definition", () => {
+ const invalidTool = {
+ description: "",
+ execute: async () => "result",
+ }
+
+ expect(() => registry.register("bad_tool", invalidTool as ToolDefinition)).toThrow(
+ /Invalid tool definition/,
+ )
+ })
+
+ it("should overwrite existing tool with same id", () => {
+ const tool1: ToolDefinition = {
+ description: "First version",
+ execute: async () => "v1",
+ }
+
+ const tool2: ToolDefinition = {
+ description: "Second version",
+ execute: async () => "v2",
+ }
+
+ registry.register("tool", tool1)
+ registry.register("tool", tool2)
+
+ expect(registry.size).toBe(1)
+ expect(registry.get("tool")?.description).toBe("Second version")
+ })
+ })
+
+ describe("unregister", () => {
+ it("should remove a registered tool", () => {
+ registry.register("tool", {
+ description: "Test",
+ execute: async () => "result",
+ })
+
+ const result = registry.unregister("tool")
+
+ expect(result).toBe(true)
+ expect(registry.has("tool")).toBe(false)
+ })
+
+ it("should return false for non-existent tool", () => {
+ const result = registry.unregister("nonexistent")
+ expect(result).toBe(false)
+ })
+ })
+
+ describe("get", () => {
+ it("should return registered tool", () => {
+ registry.register("my_tool", {
+ description: "My tool",
+ execute: async () => "result",
+ })
+
+ const tool = registry.get("my_tool")
+
+ expect(tool).toBeDefined()
+ expect(tool?.id).toBe("my_tool")
+ expect(tool?.description).toBe("My tool")
+ })
+
+ it("should return undefined for non-existent tool", () => {
+ expect(registry.get("nonexistent")).toBeUndefined()
+ })
+ })
+
+ describe("list", () => {
+ it("should return all tool IDs", () => {
+ registry.register("tool_a", { description: "A", execute: async () => "a" })
+ registry.register("tool_b", { description: "B", execute: async () => "b" })
+ registry.register("tool_c", { description: "C", execute: async () => "c" })
+
+ const ids = registry.list()
+
+ expect(ids).toHaveLength(3)
+ expect(ids).toContain("tool_a")
+ expect(ids).toContain("tool_b")
+ expect(ids).toContain("tool_c")
+ })
+
+ it("should return empty array when no tools registered", () => {
+ expect(registry.list()).toEqual([])
+ })
+ })
+
+ describe("getAll", () => {
+ it("should return a copy of all tools", () => {
+ registry.register("tool1", { description: "Tool 1", execute: async () => "1" })
+ registry.register("tool2", { description: "Tool 2", execute: async () => "2" })
+
+ const all = registry.getAll()
+
+ expect(all.size).toBe(2)
+ expect(all.get("tool1")?.description).toBe("Tool 1")
+ expect(all.get("tool2")?.description).toBe("Tool 2")
+
+ // Verify it's a copy
+ all.delete("tool1")
+ expect(registry.has("tool1")).toBe(true)
+ })
+ })
+
+ describe("execute", () => {
+ it("should execute a tool with arguments", async () => {
+ registry.register("greeter", {
+ description: "Greets someone",
+ parameters: z.object({ name: z.string() }),
+ execute: async (args) => `Hello, ${(args as { name: string }).name}!`,
+ })
+
+ const result = await registry.execute("greeter", { name: "World" }, testContext)
+
+ expect(result).toBe("Hello, World!")
+ })
+
+ it("should throw for non-existent tool", async () => {
+ await expect(registry.execute("nonexistent", {}, testContext)).rejects.toThrow(
+ "Tool not found: nonexistent",
+ )
+ })
+
+ it("should validate arguments against Zod schema", async () => {
+ registry.register("typed_tool", {
+ description: "Tool with validation",
+ parameters: z.object({
+ count: z.number().min(0),
+ }),
+ execute: async (args) => `Count: ${(args as { count: number }).count}`,
+ })
+
+ // Valid args.
+ const result = await registry.execute("typed_tool", { count: 5 }, testContext)
+ expect(result).toBe("Count: 5")
+
+ // Invalid args - negative number.
+ await expect(registry.execute("typed_tool", { count: -1 }, testContext)).rejects.toThrow()
+
+ // Invalid args - wrong type.
+ await expect(registry.execute("typed_tool", { count: "five" }, testContext)).rejects.toThrow()
+ })
+
+ it("should pass context to execute function", async () => {
+ let receivedContext: ToolContext | null = null
+
+ registry.register("context_checker", {
+ description: "Checks context",
+ execute: async (_args, ctx) => {
+ receivedContext = ctx
+ return "done"
+ },
+ })
+
+ await registry.execute("context_checker", {}, testContext)
+
+ expect(receivedContext).toEqual(testContext)
+ })
+ })
+
+ describe("toJsonSchema", () => {
+ it("should generate JSON schema for all tools", () => {
+ registry.register("tool1", {
+ description: "First tool",
+ parameters: z.object({ a: z.string() }),
+ execute: async () => "1",
+ })
+
+ registry.register("tool2", {
+ description: "Second tool",
+ execute: async () => "2",
+ })
+
+ const schemas = registry.toJsonSchema()
+
+ expect(schemas).toHaveLength(2)
+
+ const tool1Schema = schemas.find((s) => s.name === "tool1")
+ expect(tool1Schema).toBeDefined()
+ expect(tool1Schema?.description).toBe("First tool")
+ expect(tool1Schema?.parameters.note).toBe("(Zod schema - would be converted to JSON Schema)")
+
+ const tool2Schema = schemas.find((s) => s.name === "tool2")
+ expect(tool2Schema).toBeDefined()
+ expect(tool2Schema?.description).toBe("Second tool")
+ })
+ })
+
+ describe("clear", () => {
+ it("should remove all registered tools", () => {
+ registry.register("tool1", { description: "1", execute: async () => "1" })
+ registry.register("tool2", { description: "2", execute: async () => "2" })
+
+ expect(registry.size).toBe(2)
+
+ registry.clear()
+
+ expect(registry.size).toBe(0)
+ expect(registry.list()).toEqual([])
+ })
+ })
+
+ describe("loadFromDirectory", () => {
+ it("should load tools from TypeScript files", async () => {
+ const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
+
+ expect(result.loaded).toContain("simple")
+ expect(registry.has("simple")).toBe(true)
+ })
+
+ it("should handle named exports", async () => {
+ const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
+
+ expect(result.loaded).toContain("multi_toolA")
+ expect(result.loaded).toContain("multi_toolB")
+ })
+
+ it("should report validation failures", async () => {
+ const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
+
+ const invalidFailure = result.failed.find((f) => f.file === "invalid.ts")
+ expect(invalidFailure).toBeDefined()
+ expect(invalidFailure?.error).toContain("Invalid tool definition")
+ })
+
+ it("should return empty results for non-existent directory", async () => {
+ const result = await registry.loadFromDirectory("/nonexistent/path")
+
+ expect(result.loaded).toHaveLength(0)
+ expect(result.failed).toHaveLength(0)
+ })
+
+ it("should skip non-tool exports silently", async () => {
+ const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
+
+ expect(result.loaded).toContain("mixed_validTool")
+ // The non-tool exports should not appear in loaded or failed
+ expect(result.loaded).not.toContain("mixed_someString")
+ expect(result.loaded).not.toContain("mixed_someNumber")
+ expect(result.loaded).not.toContain("mixed_someObject")
+ })
+
+ it("should support args as alias for parameters", async () => {
+ const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
+
+ expect(result.loaded).toContain("legacy")
+
+ const tool = registry.get("legacy")
+ expect(tool?.parameters).toBeDefined()
+ })
+ })
+
+ describe("clearCache", () => {
+ it("should clear the TypeScript compilation cache", async () => {
+ await registry.loadFromDirectory(TEST_FIXTURES_DIR)
+ registry.clearCache()
+
+ // Should be able to load again without issues.
+ registry.clear()
+ const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
+
+ expect(result.loaded).toContain("cached")
+ })
+ })
+})
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/cached.ts b/packages/core/src/custom-tools/__tests__/fixtures/cached.ts
new file mode 100644
index 00000000000..7ec470953aa
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/fixtures/cached.ts
@@ -0,0 +1,9 @@
+import { z } from "zod"
+
+export default {
+ description: "Cached tool",
+ parameters: z.object({}),
+ async execute() {
+ return "cached"
+ },
+}
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/invalid.ts b/packages/core/src/custom-tools/__tests__/fixtures/invalid.ts
new file mode 100644
index 00000000000..35c6c37e444
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/fixtures/invalid.ts
@@ -0,0 +1,4 @@
+export default {
+ description: "", // Invalid: empty description.
+ execute: async () => "result",
+}
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts b/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts
new file mode 100644
index 00000000000..dad4d986dd8
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts
@@ -0,0 +1,9 @@
+import { z } from "zod"
+
+export default {
+ description: "Legacy tool using args",
+ args: z.object({ input: z.string() }),
+ async execute(args: { input: string }) {
+ return args.input
+ },
+}
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts b/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts
new file mode 100644
index 00000000000..8d3cd8f049b
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts
@@ -0,0 +1,15 @@
+import { z } from "zod"
+
+// This is a valid tool.
+export const validTool = {
+ description: "Valid",
+ parameters: z.object({}),
+ async execute() {
+ return "valid"
+ },
+}
+
+// These should be silently skipped.
+export const someString = "not a tool"
+export const someNumber = 42
+export const someObject = { foo: "bar" }
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/multi.ts b/packages/core/src/custom-tools/__tests__/fixtures/multi.ts
new file mode 100644
index 00000000000..c85abd97af5
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/fixtures/multi.ts
@@ -0,0 +1,17 @@
+import { z } from "zod"
+
+export const toolA = {
+ description: "Tool A",
+ parameters: z.object({}),
+ async execute() {
+ return "A"
+ },
+}
+
+export const toolB = {
+ description: "Tool B",
+ parameters: z.object({}),
+ async execute() {
+ return "B"
+ },
+}
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/simple.ts b/packages/core/src/custom-tools/__tests__/fixtures/simple.ts
new file mode 100644
index 00000000000..744129cb968
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/fixtures/simple.ts
@@ -0,0 +1,9 @@
+import { z } from "zod"
+
+export default {
+ description: "Simple tool",
+ parameters: z.object({ value: z.string() }),
+ async execute(args: { value: string }) {
+ return "Result: " + args.value
+ },
+}
diff --git a/packages/core/src/custom-tools/custom-tool-registry.ts b/packages/core/src/custom-tools/custom-tool-registry.ts
new file mode 100644
index 00000000000..11342b88154
--- /dev/null
+++ b/packages/core/src/custom-tools/custom-tool-registry.ts
@@ -0,0 +1,333 @@
+/**
+ * CustomToolRegistry - A reusable class for dynamically loading and managing TypeScript tools.
+ *
+ * Features:
+ * - Dynamic TypeScript/JavaScript tool loading with esbuild transpilation.
+ * - Zod-based validation of tool definitions.
+ * - Tool execution with context.
+ * - JSON Schema generation for LLM integration.
+ */
+
+import fs from "fs"
+import path from "path"
+import { createHash } from "crypto"
+import os from "os"
+
+import { build } from "esbuild"
+import { z, type ZodType } from "zod"
+
+export interface ToolContext {
+ sessionID: string
+ messageID: string
+ agent: string
+}
+
+export interface ToolDefinition {
+ description: string
+ parameters?: ZodType
+ args?: ZodType
+ execute: (args: unknown, context: ToolContext) => Promise
+}
+
+export interface RegisteredTool {
+ id: string
+ description: string
+ parameters?: ZodType
+ execute: (args: unknown, context: ToolContext) => Promise
+}
+
+export interface ToolSchema {
+ name: string
+ description: string
+ parameters: {
+ type: string
+ properties: Record
+ required: string[]
+ note?: string
+ }
+}
+
+export interface LoadResult {
+ loaded: string[]
+ failed: Array<{ file: string; error: string }>
+}
+
+/**
+ * Check if a value is a Zod schema by looking for the _def property
+ * which is present on all Zod types.
+ */
+function isZodSchema(value: unknown): value is ZodType {
+ return (
+ value !== null &&
+ typeof value === "object" &&
+ "_def" in value &&
+ typeof (value as Record)._def === "object"
+ )
+}
+
+/**
+ * Zod schema to validate the shape of imported tool definitions.
+ * This ensures tools have the required structure before registration.
+ */
+const ToolDefinitionSchema = z.object({
+ description: z.string().min(1, "Tool must have a non-empty description"),
+ parameters: z.custom(isZodSchema, "parameters must be a Zod schema").optional(),
+ args: z.custom(isZodSchema, "args must be a Zod schema").optional(),
+ execute: z
+ .function()
+ .args(z.unknown(), z.unknown())
+ .returns(z.promise(z.string()))
+ .describe("Async function that executes the tool"),
+})
+
+export interface RegistryOptions {
+ /** Directory for caching compiled TypeScript files. */
+ cacheDir?: string
+ /** Additional paths for resolving node modules (useful for tools outside node_modules). */
+ nodePaths?: string[]
+}
+
+export class CustomToolRegistry {
+ private tools = new Map()
+ private tsCache = new Map()
+ private cacheDir: string
+ private nodePaths: string[]
+
+ constructor(options?: RegistryOptions) {
+ this.cacheDir = options?.cacheDir ?? path.join(os.tmpdir(), "dynamic-tools-cache")
+ // Default to current working directory's node_modules.
+ this.nodePaths = options?.nodePaths ?? [path.join(process.cwd(), "node_modules")]
+ }
+
+ /**
+ * Load all tools from a directory.
+ * Supports both .ts and .js files.
+ */
+ async loadFromDirectory(toolDir: string): Promise {
+ const result: LoadResult = { loaded: [], failed: [] }
+
+ if (!fs.existsSync(toolDir)) {
+ return result
+ }
+
+ const files = fs.readdirSync(toolDir).filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
+
+ for (const file of files) {
+ const filePath = path.join(toolDir, file)
+ const namespace = path.basename(file, path.extname(file))
+
+ try {
+ const mod = await this.importTypeScript(filePath)
+
+ for (const [exportName, value] of Object.entries(mod)) {
+ const def = this.validateToolDefinition(exportName, value)
+ if (!def) continue
+
+ const toolId = exportName === "default" ? namespace : `${namespace}_${exportName}`
+ this.tools.set(toolId, {
+ id: toolId,
+ description: def.description,
+ parameters: def.parameters || def.args,
+ execute: def.execute,
+ })
+
+ result.loaded.push(toolId)
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ result.failed.push({ file, error: message })
+ }
+ }
+
+ return result
+ }
+
+ /**
+ * Register a tool directly (without loading from file).
+ */
+ register(id: string, definition: ToolDefinition): void {
+ const validated = this.validateToolDefinition(id, definition)
+ if (!validated) {
+ throw new Error(`Invalid tool definition for '${id}'`)
+ }
+
+ this.tools.set(id, {
+ id,
+ description: validated.description,
+ parameters: validated.parameters || validated.args,
+ execute: validated.execute,
+ })
+ }
+
+ /**
+ * Unregister a tool by ID.
+ */
+ unregister(id: string): boolean {
+ return this.tools.delete(id)
+ }
+
+ /**
+ * Get a tool by ID.
+ */
+ get(id: string): RegisteredTool | undefined {
+ return this.tools.get(id)
+ }
+
+ /**
+ * Check if a tool exists.
+ */
+ has(id: string): boolean {
+ return this.tools.has(id)
+ }
+
+ /**
+ * Get all registered tool IDs.
+ */
+ list(): string[] {
+ return Array.from(this.tools.keys())
+ }
+
+ /**
+ * Get all registered tools.
+ */
+ getAll(): Map {
+ return new Map(this.tools)
+ }
+
+ /**
+ * Get the number of registered tools.
+ */
+ get size(): number {
+ return this.tools.size
+ }
+
+ /**
+ * Execute a tool with given arguments.
+ */
+ async execute(toolId: string, args: unknown, context: ToolContext): Promise {
+ const tool = this.tools.get(toolId)
+ if (!tool) {
+ throw new Error(`Tool not found: ${toolId}`)
+ }
+
+ // Validate args against schema if available
+ if (tool.parameters && "parse" in tool.parameters) {
+ ;(tool.parameters as { parse: (args: unknown) => void }).parse(args)
+ }
+
+ return tool.execute(args, context)
+ }
+
+ /**
+ * Generate JSON schema representation of all tools (for LLM integration).
+ */
+ toJsonSchema(): ToolSchema[] {
+ const schemas: ToolSchema[] = []
+
+ for (const [id, tool] of this.tools) {
+ const schema: ToolSchema = {
+ name: id,
+ description: tool.description,
+ parameters: {
+ type: "object",
+ properties: {},
+ required: [],
+ },
+ }
+
+ if (tool.parameters && "_def" in tool.parameters) {
+ schema.parameters.note = "(Zod schema - would be converted to JSON Schema)"
+ }
+
+ schemas.push(schema)
+ }
+
+ return schemas
+ }
+
+ /**
+ * Clear all registered tools.
+ */
+ clear(): void {
+ this.tools.clear()
+ }
+
+ /**
+ * Clear the TypeScript compilation cache.
+ */
+ clearCache(): void {
+ this.tsCache.clear()
+ }
+
+ /**
+ * Dynamically import a TypeScript or JavaScript file.
+ * TypeScript files are transpiled on-the-fly using esbuild.
+ */
+ private async importTypeScript(filePath: string): Promise> {
+ const absolutePath = path.resolve(filePath)
+ const ext = path.extname(absolutePath)
+
+ if (ext === ".js" || ext === ".mjs") {
+ return import(`file://${absolutePath}`)
+ }
+
+ const stat = fs.statSync(absolutePath)
+ const cacheKey = `${absolutePath}:${stat.mtimeMs}`
+
+ // Check if we have a cached version.
+ if (this.tsCache.has(cacheKey)) {
+ const cachedPath = this.tsCache.get(cacheKey)!
+ return import(`file://${cachedPath}`)
+ }
+
+ // Ensure cache directory exists.
+ fs.mkdirSync(this.cacheDir, { recursive: true })
+
+ const hash = createHash("sha256").update(cacheKey).digest("hex").slice(0, 16)
+ const tempFile = path.join(this.cacheDir, `${hash}.mjs`)
+
+ // Bundle the TypeScript file with dependencies.
+ await build({
+ entryPoints: [absolutePath],
+ bundle: true,
+ format: "esm",
+ platform: "node",
+ target: "node18",
+ outfile: tempFile,
+ sourcemap: "inline",
+ packages: "bundle",
+ // Include node_modules paths for module resolution.
+ nodePaths: this.nodePaths,
+ })
+
+ this.tsCache.set(cacheKey, tempFile)
+ return import(`file://${tempFile}`)
+ }
+
+ /**
+ * Validate a tool definition and return a typed result.
+ * Returns null for non-tool exports, throws for invalid tools.
+ */
+ private validateToolDefinition(exportName: string, value: unknown): ToolDefinition | null {
+ // Quick pre-check to filter out non-objects.
+ if (!value || typeof value !== "object") {
+ return null
+ }
+
+ // Check if it looks like a tool (has execute function).
+ if (!("execute" in value) || typeof (value as Record).execute !== "function") {
+ return null
+ }
+
+ const result = ToolDefinitionSchema.safeParse(value)
+
+ if (!result.success) {
+ const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")
+ throw new Error(`Invalid tool definition for '${exportName}': ${errors}`)
+ }
+
+ return result.data as ToolDefinition
+ }
+}
+
+export { isZodSchema, ToolDefinitionSchema }
diff --git a/packages/core/src/custom-tools/index.ts b/packages/core/src/custom-tools/index.ts
new file mode 100644
index 00000000000..1063c103c54
--- /dev/null
+++ b/packages/core/src/custom-tools/index.ts
@@ -0,0 +1 @@
+export * from "./custom-tool-registry.js"
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
new file mode 100644
index 00000000000..fd7c93f68a1
--- /dev/null
+++ b/packages/core/src/index.ts
@@ -0,0 +1 @@
+export * from "./custom-tools/index.js"
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
new file mode 100644
index 00000000000..2a73ee92bb0
--- /dev/null
+++ b/packages/core/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@roo-code/config-typescript/base.json",
+ "compilerOptions": {
+ "types": ["vitest/globals"],
+ "outDir": "dist"
+ },
+ "include": ["src", "scripts", "*.config.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts
new file mode 100644
index 00000000000..b6d6dbb880f
--- /dev/null
+++ b/packages/core/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vitest/config"
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: "node",
+ watch: false,
+ },
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d94adb27944..90f696531cc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -474,6 +474,31 @@ importers:
packages/config-typescript: {}
+ packages/core:
+ dependencies:
+ '@roo-code/types':
+ specifier: workspace:^
+ version: link:../types
+ esbuild:
+ specifier: '>=0.25.0'
+ version: 0.25.9
+ zod:
+ specifier: ^3.25.61
+ version: 3.25.76
+ devDependencies:
+ '@roo-code/config-eslint':
+ specifier: workspace:^
+ version: link:../config-eslint
+ '@roo-code/config-typescript':
+ specifier: workspace:^
+ version: link:../config-typescript
+ '@types/node':
+ specifier: ^24.1.0
+ version: 24.2.1
+ vitest:
+ specifier: ^3.2.3
+ version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
+
packages/evals:
dependencies:
'@roo-code/ipc':
@@ -14131,7 +14156,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
- vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
+ vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
'@vitest/utils@3.2.4':
dependencies:
From 0b9602c8ef3f4a1ebd45480f4cf61076a518d563 Mon Sep 17 00:00:00 2001
From: cte
Date: Sun, 14 Dec 2025 19:38:23 -0800
Subject: [PATCH 02/18] More progress
---
.roo/tools/__tests__/system-time.spec.ts | 69 ++++++++++++
.roo/tools/eslint.config.mjs | 4 +
.roo/tools/package.json | 18 ++++
.roo/tools/system-time.ts | 41 +++++++
.roo/tools/tsconfig.json | 9 ++
.roo/tools/vitest.config.ts | 9 ++
.../custom-tools/__tests__/fixtures/cached.ts | 7 +-
.../custom-tools/__tests__/fixtures/legacy.ts | 9 +-
.../custom-tools/__tests__/fixtures/mixed.ts | 7 +-
.../custom-tools/__tests__/fixtures/multi.ts | 12 ++-
.../custom-tools/__tests__/fixtures/simple.ts | 9 +-
.../src/custom-tools/custom-tool-registry.ts | 29 +++++
.../types/src/__tests__/custom-tool.spec.ts | 84 +++++++++++++++
packages/types/src/custom-tool.ts | 100 ++++++++++++++++++
packages/types/src/index.ts | 1 +
pnpm-lock.yaml | 18 ++++
pnpm-workspace.yaml | 1 +
src/package.json | 1 +
18 files changed, 409 insertions(+), 19 deletions(-)
create mode 100644 .roo/tools/__tests__/system-time.spec.ts
create mode 100644 .roo/tools/eslint.config.mjs
create mode 100644 .roo/tools/package.json
create mode 100644 .roo/tools/system-time.ts
create mode 100644 .roo/tools/tsconfig.json
create mode 100644 .roo/tools/vitest.config.ts
create mode 100644 packages/types/src/__tests__/custom-tool.spec.ts
create mode 100644 packages/types/src/custom-tool.ts
diff --git a/.roo/tools/__tests__/system-time.spec.ts b/.roo/tools/__tests__/system-time.spec.ts
new file mode 100644
index 00000000000..d24578db30d
--- /dev/null
+++ b/.roo/tools/__tests__/system-time.spec.ts
@@ -0,0 +1,69 @@
+import type { CustomToolContext } from "@roo-code/types"
+
+import systemTime from "../system-time.js"
+
+const mockContext: CustomToolContext = {
+ sessionID: "test-session",
+ messageID: "test-message",
+ agent: "test-agent",
+}
+
+describe("system-time tool", () => {
+ describe("definition", () => {
+ it("should have a description", () => {
+ expect(systemTime.description).toBe(
+ "Returns the current system date and time in a friendly, human-readable format.",
+ )
+ })
+
+ it("should have optional timezone parameter", () => {
+ expect(systemTime.parameters).toBeDefined()
+ const shape = systemTime.parameters!.shape
+ expect(shape.timezone).toBeDefined()
+ expect(shape.timezone.isOptional()).toBe(true)
+ })
+ })
+
+ describe("execute", () => {
+ it("should return a formatted date/time string", async () => {
+ const result = await systemTime.execute({}, mockContext)
+
+ expect(result).toMatch(/^The current date and time is:/)
+ // Should include weekday
+ expect(result).toMatch(/(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)/)
+ // Should include month
+ expect(result).toMatch(
+ /(January|February|March|April|May|June|July|August|September|October|November|December)/,
+ )
+ // Should include time format (e.g., "12:30:45")
+ expect(result).toMatch(/\d{1,2}:\d{2}:\d{2}/)
+ })
+
+ it("should use system timezone when no timezone provided", async () => {
+ const result = await systemTime.execute({}, mockContext)
+
+ // Should include timezone abbreviation (e.g., PST, EST, UTC, etc.)
+ expect(result).toMatch(/[A-Z]{2,5}$/)
+ })
+
+ it("should format with specified timezone", async () => {
+ const result = await systemTime.execute({ timezone: "UTC" }, mockContext)
+
+ expect(result).toMatch(/^The current date and time is:/)
+ // Should include UTC timezone indicator
+ expect(result).toMatch(/UTC/)
+ })
+
+ it("should work with different timezone formats", async () => {
+ const result = await systemTime.execute({ timezone: "America/New_York" }, mockContext)
+
+ expect(result).toMatch(/^The current date and time is:/)
+ // Should include Eastern timezone indicator (EST or EDT depending on daylight saving)
+ expect(result).toMatch(/(EST|EDT)/)
+ })
+
+ it("should throw error for invalid timezone", async () => {
+ await expect(systemTime.execute({ timezone: "Invalid/Timezone" }, mockContext)).rejects.toThrow()
+ })
+ })
+})
diff --git a/.roo/tools/eslint.config.mjs b/.roo/tools/eslint.config.mjs
new file mode 100644
index 00000000000..694bf736642
--- /dev/null
+++ b/.roo/tools/eslint.config.mjs
@@ -0,0 +1,4 @@
+import { config } from "@roo-code/config-eslint/base"
+
+/** @type {import("eslint").Linter.Config} */
+export default [...config]
diff --git a/.roo/tools/package.json b/.roo/tools/package.json
new file mode 100644
index 00000000000..6233d15c268
--- /dev/null
+++ b/.roo/tools/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@roo-code/custom-tools",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "description": "Custom tools for the Roo Code project itself",
+ "scripts": {
+ "lint": "eslint . --ext=ts --max-warnings=0",
+ "check-types": "tsc --noEmit",
+ "test": "vitest run"
+ },
+ "devDependencies": {
+ "@roo-code/config-eslint": "workspace:^",
+ "@roo-code/config-typescript": "workspace:^",
+ "@roo-code/types": "workspace:^",
+ "vitest": "^3.2.3"
+ }
+}
diff --git a/.roo/tools/system-time.ts b/.roo/tools/system-time.ts
new file mode 100644
index 00000000000..485d07bb334
--- /dev/null
+++ b/.roo/tools/system-time.ts
@@ -0,0 +1,41 @@
+import { z, defineCustomTool } from "@roo-code/types"
+
+/**
+ * A simple custom tool that returns the current date and time in a friendly format.
+ *
+ * To create your own custom tools:
+ * 1. Install @roo-code/types: npm install @roo-code/types
+ * 2. Create a .ts file in .roo/tools/
+ * 3. Export a default tool definition using defineCustomTool()
+ */
+export default defineCustomTool({
+ name: "system-time",
+ description: "Returns the current system date and time in a friendly, human-readable format.",
+ parameters: z.object({
+ timezone: z
+ .string()
+ .optional()
+ .describe("Optional timezone to display the time in (e.g., 'America/New_York', 'Europe/London')"),
+ }),
+ async execute(args) {
+ const options: Intl.DateTimeFormatOptions = {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ timeZoneName: "short",
+ }
+
+ if (args.timezone) {
+ options.timeZone = args.timezone
+ }
+
+ const now = new Date()
+ const formatted = now.toLocaleString("en-US", options)
+
+ return `The current date and time is: ${formatted}`
+ },
+})
diff --git a/.roo/tools/tsconfig.json b/.roo/tools/tsconfig.json
new file mode 100644
index 00000000000..4164293c1d3
--- /dev/null
+++ b/.roo/tools/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@roo-code/config-typescript/base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["vitest/globals"]
+ },
+ "include": ["*.ts", "__tests__/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/.roo/tools/vitest.config.ts b/.roo/tools/vitest.config.ts
new file mode 100644
index 00000000000..b6d6dbb880f
--- /dev/null
+++ b/.roo/tools/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vitest/config"
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: "node",
+ watch: false,
+ },
+})
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/cached.ts b/packages/core/src/custom-tools/__tests__/fixtures/cached.ts
index 7ec470953aa..1c61d7e6d3e 100644
--- a/packages/core/src/custom-tools/__tests__/fixtures/cached.ts
+++ b/packages/core/src/custom-tools/__tests__/fixtures/cached.ts
@@ -1,9 +1,10 @@
-import { z } from "zod"
+import { z, defineCustomTool } from "@roo-code/types"
-export default {
+export default defineCustomTool({
+ name: "cached",
description: "Cached tool",
parameters: z.object({}),
async execute() {
return "cached"
},
-}
+})
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts b/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts
index dad4d986dd8..69c1a99b164 100644
--- a/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts
+++ b/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts
@@ -1,9 +1,10 @@
-import { z } from "zod"
+import { z, defineCustomTool } from "@roo-code/types"
-export default {
+export default defineCustomTool({
+ name: "legacy",
description: "Legacy tool using args",
- args: z.object({ input: z.string() }),
+ parameters: z.object({ input: z.string().describe("The input string") }),
async execute(args: { input: string }) {
return args.input
},
-}
+})
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts b/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts
index 8d3cd8f049b..fe5add27a86 100644
--- a/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts
+++ b/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts
@@ -1,13 +1,14 @@
-import { z } from "zod"
+import { z, defineCustomTool } from "@roo-code/types"
// This is a valid tool.
-export const validTool = {
+export const validTool = defineCustomTool({
+ name: "mixed_validTool",
description: "Valid",
parameters: z.object({}),
async execute() {
return "valid"
},
-}
+})
// These should be silently skipped.
export const someString = "not a tool"
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/multi.ts b/packages/core/src/custom-tools/__tests__/fixtures/multi.ts
index c85abd97af5..a3b2ec0d3d2 100644
--- a/packages/core/src/custom-tools/__tests__/fixtures/multi.ts
+++ b/packages/core/src/custom-tools/__tests__/fixtures/multi.ts
@@ -1,17 +1,19 @@
-import { z } from "zod"
+import { z, defineCustomTool } from "@roo-code/types"
-export const toolA = {
+export const toolA = defineCustomTool({
+ name: "multi_toolA",
description: "Tool A",
parameters: z.object({}),
async execute() {
return "A"
},
-}
+})
-export const toolB = {
+export const toolB = defineCustomTool({
+ name: "multi_toolB",
description: "Tool B",
parameters: z.object({}),
async execute() {
return "B"
},
-}
+})
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/simple.ts b/packages/core/src/custom-tools/__tests__/fixtures/simple.ts
index 744129cb968..a3992b78a37 100644
--- a/packages/core/src/custom-tools/__tests__/fixtures/simple.ts
+++ b/packages/core/src/custom-tools/__tests__/fixtures/simple.ts
@@ -1,9 +1,10 @@
-import { z } from "zod"
+import { z, defineCustomTool } from "@roo-code/types"
-export default {
+export default defineCustomTool({
+ name: "simple",
description: "Simple tool",
- parameters: z.object({ value: z.string() }),
+ parameters: z.object({ value: z.string().describe("The input value") }),
async execute(args: { value: string }) {
return "Result: " + args.value
},
-}
+})
diff --git a/packages/core/src/custom-tools/custom-tool-registry.ts b/packages/core/src/custom-tools/custom-tool-registry.ts
index 11342b88154..44d4d302b83 100644
--- a/packages/core/src/custom-tools/custom-tool-registry.ts
+++ b/packages/core/src/custom-tools/custom-tool-registry.ts
@@ -16,6 +16,20 @@ import os from "os"
import { build } from "esbuild"
import { z, type ZodType } from "zod"
+/**
+ * Default subdirectory name for custom tools within a .roo directory.
+ * Tools placed in `{rooDir}/tools/` will be automatically discovered and loaded.
+ *
+ * @example
+ * ```ts
+ * // Typical usage with getRooDirectoriesForCwd from roo-config:
+ * for (const rooDir of getRooDirectoriesForCwd(cwd)) {
+ * await registry.loadFromDirectory(path.join(rooDir, TOOLS_DIR_NAME))
+ * }
+ * ```
+ */
+export const TOOLS_DIR_NAME = "tools"
+
export interface ToolContext {
sessionID: string
messageID: string
@@ -102,6 +116,21 @@ export class CustomToolRegistry {
/**
* Load all tools from a directory.
* Supports both .ts and .js files.
+ *
+ * @param toolDir - Absolute path to the tools directory
+ * @returns LoadResult with lists of loaded and failed tools
+ *
+ * @example
+ * ```ts
+ * // Load tools from multiple .roo directories (global and project):
+ * import { getRooDirectoriesForCwd } from "../services/roo-config"
+ * import { CustomToolRegistry, TOOLS_DIR_NAME } from "@roo-code/core"
+ *
+ * const registry = new CustomToolRegistry()
+ * for (const rooDir of getRooDirectoriesForCwd(cwd)) {
+ * await registry.loadFromDirectory(path.join(rooDir, TOOLS_DIR_NAME))
+ * }
+ * ```
*/
async loadFromDirectory(toolDir: string): Promise {
const result: LoadResult = { loaded: [], failed: [] }
diff --git a/packages/types/src/__tests__/custom-tool.spec.ts b/packages/types/src/__tests__/custom-tool.spec.ts
new file mode 100644
index 00000000000..1f19963514f
--- /dev/null
+++ b/packages/types/src/__tests__/custom-tool.spec.ts
@@ -0,0 +1,84 @@
+import { z, defineCustomTool, type CustomToolDefinition, type CustomToolContext } from "../custom-tool.js"
+
+describe("custom-tool utilities", () => {
+ describe("z (Zod re-export)", () => {
+ it("should export z from zod", () => {
+ expect(z).toBeDefined()
+ expect(z.string).toBeInstanceOf(Function)
+ expect(z.object).toBeInstanceOf(Function)
+ expect(z.number).toBeInstanceOf(Function)
+ })
+
+ it("should allow creating schemas", () => {
+ const schema = z.object({
+ name: z.string(),
+ count: z.number().optional(),
+ })
+
+ const result = schema.parse({ name: "test" })
+ expect(result).toEqual({ name: "test" })
+ })
+ })
+
+ describe("defineCustomTool", () => {
+ it("should return the same definition object", () => {
+ const definition = {
+ name: "test-tool",
+ description: "Test tool",
+ parameters: z.object({ input: z.string() }),
+ execute: async (args: { input: string }) => `Result: ${args.input}`,
+ }
+
+ const result = defineCustomTool(definition)
+ expect(result).toBe(definition)
+ })
+
+ it("should work without parameters", () => {
+ const tool = defineCustomTool({
+ name: "no-params-tool",
+ description: "No params tool",
+ execute: async () => "done",
+ })
+
+ expect(tool.description).toBe("No params tool")
+ expect(tool.parameters).toBeUndefined()
+ })
+
+ it("should preserve type inference for execute args", async () => {
+ const tool = defineCustomTool({
+ name: "typed-tool",
+ description: "Typed tool",
+ parameters: z.object({
+ name: z.string(),
+ count: z.number(),
+ }),
+ execute: async (args) => {
+ // TypeScript should infer args as { name: string, count: number }.
+ return `Hello ${args.name}, count is ${args.count}`
+ },
+ })
+
+ const context: CustomToolContext = {
+ sessionID: "test-session",
+ messageID: "test-message",
+ agent: "test-agent",
+ }
+
+ const result = await tool.execute({ name: "World", count: 42 }, context)
+ expect(result).toBe("Hello World, count is 42")
+ })
+ })
+
+ describe("CustomToolDefinition type", () => {
+ it("should accept valid definitions", () => {
+ const def: CustomToolDefinition = {
+ name: "valid-tool",
+ description: "A valid tool",
+ parameters: z.object({}),
+ execute: async () => "result",
+ }
+
+ expect(def.description).toBe("A valid tool")
+ })
+ })
+})
diff --git a/packages/types/src/custom-tool.ts b/packages/types/src/custom-tool.ts
new file mode 100644
index 00000000000..1b7686ff049
--- /dev/null
+++ b/packages/types/src/custom-tool.ts
@@ -0,0 +1,100 @@
+/**
+ * Custom Tool Definition Utilities
+ *
+ * This module provides utilities for defining custom tools that can be
+ * loaded by the Roo Code extension. Install @roo-code/types in your
+ * project to use these utilities.
+ *
+ * @example
+ * ```ts
+ * import { z, defineCustomTool } from "@roo-code/types"
+ *
+ * export default defineCustomTool({
+ * description: "Greets a user by name",
+ * parameters: z.object({
+ * name: z.string().describe("The name to greet"),
+ * }),
+ * async execute(args) {
+ * return `Hello, ${args.name}!`
+ * }
+ * })
+ * ```
+ */
+
+// Re-export Zod for convenient parameter schema definition
+export { z } from "zod"
+export type { ZodType, ZodObject, ZodRawShape } from "zod"
+
+import type { ZodType, infer as ZodInfer } from "zod"
+
+/**
+ * Context provided to tool execute functions.
+ */
+export interface CustomToolContext {
+ /** Unique identifier for the current session */
+ sessionID: string
+ /** Unique identifier for the current message */
+ messageID: string
+ /** The agent/mode that invoked the tool */
+ agent: string
+}
+
+/**
+ * Definition structure for a custom tool.
+ *
+ * @template T - The Zod schema type for parameters
+ */
+export interface CustomToolDefinition {
+ /**
+ * The name of the tool.
+ * This is used to identify the tool in the prompt and in the tool registry.
+ */
+ name: string
+
+ /**
+ * A description of what the tool does.
+ * This is shown to the AI model to help it decide when to use the tool.
+ */
+ description: string
+
+ /**
+ * Optional Zod schema defining the tool's parameters.
+ * Use `z.object({})` to define the shape of arguments.
+ */
+ parameters?: T
+
+ /**
+ * The function that executes the tool.
+ *
+ * @param args - The validated arguments (typed based on the parameters schema)
+ * @param context - Execution context with session and message info
+ * @returns A string result to return to the AI
+ */
+ execute: (args: T extends ZodType ? ZodInfer : unknown, context: CustomToolContext) => Promise
+}
+
+/**
+ * Helper function to define a custom tool with proper type inference.
+ *
+ * This is optional - you can also just export a plain object that matches
+ * the CustomToolDefinition interface.
+ *
+ * @example
+ * ```ts
+ * import { z, defineCustomTool } from "@roo-code/types"
+ *
+ * export default defineCustomTool({
+ * description: "Add two numbers",
+ * parameters: z.object({
+ * a: z.number().describe("First number"),
+ * b: z.number().describe("Second number"),
+ * }),
+ * async execute({ a, b }) {
+ * return `The sum is ${a + b}`
+ * }
+ * })
+ * ```
+ */
+export function defineCustomTool(definition: CustomToolDefinition): CustomToolDefinition {
+ return definition
+}
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 4ab60899df8..77ad6042062 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -3,6 +3,7 @@ export * from "./cloud.js"
export * from "./codebase-index.js"
export * from "./context-management.js"
export * from "./cookie-consent.js"
+export * from "./custom-tool.js"
export * from "./events.js"
export * from "./experiment.js"
export * from "./followup.js"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 90f696531cc..6c8bc19160a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -78,6 +78,21 @@ importers:
specifier: ^5.4.5
version: 5.8.3
+ .roo/tools:
+ devDependencies:
+ '@roo-code/config-eslint':
+ specifier: workspace:^
+ version: link:../../packages/config-eslint
+ '@roo-code/config-typescript':
+ specifier: workspace:^
+ version: link:../../packages/config-typescript
+ '@roo-code/types':
+ specifier: workspace:^
+ version: link:../../packages/types
+ vitest:
+ specifier: ^3.2.3
+ version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
+
apps/vscode-e2e:
devDependencies:
'@roo-code/config-eslint':
@@ -679,6 +694,9 @@ importers:
'@roo-code/cloud':
specifier: workspace:^
version: link:../packages/cloud
+ '@roo-code/core':
+ specifier: workspace:^
+ version: link:../packages/core
'@roo-code/ipc':
specifier: workspace:^
version: link:../packages/ipc
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index b16bbea1508..661aaa0254d 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -3,3 +3,4 @@ packages:
- "webview-ui" # Should be apps/vscode-webview
- "apps/*"
- "packages/*"
+ - ".roo/tools" # Custom tools for this workspace
diff --git a/src/package.json b/src/package.json
index 7858f03408f..eb27df9a977 100644
--- a/src/package.json
+++ b/src/package.json
@@ -441,6 +441,7 @@
"@modelcontextprotocol/sdk": "1.12.0",
"@qdrant/js-client-rest": "^1.14.0",
"@roo-code/cloud": "workspace:^",
+ "@roo-code/core": "workspace:^",
"@roo-code/ipc": "workspace:^",
"@roo-code/telemetry": "workspace:^",
"@roo-code/types": "workspace:^",
From 01834663fa3aa05cd1c8e9f9981b7ffe619743c6 Mon Sep 17 00:00:00 2001
From: cte
Date: Sun, 14 Dec 2025 22:14:13 -0800
Subject: [PATCH 03/18] Switch to use OpenAI types, speed up validation
---
.roo/tools/__tests__/system-time.spec.ts | 17 +-
.roo/tools/system-time.ts | 8 +-
packages/core/package.json | 1 +
.../__tests__/custom-tool-registry.spec.ts | 176 +++++++---------
.../src/custom-tools/custom-tool-registry.ts | 191 +++++++-----------
.../types/src/__tests__/custom-tool.spec.ts | 8 +-
packages/types/src/custom-tool.ts | 59 ++++--
pnpm-lock.yaml | 8 +
8 files changed, 212 insertions(+), 256 deletions(-)
diff --git a/.roo/tools/__tests__/system-time.spec.ts b/.roo/tools/__tests__/system-time.spec.ts
index d24578db30d..8430400bb43 100644
--- a/.roo/tools/__tests__/system-time.spec.ts
+++ b/.roo/tools/__tests__/system-time.spec.ts
@@ -1,11 +1,10 @@
-import type { CustomToolContext } from "@roo-code/types"
+import type { CustomToolContext, TaskLike } from "@roo-code/types"
import systemTime from "../system-time.js"
const mockContext: CustomToolContext = {
- sessionID: "test-session",
- messageID: "test-message",
- agent: "test-agent",
+ mode: "code",
+ task: { taskId: "test-task-id" } as unknown as TaskLike,
}
describe("system-time tool", () => {
@@ -27,38 +26,28 @@ describe("system-time tool", () => {
describe("execute", () => {
it("should return a formatted date/time string", async () => {
const result = await systemTime.execute({}, mockContext)
-
expect(result).toMatch(/^The current date and time is:/)
- // Should include weekday
expect(result).toMatch(/(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)/)
- // Should include month
expect(result).toMatch(
/(January|February|March|April|May|June|July|August|September|October|November|December)/,
)
- // Should include time format (e.g., "12:30:45")
expect(result).toMatch(/\d{1,2}:\d{2}:\d{2}/)
})
it("should use system timezone when no timezone provided", async () => {
const result = await systemTime.execute({}, mockContext)
-
- // Should include timezone abbreviation (e.g., PST, EST, UTC, etc.)
expect(result).toMatch(/[A-Z]{2,5}$/)
})
it("should format with specified timezone", async () => {
const result = await systemTime.execute({ timezone: "UTC" }, mockContext)
-
expect(result).toMatch(/^The current date and time is:/)
- // Should include UTC timezone indicator
expect(result).toMatch(/UTC/)
})
it("should work with different timezone formats", async () => {
const result = await systemTime.execute({ timezone: "America/New_York" }, mockContext)
-
expect(result).toMatch(/^The current date and time is:/)
- // Should include Eastern timezone indicator (EST or EDT depending on daylight saving)
expect(result).toMatch(/(EST|EDT)/)
})
diff --git a/.roo/tools/system-time.ts b/.roo/tools/system-time.ts
index 485d07bb334..f93886402b0 100644
--- a/.roo/tools/system-time.ts
+++ b/.roo/tools/system-time.ts
@@ -1,4 +1,4 @@
-import { z, defineCustomTool } from "@roo-code/types"
+import { defineCustomTool, parametersSchema } from "@roo-code/types"
/**
* A simple custom tool that returns the current date and time in a friendly format.
@@ -7,12 +7,14 @@ import { z, defineCustomTool } from "@roo-code/types"
* 1. Install @roo-code/types: npm install @roo-code/types
* 2. Create a .ts file in .roo/tools/
* 3. Export a default tool definition using defineCustomTool()
+ *
+ * Note that `parametersSchema` is just an alias for `z` (from zod).
*/
export default defineCustomTool({
name: "system-time",
description: "Returns the current system date and time in a friendly, human-readable format.",
- parameters: z.object({
- timezone: z
+ parameters: parametersSchema.object({
+ timezone: parametersSchema
.string()
.optional()
.describe("Optional timezone to display the time in (e.g., 'America/New_York', 'Europe/London')"),
diff --git a/packages/core/package.json b/packages/core/package.json
index f14c6d2d589..99f1b26fb03 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -13,6 +13,7 @@
"dependencies": {
"@roo-code/types": "workspace:^",
"esbuild": "^0.25.0",
+ "openai": "^5.12.2",
"zod": "^3.25.61"
},
"devDependencies": {
diff --git a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
index 56bb5e7fce3..4a137d97983 100644
--- a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
+++ b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
@@ -2,22 +2,17 @@ import { z } from "zod"
import path from "path"
import { fileURLToPath } from "url"
-import {
- type ToolContext,
- type ToolDefinition,
- CustomToolRegistry,
- ToolDefinitionSchema,
- isZodSchema,
-} from "../custom-tool-registry.js"
+import { type CustomToolDefinition, type CustomToolContext, type TaskLike } from "@roo-code/types"
+
+import { CustomToolRegistry } from "../custom-tool-registry.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const TEST_FIXTURES_DIR = path.join(__dirname, "fixtures")
-const testContext: ToolContext = {
- sessionID: "test-session",
- messageID: "test-message",
- agent: "test-agent",
+const testContext: CustomToolContext = {
+ mode: "code",
+ task: { taskId: "test-task-id" } as unknown as TaskLike,
}
describe("CustomToolRegistry", () => {
@@ -27,79 +22,86 @@ describe("CustomToolRegistry", () => {
registry = new CustomToolRegistry()
})
- describe("isZodSchema", () => {
- it("should return true for Zod schemas", () => {
- expect(isZodSchema(z.string())).toBe(true)
- expect(isZodSchema(z.number())).toBe(true)
- expect(isZodSchema(z.object({ foo: z.string() }))).toBe(true)
- expect(isZodSchema(z.array(z.number()))).toBe(true)
- })
-
- it("should return false for non-Zod values", () => {
- expect(isZodSchema(null)).toBe(false)
- expect(isZodSchema(undefined)).toBe(false)
- expect(isZodSchema("string")).toBe(false)
- expect(isZodSchema(123)).toBe(false)
- expect(isZodSchema({})).toBe(false)
- expect(isZodSchema({ _def: "not an object" })).toBe(false)
- expect(isZodSchema([])).toBe(false)
- })
- })
-
- describe("ToolDefinitionSchema", () => {
- it("should validate a correct tool definition", () => {
+ describe("validation", () => {
+ it("should accept a valid tool definition", () => {
const validTool = {
+ name: "valid_tool",
description: "A valid tool",
parameters: z.object({ name: z.string() }),
execute: async () => "result",
}
- const result = ToolDefinitionSchema.safeParse(validTool)
- expect(result.success).toBe(true)
+ expect(() => registry.register(validTool)).not.toThrow()
+ expect(registry.has("valid_tool")).toBe(true)
})
it("should reject empty description", () => {
const invalidTool = {
+ name: "invalid_tool",
description: "",
parameters: z.object({}),
execute: async () => "result",
}
- const result = ToolDefinitionSchema.safeParse(invalidTool)
- expect(result.success).toBe(false)
+ expect(() => registry.register(invalidTool as CustomToolDefinition)).toThrow(/Invalid tool definition/)
})
it("should reject non-Zod parameters", () => {
const invalidTool = {
+ name: "bad_params_tool",
description: "Tool with bad params",
parameters: { foo: "bar" },
execute: async () => "result",
}
- const result = ToolDefinitionSchema.safeParse(invalidTool)
- expect(result.success).toBe(false)
+ expect(() => registry.register(invalidTool as unknown as CustomToolDefinition)).toThrow(
+ /Invalid tool definition/,
+ )
})
it("should allow missing parameters", () => {
const toolWithoutParams = {
+ name: "no_params_tool",
description: "Tool without parameters",
execute: async () => "result",
}
- const result = ToolDefinitionSchema.safeParse(toolWithoutParams)
- expect(result.success).toBe(true)
+ expect(() => registry.register(toolWithoutParams)).not.toThrow()
+ expect(registry.has("no_params_tool")).toBe(true)
+ })
+
+ it("should reject empty name", () => {
+ const invalidTool = {
+ name: "",
+ description: "Tool with empty name",
+ execute: async () => "result",
+ }
+
+ expect(() => registry.register(invalidTool as CustomToolDefinition)).toThrow(/Invalid tool definition/)
+ })
+
+ it("should reject missing name", () => {
+ const invalidTool = {
+ description: "Tool without name",
+ execute: async () => "result",
+ }
+
+ expect(() => registry.register(invalidTool as unknown as CustomToolDefinition)).toThrow(
+ /Invalid tool definition/,
+ )
})
})
describe("register", () => {
it("should register a valid tool", () => {
- const tool: ToolDefinition = {
+ const tool: CustomToolDefinition = {
+ name: "test_tool",
description: "Test tool",
parameters: z.object({ input: z.string() }),
- execute: async (args) => `Processed: ${(args as { input: string }).input}`,
+ execute: async (args: { input: string }) => `Processed: ${args.input}`,
}
- registry.register("test_tool", tool)
+ registry.register(tool)
expect(registry.has("test_tool")).toBe(true)
expect(registry.size).toBe(1)
@@ -107,28 +109,29 @@ describe("CustomToolRegistry", () => {
it("should throw for invalid tool definition", () => {
const invalidTool = {
+ name: "bad_tool",
description: "",
execute: async () => "result",
}
- expect(() => registry.register("bad_tool", invalidTool as ToolDefinition)).toThrow(
- /Invalid tool definition/,
- )
+ expect(() => registry.register(invalidTool as CustomToolDefinition)).toThrow(/Invalid tool definition/)
})
it("should overwrite existing tool with same id", () => {
- const tool1: ToolDefinition = {
+ const tool1: CustomToolDefinition = {
+ name: "tool",
description: "First version",
execute: async () => "v1",
}
- const tool2: ToolDefinition = {
+ const tool2: CustomToolDefinition = {
+ name: "tool",
description: "Second version",
execute: async () => "v2",
}
- registry.register("tool", tool1)
- registry.register("tool", tool2)
+ registry.register(tool1)
+ registry.register(tool2)
expect(registry.size).toBe(1)
expect(registry.get("tool")?.description).toBe("Second version")
@@ -137,7 +140,8 @@ describe("CustomToolRegistry", () => {
describe("unregister", () => {
it("should remove a registered tool", () => {
- registry.register("tool", {
+ registry.register({
+ name: "tool",
description: "Test",
execute: async () => "result",
})
@@ -156,7 +160,8 @@ describe("CustomToolRegistry", () => {
describe("get", () => {
it("should return registered tool", () => {
- registry.register("my_tool", {
+ registry.register({
+ name: "my_tool",
description: "My tool",
execute: async () => "result",
})
@@ -164,7 +169,7 @@ describe("CustomToolRegistry", () => {
const tool = registry.get("my_tool")
expect(tool).toBeDefined()
- expect(tool?.id).toBe("my_tool")
+ expect(tool?.name).toBe("my_tool")
expect(tool?.description).toBe("My tool")
})
@@ -175,9 +180,9 @@ describe("CustomToolRegistry", () => {
describe("list", () => {
it("should return all tool IDs", () => {
- registry.register("tool_a", { description: "A", execute: async () => "a" })
- registry.register("tool_b", { description: "B", execute: async () => "b" })
- registry.register("tool_c", { description: "C", execute: async () => "c" })
+ registry.register({ name: "tool_a", description: "A", execute: async () => "a" })
+ registry.register({ name: "tool_b", description: "B", execute: async () => "b" })
+ registry.register({ name: "tool_c", description: "C", execute: async () => "c" })
const ids = registry.list()
@@ -193,25 +198,22 @@ describe("CustomToolRegistry", () => {
})
describe("getAll", () => {
- it("should return a copy of all tools", () => {
- registry.register("tool1", { description: "Tool 1", execute: async () => "1" })
- registry.register("tool2", { description: "Tool 2", execute: async () => "2" })
+ it("should return all tools as array", () => {
+ registry.register({ name: "tool1", description: "Tool 1", execute: async () => "1" })
+ registry.register({ name: "tool2", description: "Tool 2", execute: async () => "2" })
const all = registry.getAll()
- expect(all.size).toBe(2)
- expect(all.get("tool1")?.description).toBe("Tool 1")
- expect(all.get("tool2")?.description).toBe("Tool 2")
-
- // Verify it's a copy
- all.delete("tool1")
- expect(registry.has("tool1")).toBe(true)
+ expect(all).toHaveLength(2)
+ expect(all.find((t) => t.name === "tool1")?.description).toBe("Tool 1")
+ expect(all.find((t) => t.name === "tool2")?.description).toBe("Tool 2")
})
})
describe("execute", () => {
it("should execute a tool with arguments", async () => {
- registry.register("greeter", {
+ registry.register({
+ name: "greeter",
description: "Greets someone",
parameters: z.object({ name: z.string() }),
execute: async (args) => `Hello, ${(args as { name: string }).name}!`,
@@ -229,7 +231,8 @@ describe("CustomToolRegistry", () => {
})
it("should validate arguments against Zod schema", async () => {
- registry.register("typed_tool", {
+ registry.register({
+ name: "typed_tool",
description: "Tool with validation",
parameters: z.object({
count: z.number().min(0),
@@ -249,9 +252,10 @@ describe("CustomToolRegistry", () => {
})
it("should pass context to execute function", async () => {
- let receivedContext: ToolContext | null = null
+ let receivedContext: CustomToolContext | null = null
- registry.register("context_checker", {
+ registry.register({
+ name: "context_checker",
description: "Checks context",
execute: async (_args, ctx) => {
receivedContext = ctx
@@ -265,38 +269,10 @@ describe("CustomToolRegistry", () => {
})
})
- describe("toJsonSchema", () => {
- it("should generate JSON schema for all tools", () => {
- registry.register("tool1", {
- description: "First tool",
- parameters: z.object({ a: z.string() }),
- execute: async () => "1",
- })
-
- registry.register("tool2", {
- description: "Second tool",
- execute: async () => "2",
- })
-
- const schemas = registry.toJsonSchema()
-
- expect(schemas).toHaveLength(2)
-
- const tool1Schema = schemas.find((s) => s.name === "tool1")
- expect(tool1Schema).toBeDefined()
- expect(tool1Schema?.description).toBe("First tool")
- expect(tool1Schema?.parameters.note).toBe("(Zod schema - would be converted to JSON Schema)")
-
- const tool2Schema = schemas.find((s) => s.name === "tool2")
- expect(tool2Schema).toBeDefined()
- expect(tool2Schema?.description).toBe("Second tool")
- })
- })
-
describe("clear", () => {
it("should remove all registered tools", () => {
- registry.register("tool1", { description: "1", execute: async () => "1" })
- registry.register("tool2", { description: "2", execute: async () => "2" })
+ registry.register({ name: "tool1", description: "1", execute: async () => "1" })
+ registry.register({ name: "tool2", description: "2", execute: async () => "2" })
expect(registry.size).toBe(2)
diff --git a/packages/core/src/custom-tools/custom-tool-registry.ts b/packages/core/src/custom-tools/custom-tool-registry.ts
index 44d4d302b83..f75155437da 100644
--- a/packages/core/src/custom-tools/custom-tool-registry.ts
+++ b/packages/core/src/custom-tools/custom-tool-registry.ts
@@ -3,7 +3,7 @@
*
* Features:
* - Dynamic TypeScript/JavaScript tool loading with esbuild transpilation.
- * - Zod-based validation of tool definitions.
+ * - Runtime validation of tool definitions.
* - Tool execution with context.
* - JSON Schema generation for LLM integration.
*/
@@ -14,7 +14,8 @@ import { createHash } from "crypto"
import os from "os"
import { build } from "esbuild"
-import { z, type ZodType } from "zod"
+
+import { type CustomToolContext, type CustomToolDefinition, type ZodLikeSchema } from "@roo-code/types"
/**
* Default subdirectory name for custom tools within a .roo directory.
@@ -30,70 +31,11 @@ import { z, type ZodType } from "zod"
*/
export const TOOLS_DIR_NAME = "tools"
-export interface ToolContext {
- sessionID: string
- messageID: string
- agent: string
-}
-
-export interface ToolDefinition {
- description: string
- parameters?: ZodType
- args?: ZodType
- execute: (args: unknown, context: ToolContext) => Promise
-}
-
-export interface RegisteredTool {
- id: string
- description: string
- parameters?: ZodType
- execute: (args: unknown, context: ToolContext) => Promise
-}
-
-export interface ToolSchema {
- name: string
- description: string
- parameters: {
- type: string
- properties: Record
- required: string[]
- note?: string
- }
-}
-
export interface LoadResult {
loaded: string[]
failed: Array<{ file: string; error: string }>
}
-/**
- * Check if a value is a Zod schema by looking for the _def property
- * which is present on all Zod types.
- */
-function isZodSchema(value: unknown): value is ZodType {
- return (
- value !== null &&
- typeof value === "object" &&
- "_def" in value &&
- typeof (value as Record)._def === "object"
- )
-}
-
-/**
- * Zod schema to validate the shape of imported tool definitions.
- * This ensures tools have the required structure before registration.
- */
-const ToolDefinitionSchema = z.object({
- description: z.string().min(1, "Tool must have a non-empty description"),
- parameters: z.custom(isZodSchema, "parameters must be a Zod schema").optional(),
- args: z.custom(isZodSchema, "args must be a Zod schema").optional(),
- execute: z
- .function()
- .args(z.unknown(), z.unknown())
- .returns(z.promise(z.string()))
- .describe("Async function that executes the tool"),
-})
-
export interface RegistryOptions {
/** Directory for caching compiled TypeScript files. */
cacheDir?: string
@@ -102,10 +44,11 @@ export interface RegistryOptions {
}
export class CustomToolRegistry {
- private tools = new Map()
+ private tools = new Map()
private tsCache = new Map()
private cacheDir: string
private nodePaths: string[]
+ private lastLoaded: Map = new Map()
constructor(options?: RegistryOptions) {
this.cacheDir = options?.cacheDir ?? path.join(os.tmpdir(), "dynamic-tools-cache")
@@ -143,24 +86,19 @@ export class CustomToolRegistry {
for (const file of files) {
const filePath = path.join(toolDir, file)
- const namespace = path.basename(file, path.extname(file))
try {
const mod = await this.importTypeScript(filePath)
for (const [exportName, value] of Object.entries(mod)) {
const def = this.validateToolDefinition(exportName, value)
- if (!def) continue
- const toolId = exportName === "default" ? namespace : `${namespace}_${exportName}`
- this.tools.set(toolId, {
- id: toolId,
- description: def.description,
- parameters: def.parameters || def.args,
- execute: def.execute,
- })
+ if (!def) {
+ continue
+ }
- result.loaded.push(toolId)
+ this.tools.set(def.name, def)
+ result.loaded.push(def.name)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
@@ -171,21 +109,30 @@ export class CustomToolRegistry {
return result
}
+ async loadFromDirectoryIfStale(toolDir: string): Promise {
+ const lastLoaded = this.lastLoaded.get(toolDir)
+ const stat = fs.statSync(toolDir)
+ const isStale = lastLoaded ? stat.mtimeMs > lastLoaded : true
+
+ if (isStale) {
+ return this.loadFromDirectory(toolDir)
+ }
+
+ return { loaded: this.list(), failed: [] }
+ }
+
/**
* Register a tool directly (without loading from file).
*/
- register(id: string, definition: ToolDefinition): void {
+ register(definition: CustomToolDefinition): void {
+ const { name: id } = definition
const validated = this.validateToolDefinition(id, definition)
+
if (!validated) {
throw new Error(`Invalid tool definition for '${id}'`)
}
- this.tools.set(id, {
- id,
- description: validated.description,
- parameters: validated.parameters || validated.args,
- execute: validated.execute,
- })
+ this.tools.set(id, validated)
}
/**
@@ -198,7 +145,7 @@ export class CustomToolRegistry {
/**
* Get a tool by ID.
*/
- get(id: string): RegisteredTool | undefined {
+ get(id: string): CustomToolDefinition | undefined {
return this.tools.get(id)
}
@@ -219,8 +166,8 @@ export class CustomToolRegistry {
/**
* Get all registered tools.
*/
- getAll(): Map {
- return new Map(this.tools)
+ getAll(): CustomToolDefinition[] {
+ return Array.from(this.tools.values())
}
/**
@@ -233,13 +180,14 @@ export class CustomToolRegistry {
/**
* Execute a tool with given arguments.
*/
- async execute(toolId: string, args: unknown, context: ToolContext): Promise {
+ async execute(toolId: string, args: unknown, context: CustomToolContext): Promise {
const tool = this.tools.get(toolId)
+
if (!tool) {
throw new Error(`Tool not found: ${toolId}`)
}
- // Validate args against schema if available
+ // Validate args against schema if available.
if (tool.parameters && "parse" in tool.parameters) {
;(tool.parameters as { parse: (args: unknown) => void }).parse(args)
}
@@ -247,33 +195,6 @@ export class CustomToolRegistry {
return tool.execute(args, context)
}
- /**
- * Generate JSON schema representation of all tools (for LLM integration).
- */
- toJsonSchema(): ToolSchema[] {
- const schemas: ToolSchema[] = []
-
- for (const [id, tool] of this.tools) {
- const schema: ToolSchema = {
- name: id,
- description: tool.description,
- parameters: {
- type: "object",
- properties: {},
- required: [],
- },
- }
-
- if (tool.parameters && "_def" in tool.parameters) {
- schema.parameters.note = "(Zod schema - would be converted to JSON Schema)"
- }
-
- schemas.push(schema)
- }
-
- return schemas
- }
-
/**
* Clear all registered tools.
*/
@@ -292,7 +213,7 @@ export class CustomToolRegistry {
* Dynamically import a TypeScript or JavaScript file.
* TypeScript files are transpiled on-the-fly using esbuild.
*/
- private async importTypeScript(filePath: string): Promise> {
+ private async importTypeScript(filePath: string): Promise> {
const absolutePath = path.resolve(filePath)
const ext = path.extname(absolutePath)
@@ -333,11 +254,24 @@ export class CustomToolRegistry {
return import(`file://${tempFile}`)
}
+ /**
+ * Check if a value is a Zod schema by looking for the _def property
+ * which is present on all Zod types.
+ */
+ private isZodSchema(value: unknown): value is ZodLikeSchema {
+ return (
+ value !== null &&
+ typeof value === "object" &&
+ "_def" in value &&
+ typeof (value as Record)._def === "object"
+ )
+ }
+
/**
* Validate a tool definition and return a typed result.
* Returns null for non-tool exports, throws for invalid tools.
*/
- private validateToolDefinition(exportName: string, value: unknown): ToolDefinition | null {
+ private validateToolDefinition(exportName: string, value: unknown): CustomToolDefinition | null {
// Quick pre-check to filter out non-objects.
if (!value || typeof value !== "object") {
return null
@@ -348,15 +282,34 @@ export class CustomToolRegistry {
return null
}
- const result = ToolDefinitionSchema.safeParse(value)
+ const obj = value as Record
+ const errors: string[] = []
+
+ // Validate name.
+ if (typeof obj.name !== "string") {
+ errors.push("name: Expected string")
+ } else if (obj.name.length === 0) {
+ errors.push("name: Tool must have a non-empty name")
+ }
+
+ // Validate description.
+ if (typeof obj.description !== "string") {
+ errors.push("description: Expected string")
+ } else if (obj.description.length === 0) {
+ errors.push("description: Tool must have a non-empty description")
+ }
+
+ // Validate parameters (optional).
+ if (obj.parameters !== undefined && !this.isZodSchema(obj.parameters)) {
+ errors.push("parameters: parameters must be a Zod schema")
+ }
- if (!result.success) {
- const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")
- throw new Error(`Invalid tool definition for '${exportName}': ${errors}`)
+ if (errors.length > 0) {
+ throw new Error(`Invalid tool definition for '${exportName}': ${errors.join(", ")}`)
}
- return result.data as ToolDefinition
+ return value as CustomToolDefinition
}
}
-export { isZodSchema, ToolDefinitionSchema }
+export const customToolRegistry = new CustomToolRegistry()
diff --git a/packages/types/src/__tests__/custom-tool.spec.ts b/packages/types/src/__tests__/custom-tool.spec.ts
index 1f19963514f..dc514b17ea2 100644
--- a/packages/types/src/__tests__/custom-tool.spec.ts
+++ b/packages/types/src/__tests__/custom-tool.spec.ts
@@ -1,4 +1,5 @@
-import { z, defineCustomTool, type CustomToolDefinition, type CustomToolContext } from "../custom-tool.js"
+import { type CustomToolDefinition, type CustomToolContext, defineCustomTool, z } from "../custom-tool.js"
+import type { TaskLike } from "../task.js"
describe("custom-tool utilities", () => {
describe("z (Zod re-export)", () => {
@@ -59,9 +60,8 @@ describe("custom-tool utilities", () => {
})
const context: CustomToolContext = {
- sessionID: "test-session",
- messageID: "test-message",
- agent: "test-agent",
+ mode: "code",
+ task: { taskId: "test-task-id" } as unknown as TaskLike,
}
const result = await tool.execute({ name: "World", count: 42 }, context)
diff --git a/packages/types/src/custom-tool.ts b/packages/types/src/custom-tool.ts
index 1b7686ff049..ae6df15b246 100644
--- a/packages/types/src/custom-tool.ts
+++ b/packages/types/src/custom-tool.ts
@@ -21,30 +21,36 @@
* ```
*/
-// Re-export Zod for convenient parameter schema definition
-export { z } from "zod"
-export type { ZodType, ZodObject, ZodRawShape } from "zod"
-
import type { ZodType, infer as ZodInfer } from "zod"
+import { TaskLike } from "./task.js"
+
/**
* Context provided to tool execute functions.
*/
export interface CustomToolContext {
- /** Unique identifier for the current session */
- sessionID: string
- /** Unique identifier for the current message */
- messageID: string
- /** The agent/mode that invoked the tool */
- agent: string
+ mode: string
+ task: TaskLike
+}
+
+/**
+ * A Zod-like schema interface. We use this instead of ZodType directly
+ * to avoid TypeScript's excessive type instantiation (TS2589).
+ */
+export interface ZodLikeSchema {
+ _def: unknown
+ parse: (data: unknown) => unknown
+ safeParse: (data: unknown) => { success: boolean; data?: unknown; error?: unknown }
}
/**
* Definition structure for a custom tool.
*
- * @template T - The Zod schema type for parameters
+ * Note: This interface uses simple types to avoid TypeScript performance issues
+ * with Zod's complex type inference. For type-safe parameter inference, use
+ * the `defineCustomTool` helper function instead of annotating with this interface.
*/
-export interface CustomToolDefinition {
+export interface CustomToolDefinition {
/**
* The name of the tool.
* This is used to identify the tool in the prompt and in the tool registry.
@@ -61,16 +67,29 @@ export interface CustomToolDefinition {
* Optional Zod schema defining the tool's parameters.
* Use `z.object({})` to define the shape of arguments.
*/
- parameters?: T
+ parameters?: ZodLikeSchema
/**
* The function that executes the tool.
*
- * @param args - The validated arguments (typed based on the parameters schema)
+ * @param args - The validated arguments
* @param context - Execution context with session and message info
* @returns A string result to return to the AI
*/
- execute: (args: T extends ZodType ? ZodInfer : unknown, context: CustomToolContext) => Promise
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ execute: (args: any, context: CustomToolContext) => Promise
+}
+
+/**
+ * Type-safe definition structure for a custom tool with inferred parameter types.
+ * Use this with `defineCustomTool` for full type inference.
+ *
+ * @template T - The Zod schema type for parameters
+ */
+export interface TypedCustomToolDefinition
+ extends Omit {
+ parameters?: T
+ execute: (args: ZodInfer, context: CustomToolContext) => Promise
}
/**
@@ -84,6 +103,7 @@ export interface CustomToolDefinition {
* import { z, defineCustomTool } from "@roo-code/types"
*
* export default defineCustomTool({
+ * name: "add_numbers",
* description: "Add two numbers",
* parameters: z.object({
* a: z.number().describe("First number"),
@@ -95,6 +115,13 @@ export interface CustomToolDefinition {
* })
* ```
*/
-export function defineCustomTool(definition: CustomToolDefinition): CustomToolDefinition {
+export function defineCustomTool(
+ definition: TypedCustomToolDefinition,
+): TypedCustomToolDefinition {
return definition
}
+
+// Re-export Zod for convenient parameter schema definition.
+export { z as parametersSchema, z } from "zod"
+
+export type { ZodType, ZodObject, ZodRawShape } from "zod"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6c8bc19160a..5591f160d79 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -497,6 +497,9 @@ importers:
esbuild:
specifier: '>=0.25.0'
version: 0.25.9
+ openai:
+ specifier: ^5.12.2
+ version: 5.12.2(ws@8.18.3)(zod@3.25.76)
zod:
specifier: ^3.25.61
version: 3.25.76
@@ -18461,6 +18464,11 @@ snapshots:
ws: 8.18.3
zod: 3.25.61
+ openai@5.12.2(ws@8.18.3)(zod@3.25.76):
+ optionalDependencies:
+ ws: 8.18.3
+ zod: 3.25.76
+
option@0.2.4: {}
optionator@0.9.4:
From a1a6b967127803d8555afb9efeaa84614cc08577 Mon Sep 17 00:00:00 2001
From: cte
Date: Sun, 14 Dec 2025 22:37:28 -0800
Subject: [PATCH 04/18] Add logging, error handling
---
.../src/custom-tools/custom-tool-registry.ts | 44 +++++++++++--------
1 file changed, 26 insertions(+), 18 deletions(-)
diff --git a/packages/core/src/custom-tools/custom-tool-registry.ts b/packages/core/src/custom-tools/custom-tool-registry.ts
index f75155437da..301ddb13034 100644
--- a/packages/core/src/custom-tools/custom-tool-registry.ts
+++ b/packages/core/src/custom-tools/custom-tool-registry.ts
@@ -78,32 +78,40 @@ export class CustomToolRegistry {
async loadFromDirectory(toolDir: string): Promise {
const result: LoadResult = { loaded: [], failed: [] }
- if (!fs.existsSync(toolDir)) {
- return result
- }
+ try {
+ if (!fs.existsSync(toolDir)) {
+ return result
+ }
- const files = fs.readdirSync(toolDir).filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
+ const files = fs.readdirSync(toolDir).filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
- for (const file of files) {
- const filePath = path.join(toolDir, file)
+ for (const file of files) {
+ const filePath = path.join(toolDir, file)
- try {
- const mod = await this.importTypeScript(filePath)
+ try {
+ console.log(`[CustomToolRegistry] importing tool from ${filePath}`)
+ const mod = await this.importTypeScript(filePath)
- for (const [exportName, value] of Object.entries(mod)) {
- const def = this.validateToolDefinition(exportName, value)
+ for (const [exportName, value] of Object.entries(mod)) {
+ const def = this.validateToolDefinition(exportName, value)
- if (!def) {
- continue
- }
+ if (!def) {
+ continue
+ }
- this.tools.set(def.name, def)
- result.loaded.push(def.name)
+ this.tools.set(def.name, def)
+ console.log(`[CustomToolRegistry] loaded tool ${def.name} from ${filePath}`)
+ result.loaded.push(def.name)
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ console.error(`[CustomToolRegistry] importTypeScript(${filePath}) failed: ${message}`)
+ result.failed.push({ file, error: message })
}
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error)
- result.failed.push({ file, error: message })
}
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ console.error(`[CustomToolRegistry] loadFromDirectory(${toolDir}) failed: ${message}`)
}
return result
From ea3694de01ffd2128fb40b5fc6693156bdc8de63 Mon Sep 17 00:00:00 2001
From: cte
Date: Mon, 15 Dec 2025 00:28:47 -0800
Subject: [PATCH 05/18] Add formatters
---
.../__snapshots__/format-native.spec.ts.snap | 270 ++++++++++++++++++
.../__snapshots__/format-xml.spec.ts.snap | 129 +++++++++
.../__tests__/custom-tool-registry.spec.ts | 8 +-
.../custom-tools/__tests__/fixtures/cached.ts | 4 +-
.../custom-tools/__tests__/fixtures/legacy.ts | 4 +-
.../custom-tools/__tests__/fixtures/mixed.ts | 4 +-
.../custom-tools/__tests__/fixtures/multi.ts | 6 +-
.../custom-tools/__tests__/fixtures/simple.ts | 4 +-
.../__tests__/fixtures/system-time.ts | 29 ++
.../__tests__/format-native.spec.ts | 256 +++++++++++++++++
.../custom-tools/__tests__/format-xml.spec.ts | 192 +++++++++++++
.../src/custom-tools/__tests__/serialize.ts | 225 +++++++++++++++
.../src/custom-tools/custom-tool-registry.ts | 18 +-
.../core/src/custom-tools/format-native.ts | 7 +
packages/core/src/custom-tools/format-xml.ts | 64 +++++
packages/core/src/custom-tools/index.ts | 3 +
packages/core/src/custom-tools/serialize.ts | 17 ++
.../types/src/__tests__/custom-tool.spec.ts | 7 +-
packages/types/src/custom-tool.ts | 37 ++-
19 files changed, 1248 insertions(+), 36 deletions(-)
create mode 100644 packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap
create mode 100644 packages/core/src/custom-tools/__tests__/__snapshots__/format-xml.spec.ts.snap
create mode 100644 packages/core/src/custom-tools/__tests__/fixtures/system-time.ts
create mode 100644 packages/core/src/custom-tools/__tests__/format-native.spec.ts
create mode 100644 packages/core/src/custom-tools/__tests__/format-xml.spec.ts
create mode 100644 packages/core/src/custom-tools/__tests__/serialize.ts
create mode 100644 packages/core/src/custom-tools/format-native.ts
create mode 100644 packages/core/src/custom-tools/format-xml.ts
create mode 100644 packages/core/src/custom-tools/serialize.ts
diff --git a/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap b/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap
new file mode 100644
index 00000000000..98e3a4e9f87
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap
@@ -0,0 +1,270 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Native Protocol snapshots > should generate correct native definition for cached tool 1`] = `
+{
+ "function": {
+ "description": "Cached tool",
+ "name": "cached",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object",
+ },
+ },
+ "type": "function",
+}
+`;
+
+exports[`Native Protocol snapshots > should generate correct native definition for legacy tool (using args) 1`] = `
+{
+ "function": {
+ "description": "Legacy tool using args",
+ "name": "legacy",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "input": {
+ "description": "The input string",
+ "type": "string",
+ },
+ },
+ "required": [
+ "input",
+ ],
+ "type": "object",
+ },
+ },
+ "type": "function",
+}
+`;
+
+exports[`Native Protocol snapshots > should generate correct native definition for mixed export tool 1`] = `
+{
+ "function": {
+ "description": "Valid",
+ "name": "mixed_validTool",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object",
+ },
+ },
+ "type": "function",
+}
+`;
+
+exports[`Native Protocol snapshots > should generate correct native definition for simple tool 1`] = `
+{
+ "function": {
+ "description": "Simple tool",
+ "name": "simple",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "value": {
+ "description": "The input value",
+ "type": "string",
+ },
+ },
+ "required": [
+ "value",
+ ],
+ "type": "object",
+ },
+ },
+ "type": "function",
+}
+`;
+
+exports[`Native Protocol snapshots > should generate correct native definition for system time tool 1`] = `
+{
+ "function": {
+ "description": "Returns the current system date and time in a friendly, human-readable format.",
+ "name": "system_time",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "timezone": {
+ "anyOf": [
+ {
+ "type": "string",
+ },
+ {
+ "type": "null",
+ },
+ ],
+ "description": "Timezone to display the time in (e.g., 'America/New_York', 'Europe/London')",
+ },
+ },
+ "required": [
+ "timezone",
+ ],
+ "type": "object",
+ },
+ },
+ "type": "function",
+}
+`;
+
+exports[`Native Protocol snapshots > should generate correct native definitions for all fixtures combined 1`] = `
+[
+ {
+ "function": {
+ "description": "Simple tool",
+ "name": "simple",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "value": {
+ "description": "The input value",
+ "type": "string",
+ },
+ },
+ "required": [
+ "value",
+ ],
+ "type": "object",
+ },
+ },
+ "type": "function",
+ },
+ {
+ "function": {
+ "description": "Cached tool",
+ "name": "cached",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object",
+ },
+ },
+ "type": "function",
+ },
+ {
+ "function": {
+ "description": "Legacy tool using args",
+ "name": "legacy",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "input": {
+ "description": "The input string",
+ "type": "string",
+ },
+ },
+ "required": [
+ "input",
+ ],
+ "type": "object",
+ },
+ },
+ "type": "function",
+ },
+ {
+ "function": {
+ "description": "Tool A",
+ "name": "multi_toolA",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object",
+ },
+ },
+ "type": "function",
+ },
+ {
+ "function": {
+ "description": "Tool B",
+ "name": "multi_toolB",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object",
+ },
+ },
+ "type": "function",
+ },
+ {
+ "function": {
+ "description": "Valid",
+ "name": "mixed_validTool",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object",
+ },
+ },
+ "type": "function",
+ },
+ {
+ "function": {
+ "description": "Returns the current system date and time in a friendly, human-readable format.",
+ "name": "system_time",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "timezone": {
+ "anyOf": [
+ {
+ "type": "string",
+ },
+ {
+ "type": "null",
+ },
+ ],
+ "description": "Timezone to display the time in (e.g., 'America/New_York', 'Europe/London')",
+ },
+ },
+ "required": [
+ "timezone",
+ ],
+ "type": "object",
+ },
+ },
+ "type": "function",
+ },
+]
+`;
+
+exports[`Native Protocol snapshots > should generate correct native definitions for multi export tools 1`] = `
+[
+ {
+ "function": {
+ "description": "Tool A",
+ "name": "multi_toolA",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object",
+ },
+ },
+ "type": "function",
+ },
+ {
+ "function": {
+ "description": "Tool B",
+ "name": "multi_toolB",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object",
+ },
+ },
+ "type": "function",
+ },
+]
+`;
diff --git a/packages/core/src/custom-tools/__tests__/__snapshots__/format-xml.spec.ts.snap b/packages/core/src/custom-tools/__tests__/__snapshots__/format-xml.spec.ts.snap
new file mode 100644
index 00000000000..b4503fa925d
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/__snapshots__/format-xml.spec.ts.snap
@@ -0,0 +1,129 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`XML Protocol snapshots > should generate correct XML description for all fixtures combined 1`] = `
+"# Custom Tools
+
+The following custom tools are available for this mode. Use them in the same way as built-in tools.
+
+## simple
+Description: Simple tool
+Parameters:
+- value: (required) The input value (type: string)
+Usage:
+
+value value here
+
+
+## cached
+Description: Cached tool
+Parameters:
+Usage:
+
+
+
+## legacy
+Description: Legacy tool using args
+Parameters:
+- input: (required) The input string (type: string)
+Usage:
+
+ input value here
+
+
+## multi_toolA
+Description: Tool A
+Parameters:
+Usage:
+
+
+
+## multi_toolB
+Description: Tool B
+Parameters:
+Usage:
+
+
+
+## mixed_validTool
+Description: Valid
+Parameters:
+Usage:
+
+ "
+`;
+
+exports[`XML Protocol snapshots > should generate correct XML description for cached tool 1`] = `
+"# Custom Tools
+
+The following custom tools are available for this mode. Use them in the same way as built-in tools.
+
+## cached
+Description: Cached tool
+Parameters:
+Usage:
+
+ "
+`;
+
+exports[`XML Protocol snapshots > should generate correct XML description for legacy tool (using args) 1`] = `
+"# Custom Tools
+
+The following custom tools are available for this mode. Use them in the same way as built-in tools.
+
+## legacy
+Description: Legacy tool using args
+Parameters:
+- input: (required) The input string (type: string)
+Usage:
+
+ input value here
+ "
+`;
+
+exports[`XML Protocol snapshots > should generate correct XML description for mixed export tool 1`] = `
+"# Custom Tools
+
+The following custom tools are available for this mode. Use them in the same way as built-in tools.
+
+## mixed_validTool
+Description: Valid
+Parameters:
+Usage:
+
+ "
+`;
+
+exports[`XML Protocol snapshots > should generate correct XML description for multi export tools 1`] = `
+"# Custom Tools
+
+The following custom tools are available for this mode. Use them in the same way as built-in tools.
+
+## multi_toolA
+Description: Tool A
+Parameters:
+Usage:
+
+
+
+## multi_toolB
+Description: Tool B
+Parameters:
+Usage:
+
+ "
+`;
+
+exports[`XML Protocol snapshots > should generate correct XML description for simple tool 1`] = `
+"# Custom Tools
+
+The following custom tools are available for this mode. Use them in the same way as built-in tools.
+
+## simple
+Description: Simple tool
+Parameters:
+- value: (required) The input value (type: string)
+Usage:
+
+value value here
+ "
+`;
diff --git a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
index 4a137d97983..fdbb21d17a9 100644
--- a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
+++ b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
@@ -1,8 +1,12 @@
-import { z } from "zod"
import path from "path"
import { fileURLToPath } from "url"
-import { type CustomToolDefinition, type CustomToolContext, type TaskLike } from "@roo-code/types"
+import {
+ type CustomToolDefinition,
+ type CustomToolContext,
+ type TaskLike,
+ parametersSchema as z,
+} from "@roo-code/types"
import { CustomToolRegistry } from "../custom-tool-registry.js"
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/cached.ts b/packages/core/src/custom-tools/__tests__/fixtures/cached.ts
index 1c61d7e6d3e..a553d2be6c6 100644
--- a/packages/core/src/custom-tools/__tests__/fixtures/cached.ts
+++ b/packages/core/src/custom-tools/__tests__/fixtures/cached.ts
@@ -1,9 +1,9 @@
-import { z, defineCustomTool } from "@roo-code/types"
+import { parametersSchema, defineCustomTool } from "@roo-code/types"
export default defineCustomTool({
name: "cached",
description: "Cached tool",
- parameters: z.object({}),
+ parameters: parametersSchema.object({}),
async execute() {
return "cached"
},
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts b/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts
index 69c1a99b164..32b2004e734 100644
--- a/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts
+++ b/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts
@@ -1,9 +1,9 @@
-import { z, defineCustomTool } from "@roo-code/types"
+import { parametersSchema, defineCustomTool } from "@roo-code/types"
export default defineCustomTool({
name: "legacy",
description: "Legacy tool using args",
- parameters: z.object({ input: z.string().describe("The input string") }),
+ parameters: parametersSchema.object({ input: parametersSchema.string().describe("The input string") }),
async execute(args: { input: string }) {
return args.input
},
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts b/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts
index fe5add27a86..8ab95080d11 100644
--- a/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts
+++ b/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts
@@ -1,10 +1,10 @@
-import { z, defineCustomTool } from "@roo-code/types"
+import { parametersSchema, defineCustomTool } from "@roo-code/types"
// This is a valid tool.
export const validTool = defineCustomTool({
name: "mixed_validTool",
description: "Valid",
- parameters: z.object({}),
+ parameters: parametersSchema.object({}),
async execute() {
return "valid"
},
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/multi.ts b/packages/core/src/custom-tools/__tests__/fixtures/multi.ts
index a3b2ec0d3d2..229e20305f2 100644
--- a/packages/core/src/custom-tools/__tests__/fixtures/multi.ts
+++ b/packages/core/src/custom-tools/__tests__/fixtures/multi.ts
@@ -1,9 +1,9 @@
-import { z, defineCustomTool } from "@roo-code/types"
+import { parametersSchema, defineCustomTool } from "@roo-code/types"
export const toolA = defineCustomTool({
name: "multi_toolA",
description: "Tool A",
- parameters: z.object({}),
+ parameters: parametersSchema.object({}),
async execute() {
return "A"
},
@@ -12,7 +12,7 @@ export const toolA = defineCustomTool({
export const toolB = defineCustomTool({
name: "multi_toolB",
description: "Tool B",
- parameters: z.object({}),
+ parameters: parametersSchema.object({}),
async execute() {
return "B"
},
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/simple.ts b/packages/core/src/custom-tools/__tests__/fixtures/simple.ts
index a3992b78a37..d2c6ae600b4 100644
--- a/packages/core/src/custom-tools/__tests__/fixtures/simple.ts
+++ b/packages/core/src/custom-tools/__tests__/fixtures/simple.ts
@@ -1,9 +1,9 @@
-import { z, defineCustomTool } from "@roo-code/types"
+import { parametersSchema, defineCustomTool } from "@roo-code/types"
export default defineCustomTool({
name: "simple",
description: "Simple tool",
- parameters: z.object({ value: z.string().describe("The input value") }),
+ parameters: parametersSchema.object({ value: parametersSchema.string().describe("The input value") }),
async execute(args: { value: string }) {
return "Result: " + args.value
},
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/system-time.ts b/packages/core/src/custom-tools/__tests__/fixtures/system-time.ts
new file mode 100644
index 00000000000..3f052af8555
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/fixtures/system-time.ts
@@ -0,0 +1,29 @@
+import { parametersSchema, defineCustomTool } from "@roo-code/types"
+
+export default defineCustomTool({
+ name: "system_time",
+ description: "Returns the current system date and time in a friendly, human-readable format.",
+ parameters: parametersSchema.object({
+ timezone: parametersSchema
+ .string()
+ .nullable()
+ .describe("Timezone to display the time in (e.g., 'America/New_York', 'Europe/London')"),
+ }),
+ async execute({ timezone }) {
+ const now = new Date()
+
+ const formatted = now.toLocaleString("en-US", {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ timeZoneName: "short",
+ timeZone: timezone ?? undefined,
+ })
+
+ return `The current date and time is: ${formatted}`
+ },
+})
diff --git a/packages/core/src/custom-tools/__tests__/format-native.spec.ts b/packages/core/src/custom-tools/__tests__/format-native.spec.ts
new file mode 100644
index 00000000000..d95bb3fa259
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/format-native.spec.ts
@@ -0,0 +1,256 @@
+// pnpm --filter @roo-code/core test src/custom-tools/__tests__/format-native.spec.ts
+
+import { type SerializedCustomToolDefinition, parametersSchema as z, defineCustomTool } from "@roo-code/types"
+
+import { serializeCustomTool, serializeCustomTools } from "../serialize.js"
+import { formatNative } from "../format-native.js"
+
+import simpleTool from "./fixtures/simple.js"
+import cachedTool from "./fixtures/cached.js"
+import legacyTool from "./fixtures/legacy.js"
+import { toolA, toolB } from "./fixtures/multi.js"
+import { validTool as mixedValidTool } from "./fixtures/mixed.js"
+import systemTimeTool from "./fixtures/system-time.js"
+
+const fixtureTools = {
+ simple: simpleTool,
+ cached: cachedTool,
+ legacy: legacyTool,
+ multi_toolA: toolA,
+ multi_toolB: toolB,
+ mixed_validTool: mixedValidTool,
+ systemTime: systemTimeTool,
+}
+
+describe("formatNative", () => {
+ it("should convert a tool without args", () => {
+ const tool = defineCustomTool({
+ name: "simple_tool",
+ description: "A simple tool",
+ async execute() {
+ return "done"
+ },
+ })
+
+ const serialized = serializeCustomTool(tool)
+ const result = formatNative(serialized)
+
+ expect(result).toEqual({
+ type: "function",
+ function: {
+ name: "simple_tool",
+ description: "A simple tool",
+ },
+ })
+ })
+
+ it("should convert a tool with required args", () => {
+ const tool = defineCustomTool({
+ name: "greeter",
+ description: "Greets a person",
+ parameters: z.object({
+ name: z.string().describe("Person's name"),
+ }),
+ async execute({ name }) {
+ return `Hello, ${name}!`
+ },
+ })
+
+ const serialized = serializeCustomTool(tool)
+ const result = formatNative(serialized)
+
+ expect(result.type).toBe("function")
+ expect(result.function.name).toBe("greeter")
+ expect(result.function.description).toBe("Greets a person")
+ expect(result.function.parameters?.properties).toEqual({
+ name: {
+ type: "string",
+ description: "Person's name",
+ },
+ })
+ expect(result.function.parameters?.required).toEqual(["name"])
+ expect(result.function.parameters?.additionalProperties).toBe(false)
+ })
+
+ it("should convert a tool with optional args", () => {
+ const tool = defineCustomTool({
+ name: "optional_tool",
+ description: "Tool with optional args",
+ parameters: z.object({
+ format: z.string().optional().describe("Output format"),
+ }),
+ async execute() {
+ return "done"
+ },
+ })
+
+ const serialized = serializeCustomTool(tool)
+ const result = formatNative(serialized)
+
+ expect(result.function.parameters?.required).toBeUndefined()
+ expect(result.function.parameters?.properties).toEqual({
+ format: {
+ type: "string",
+ description: "Output format",
+ },
+ })
+ })
+
+ it("should convert a tool with mixed required and optional args", () => {
+ const tool = defineCustomTool({
+ name: "mixed_tool",
+ description: "Tool with mixed args",
+ parameters: z.object({
+ input: z.string().describe("Required input"),
+ options: z.object({}).optional().describe("Optional config"),
+ count: z.number().describe("Also required"),
+ }),
+ async execute() {
+ return "done"
+ },
+ })
+
+ const serialized = serializeCustomTool(tool)
+ const result = formatNative(serialized)
+
+ expect(result.function.parameters?.required).toEqual(["input", "count"])
+ expect(result.function.parameters?.properties).toEqual({
+ input: {
+ type: "string",
+ description: "Required input",
+ },
+ options: {
+ additionalProperties: false,
+ properties: {},
+ type: "object",
+ description: "Optional config",
+ },
+ count: {
+ type: "number",
+ description: "Also required",
+ },
+ })
+ })
+
+ it("should map type strings to JSON Schema types", () => {
+ const tool = defineCustomTool({
+ name: "typed_tool",
+ description: "Tool with various types",
+ parameters: z.object({
+ str: z.string().describe("A string"),
+ num: z.number().describe("A number"),
+ bool: z.boolean().describe("A boolean"),
+ obj: z.object({}).describe("An object"),
+ arr: z.array(z.string()).describe("An array"),
+ }),
+ async execute() {
+ return "done"
+ },
+ })
+
+ const serialized = serializeCustomTool(tool)
+ const result = formatNative(serialized)
+ const props = result.function.parameters?.properties as
+ | Record
+ | undefined
+
+ expect(props?.str?.type).toBe("string")
+ expect(props?.num?.type).toBe("number")
+ expect(props?.bool?.type).toBe("boolean")
+ expect(props?.obj?.type).toBe("object")
+ expect(props?.arr?.type).toBe("array")
+ })
+
+ it("should pass through raw parameters as-is", () => {
+ // formatNative is a simple wrapper that passes through parameters unchanged
+ const serialized = {
+ name: "test_tool",
+ description: "Tool with specific type",
+ parameters: {
+ type: "object",
+ properties: {
+ data: { type: "integer", description: "Integer type" },
+ },
+ },
+ } as SerializedCustomToolDefinition
+
+ const result = formatNative(serialized)
+
+ expect(result.type).toBe("function")
+ expect(result.function.name).toBe("test_tool")
+ const props = result.function.parameters?.properties as Record | undefined
+ expect(props?.data?.type).toBe("integer")
+ })
+
+ it("should convert multiple tools", () => {
+ const tools = [
+ defineCustomTool({
+ name: "tool_a",
+ description: "First tool",
+ async execute() {
+ return "a"
+ },
+ }),
+ defineCustomTool({
+ name: "tool_b",
+ description: "Second tool",
+ async execute() {
+ return "b"
+ },
+ }),
+ ]
+
+ const serialized = serializeCustomTools(tools)
+ const result = serialized.map(formatNative)
+
+ expect(result).toHaveLength(2)
+ expect(result[0]?.function.name).toBe("tool_a")
+ expect(result[1]?.function.name).toBe("tool_b")
+ expect(result.every((t) => t.type === "function")).toBe(true)
+ })
+})
+
+describe("Native Protocol snapshots", () => {
+ it("should generate correct native definition for simple tool", () => {
+ const serialized = serializeCustomTool(fixtureTools.simple)
+ const result = formatNative(serialized)
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should generate correct native definition for cached tool", () => {
+ const serialized = serializeCustomTool(fixtureTools.cached)
+ const result = formatNative(serialized)
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should generate correct native definition for legacy tool (using args)", () => {
+ const serialized = serializeCustomTool(fixtureTools.legacy)
+ const result = formatNative(serialized)
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should generate correct native definitions for multi export tools", () => {
+ const serializedA = serializeCustomTool(fixtureTools.multi_toolA)
+ const serializedB = serializeCustomTool(fixtureTools.multi_toolB)
+ const result = [serializedA, serializedB].map(formatNative)
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should generate correct native definition for mixed export tool", () => {
+ const serialized = serializeCustomTool(fixtureTools.mixed_validTool)
+ const result = formatNative(serialized)
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should generate correct native definition for system time tool", () => {
+ const serialized = serializeCustomTool(fixtureTools.systemTime)
+ const result = formatNative(serialized)
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should generate correct native definitions for all fixtures combined", () => {
+ const allSerialized = Object.values(fixtureTools).map(serializeCustomTool)
+ const result = allSerialized.map(formatNative)
+ expect(result).toMatchSnapshot()
+ })
+})
diff --git a/packages/core/src/custom-tools/__tests__/format-xml.spec.ts b/packages/core/src/custom-tools/__tests__/format-xml.spec.ts
new file mode 100644
index 00000000000..0be07723476
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/format-xml.spec.ts
@@ -0,0 +1,192 @@
+// pnpm --filter @roo-code/core test src/custom-tools/__tests__/format-xml.spec.ts
+
+import { type SerializedCustomToolDefinition, parametersSchema as z, defineCustomTool } from "@roo-code/types"
+
+import { serializeCustomTool, serializeCustomTools } from "../serialize.js"
+import { formatXml } from "../format-xml.js"
+
+import simpleTool from "./fixtures/simple.js"
+import cachedTool from "./fixtures/cached.js"
+import legacyTool from "./fixtures/legacy.js"
+import { toolA, toolB } from "./fixtures/multi.js"
+import { validTool as mixedValidTool } from "./fixtures/mixed.js"
+
+const fixtureTools = {
+ simple: simpleTool,
+ cached: cachedTool,
+ legacy: legacyTool,
+ multi_toolA: toolA,
+ multi_toolB: toolB,
+ mixed_validTool: mixedValidTool,
+}
+
+describe("formatXml", () => {
+ it("should return empty string for empty tools array", () => {
+ expect(formatXml([])).toBe("")
+ })
+
+ it("should throw for undefined tools", () => {
+ expect(() => formatXml(undefined as unknown as SerializedCustomToolDefinition[])).toThrow()
+ })
+
+ it("should generate description for a single tool without args", () => {
+ const tool = defineCustomTool({
+ name: "my_tool",
+ description: "A simple tool that does something",
+ async execute() {
+ return "done"
+ },
+ })
+
+ const serialized = serializeCustomTool(tool)
+ const result = formatXml([serialized])
+
+ expect(result).toContain("# Custom Tools")
+ expect(result).toContain("## my_tool")
+ expect(result).toContain("Description: A simple tool that does something")
+ expect(result).toContain("Parameters: None")
+ expect(result).toContain("")
+ expect(result).toContain(" ")
+ })
+
+ it("should generate description for a tool with required args", () => {
+ const tool = defineCustomTool({
+ name: "greeter",
+ description: "Greets a person by name",
+ parameters: z.object({
+ name: z.string().describe("The name of the person to greet"),
+ }),
+ async execute({ name }) {
+ return `Hello, ${name}!`
+ },
+ })
+
+ const serialized = serializeCustomTool(tool)
+ const result = formatXml([serialized])
+
+ expect(result).toContain("## greeter")
+ expect(result).toContain("Description: Greets a person by name")
+ expect(result).toContain("Parameters:")
+ expect(result).toContain("- name: (required) The name of the person to greet (type: string)")
+ expect(result).toContain("")
+ expect(result).toContain("name value here ")
+ expect(result).toContain(" ")
+ })
+
+ it("should generate description for a tool with optional args", () => {
+ const tool = defineCustomTool({
+ name: "configurable_tool",
+ description: "A tool with optional configuration",
+ parameters: z.object({
+ input: z.string().describe("The input to process"),
+ format: z.string().optional().describe("Output format"),
+ }),
+ async execute({ input, format }) {
+ return format ? `${input} (${format})` : input
+ },
+ })
+
+ const serialized = serializeCustomTool(tool)
+ const result = formatXml([serialized])
+
+ expect(result).toContain("- input: (required) The input to process (type: string)")
+ expect(result).toContain("- format: (optional) Output format (type: string)")
+ expect(result).toContain(" input value here")
+ expect(result).toContain("optional format value ")
+ })
+
+ it("should generate descriptions for multiple tools", () => {
+ const tools = [
+ defineCustomTool({
+ name: "tool_a",
+ description: "First tool",
+ async execute() {
+ return "a"
+ },
+ }),
+ defineCustomTool({
+ name: "tool_b",
+ description: "Second tool",
+ parameters: z.object({
+ value: z.number().describe("A numeric value"),
+ }),
+ async execute() {
+ return "b"
+ },
+ }),
+ ]
+
+ const serialized = serializeCustomTools(tools)
+ const result = formatXml(serialized)
+
+ expect(result).toContain("## tool_a")
+ expect(result).toContain("Description: First tool")
+ expect(result).toContain("## tool_b")
+ expect(result).toContain("Description: Second tool")
+ expect(result).toContain("- value: (required) A numeric value (type: number)")
+ })
+
+ it("should treat args in required array as required", () => {
+ // Using a raw SerializedToolDefinition to test the required behavior.
+ const tools: SerializedCustomToolDefinition[] = [
+ {
+ name: "test_tool",
+ description: "Test tool",
+ parameters: {
+ type: "object",
+ properties: {
+ data: {
+ type: "object",
+ description: "Some data",
+ },
+ },
+ required: ["data"],
+ },
+ },
+ ]
+
+ const result = formatXml(tools)
+
+ expect(result).toContain("- data: (required) Some data (type: object)")
+ expect(result).toContain("data value here ")
+ })
+})
+
+describe("XML Protocol snapshots", () => {
+ it("should generate correct XML description for simple tool", () => {
+ const serialized = serializeCustomTool(fixtureTools.simple)
+ const result = formatXml([serialized])
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should generate correct XML description for cached tool", () => {
+ const serialized = serializeCustomTool(fixtureTools.cached)
+ const result = formatXml([serialized])
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should generate correct XML description for legacy tool (using args)", () => {
+ const serialized = serializeCustomTool(fixtureTools.legacy)
+ const result = formatXml([serialized])
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should generate correct XML description for multi export tools", () => {
+ const serializedA = serializeCustomTool(fixtureTools.multi_toolA)
+ const serializedB = serializeCustomTool(fixtureTools.multi_toolB)
+ const result = formatXml([serializedA, serializedB])
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should generate correct XML description for mixed export tool", () => {
+ const serialized = serializeCustomTool(fixtureTools.mixed_validTool)
+ const result = formatXml([serialized])
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should generate correct XML description for all fixtures combined", () => {
+ const allSerialized = Object.values(fixtureTools).map(serializeCustomTool)
+ const result = formatXml(allSerialized)
+ expect(result).toMatchSnapshot()
+ })
+})
diff --git a/packages/core/src/custom-tools/__tests__/serialize.ts b/packages/core/src/custom-tools/__tests__/serialize.ts
new file mode 100644
index 00000000000..05c125f49ef
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/serialize.ts
@@ -0,0 +1,225 @@
+// pnpm --filter @roo-code/core test src/custom-tools/__tests__/serialize.spec.ts
+
+import { parametersSchema as z, defineCustomTool } from "@roo-code/types"
+
+import { serializeCustomTool, serializeCustomTools } from "../serialize.js"
+
+import simpleTool from "./fixtures/simple.js"
+import cachedTool from "./fixtures/cached.js"
+import legacyTool from "./fixtures/legacy.js"
+import { toolA, toolB } from "./fixtures/multi.js"
+import { validTool as mixedValidTool } from "./fixtures/mixed.js"
+
+const fixtureTools = {
+ simple: simpleTool,
+ cached: cachedTool,
+ legacy: legacyTool,
+ multi_toolA: toolA,
+ multi_toolB: toolB,
+ mixed_validTool: mixedValidTool,
+}
+
+describe("serializeCustomTool", () => {
+ it("should serialize a tool without parameters", () => {
+ const tool = defineCustomTool({
+ name: "simple_tool",
+ description: "A simple tool that does something",
+ async execute() {
+ return "done"
+ },
+ })
+
+ const result = serializeCustomTool(tool)
+
+ expect(result).toEqual({
+ name: "simple_tool",
+ description: "A simple tool that does something",
+ })
+ })
+
+ it("should serialize a tool with required string parameter", () => {
+ const tool = defineCustomTool({
+ name: "greeter",
+ description: "Greets a person by name",
+ parameters: z.object({
+ name: z.string().describe("The name of the person to greet"),
+ }),
+ async execute({ name }) {
+ return `Hello, ${name}!`
+ },
+ })
+
+ const result = serializeCustomTool(tool)
+
+ expect(result.name).toBe("greeter")
+ expect(result.description).toBe("Greets a person by name")
+ expect(result.parameters?.properties?.name).toEqual({
+ type: "string",
+ description: "The name of the person to greet",
+ })
+ expect(result.parameters?.required).toEqual(["name"])
+ })
+
+ it("should serialize a tool with optional parameter", () => {
+ const tool = defineCustomTool({
+ name: "configurable_tool",
+ description: "A tool with optional configuration",
+ parameters: z.object({
+ input: z.string().describe("The input to process"),
+ format: z.string().optional().describe("Output format"),
+ }),
+ async execute({ input, format }) {
+ return format ? `${input} (${format})` : input
+ },
+ })
+
+ const result = serializeCustomTool(tool)
+
+ expect(result.parameters?.properties?.input).toEqual({
+ type: "string",
+ description: "The input to process",
+ })
+
+ expect(result.parameters?.properties?.format).toEqual({
+ type: "string",
+ description: "Output format",
+ })
+
+ // Only required params should be in the required array
+ expect(result.parameters?.required).toEqual(["input"])
+ })
+
+ it("should serialize a tool with various types", () => {
+ const tool = defineCustomTool({
+ name: "typed_tool",
+ description: "Tool with various types",
+ parameters: z.object({
+ str: z.string().describe("A string"),
+ num: z.number().describe("A number"),
+ bool: z.boolean().describe("A boolean"),
+ obj: z.object({}).describe("An object"),
+ arr: z.array(z.string()).describe("An array"),
+ }),
+ async execute() {
+ return "done"
+ },
+ })
+
+ const result = serializeCustomTool(tool)
+
+ expect(result.parameters?.properties?.str).toEqual({
+ description: "A string",
+ type: "string",
+ })
+ expect(result.parameters?.properties?.num).toEqual({
+ description: "A number",
+ type: "number",
+ })
+ expect(result.parameters?.properties?.bool).toEqual({
+ description: "A boolean",
+ type: "boolean",
+ })
+ expect(result.parameters?.properties?.obj).toEqual({
+ additionalProperties: false,
+ description: "An object",
+ properties: {},
+ type: "object",
+ })
+ expect(result.parameters?.properties?.arr).toEqual({
+ description: "An array",
+ items: { type: "string" },
+ type: "array",
+ })
+ })
+
+ it("should handle nullable parameters as optional", () => {
+ const tool = defineCustomTool({
+ name: "nullable_tool",
+ description: "Tool with nullable param",
+ parameters: z.object({
+ value: z.string().nullable().describe("A nullable value"),
+ }),
+ async execute() {
+ return "done"
+ },
+ })
+
+ const result = serializeCustomTool(tool)
+
+ expect(result.parameters?.required).toEqual(["value"])
+ })
+
+ it("should handle default values as optional", () => {
+ const tool = defineCustomTool({
+ name: "default_tool",
+ description: "Tool with default param",
+ parameters: z.object({
+ count: z.number().default(10).describe("A count with default"),
+ }),
+ async execute() {
+ return "done"
+ },
+ })
+
+ const result = serializeCustomTool(tool)
+
+ expect(result.parameters?.required).toEqual(["count"])
+ })
+})
+
+describe("serializeCustomTools", () => {
+ it("should return empty array for empty tools array", () => {
+ expect(serializeCustomTools([])).toEqual([])
+ })
+
+ it("should serialize multiple tools", () => {
+ const tools = [
+ defineCustomTool({
+ name: "tool_a",
+ description: "First tool",
+ async execute() {
+ return "a"
+ },
+ }),
+ defineCustomTool({
+ name: "tool_b",
+ description: "Second tool",
+ parameters: z.object({
+ value: z.number().describe("A numeric value"),
+ }),
+ async execute() {
+ return "b"
+ },
+ }),
+ ]
+
+ const result = serializeCustomTools(tools)
+
+ expect(result).toHaveLength(2)
+ expect(result[0]?.name).toBe("tool_a")
+ expect(result[1]?.name).toBe("tool_b")
+ expect(result[1]?.parameters?.properties?.value).toBeDefined()
+ })
+})
+
+describe("Serialization snapshots", () => {
+ it("should correctly serialize simple tool", () => {
+ const result = serializeCustomTool(fixtureTools.simple)
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should correctly serialize cached tool", () => {
+ const result = serializeCustomTool(fixtureTools.cached)
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should correctly serialize legacy tool (using args)", () => {
+ const result = serializeCustomTool(fixtureTools.legacy)
+ expect(result).toMatchSnapshot()
+ })
+
+ it("should correctly serialize all fixtures", () => {
+ const result = Object.values(fixtureTools).map(serializeCustomTool)
+ expect(result).toMatchSnapshot()
+ })
+})
diff --git a/packages/core/src/custom-tools/custom-tool-registry.ts b/packages/core/src/custom-tools/custom-tool-registry.ts
index 301ddb13034..f5710d1ea4f 100644
--- a/packages/core/src/custom-tools/custom-tool-registry.ts
+++ b/packages/core/src/custom-tools/custom-tool-registry.ts
@@ -15,7 +15,14 @@ import os from "os"
import { build } from "esbuild"
-import { type CustomToolContext, type CustomToolDefinition, type ZodLikeSchema } from "@roo-code/types"
+import type {
+ CustomToolDefinition,
+ SerializedCustomToolDefinition,
+ CustomToolParametersSchema,
+ CustomToolContext,
+} from "@roo-code/types"
+
+import { serializeCustomTool } from "./serialize.js"
/**
* Default subdirectory name for custom tools within a .roo directory.
@@ -123,6 +130,7 @@ export class CustomToolRegistry {
const isStale = lastLoaded ? stat.mtimeMs > lastLoaded : true
if (isStale) {
+ this.lastLoaded.set(toolDir, stat.mtimeMs)
return this.loadFromDirectory(toolDir)
}
@@ -178,6 +186,10 @@ export class CustomToolRegistry {
return Array.from(this.tools.values())
}
+ getAllSerialized(): SerializedCustomToolDefinition[] {
+ return this.getAll().map(serializeCustomTool)
+ }
+
/**
* Get the number of registered tools.
*/
@@ -266,7 +278,7 @@ export class CustomToolRegistry {
* Check if a value is a Zod schema by looking for the _def property
* which is present on all Zod types.
*/
- private isZodSchema(value: unknown): value is ZodLikeSchema {
+ private isParametersSchema(value: unknown): value is CustomToolParametersSchema {
return (
value !== null &&
typeof value === "object" &&
@@ -308,7 +320,7 @@ export class CustomToolRegistry {
}
// Validate parameters (optional).
- if (obj.parameters !== undefined && !this.isZodSchema(obj.parameters)) {
+ if (obj.parameters !== undefined && !this.isParametersSchema(obj.parameters)) {
errors.push("parameters: parameters must be a Zod schema")
}
diff --git a/packages/core/src/custom-tools/format-native.ts b/packages/core/src/custom-tools/format-native.ts
new file mode 100644
index 00000000000..69ecb8f9ed9
--- /dev/null
+++ b/packages/core/src/custom-tools/format-native.ts
@@ -0,0 +1,7 @@
+import type { OpenAI } from "openai"
+
+import type { SerializedCustomToolDefinition } from "@roo-code/types"
+
+export function formatNative(tool: SerializedCustomToolDefinition): OpenAI.Chat.ChatCompletionFunctionTool {
+ return { type: "function", function: tool }
+}
diff --git a/packages/core/src/custom-tools/format-xml.ts b/packages/core/src/custom-tools/format-xml.ts
new file mode 100644
index 00000000000..7827cee33e5
--- /dev/null
+++ b/packages/core/src/custom-tools/format-xml.ts
@@ -0,0 +1,64 @@
+import type { SerializedCustomToolDefinition, SerializedCustomToolParameters } from "@roo-code/types"
+
+function getParameterDescription(name: string, parameter: SerializedCustomToolParameters, required: string[]): string {
+ const requiredText = required.includes(name) ? "(required)" : "(optional)"
+ return `- ${name}: ${requiredText} ${parameter.description} (type: ${parameter.type})`
+}
+
+function getUsage(tool: SerializedCustomToolDefinition): string {
+ const lines: string[] = [`<${tool.name}>`]
+
+ if (tool.parameters) {
+ const required = tool.parameters.required ?? []
+
+ for (const [argName, _argType] of Object.entries(tool.parameters.properties ?? {})) {
+ const placeholder = required.includes(argName) ? `${argName} value here` : `optional ${argName} value`
+ lines.push(`<${argName}>${placeholder}${argName}>`)
+ }
+ }
+
+ lines.push(`${tool.name}>`)
+ return lines.join("\n")
+}
+
+function getDescription(tool: SerializedCustomToolDefinition): string {
+ const parts: string[] = []
+
+ parts.push(`## ${tool.name}`)
+ parts.push(`Description: ${tool.description}`)
+
+ if (tool.parameters?.properties) {
+ const required = tool.parameters?.required ?? []
+ parts.push("Parameters:")
+
+ for (const [name, parameter] of Object.entries(tool.parameters.properties)) {
+ // What should we do with `boolean` values for `parameter`?
+ if (typeof parameter !== "object") {
+ continue
+ }
+
+ parts.push(getParameterDescription(name, parameter, required))
+ }
+ } else {
+ parts.push("Parameters: None")
+ }
+
+ parts.push("Usage:")
+ parts.push(getUsage(tool))
+
+ return parts.join("\n")
+}
+
+export function formatXml(tools: SerializedCustomToolDefinition[]): string {
+ if (tools.length === 0) {
+ return ""
+ }
+
+ const descriptions = tools.map((tool) => getDescription(tool))
+
+ return `# Custom Tools
+
+The following custom tools are available for this mode. Use them in the same way as built-in tools.
+
+${descriptions.join("\n\n")}`
+}
diff --git a/packages/core/src/custom-tools/index.ts b/packages/core/src/custom-tools/index.ts
index 1063c103c54..c8b44ec1175 100644
--- a/packages/core/src/custom-tools/index.ts
+++ b/packages/core/src/custom-tools/index.ts
@@ -1 +1,4 @@
export * from "./custom-tool-registry.js"
+export * from "./serialize.js"
+export * from "./format-xml.js"
+export * from "./format-native.js"
diff --git a/packages/core/src/custom-tools/serialize.ts b/packages/core/src/custom-tools/serialize.ts
new file mode 100644
index 00000000000..dc4eceb87e5
--- /dev/null
+++ b/packages/core/src/custom-tools/serialize.ts
@@ -0,0 +1,17 @@
+import { type CustomToolDefinition, type SerializedCustomToolDefinition, parametersSchema } from "@roo-code/types"
+
+export function serializeCustomTool({
+ name,
+ description,
+ parameters,
+}: CustomToolDefinition): SerializedCustomToolDefinition {
+ return {
+ name,
+ description,
+ parameters: parameters ? parametersSchema.toJSONSchema(parameters) : undefined,
+ }
+}
+
+export function serializeCustomTools(tools: CustomToolDefinition[]): SerializedCustomToolDefinition[] {
+ return tools.map(serializeCustomTool)
+}
diff --git a/packages/types/src/__tests__/custom-tool.spec.ts b/packages/types/src/__tests__/custom-tool.spec.ts
index dc514b17ea2..9514b9fcaa2 100644
--- a/packages/types/src/__tests__/custom-tool.spec.ts
+++ b/packages/types/src/__tests__/custom-tool.spec.ts
@@ -1,4 +1,9 @@
-import { type CustomToolDefinition, type CustomToolContext, defineCustomTool, z } from "../custom-tool.js"
+import {
+ type CustomToolDefinition,
+ type CustomToolContext,
+ defineCustomTool,
+ parametersSchema as z,
+} from "../custom-tool.js"
import type { TaskLike } from "../task.js"
describe("custom-tool utilities", () => {
diff --git a/packages/types/src/custom-tool.ts b/packages/types/src/custom-tool.ts
index ae6df15b246..ec77067495f 100644
--- a/packages/types/src/custom-tool.ts
+++ b/packages/types/src/custom-tool.ts
@@ -21,10 +21,18 @@
* ```
*/
-import type { ZodType, infer as ZodInfer } from "zod"
+import type { ZodType, infer as ZodInfer, z } from "zod/v4"
import { TaskLike } from "./task.js"
+// Re-export from Zod for convenience.
+
+export { z as parametersSchema } from "zod/v4"
+
+export type CustomToolParametersSchema = ZodType
+
+export type SerializedCustomToolParameters = z.core.JSONSchema.JSONSchema
+
/**
* Context provided to tool execute functions.
*/
@@ -33,16 +41,6 @@ export interface CustomToolContext {
task: TaskLike
}
-/**
- * A Zod-like schema interface. We use this instead of ZodType directly
- * to avoid TypeScript's excessive type instantiation (TS2589).
- */
-export interface ZodLikeSchema {
- _def: unknown
- parse: (data: unknown) => unknown
- safeParse: (data: unknown) => { success: boolean; data?: unknown; error?: unknown }
-}
-
/**
* Definition structure for a custom tool.
*
@@ -67,7 +65,7 @@ export interface CustomToolDefinition {
* Optional Zod schema defining the tool's parameters.
* Use `z.object({})` to define the shape of arguments.
*/
- parameters?: ZodLikeSchema
+ parameters?: CustomToolParametersSchema
/**
* The function that executes the tool.
@@ -80,13 +78,19 @@ export interface CustomToolDefinition {
execute: (args: any, context: CustomToolContext) => Promise
}
+export interface SerializedCustomToolDefinition {
+ name: string
+ description: string
+ parameters?: SerializedCustomToolParameters
+}
+
/**
* Type-safe definition structure for a custom tool with inferred parameter types.
* Use this with `defineCustomTool` for full type inference.
*
* @template T - The Zod schema type for parameters
*/
-export interface TypedCustomToolDefinition
+export interface TypedCustomToolDefinition
extends Omit {
parameters?: T
execute: (args: ZodInfer, context: CustomToolContext) => Promise
@@ -115,13 +119,8 @@ export interface TypedCustomToolDefinition
* })
* ```
*/
-export function defineCustomTool(
+export function defineCustomTool(
definition: TypedCustomToolDefinition,
): TypedCustomToolDefinition {
return definition
}
-
-// Re-export Zod for convenient parameter schema definition.
-export { z as parametersSchema, z } from "zod"
-
-export type { ZodType, ZodObject, ZodRawShape } from "zod"
From 6ae088e8fbf3870a8ff009c522576505a0d6be32 Mon Sep 17 00:00:00 2001
From: cte
Date: Mon, 15 Dec 2025 01:37:49 -0800
Subject: [PATCH 06/18] Fix edge cases with `formatNative`
---
packages/core/src/custom-tools/format-native.ts | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/packages/core/src/custom-tools/format-native.ts b/packages/core/src/custom-tools/format-native.ts
index 69ecb8f9ed9..9b0260cbd53 100644
--- a/packages/core/src/custom-tools/format-native.ts
+++ b/packages/core/src/custom-tools/format-native.ts
@@ -3,5 +3,15 @@ import type { OpenAI } from "openai"
import type { SerializedCustomToolDefinition } from "@roo-code/types"
export function formatNative(tool: SerializedCustomToolDefinition): OpenAI.Chat.ChatCompletionFunctionTool {
- return { type: "function", function: tool }
+ const { parameters } = tool
+
+ if (parameters) {
+ delete parameters["$schema"]
+
+ if (!parameters.required) {
+ parameters.required = []
+ }
+ }
+
+ return { type: "function", function: { ...tool, strict: true, parameters } }
}
From 473a189387166e87aab5c286c578ed3d2be6cfc9 Mon Sep 17 00:00:00 2001
From: cte
Date: Mon, 15 Dec 2025 01:38:26 -0800
Subject: [PATCH 07/18] Add comments
---
packages/core/src/custom-tools/format-native.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/packages/core/src/custom-tools/format-native.ts b/packages/core/src/custom-tools/format-native.ts
index 9b0260cbd53..8d87a2f353a 100644
--- a/packages/core/src/custom-tools/format-native.ts
+++ b/packages/core/src/custom-tools/format-native.ts
@@ -6,8 +6,10 @@ export function formatNative(tool: SerializedCustomToolDefinition): OpenAI.Chat.
const { parameters } = tool
if (parameters) {
+ // We don't need the $schema property; none of the other tools specify it.
delete parameters["$schema"]
+ // https://community.openai.com/t/on-the-function-calling-what-about-if-i-have-no-parameter-to-call/516876
if (!parameters.required) {
parameters.required = []
}
From 2f3fa97eb3a9e0e8f6016cbe22e65c543f3ef7c6 Mon Sep 17 00:00:00 2001
From: cte
Date: Mon, 15 Dec 2025 02:01:00 -0800
Subject: [PATCH 08/18] Fix tests
---
.../__snapshots__/format-native.spec.ts.snap | 36 +++++++++++--------
.../__tests__/custom-tool-registry.spec.ts | 16 ++++-----
.../__tests__/format-native.spec.ts | 4 ++-
3 files changed, 33 insertions(+), 23 deletions(-)
diff --git a/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap b/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap
index 98e3a4e9f87..9e9918def94 100644
--- a/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap
+++ b/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap
@@ -6,11 +6,12 @@ exports[`Native Protocol snapshots > should generate correct native definition f
"description": "Cached tool",
"name": "cached",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {},
+ "required": [],
"type": "object",
},
+ "strict": true,
},
"type": "function",
}
@@ -22,7 +23,6 @@ exports[`Native Protocol snapshots > should generate correct native definition f
"description": "Legacy tool using args",
"name": "legacy",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"input": {
@@ -35,6 +35,7 @@ exports[`Native Protocol snapshots > should generate correct native definition f
],
"type": "object",
},
+ "strict": true,
},
"type": "function",
}
@@ -46,11 +47,12 @@ exports[`Native Protocol snapshots > should generate correct native definition f
"description": "Valid",
"name": "mixed_validTool",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {},
+ "required": [],
"type": "object",
},
+ "strict": true,
},
"type": "function",
}
@@ -62,7 +64,6 @@ exports[`Native Protocol snapshots > should generate correct native definition f
"description": "Simple tool",
"name": "simple",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"value": {
@@ -75,6 +76,7 @@ exports[`Native Protocol snapshots > should generate correct native definition f
],
"type": "object",
},
+ "strict": true,
},
"type": "function",
}
@@ -86,7 +88,6 @@ exports[`Native Protocol snapshots > should generate correct native definition f
"description": "Returns the current system date and time in a friendly, human-readable format.",
"name": "system_time",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"timezone": {
@@ -106,6 +107,7 @@ exports[`Native Protocol snapshots > should generate correct native definition f
],
"type": "object",
},
+ "strict": true,
},
"type": "function",
}
@@ -118,7 +120,6 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"description": "Simple tool",
"name": "simple",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"value": {
@@ -131,6 +132,7 @@ exports[`Native Protocol snapshots > should generate correct native definitions
],
"type": "object",
},
+ "strict": true,
},
"type": "function",
},
@@ -139,11 +141,12 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"description": "Cached tool",
"name": "cached",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {},
+ "required": [],
"type": "object",
},
+ "strict": true,
},
"type": "function",
},
@@ -152,7 +155,6 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"description": "Legacy tool using args",
"name": "legacy",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"input": {
@@ -165,6 +167,7 @@ exports[`Native Protocol snapshots > should generate correct native definitions
],
"type": "object",
},
+ "strict": true,
},
"type": "function",
},
@@ -173,11 +176,12 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"description": "Tool A",
"name": "multi_toolA",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {},
+ "required": [],
"type": "object",
},
+ "strict": true,
},
"type": "function",
},
@@ -186,11 +190,12 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"description": "Tool B",
"name": "multi_toolB",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {},
+ "required": [],
"type": "object",
},
+ "strict": true,
},
"type": "function",
},
@@ -199,11 +204,12 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"description": "Valid",
"name": "mixed_validTool",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {},
+ "required": [],
"type": "object",
},
+ "strict": true,
},
"type": "function",
},
@@ -212,7 +218,6 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"description": "Returns the current system date and time in a friendly, human-readable format.",
"name": "system_time",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"timezone": {
@@ -232,6 +237,7 @@ exports[`Native Protocol snapshots > should generate correct native definitions
],
"type": "object",
},
+ "strict": true,
},
"type": "function",
},
@@ -245,11 +251,12 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"description": "Tool A",
"name": "multi_toolA",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {},
+ "required": [],
"type": "object",
},
+ "strict": true,
},
"type": "function",
},
@@ -258,11 +265,12 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"description": "Tool B",
"name": "multi_toolB",
"parameters": {
- "$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {},
+ "required": [],
"type": "object",
},
+ "strict": true,
},
"type": "function",
},
diff --git a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
index fdbb21d17a9..848a27f3410 100644
--- a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
+++ b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
@@ -287,20 +287,20 @@ describe("CustomToolRegistry", () => {
})
})
- describe("loadFromDirectory", () => {
+ describe.sequential("loadFromDirectory", () => {
it("should load tools from TypeScript files", async () => {
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
expect(result.loaded).toContain("simple")
expect(registry.has("simple")).toBe(true)
- })
+ }, 60000)
it("should handle named exports", async () => {
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
expect(result.loaded).toContain("multi_toolA")
expect(result.loaded).toContain("multi_toolB")
- })
+ }, 30000)
it("should report validation failures", async () => {
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
@@ -308,7 +308,7 @@ describe("CustomToolRegistry", () => {
const invalidFailure = result.failed.find((f) => f.file === "invalid.ts")
expect(invalidFailure).toBeDefined()
expect(invalidFailure?.error).toContain("Invalid tool definition")
- })
+ }, 30000)
it("should return empty results for non-existent directory", async () => {
const result = await registry.loadFromDirectory("/nonexistent/path")
@@ -325,7 +325,7 @@ describe("CustomToolRegistry", () => {
expect(result.loaded).not.toContain("mixed_someString")
expect(result.loaded).not.toContain("mixed_someNumber")
expect(result.loaded).not.toContain("mixed_someObject")
- })
+ }, 30000)
it("should support args as alias for parameters", async () => {
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
@@ -334,10 +334,10 @@ describe("CustomToolRegistry", () => {
const tool = registry.get("legacy")
expect(tool?.parameters).toBeDefined()
- })
+ }, 30000)
})
- describe("clearCache", () => {
+ describe.sequential("clearCache", () => {
it("should clear the TypeScript compilation cache", async () => {
await registry.loadFromDirectory(TEST_FIXTURES_DIR)
registry.clearCache()
@@ -347,6 +347,6 @@ describe("CustomToolRegistry", () => {
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
expect(result.loaded).toContain("cached")
- })
+ }, 30000)
})
})
diff --git a/packages/core/src/custom-tools/__tests__/format-native.spec.ts b/packages/core/src/custom-tools/__tests__/format-native.spec.ts
index d95bb3fa259..ee88de2b89f 100644
--- a/packages/core/src/custom-tools/__tests__/format-native.spec.ts
+++ b/packages/core/src/custom-tools/__tests__/format-native.spec.ts
@@ -40,6 +40,8 @@ describe("formatNative", () => {
function: {
name: "simple_tool",
description: "A simple tool",
+ parameters: undefined,
+ strict: true,
},
})
})
@@ -87,7 +89,7 @@ describe("formatNative", () => {
const serialized = serializeCustomTool(tool)
const result = formatNative(serialized)
- expect(result.function.parameters?.required).toBeUndefined()
+ expect(result.function.parameters?.required).toEqual([])
expect(result.function.parameters?.properties).toEqual({
format: {
type: "string",
From 8f48005295c18fe8bd294e7c4745273574db68ab Mon Sep 17 00:00:00 2001
From: Roo Code
Date: Mon, 15 Dec 2025 10:10:18 +0000
Subject: [PATCH 09/18] Fix: Address PR reviewer issues
- clearCache() now also removes compiled files from disk
- loadFromDirectoryIfStale() checks directory existence before statSync
- Renamed serialize.ts to serialize.spec.ts for test discovery
- formatXml handles anyOf schema for nullable parameters
- formatNative avoids mutating input object
---
.../__snapshots__/serialize.spec.ts.snap | 137 ++++++++++++++++++
.../{serialize.ts => serialize.spec.ts} | 0
.../src/custom-tools/custom-tool-registry.ts | 22 ++-
.../core/src/custom-tools/format-native.ts | 6 +-
packages/core/src/custom-tools/format-xml.ts | 27 +++-
5 files changed, 189 insertions(+), 3 deletions(-)
create mode 100644 packages/core/src/custom-tools/__tests__/__snapshots__/serialize.spec.ts.snap
rename packages/core/src/custom-tools/__tests__/{serialize.ts => serialize.spec.ts} (100%)
diff --git a/packages/core/src/custom-tools/__tests__/__snapshots__/serialize.spec.ts.snap b/packages/core/src/custom-tools/__tests__/__snapshots__/serialize.spec.ts.snap
new file mode 100644
index 00000000000..6b55c3f8e17
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/__snapshots__/serialize.spec.ts.snap
@@ -0,0 +1,137 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Serialization snapshots > should correctly serialize all fixtures 1`] = `
+[
+ {
+ "description": "Simple tool",
+ "name": "simple",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "value": {
+ "description": "The input value",
+ "type": "string",
+ },
+ },
+ "required": [
+ "value",
+ ],
+ "type": "object",
+ },
+ },
+ {
+ "description": "Cached tool",
+ "name": "cached",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object",
+ },
+ },
+ {
+ "description": "Legacy tool using args",
+ "name": "legacy",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "input": {
+ "description": "The input string",
+ "type": "string",
+ },
+ },
+ "required": [
+ "input",
+ ],
+ "type": "object",
+ },
+ },
+ {
+ "description": "Tool A",
+ "name": "multi_toolA",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object",
+ },
+ },
+ {
+ "description": "Tool B",
+ "name": "multi_toolB",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object",
+ },
+ },
+ {
+ "description": "Valid",
+ "name": "mixed_validTool",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object",
+ },
+ },
+]
+`;
+
+exports[`Serialization snapshots > should correctly serialize cached tool 1`] = `
+{
+ "description": "Cached tool",
+ "name": "cached",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {},
+ "type": "object",
+ },
+}
+`;
+
+exports[`Serialization snapshots > should correctly serialize legacy tool (using args) 1`] = `
+{
+ "description": "Legacy tool using args",
+ "name": "legacy",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "input": {
+ "description": "The input string",
+ "type": "string",
+ },
+ },
+ "required": [
+ "input",
+ ],
+ "type": "object",
+ },
+}
+`;
+
+exports[`Serialization snapshots > should correctly serialize simple tool 1`] = `
+{
+ "description": "Simple tool",
+ "name": "simple",
+ "parameters": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "value": {
+ "description": "The input value",
+ "type": "string",
+ },
+ },
+ "required": [
+ "value",
+ ],
+ "type": "object",
+ },
+}
+`;
diff --git a/packages/core/src/custom-tools/__tests__/serialize.ts b/packages/core/src/custom-tools/__tests__/serialize.spec.ts
similarity index 100%
rename from packages/core/src/custom-tools/__tests__/serialize.ts
rename to packages/core/src/custom-tools/__tests__/serialize.spec.ts
diff --git a/packages/core/src/custom-tools/custom-tool-registry.ts b/packages/core/src/custom-tools/custom-tool-registry.ts
index f5710d1ea4f..fa976abd40f 100644
--- a/packages/core/src/custom-tools/custom-tool-registry.ts
+++ b/packages/core/src/custom-tools/custom-tool-registry.ts
@@ -125,6 +125,11 @@ export class CustomToolRegistry {
}
async loadFromDirectoryIfStale(toolDir: string): Promise {
+ // Return empty result if directory doesn't exist
+ if (!fs.existsSync(toolDir)) {
+ return { loaded: [], failed: [] }
+ }
+
const lastLoaded = this.lastLoaded.get(toolDir)
const stat = fs.statSync(toolDir)
const isStale = lastLoaded ? stat.mtimeMs > lastLoaded : true
@@ -223,10 +228,25 @@ export class CustomToolRegistry {
}
/**
- * Clear the TypeScript compilation cache.
+ * Clear the TypeScript compilation cache (both in-memory and on disk).
*/
clearCache(): void {
this.tsCache.clear()
+
+ // Also remove compiled files from disk
+ if (fs.existsSync(this.cacheDir)) {
+ try {
+ const files = fs.readdirSync(this.cacheDir)
+ for (const file of files) {
+ if (file.endsWith(".mjs")) {
+ fs.unlinkSync(path.join(this.cacheDir, file))
+ }
+ }
+ } catch (error) {
+ // Silently ignore cleanup errors
+ console.error(`[CustomToolRegistry] clearCache failed to clean disk cache: ${error}`)
+ }
+ }
}
/**
diff --git a/packages/core/src/custom-tools/format-native.ts b/packages/core/src/custom-tools/format-native.ts
index 8d87a2f353a..c1c0018d62d 100644
--- a/packages/core/src/custom-tools/format-native.ts
+++ b/packages/core/src/custom-tools/format-native.ts
@@ -3,9 +3,13 @@ import type { OpenAI } from "openai"
import type { SerializedCustomToolDefinition } from "@roo-code/types"
export function formatNative(tool: SerializedCustomToolDefinition): OpenAI.Chat.ChatCompletionFunctionTool {
- const { parameters } = tool
+ // Create a shallow copy to avoid mutating the input object
+ let parameters = tool.parameters
if (parameters) {
+ // Create a new object with the modifications instead of mutating the original
+ parameters = { ...parameters }
+
// We don't need the $schema property; none of the other tools specify it.
delete parameters["$schema"]
diff --git a/packages/core/src/custom-tools/format-xml.ts b/packages/core/src/custom-tools/format-xml.ts
index 7827cee33e5..01338f236cc 100644
--- a/packages/core/src/custom-tools/format-xml.ts
+++ b/packages/core/src/custom-tools/format-xml.ts
@@ -1,8 +1,33 @@
import type { SerializedCustomToolDefinition, SerializedCustomToolParameters } from "@roo-code/types"
+/**
+ * Extract the type string from a parameter schema.
+ * Handles both direct `type` property and `anyOf` schemas (used for nullable types).
+ */
+function getParameterType(parameter: SerializedCustomToolParameters): string {
+ // Direct type property
+ if (parameter.type) {
+ return String(parameter.type)
+ }
+
+ // Handle anyOf schema (used for nullable types like `string | null`)
+ if (parameter.anyOf && Array.isArray(parameter.anyOf)) {
+ const types = parameter.anyOf
+ .map((schema) => (typeof schema === "object" && schema.type ? String(schema.type) : null))
+ .filter((t): t is string => t !== null && t !== "null")
+
+ if (types.length > 0) {
+ return types.join(" | ")
+ }
+ }
+
+ return "unknown"
+}
+
function getParameterDescription(name: string, parameter: SerializedCustomToolParameters, required: string[]): string {
const requiredText = required.includes(name) ? "(required)" : "(optional)"
- return `- ${name}: ${requiredText} ${parameter.description} (type: ${parameter.type})`
+ const typeText = getParameterType(parameter)
+ return `- ${name}: ${requiredText} ${parameter.description ?? ""} (type: ${typeText})`
}
function getUsage(tool: SerializedCustomToolDefinition): string {
From 805106491ef807391516fc60f19f36411ecdcc80 Mon Sep 17 00:00:00 2001
From: cte
Date: Mon, 15 Dec 2025 02:32:01 -0800
Subject: [PATCH 10/18] Try to fix e2e tests
---
packages/types/src/custom-tool.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/types/src/custom-tool.ts b/packages/types/src/custom-tool.ts
index ec77067495f..63cd6efa4ed 100644
--- a/packages/types/src/custom-tool.ts
+++ b/packages/types/src/custom-tool.ts
@@ -21,7 +21,7 @@
* ```
*/
-import type { ZodType, infer as ZodInfer, z } from "zod/v4"
+import type { ZodType, z } from "zod/v4"
import { TaskLike } from "./task.js"
@@ -93,7 +93,7 @@ export interface SerializedCustomToolDefinition {
export interface TypedCustomToolDefinition
extends Omit {
parameters?: T
- execute: (args: ZodInfer, context: CustomToolContext) => Promise
+ execute: (args: z.infer, context: CustomToolContext) => Promise
}
/**
From 7b12c60f0d559cd32196c9d402fef4f03469abaa Mon Sep 17 00:00:00 2001
From: Chris Estreich
Date: Mon, 15 Dec 2025 14:49:20 -0800
Subject: [PATCH 11/18] Start using `@roo-code/core` (#10088)
---
pnpm-lock.yaml | 6 +--
.../assistant-message/NativeToolCallParser.ts | 22 +++++++---
.../presentAssistantMessage.ts | 43 ++++++++++++++++++-
src/core/prompts/system.ts | 28 +++++++++---
src/core/task/build-tools.ts | 29 ++++++++++---
src/core/tools/validateToolUse.ts | 11 +++++
src/esbuild.mjs | 4 +-
src/package.json | 2 +-
.../__tests__/autoImportSettings.spec.ts | 34 +++++++++++----
9 files changed, 145 insertions(+), 34 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5591f160d79..b5bcdcfbbd2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -739,6 +739,9 @@ importers:
diff-match-patch:
specifier: ^1.0.5
version: 1.0.5
+ esbuild:
+ specifier: '>=0.25.0'
+ version: 0.25.9
exceljs:
specifier: ^4.4.0
version: 4.4.0
@@ -974,9 +977,6 @@ importers:
'@vscode/vsce':
specifier: 3.3.2
version: 3.3.2
- esbuild:
- specifier: '>=0.25.0'
- version: 0.25.9
execa:
specifier: ^9.5.2
version: 9.5.3
diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts
index 250afdc3890..98dfd654660 100644
--- a/src/core/assistant-message/NativeToolCallParser.ts
+++ b/src/core/assistant-message/NativeToolCallParser.ts
@@ -1,13 +1,16 @@
+import { parseJSON } from "partial-json"
+
import { type ToolName, toolNames, type FileEntry } from "@roo-code/types"
+import { customToolRegistry } from "@roo-code/core"
+
import {
type ToolUse,
type McpToolUse,
type ToolParamName,
- toolParamNames,
type NativeToolArgs,
+ toolParamNames,
} from "../../shared/tools"
import { resolveToolAlias } from "../prompts/tools/filter-tools-for-mode"
-import { parseJSON } from "partial-json"
import type {
ApiStreamToolCallStartChunk,
ApiStreamToolCallDeltaChunk,
@@ -558,6 +561,7 @@ export class NativeToolCallParser {
}): ToolUse | McpToolUse | null {
// Check if this is a dynamic MCP tool (mcp--serverName--toolName)
const mcpPrefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR
+
if (typeof toolCall.name === "string" && toolCall.name.startsWith(mcpPrefix)) {
return this.parseDynamicMcpTool(toolCall)
}
@@ -565,8 +569,8 @@ export class NativeToolCallParser {
// Resolve tool alias to canonical name (e.g., "edit_file" -> "apply_diff", "temp_edit_file" -> "search_and_replace")
const resolvedName = resolveToolAlias(toolCall.name as string) as TName
- // Validate tool name (after alias resolution)
- if (!toolNames.includes(resolvedName as ToolName)) {
+ // Validate tool name (after alias resolution).
+ if (!toolNames.includes(resolvedName as ToolName) && !customToolRegistry.has(resolvedName)) {
console.error(`Invalid tool name: ${toolCall.name} (resolved: ${resolvedName})`)
console.error(`Valid tool names:`, toolNames)
return null
@@ -574,7 +578,7 @@ export class NativeToolCallParser {
try {
// Parse the arguments JSON string
- const args = JSON.parse(toolCall.arguments)
+ const args = toolCall.arguments === "" ? {} : JSON.parse(toolCall.arguments)
// Build legacy params object for backward compatibility with XML protocol and UI.
// Native execution path uses nativeArgs instead, which has proper typing.
@@ -589,7 +593,7 @@ export class NativeToolCallParser {
}
// Validate parameter name
- if (!toolParamNames.includes(key as ToolParamName)) {
+ if (!toolParamNames.includes(key as ToolParamName) && !customToolRegistry.has(resolvedName)) {
console.warn(`Unknown parameter '${key}' for tool '${resolvedName}'`)
console.warn(`Valid param names:`, toolParamNames)
continue
@@ -786,6 +790,12 @@ export class NativeToolCallParser {
break
default:
+ if (customToolRegistry.has(resolvedName)) {
+ nativeArgs = args as NativeArgsFor
+ } else {
+ console.error(`Unhandled tool: ${resolvedName}`)
+ }
+
break
}
diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts
index 2e8b791b349..d23120ee73d 100644
--- a/src/core/assistant-message/presentAssistantMessage.ts
+++ b/src/core/assistant-message/presentAssistantMessage.ts
@@ -4,6 +4,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
+import { customToolRegistry } from "@roo-code/core"
import { t } from "../../i18n"
@@ -1045,9 +1046,8 @@ export async function presentAssistantMessage(cline: Task) {
})
break
default: {
- // Handle unknown/invalid tool names
+ // Handle unknown/invalid tool names OR custom tools
// This is critical for native protocol where every tool_use MUST have a tool_result
- // Note: This case should rarely be reached since validateToolUse now checks for unknown tools
// CRITICAL: Don't process partial blocks for unknown tools - just let them stream in.
// If we try to show errors for partial blocks, we'd show the error on every streaming chunk,
@@ -1056,6 +1056,45 @@ export async function presentAssistantMessage(cline: Task) {
break
}
+ const customTool = customToolRegistry.get(block.name)
+
+ if (customTool) {
+ try {
+ console.log(`executing customTool -> ${JSON.stringify(customTool, null, 2)}`)
+ let customToolArgs
+
+ if (customTool.parameters) {
+ try {
+ customToolArgs = customTool.parameters.parse(block.nativeArgs || block.params || {})
+ console.log(`customToolArgs -> ${JSON.stringify(customToolArgs, null, 2)}`)
+ } catch (parseParamsError) {
+ const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}`
+ console.error(message)
+ cline.consecutiveMistakeCount++
+ await cline.say("error", message)
+ pushToolResult(formatResponse.toolError(message, toolProtocol))
+ break
+ }
+ }
+
+ console.log(`${customTool.name}.execute() -> ${JSON.stringify(customToolArgs, null, 2)}`)
+
+ const result = await customTool.execute(customToolArgs, {
+ mode: mode ?? defaultModeSlug,
+ task: cline,
+ })
+
+ pushToolResult(result)
+ cline.consecutiveMistakeCount = 0
+ } catch (executionError: any) {
+ cline.consecutiveMistakeCount++
+ await handleError(`executing custom tool "${block.name}"`, executionError)
+ }
+
+ break
+ }
+
+ // Not a custom tool - handle as unknown tool error
const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.`
cline.consecutiveMistakeCount++
cline.recordToolError(block.name as ToolName, errorMessage)
diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts
index 75b28bbb213..fe2d98504ce 100644
--- a/src/core/prompts/system.ts
+++ b/src/core/prompts/system.ts
@@ -1,9 +1,15 @@
import * as vscode from "vscode"
import * as os from "os"
-import type { ModeConfig, PromptComponent, CustomModePrompts, TodoItem } from "@roo-code/types"
-
-import type { SystemPromptSettings } from "./types"
+import {
+ type ModeConfig,
+ type PromptComponent,
+ type CustomModePrompts,
+ type TodoItem,
+ getEffectiveProtocol,
+ isNativeProtocol,
+} from "@roo-code/types"
+import { customToolRegistry, formatXml } from "@roo-code/core"
import { Mode, modes, defaultModeSlug, getModeBySlug, getGroupName, getModeSelection } from "../../shared/modes"
import { DiffStrategy } from "../../shared/tools"
@@ -15,8 +21,8 @@ import { CodeIndexManager } from "../../services/code-index/manager"
import { PromptVariables, loadSystemPromptFile } from "./sections/custom-system-prompt"
+import type { SystemPromptSettings } from "./types"
import { getToolDescriptionsForMode } from "./tools"
-import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types"
import {
getRulesSection,
getSystemInfoSection,
@@ -98,7 +104,7 @@ async function generatePrompt(
])
// Build tools catalog section only for XML protocol
- const toolsCatalog = isNativeProtocol(effectiveProtocol)
+ const builtInToolsCatalog = isNativeProtocol(effectiveProtocol)
? ""
: `\n\n${getToolDescriptionsForMode(
mode,
@@ -116,6 +122,18 @@ async function generatePrompt(
modelId,
)}`
+ let customToolsSection = ""
+
+ if (!isNativeProtocol(effectiveProtocol)) {
+ const customTools = customToolRegistry.getAllSerialized()
+
+ if (customTools.length > 0) {
+ customToolsSection = `\n\n${formatXml(customTools)}`
+ }
+ }
+
+ const toolsCatalog = builtInToolsCatalog + customToolsSection
+
const basePrompt = `${roleDefinition}
${markdownFormattingSection()}
diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts
index 575b31580e6..b7861be3270 100644
--- a/src/core/task/build-tools.ts
+++ b/src/core/task/build-tools.ts
@@ -1,6 +1,12 @@
+import path from "path"
+
import type OpenAI from "openai"
+
import type { ProviderSettings, ModeConfig, ModelInfo } from "@roo-code/types"
+import { customToolRegistry, formatNative } from "@roo-code/core"
+
import type { ClineProvider } from "../webview/ClineProvider"
+
import { getNativeTools, getMcpServerTools } from "../prompts/tools/native-tools"
import { filterNativeToolsForMode, filterMcpToolsForMode } from "../prompts/tools/filter-tools-for-mode"
@@ -40,11 +46,11 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise
const mcpHub = provider.getMcpHub()
- // Get CodeIndexManager for feature checking
+ // Get CodeIndexManager for feature checking.
const { CodeIndexManager } = await import("../../services/code-index/manager")
const codeIndexManager = CodeIndexManager.getInstance(provider.context, cwd)
- // Build settings object for tool filtering
+ // Build settings object for tool filtering.
const filterSettings = {
todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
browserToolEnabled: browserToolEnabled ?? true,
@@ -52,13 +58,13 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise
diffEnabled,
}
- // Determine if partial reads are enabled based on maxReadFileLine setting
+ // Determine if partial reads are enabled based on maxReadFileLine setting.
const partialReadsEnabled = maxReadFileLine !== -1
- // Build native tools with dynamic read_file tool based on partialReadsEnabled
+ // Build native tools with dynamic read_file tool based on partialReadsEnabled.
const nativeTools = getNativeTools(partialReadsEnabled)
- // Filter native tools based on mode restrictions
+ // Filter native tools based on mode restrictions.
const filteredNativeTools = filterNativeToolsForMode(
nativeTools,
mode,
@@ -69,9 +75,18 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise
mcpHub,
)
- // Filter MCP tools based on mode restrictions
+ // Filter MCP tools based on mode restrictions.
const mcpTools = getMcpServerTools(mcpHub)
const filteredMcpTools = filterMcpToolsForMode(mcpTools, mode, customModes, experiments)
- return [...filteredNativeTools, ...filteredMcpTools]
+ // Add custom tools if they are available.
+ await customToolRegistry.loadFromDirectoryIfStale(path.join(cwd, ".roo", "tools"))
+ const customTools = customToolRegistry.getAllSerialized()
+ let nativeCustomTools: OpenAI.Chat.ChatCompletionFunctionTool[] = []
+
+ if (customTools.length > 0) {
+ nativeCustomTools = customTools.map(formatNative)
+ }
+
+ return [...filteredNativeTools, ...filteredMcpTools, ...nativeCustomTools]
}
diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts
index d0570337414..de814f0b3c9 100644
--- a/src/core/tools/validateToolUse.ts
+++ b/src/core/tools/validateToolUse.ts
@@ -1,5 +1,6 @@
import type { ToolName, ModeConfig, ExperimentId, GroupOptions, GroupEntry } from "@roo-code/types"
import { toolNames as validToolNames } from "@roo-code/types"
+import { customToolRegistry } from "@roo-code/core"
import { type Mode, FileRestrictionError, getModeBySlug, getGroupName } from "../../shared/modes"
import { EXPERIMENT_IDS } from "../../shared/experiments"
@@ -16,6 +17,10 @@ export function isValidToolName(toolName: string): toolName is ToolName {
return true
}
+ if (customToolRegistry.has(toolName)) {
+ return true
+ }
+
// Check if it's a dynamic MCP tool (mcp_serverName_toolName format).
if (toolName.startsWith("mcp_")) {
return true
@@ -87,6 +92,12 @@ export function isToolAllowedForMode(
return true
}
+ // For now, allow all custom tools in any mode.
+ // As a follow-up we should expand the custom tool definition to include mode restrictions.
+ if (customToolRegistry.has(tool)) {
+ return true
+ }
+
// Check if this is a dynamic MCP tool (mcp_serverName_toolName)
// These should be allowed if the mcp group is allowed for the mode
const isDynamicMcpTool = tool.startsWith("mcp_")
diff --git a/src/esbuild.mjs b/src/esbuild.mjs
index f99b077e9f9..68298eb3de4 100644
--- a/src/esbuild.mjs
+++ b/src/esbuild.mjs
@@ -15,7 +15,7 @@ async function main() {
const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch")
const minify = production
- const sourcemap = true // Always generate source maps for error handling
+ const sourcemap = true // Always generate source maps for error handling.
/**
* @type {import('esbuild').BuildOptions}
@@ -100,7 +100,7 @@ async function main() {
plugins,
entryPoints: ["extension.ts"],
outfile: "dist/extension.js",
- external: ["vscode"],
+ external: ["vscode", "esbuild"],
}
/**
diff --git a/src/package.json b/src/package.json
index eb27df9a977..e5884238d2b 100644
--- a/src/package.json
+++ b/src/package.json
@@ -446,6 +446,7 @@
"@roo-code/telemetry": "workspace:^",
"@roo-code/types": "workspace:^",
"@vscode/codicons": "^0.0.36",
+ "esbuild": "^0.25.0",
"async-mutex": "^0.5.0",
"axios": "^1.12.0",
"cheerio": "^1.0.0",
@@ -535,7 +536,6 @@
"@types/vscode": "^1.84.0",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "3.3.2",
- "esbuild": "^0.25.0",
"execa": "^9.5.2",
"glob": "^11.1.0",
"mkdirp": "^3.0.1",
diff --git a/src/utils/__tests__/autoImportSettings.spec.ts b/src/utils/__tests__/autoImportSettings.spec.ts
index be0d769670f..f3911571d10 100644
--- a/src/utils/__tests__/autoImportSettings.spec.ts
+++ b/src/utils/__tests__/autoImportSettings.spec.ts
@@ -17,15 +17,33 @@ vi.mock("fs/promises", () => ({
readFile: vi.fn(),
}))
-vi.mock("path", () => ({
- join: vi.fn((...args: string[]) => args.join("/")),
- isAbsolute: vi.fn((p: string) => p.startsWith("/")),
- basename: vi.fn((p: string) => p.split("/").pop() || ""),
-}))
+vi.mock("path", async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ default: {
+ ...actual,
+ join: vi.fn((...args: string[]) => args.join("/")),
+ isAbsolute: vi.fn((p: string) => p.startsWith("/")),
+ basename: vi.fn((p: string) => p.split("/").pop() || ""),
+ },
+ join: vi.fn((...args: string[]) => args.join("/")),
+ isAbsolute: vi.fn((p: string) => p.startsWith("/")),
+ basename: vi.fn((p: string) => p.split("/").pop() || ""),
+ }
+})
-vi.mock("os", () => ({
- homedir: vi.fn(() => "/home/user"),
-}))
+vi.mock("os", async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ default: {
+ ...actual,
+ homedir: vi.fn(() => "/home/user"),
+ },
+ homedir: vi.fn(() => "/home/user"),
+ }
+})
vi.mock("../fs", () => ({
fileExistsAtPath: vi.fn(),
From 2212a3a542f3c99c1948d8dae006cfa02d5ba020 Mon Sep 17 00:00:00 2001
From: cte
Date: Mon, 15 Dec 2025 15:02:30 -0800
Subject: [PATCH 12/18] Minor cleanup
---
.../__tests__/custom-tool-registry.spec.ts | 73 +------------------
.../src/custom-tools/custom-tool-registry.ts | 73 ++++---------------
2 files changed, 15 insertions(+), 131 deletions(-)
diff --git a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
index 848a27f3410..e6dc2a38c71 100644
--- a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
+++ b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
@@ -1,12 +1,7 @@
import path from "path"
import { fileURLToPath } from "url"
-import {
- type CustomToolDefinition,
- type CustomToolContext,
- type TaskLike,
- parametersSchema as z,
-} from "@roo-code/types"
+import { type CustomToolDefinition, parametersSchema as z } from "@roo-code/types"
import { CustomToolRegistry } from "../custom-tool-registry.js"
@@ -14,11 +9,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
const TEST_FIXTURES_DIR = path.join(__dirname, "fixtures")
-const testContext: CustomToolContext = {
- mode: "code",
- task: { taskId: "test-task-id" } as unknown as TaskLike,
-}
-
describe("CustomToolRegistry", () => {
let registry: CustomToolRegistry
@@ -214,65 +204,6 @@ describe("CustomToolRegistry", () => {
})
})
- describe("execute", () => {
- it("should execute a tool with arguments", async () => {
- registry.register({
- name: "greeter",
- description: "Greets someone",
- parameters: z.object({ name: z.string() }),
- execute: async (args) => `Hello, ${(args as { name: string }).name}!`,
- })
-
- const result = await registry.execute("greeter", { name: "World" }, testContext)
-
- expect(result).toBe("Hello, World!")
- })
-
- it("should throw for non-existent tool", async () => {
- await expect(registry.execute("nonexistent", {}, testContext)).rejects.toThrow(
- "Tool not found: nonexistent",
- )
- })
-
- it("should validate arguments against Zod schema", async () => {
- registry.register({
- name: "typed_tool",
- description: "Tool with validation",
- parameters: z.object({
- count: z.number().min(0),
- }),
- execute: async (args) => `Count: ${(args as { count: number }).count}`,
- })
-
- // Valid args.
- const result = await registry.execute("typed_tool", { count: 5 }, testContext)
- expect(result).toBe("Count: 5")
-
- // Invalid args - negative number.
- await expect(registry.execute("typed_tool", { count: -1 }, testContext)).rejects.toThrow()
-
- // Invalid args - wrong type.
- await expect(registry.execute("typed_tool", { count: "five" }, testContext)).rejects.toThrow()
- })
-
- it("should pass context to execute function", async () => {
- let receivedContext: CustomToolContext | null = null
-
- registry.register({
- name: "context_checker",
- description: "Checks context",
- execute: async (_args, ctx) => {
- receivedContext = ctx
- return "done"
- },
- })
-
- await registry.execute("context_checker", {}, testContext)
-
- expect(receivedContext).toEqual(testContext)
- })
- })
-
describe("clear", () => {
it("should remove all registered tools", () => {
registry.register({ name: "tool1", description: "1", execute: async () => "1" })
@@ -321,7 +252,7 @@ describe("CustomToolRegistry", () => {
const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR)
expect(result.loaded).toContain("mixed_validTool")
- // The non-tool exports should not appear in loaded or failed
+ // The non-tool exports should not appear in loaded or failed.
expect(result.loaded).not.toContain("mixed_someString")
expect(result.loaded).not.toContain("mixed_someNumber")
expect(result.loaded).not.toContain("mixed_someObject")
diff --git a/packages/core/src/custom-tools/custom-tool-registry.ts b/packages/core/src/custom-tools/custom-tool-registry.ts
index fa976abd40f..aadc42cbda1 100644
--- a/packages/core/src/custom-tools/custom-tool-registry.ts
+++ b/packages/core/src/custom-tools/custom-tool-registry.ts
@@ -15,29 +15,10 @@ import os from "os"
import { build } from "esbuild"
-import type {
- CustomToolDefinition,
- SerializedCustomToolDefinition,
- CustomToolParametersSchema,
- CustomToolContext,
-} from "@roo-code/types"
+import type { CustomToolDefinition, SerializedCustomToolDefinition, CustomToolParametersSchema } from "@roo-code/types"
import { serializeCustomTool } from "./serialize.js"
-/**
- * Default subdirectory name for custom tools within a .roo directory.
- * Tools placed in `{rooDir}/tools/` will be automatically discovered and loaded.
- *
- * @example
- * ```ts
- * // Typical usage with getRooDirectoriesForCwd from roo-config:
- * for (const rooDir of getRooDirectoriesForCwd(cwd)) {
- * await registry.loadFromDirectory(path.join(rooDir, TOOLS_DIR_NAME))
- * }
- * ```
- */
-export const TOOLS_DIR_NAME = "tools"
-
export interface LoadResult {
loaded: string[]
failed: Array<{ file: string; error: string }>
@@ -69,18 +50,6 @@ export class CustomToolRegistry {
*
* @param toolDir - Absolute path to the tools directory
* @returns LoadResult with lists of loaded and failed tools
- *
- * @example
- * ```ts
- * // Load tools from multiple .roo directories (global and project):
- * import { getRooDirectoriesForCwd } from "../services/roo-config"
- * import { CustomToolRegistry, TOOLS_DIR_NAME } from "@roo-code/core"
- *
- * const registry = new CustomToolRegistry()
- * for (const rooDir of getRooDirectoriesForCwd(cwd)) {
- * await registry.loadFromDirectory(path.join(rooDir, TOOLS_DIR_NAME))
- * }
- * ```
*/
async loadFromDirectory(toolDir: string): Promise {
const result: LoadResult = { loaded: [], failed: [] }
@@ -97,10 +66,10 @@ export class CustomToolRegistry {
try {
console.log(`[CustomToolRegistry] importing tool from ${filePath}`)
- const mod = await this.importTypeScript(filePath)
+ const mod = await this.import(filePath)
for (const [exportName, value] of Object.entries(mod)) {
- const def = this.validateToolDefinition(exportName, value)
+ const def = this.validate(exportName, value)
if (!def) {
continue
@@ -112,7 +81,7 @@ export class CustomToolRegistry {
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
- console.error(`[CustomToolRegistry] importTypeScript(${filePath}) failed: ${message}`)
+ console.error(`[CustomToolRegistry] import(${filePath}) failed: ${message}`)
result.failed.push({ file, error: message })
}
}
@@ -125,7 +94,6 @@ export class CustomToolRegistry {
}
async loadFromDirectoryIfStale(toolDir: string): Promise {
- // Return empty result if directory doesn't exist
if (!fs.existsSync(toolDir)) {
return { loaded: [], failed: [] }
}
@@ -147,7 +115,7 @@ export class CustomToolRegistry {
*/
register(definition: CustomToolDefinition): void {
const { name: id } = definition
- const validated = this.validateToolDefinition(id, definition)
+ const validated = this.validate(id, definition)
if (!validated) {
throw new Error(`Invalid tool definition for '${id}'`)
@@ -191,6 +159,9 @@ export class CustomToolRegistry {
return Array.from(this.tools.values())
}
+ /**
+ * Get all registered tools in the serialized format.
+ */
getAllSerialized(): SerializedCustomToolDefinition[] {
return this.getAll().map(serializeCustomTool)
}
@@ -202,24 +173,6 @@ export class CustomToolRegistry {
return this.tools.size
}
- /**
- * Execute a tool with given arguments.
- */
- async execute(toolId: string, args: unknown, context: CustomToolContext): Promise {
- const tool = this.tools.get(toolId)
-
- if (!tool) {
- throw new Error(`Tool not found: ${toolId}`)
- }
-
- // Validate args against schema if available.
- if (tool.parameters && "parse" in tool.parameters) {
- ;(tool.parameters as { parse: (args: unknown) => void }).parse(args)
- }
-
- return tool.execute(args, context)
- }
-
/**
* Clear all registered tools.
*/
@@ -233,7 +186,6 @@ export class CustomToolRegistry {
clearCache(): void {
this.tsCache.clear()
- // Also remove compiled files from disk
if (fs.existsSync(this.cacheDir)) {
try {
const files = fs.readdirSync(this.cacheDir)
@@ -243,8 +195,9 @@ export class CustomToolRegistry {
}
}
} catch (error) {
- // Silently ignore cleanup errors
- console.error(`[CustomToolRegistry] clearCache failed to clean disk cache: ${error}`)
+ console.error(
+ `[CustomToolRegistry] clearCache failed to clean disk cache: ${error instanceof Error ? error.message : String(error)}`,
+ )
}
}
}
@@ -253,7 +206,7 @@ export class CustomToolRegistry {
* Dynamically import a TypeScript or JavaScript file.
* TypeScript files are transpiled on-the-fly using esbuild.
*/
- private async importTypeScript(filePath: string): Promise> {
+ private async import(filePath: string): Promise> {
const absolutePath = path.resolve(filePath)
const ext = path.extname(absolutePath)
@@ -311,7 +264,7 @@ export class CustomToolRegistry {
* Validate a tool definition and return a typed result.
* Returns null for non-tool exports, throws for invalid tools.
*/
- private validateToolDefinition(exportName: string, value: unknown): CustomToolDefinition | null {
+ private validate(exportName: string, value: unknown): CustomToolDefinition | null {
// Quick pre-check to filter out non-objects.
if (!value || typeof value !== "object") {
return null
From 2aff8f80d57b13ebf99709e1bd3a4c5d60f2288f Mon Sep 17 00:00:00 2001
From: cte
Date: Mon, 15 Dec 2025 15:08:37 -0800
Subject: [PATCH 13/18] More cleanup
---
.roo/tools/__tests__/system-time.spec.ts | 36 -----------
.roo/tools/system-time.ts | 37 +++---------
.../__snapshots__/format-native.spec.ts.snap | 59 -------------------
.../__tests__/fixtures/system-time.ts | 29 ---------
.../__tests__/format-native.spec.ts | 8 ---
5 files changed, 8 insertions(+), 161 deletions(-)
delete mode 100644 packages/core/src/custom-tools/__tests__/fixtures/system-time.ts
diff --git a/.roo/tools/__tests__/system-time.spec.ts b/.roo/tools/__tests__/system-time.spec.ts
index 8430400bb43..375bc82e718 100644
--- a/.roo/tools/__tests__/system-time.spec.ts
+++ b/.roo/tools/__tests__/system-time.spec.ts
@@ -8,21 +8,6 @@ const mockContext: CustomToolContext = {
}
describe("system-time tool", () => {
- describe("definition", () => {
- it("should have a description", () => {
- expect(systemTime.description).toBe(
- "Returns the current system date and time in a friendly, human-readable format.",
- )
- })
-
- it("should have optional timezone parameter", () => {
- expect(systemTime.parameters).toBeDefined()
- const shape = systemTime.parameters!.shape
- expect(shape.timezone).toBeDefined()
- expect(shape.timezone.isOptional()).toBe(true)
- })
- })
-
describe("execute", () => {
it("should return a formatted date/time string", async () => {
const result = await systemTime.execute({}, mockContext)
@@ -33,26 +18,5 @@ describe("system-time tool", () => {
)
expect(result).toMatch(/\d{1,2}:\d{2}:\d{2}/)
})
-
- it("should use system timezone when no timezone provided", async () => {
- const result = await systemTime.execute({}, mockContext)
- expect(result).toMatch(/[A-Z]{2,5}$/)
- })
-
- it("should format with specified timezone", async () => {
- const result = await systemTime.execute({ timezone: "UTC" }, mockContext)
- expect(result).toMatch(/^The current date and time is:/)
- expect(result).toMatch(/UTC/)
- })
-
- it("should work with different timezone formats", async () => {
- const result = await systemTime.execute({ timezone: "America/New_York" }, mockContext)
- expect(result).toMatch(/^The current date and time is:/)
- expect(result).toMatch(/(EST|EDT)/)
- })
-
- it("should throw error for invalid timezone", async () => {
- await expect(systemTime.execute({ timezone: "Invalid/Timezone" }, mockContext)).rejects.toThrow()
- })
})
})
diff --git a/.roo/tools/system-time.ts b/.roo/tools/system-time.ts
index f93886402b0..e7c886398c2 100644
--- a/.roo/tools/system-time.ts
+++ b/.roo/tools/system-time.ts
@@ -1,26 +1,11 @@
-import { defineCustomTool, parametersSchema } from "@roo-code/types"
+import { parametersSchema, defineCustomTool } from "@roo-code/types"
-/**
- * A simple custom tool that returns the current date and time in a friendly format.
- *
- * To create your own custom tools:
- * 1. Install @roo-code/types: npm install @roo-code/types
- * 2. Create a .ts file in .roo/tools/
- * 3. Export a default tool definition using defineCustomTool()
- *
- * Note that `parametersSchema` is just an alias for `z` (from zod).
- */
export default defineCustomTool({
- name: "system-time",
+ name: "system_time",
description: "Returns the current system date and time in a friendly, human-readable format.",
- parameters: parametersSchema.object({
- timezone: parametersSchema
- .string()
- .optional()
- .describe("Optional timezone to display the time in (e.g., 'America/New_York', 'Europe/London')"),
- }),
- async execute(args) {
- const options: Intl.DateTimeFormatOptions = {
+ parameters: parametersSchema.object({}),
+ async execute() {
+ const systemTime = new Date().toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
@@ -29,15 +14,9 @@ export default defineCustomTool({
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
- }
+ timeZone: "America/Los_Angeles",
+ })
- if (args.timezone) {
- options.timeZone = args.timezone
- }
-
- const now = new Date()
- const formatted = now.toLocaleString("en-US", options)
-
- return `The current date and time is: ${formatted}`
+ return `The current date and time is: ${systemTime}`
},
})
diff --git a/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap b/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap
index 9e9918def94..d734e60baf9 100644
--- a/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap
+++ b/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap
@@ -82,37 +82,6 @@ exports[`Native Protocol snapshots > should generate correct native definition f
}
`;
-exports[`Native Protocol snapshots > should generate correct native definition for system time tool 1`] = `
-{
- "function": {
- "description": "Returns the current system date and time in a friendly, human-readable format.",
- "name": "system_time",
- "parameters": {
- "additionalProperties": false,
- "properties": {
- "timezone": {
- "anyOf": [
- {
- "type": "string",
- },
- {
- "type": "null",
- },
- ],
- "description": "Timezone to display the time in (e.g., 'America/New_York', 'Europe/London')",
- },
- },
- "required": [
- "timezone",
- ],
- "type": "object",
- },
- "strict": true,
- },
- "type": "function",
-}
-`;
-
exports[`Native Protocol snapshots > should generate correct native definitions for all fixtures combined 1`] = `
[
{
@@ -213,34 +182,6 @@ exports[`Native Protocol snapshots > should generate correct native definitions
},
"type": "function",
},
- {
- "function": {
- "description": "Returns the current system date and time in a friendly, human-readable format.",
- "name": "system_time",
- "parameters": {
- "additionalProperties": false,
- "properties": {
- "timezone": {
- "anyOf": [
- {
- "type": "string",
- },
- {
- "type": "null",
- },
- ],
- "description": "Timezone to display the time in (e.g., 'America/New_York', 'Europe/London')",
- },
- },
- "required": [
- "timezone",
- ],
- "type": "object",
- },
- "strict": true,
- },
- "type": "function",
- },
]
`;
diff --git a/packages/core/src/custom-tools/__tests__/fixtures/system-time.ts b/packages/core/src/custom-tools/__tests__/fixtures/system-time.ts
deleted file mode 100644
index 3f052af8555..00000000000
--- a/packages/core/src/custom-tools/__tests__/fixtures/system-time.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { parametersSchema, defineCustomTool } from "@roo-code/types"
-
-export default defineCustomTool({
- name: "system_time",
- description: "Returns the current system date and time in a friendly, human-readable format.",
- parameters: parametersSchema.object({
- timezone: parametersSchema
- .string()
- .nullable()
- .describe("Timezone to display the time in (e.g., 'America/New_York', 'Europe/London')"),
- }),
- async execute({ timezone }) {
- const now = new Date()
-
- const formatted = now.toLocaleString("en-US", {
- weekday: "long",
- year: "numeric",
- month: "long",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- timeZoneName: "short",
- timeZone: timezone ?? undefined,
- })
-
- return `The current date and time is: ${formatted}`
- },
-})
diff --git a/packages/core/src/custom-tools/__tests__/format-native.spec.ts b/packages/core/src/custom-tools/__tests__/format-native.spec.ts
index ee88de2b89f..33b909623e3 100644
--- a/packages/core/src/custom-tools/__tests__/format-native.spec.ts
+++ b/packages/core/src/custom-tools/__tests__/format-native.spec.ts
@@ -10,7 +10,6 @@ import cachedTool from "./fixtures/cached.js"
import legacyTool from "./fixtures/legacy.js"
import { toolA, toolB } from "./fixtures/multi.js"
import { validTool as mixedValidTool } from "./fixtures/mixed.js"
-import systemTimeTool from "./fixtures/system-time.js"
const fixtureTools = {
simple: simpleTool,
@@ -19,7 +18,6 @@ const fixtureTools = {
multi_toolA: toolA,
multi_toolB: toolB,
mixed_validTool: mixedValidTool,
- systemTime: systemTimeTool,
}
describe("formatNative", () => {
@@ -244,12 +242,6 @@ describe("Native Protocol snapshots", () => {
expect(result).toMatchSnapshot()
})
- it("should generate correct native definition for system time tool", () => {
- const serialized = serializeCustomTool(fixtureTools.systemTime)
- const result = formatNative(serialized)
- expect(result).toMatchSnapshot()
- })
-
it("should generate correct native definitions for all fixtures combined", () => {
const allSerialized = Object.values(fixtureTools).map(serializeCustomTool)
const result = allSerialized.map(formatNative)
From b45c26efd62d6aeb6cb90a27777b3e581a548935 Mon Sep 17 00:00:00 2001
From: cte
Date: Mon, 15 Dec 2025 15:09:50 -0800
Subject: [PATCH 14/18] More cleanup
---
packages/types/src/custom-tool.ts | 23 -----------------------
1 file changed, 23 deletions(-)
diff --git a/packages/types/src/custom-tool.ts b/packages/types/src/custom-tool.ts
index 63cd6efa4ed..2ad40c4d234 100644
--- a/packages/types/src/custom-tool.ts
+++ b/packages/types/src/custom-tool.ts
@@ -1,26 +1,3 @@
-/**
- * Custom Tool Definition Utilities
- *
- * This module provides utilities for defining custom tools that can be
- * loaded by the Roo Code extension. Install @roo-code/types in your
- * project to use these utilities.
- *
- * @example
- * ```ts
- * import { z, defineCustomTool } from "@roo-code/types"
- *
- * export default defineCustomTool({
- * description: "Greets a user by name",
- * parameters: z.object({
- * name: z.string().describe("The name to greet"),
- * }),
- * async execute(args) {
- * return `Hello, ${args.name}!`
- * }
- * })
- * ```
- */
-
import type { ZodType, z } from "zod/v4"
import { TaskLike } from "./task.js"
From 18acb96251ee7c44913cceb529b908fe582dcd30 Mon Sep 17 00:00:00 2001
From: Chris Estreich
Date: Sat, 20 Dec 2025 20:42:44 -0800
Subject: [PATCH 15/18] Fix custom tool transpilation and bundling (#10242)
---
packages/build/src/esbuild.ts | 52 ++++++
packages/core/package.json | 1 +
.../__tests__/esbuild-runner.spec.ts | 156 ++++++++++++++++
.../src/custom-tools/custom-tool-registry.ts | 50 +++--
.../core/src/custom-tools/esbuild-runner.ts | 175 ++++++++++++++++++
pnpm-lock.yaml | 16 +-
src/extension.ts | 4 +
src/package.json | 2 +-
8 files changed, 437 insertions(+), 19 deletions(-)
create mode 100644 packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts
create mode 100644 packages/core/src/custom-tools/esbuild-runner.ts
diff --git a/packages/build/src/esbuild.ts b/packages/build/src/esbuild.ts
index 3b793c2cc93..952e823eeca 100644
--- a/packages/build/src/esbuild.ts
+++ b/packages/build/src/esbuild.ts
@@ -158,6 +158,58 @@ export function copyWasms(srcDir: string, distDir: string): void {
})
console.log(`[copyWasms] Copied ${wasmFiles.length} tree-sitter language wasms to ${distDir}`)
+
+ // Copy esbuild-wasm files for custom tool transpilation (cross-platform).
+ copyEsbuildWasmFiles(nodeModulesDir, distDir)
+}
+
+/**
+ * Copy esbuild-wasm files to the dist/bin directory.
+ *
+ * This function copies the esbuild-wasm CLI and WASM binary, which provides
+ * a cross-platform esbuild implementation that works on all platforms.
+ *
+ * Files copied:
+ * - bin/esbuild (Node.js CLI script)
+ * - esbuild.wasm (WASM binary)
+ * - wasm_exec_node.js (Go WASM runtime for Node.js)
+ * - wasm_exec.js (Go WASM runtime dependency)
+ */
+function copyEsbuildWasmFiles(nodeModulesDir: string, distDir: string): void {
+ const esbuildWasmDir = path.join(nodeModulesDir, "esbuild-wasm")
+
+ if (!fs.existsSync(esbuildWasmDir)) {
+ throw new Error(`Directory does not exist: ${esbuildWasmDir}`)
+ }
+
+ // Create bin directory in dist.
+ const binDir = path.join(distDir, "bin")
+ fs.mkdirSync(binDir, { recursive: true })
+
+ // Files to copy - the esbuild CLI script expects wasm_exec_node.js and esbuild.wasm
+ // to be one directory level up from the bin directory (i.e., in distDir directly).
+ // wasm_exec_node.js requires wasm_exec.js, so we need to copy that too.
+ const filesToCopy = [
+ { src: path.join(esbuildWasmDir, "bin", "esbuild"), dest: path.join(binDir, "esbuild") },
+ { src: path.join(esbuildWasmDir, "esbuild.wasm"), dest: path.join(distDir, "esbuild.wasm") },
+ { src: path.join(esbuildWasmDir, "wasm_exec_node.js"), dest: path.join(distDir, "wasm_exec_node.js") },
+ { src: path.join(esbuildWasmDir, "wasm_exec.js"), dest: path.join(distDir, "wasm_exec.js") },
+ ]
+
+ for (const { src, dest } of filesToCopy) {
+ fs.copyFileSync(src, dest)
+
+ // Make CLI executable.
+ if (src.endsWith("esbuild")) {
+ try {
+ fs.chmodSync(dest, 0o755)
+ } catch {
+ // Ignore chmod errors on Windows.
+ }
+ }
+ }
+
+ console.log(`[copyWasms] Copied ${filesToCopy.length} esbuild-wasm files to ${distDir}`)
}
export function copyLocales(srcDir: string, distDir: string): void {
diff --git a/packages/core/package.json b/packages/core/package.json
index 99f1b26fb03..5151d88d951 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -13,6 +13,7 @@
"dependencies": {
"@roo-code/types": "workspace:^",
"esbuild": "^0.25.0",
+ "execa": "^9.5.2",
"openai": "^5.12.2",
"zod": "^3.25.61"
},
diff --git a/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts b/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts
new file mode 100644
index 00000000000..78581fc7c58
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts
@@ -0,0 +1,156 @@
+import fs from "fs"
+import os from "os"
+import path from "path"
+
+import { getEsbuildScriptPath, runEsbuild } from "../esbuild-runner.js"
+
+describe("getEsbuildScriptPath", () => {
+ it("should find esbuild-wasm script in node_modules in development", () => {
+ const scriptPath = getEsbuildScriptPath()
+
+ // Should find the script.
+ expect(typeof scriptPath).toBe("string")
+ expect(scriptPath.length).toBeGreaterThan(0)
+
+ // The script should exist.
+ expect(fs.existsSync(scriptPath)).toBe(true)
+
+ // Should be the esbuild script (not a binary).
+ expect(scriptPath).toMatch(/esbuild$/)
+ })
+
+ it("should prefer production path when extensionPath is provided and script exists", () => {
+ // Create a temporary directory with a fake script.
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-"))
+ const binDir = path.join(tempDir, "dist", "bin")
+ fs.mkdirSync(binDir, { recursive: true })
+
+ const fakeScriptPath = path.join(binDir, "esbuild")
+ fs.writeFileSync(fakeScriptPath, "#!/usr/bin/env node\nconsole.log('fake esbuild')")
+
+ try {
+ const result = getEsbuildScriptPath(tempDir)
+ expect(result).toBe(fakeScriptPath)
+ } finally {
+ fs.rmSync(tempDir, { recursive: true, force: true })
+ }
+ })
+
+ it("should fall back to node_modules when production script does not exist", () => {
+ // Pass a non-existent extension path.
+ const result = getEsbuildScriptPath("/nonexistent/extension/path")
+
+ // Should fall back to development path.
+ expect(typeof result).toBe("string")
+ expect(result.length).toBeGreaterThan(0)
+ expect(fs.existsSync(result)).toBe(true)
+ })
+})
+
+describe("runEsbuild", () => {
+ let tempDir: string
+
+ beforeEach(() => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-"))
+ })
+
+ afterEach(() => {
+ fs.rmSync(tempDir, { recursive: true, force: true })
+ })
+
+ it("should compile a TypeScript file to ESM", async () => {
+ // Create a simple TypeScript file.
+ const inputFile = path.join(tempDir, "input.ts")
+ const outputFile = path.join(tempDir, "output.mjs")
+
+ fs.writeFileSync(
+ inputFile,
+ `
+ export const greeting = "Hello, World!"
+ export function add(a: number, b: number): number {
+ return a + b
+ }
+ `,
+ )
+
+ await runEsbuild({
+ entryPoint: inputFile,
+ outfile: outputFile,
+ format: "esm",
+ platform: "node",
+ target: "node18",
+ bundle: true,
+ })
+
+ // Verify output file exists.
+ expect(fs.existsSync(outputFile)).toBe(true)
+
+ // Verify output content is valid JavaScript.
+ const outputContent = fs.readFileSync(outputFile, "utf-8")
+ expect(outputContent).toContain("Hello, World!")
+ expect(outputContent).toContain("add")
+ }, 30000)
+
+ it("should generate inline source maps when specified", async () => {
+ const inputFile = path.join(tempDir, "input.ts")
+ const outputFile = path.join(tempDir, "output.mjs")
+
+ fs.writeFileSync(inputFile, `export const value = 42`)
+
+ await runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm", sourcemap: "inline" })
+
+ const outputContent = fs.readFileSync(outputFile, "utf-8")
+ expect(outputContent).toContain("sourceMappingURL=data:")
+ }, 30000)
+
+ it("should throw an error for invalid TypeScript", async () => {
+ const inputFile = path.join(tempDir, "invalid.ts")
+ const outputFile = path.join(tempDir, "output.mjs")
+
+ // Write syntactically invalid TypeScript.
+ fs.writeFileSync(inputFile, `export const value = {{{ invalid syntax`)
+
+ await expect(runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm" })).rejects.toThrow()
+ }, 30000)
+
+ it("should throw an error for non-existent file", async () => {
+ const nonExistentFile = path.join(tempDir, "does-not-exist.ts")
+ const outputFile = path.join(tempDir, "output.mjs")
+
+ await expect(runEsbuild({ entryPoint: nonExistentFile, outfile: outputFile, format: "esm" })).rejects.toThrow()
+ }, 30000)
+
+ it("should bundle dependencies when bundle option is true", async () => {
+ // Create two files where one imports the other.
+ const libFile = path.join(tempDir, "lib.ts")
+ const mainFile = path.join(tempDir, "main.ts")
+ const outputFile = path.join(tempDir, "output.mjs")
+
+ fs.writeFileSync(libFile, `export const PI = 3.14159`)
+ fs.writeFileSync(
+ mainFile,
+ `
+ import { PI } from "./lib.js"
+ export const circumference = (r: number) => 2 * PI * r
+ `,
+ )
+
+ await runEsbuild({ entryPoint: mainFile, outfile: outputFile, format: "esm", bundle: true })
+
+ const outputContent = fs.readFileSync(outputFile, "utf-8")
+ // The PI constant should be bundled inline.
+ expect(outputContent).toContain("3.14159")
+ }, 30000)
+
+ it("should respect platform option", async () => {
+ const inputFile = path.join(tempDir, "input.ts")
+ const outputFile = path.join(tempDir, "output.mjs")
+
+ fs.writeFileSync(inputFile, `export const value = process.env.NODE_ENV`)
+
+ await runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm", platform: "node" })
+
+ // File should be created successfully.
+ expect(fs.existsSync(outputFile)).toBe(true)
+ }, 30000)
+})
diff --git a/packages/core/src/custom-tools/custom-tool-registry.ts b/packages/core/src/custom-tools/custom-tool-registry.ts
index aadc42cbda1..4ef88f50ec6 100644
--- a/packages/core/src/custom-tools/custom-tool-registry.ts
+++ b/packages/core/src/custom-tools/custom-tool-registry.ts
@@ -13,11 +13,10 @@ import path from "path"
import { createHash } from "crypto"
import os from "os"
-import { build } from "esbuild"
-
import type { CustomToolDefinition, SerializedCustomToolDefinition, CustomToolParametersSchema } from "@roo-code/types"
import { serializeCustomTool } from "./serialize.js"
+import { runEsbuild } from "./esbuild-runner.js"
export interface LoadResult {
loaded: string[]
@@ -29,6 +28,8 @@ export interface RegistryOptions {
cacheDir?: string
/** Additional paths for resolving node modules (useful for tools outside node_modules). */
nodePaths?: string[]
+ /** Path to the extension root directory (for finding bundled esbuild binary in production). */
+ extensionPath?: string
}
export class CustomToolRegistry {
@@ -36,12 +37,14 @@ export class CustomToolRegistry {
private tsCache = new Map()
private cacheDir: string
private nodePaths: string[]
+ private extensionPath?: string
private lastLoaded: Map = new Map()
constructor(options?: RegistryOptions) {
this.cacheDir = options?.cacheDir ?? path.join(os.tmpdir(), "dynamic-tools-cache")
// Default to current working directory's node_modules.
this.nodePaths = options?.nodePaths ?? [path.join(process.cwd(), "node_modules")]
+ this.extensionPath = options?.extensionPath
}
/**
@@ -180,6 +183,21 @@ export class CustomToolRegistry {
this.tools.clear()
}
+ /**
+ * Set the extension path for finding bundled esbuild binary.
+ * This should be called with context.extensionPath when the extension activates.
+ */
+ setExtensionPath(extensionPath: string): void {
+ this.extensionPath = extensionPath
+ }
+
+ /**
+ * Get the current extension path.
+ */
+ getExtensionPath(): string | undefined {
+ return this.extensionPath
+ }
+
/**
* Clear the TypeScript compilation cache (both in-memory and on disk).
*/
@@ -229,19 +247,21 @@ export class CustomToolRegistry {
const hash = createHash("sha256").update(cacheKey).digest("hex").slice(0, 16)
const tempFile = path.join(this.cacheDir, `${hash}.mjs`)
- // Bundle the TypeScript file with dependencies.
- await build({
- entryPoints: [absolutePath],
- bundle: true,
- format: "esm",
- platform: "node",
- target: "node18",
- outfile: tempFile,
- sourcemap: "inline",
- packages: "bundle",
- // Include node_modules paths for module resolution.
- nodePaths: this.nodePaths,
- })
+ // Bundle the TypeScript file with dependencies using esbuild CLI.
+ await runEsbuild(
+ {
+ entryPoint: absolutePath,
+ outfile: tempFile,
+ format: "esm",
+ platform: "node",
+ target: "node18",
+ bundle: true,
+ sourcemap: "inline",
+ packages: "bundle",
+ nodePaths: this.nodePaths,
+ },
+ this.extensionPath,
+ )
this.tsCache.set(cacheKey, tempFile)
return import(`file://${tempFile}`)
diff --git a/packages/core/src/custom-tools/esbuild-runner.ts b/packages/core/src/custom-tools/esbuild-runner.ts
new file mode 100644
index 00000000000..41384789212
--- /dev/null
+++ b/packages/core/src/custom-tools/esbuild-runner.ts
@@ -0,0 +1,175 @@
+/**
+ * esbuild-runner - Runs esbuild-wasm CLI to transpile TypeScript files.
+ *
+ * This module provides a way to run esbuild as a CLI process instead of using
+ * the JavaScript API. This uses esbuild-wasm which is cross-platform and works
+ * on all operating systems without needing native binaries.
+ *
+ * In production, the esbuild-wasm CLI script is bundled in dist/bin/.
+ * In development, it falls back to using esbuild-wasm from node_modules.
+ */
+
+import path from "path"
+import fs from "fs"
+import { fileURLToPath } from "url"
+import { execa } from "execa"
+
+// Get the directory where this module is located.
+function getModuleDir(): string | undefined {
+ try {
+ // In ESM context, import.meta.url is available.
+ // In bundled CJS, this will throw or be undefined.
+ if (typeof import.meta !== "undefined" && import.meta.url) {
+ return path.dirname(fileURLToPath(import.meta.url))
+ }
+ } catch {
+ // Ignore errors, fall through to undefined.
+ }
+
+ return undefined
+}
+
+const moduleDir = getModuleDir()
+
+export interface EsbuildOptions {
+ /** Entry point file path (absolute) */
+ entryPoint: string
+ /** Output file path (absolute) */
+ outfile: string
+ /** Output format */
+ format?: "esm" | "cjs" | "iife"
+ /** Target platform */
+ platform?: "node" | "browser" | "neutral"
+ /** Target environment (e.g., "node18") */
+ target?: string
+ /** Bundle dependencies */
+ bundle?: boolean
+ /** Generate source maps */
+ sourcemap?: boolean | "inline" | "external"
+ /** How to handle packages: "bundle" includes them, "external" leaves them */
+ packages?: "bundle" | "external"
+ /** Additional paths for module resolution */
+ nodePaths?: string[]
+}
+
+/**
+ * Find the esbuild-wasm CLI script by walking up the directory tree.
+ * In pnpm monorepos, node_modules/esbuild-wasm is a symlink to the actual package,
+ * so we don't need special pnpm handling.
+ */
+function findEsbuildWasmScript(startDir: string): string | null {
+ const maxDepth = 10
+ let currentDir = path.resolve(startDir)
+ const root = path.parse(currentDir).root
+
+ for (let i = 0; i < maxDepth && currentDir !== root; i++) {
+ // Check node_modules/esbuild-wasm/bin/esbuild at this level.
+ const scriptPath = path.join(currentDir, "node_modules", "esbuild-wasm", "bin", "esbuild")
+
+ if (fs.existsSync(scriptPath)) {
+ return scriptPath
+ }
+
+ // Also check src/node_modules for monorepo where src is a workspace.
+ const srcScriptPath = path.join(currentDir, "src", "node_modules", "esbuild-wasm", "bin", "esbuild")
+
+ if (fs.existsSync(srcScriptPath)) {
+ return srcScriptPath
+ }
+
+ currentDir = path.dirname(currentDir)
+ }
+
+ return null
+}
+
+/**
+ * Get the path to the esbuild CLI script.
+ *
+ * Resolution order:
+ * 1. Production: Look in extension's dist/bin directory for bundled script.
+ * 2. Development: Use esbuild-wasm from node_modules (relative to this module).
+ * 3. Fallback: Try process.cwd() as last resort.
+ *
+ * @param extensionPath - Path to the extension's root directory (production)
+ * @returns Path to the esbuild CLI script
+ */
+export function getEsbuildScriptPath(extensionPath?: string): string {
+ // Production: look in extension's dist/bin directory.
+ if (extensionPath) {
+ const prodPath = path.join(extensionPath, "dist", "bin", "esbuild")
+
+ if (fs.existsSync(prodPath)) {
+ return prodPath
+ }
+ }
+
+ // Development: use esbuild-wasm from node_modules relative to this module.
+ // This works when running the extension in debug mode (if moduleDir is available).
+ if (moduleDir) {
+ const devPath = findEsbuildWasmScript(moduleDir)
+
+ if (devPath) {
+ return devPath
+ }
+ }
+
+ // Fallback: try from cwd (for tests and other contexts).
+ const cwdPath = findEsbuildWasmScript(process.cwd())
+
+ if (cwdPath) {
+ return cwdPath
+ }
+
+ throw new Error("esbuild-wasm CLI not found. Ensure esbuild-wasm is installed.")
+}
+
+/**
+ * Run esbuild CLI to bundle a TypeScript file.
+ *
+ * Uses esbuild-wasm which is cross-platform and runs via Node.js.
+ *
+ * @param options - Build options
+ * @param extensionPath - Path to extension root (for finding bundled script)
+ * @returns Promise that resolves when build completes
+ * @throws Error if the build fails
+ */
+export async function runEsbuild(options: EsbuildOptions, extensionPath?: string): Promise {
+ const scriptPath = getEsbuildScriptPath(extensionPath)
+
+ const args: string[] = [
+ scriptPath,
+ options.entryPoint,
+ `--outfile=${options.outfile}`,
+ `--format=${options.format ?? "esm"}`,
+ `--platform=${options.platform ?? "node"}`,
+ `--target=${options.target ?? "node18"}`,
+ ]
+
+ if (options.bundle !== false) {
+ args.push("--bundle")
+ }
+
+ if (options.sourcemap) {
+ args.push(options.sourcemap === true ? "--sourcemap" : `--sourcemap=${options.sourcemap}`)
+ }
+
+ if (options.packages) {
+ args.push(`--packages=${options.packages}`)
+ }
+
+ // Build environment with NODE_PATH for module resolution.
+ const env: NodeJS.ProcessEnv = { ...process.env }
+
+ if (options.nodePaths && options.nodePaths.length > 0) {
+ env.NODE_PATH = options.nodePaths.join(path.delimiter)
+ }
+
+ try {
+ await execa(process.execPath, args, { env, stdin: "ignore" })
+ } catch (error) {
+ const execaError = error as { stderr?: string; stdout?: string; exitCode?: number; message: string }
+ const errorMessage = execaError.stderr || execaError.stdout || `esbuild exited with code ${execaError.exitCode}`
+ throw new Error(`esbuild failed: ${errorMessage}`)
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 23052bb3f00..66228d425c7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -497,6 +497,9 @@ importers:
esbuild:
specifier: '>=0.25.0'
version: 0.25.9
+ execa:
+ specifier: ^9.5.2
+ version: 9.6.0
openai:
specifier: ^5.12.2
version: 5.12.2(ws@8.18.3)(zod@3.25.76)
@@ -739,9 +742,6 @@ importers:
diff-match-patch:
specifier: ^1.0.5
version: 1.0.5
- esbuild:
- specifier: '>=0.25.0'
- version: 0.25.9
exceljs:
specifier: ^4.4.0
version: 4.4.0
@@ -977,6 +977,9 @@ importers:
'@vscode/vsce':
specifier: 3.3.2
version: 3.3.2
+ esbuild-wasm:
+ specifier: ^0.25.0
+ version: 0.25.12
execa:
specifier: ^9.5.2
version: 9.5.3
@@ -5832,6 +5835,11 @@ packages:
peerDependencies:
esbuild: '>=0.25.0'
+ esbuild-wasm@0.25.12:
+ resolution: {integrity: sha512-rZqkjL3Y6FwLpSHzLnaEy8Ps6veCNo1kZa9EOfJvmWtBq5dJH4iVjfmOO6Mlkv9B0tt9WFPFmb/VxlgJOnueNg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
esbuild@0.25.9:
resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
engines: {node: '>=18'}
@@ -15712,6 +15720,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ esbuild-wasm@0.25.12: {}
+
esbuild@0.25.9:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.9
diff --git a/src/extension.ts b/src/extension.ts
index f601b9d7cff..dcb941fa581 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -15,6 +15,7 @@ try {
import type { CloudUserInfo, AuthState } from "@roo-code/types"
import { CloudService, BridgeOrchestrator } from "@roo-code/cloud"
import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry"
+import { customToolRegistry } from "@roo-code/core"
import "./utils/path" // Necessary to have access to String.prototype.toPosix.
import { createOutputChannelLogger, createDualLogger } from "./utils/outputChannelLogger"
@@ -67,6 +68,9 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(outputChannel)
outputChannel.appendLine(`${Package.name} extension activated - ${JSON.stringify(Package)}`)
+ // Set extension path for custom tool registry to find bundled esbuild
+ customToolRegistry.setExtensionPath(context.extensionPath)
+
// Migrate old settings to new
await migrateSettings(context, outputChannel)
diff --git a/src/package.json b/src/package.json
index 0881b944c19..ed43df811d6 100644
--- a/src/package.json
+++ b/src/package.json
@@ -446,7 +446,6 @@
"@roo-code/telemetry": "workspace:^",
"@roo-code/types": "workspace:^",
"@vscode/codicons": "^0.0.36",
- "esbuild": "^0.25.0",
"async-mutex": "^0.5.0",
"axios": "^1.12.0",
"cheerio": "^1.0.0",
@@ -536,6 +535,7 @@
"@types/vscode": "^1.84.0",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "3.3.2",
+ "esbuild-wasm": "^0.25.0",
"execa": "^9.5.2",
"glob": "^11.1.0",
"mkdirp": "^3.0.1",
From 021b4c5374843f0a651699a10ae3e6ef3c767d12 Mon Sep 17 00:00:00 2001
From: cte
Date: Sat, 20 Dec 2025 21:05:29 -0800
Subject: [PATCH 16/18] Performance optimization
---
.../custom-tools/__tests__/custom-tool-registry.spec.ts | 2 ++
packages/core/src/custom-tools/custom-tool-registry.ts | 8 +++++++-
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
index e6dc2a38c71..27265db3098 100644
--- a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
+++ b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
@@ -1,3 +1,5 @@
+// pnpm --filter @roo-code/core test src/custom-tools/__tests__/custom-tool-registry.spec.ts
+
import path from "path"
import { fileURLToPath } from "url"
diff --git a/packages/core/src/custom-tools/custom-tool-registry.ts b/packages/core/src/custom-tools/custom-tool-registry.ts
index 4ef88f50ec6..a9b2b1e8e12 100644
--- a/packages/core/src/custom-tools/custom-tool-registry.ts
+++ b/packages/core/src/custom-tools/custom-tool-registry.ts
@@ -235,7 +235,7 @@ export class CustomToolRegistry {
const stat = fs.statSync(absolutePath)
const cacheKey = `${absolutePath}:${stat.mtimeMs}`
- // Check if we have a cached version.
+ // Check if we have a cached version in memory.
if (this.tsCache.has(cacheKey)) {
const cachedPath = this.tsCache.get(cacheKey)!
return import(`file://${cachedPath}`)
@@ -247,6 +247,12 @@ export class CustomToolRegistry {
const hash = createHash("sha256").update(cacheKey).digest("hex").slice(0, 16)
const tempFile = path.join(this.cacheDir, `${hash}.mjs`)
+ // Check if we have a cached version on disk (from a previous run/instance).
+ if (fs.existsSync(tempFile)) {
+ this.tsCache.set(cacheKey, tempFile)
+ return import(`file://${tempFile}`)
+ }
+
// Bundle the TypeScript file with dependencies using esbuild CLI.
await runEsbuild(
{
From 58ca3c2586da396464dba7e2ade131eaf4e544ba Mon Sep 17 00:00:00 2001
From: Chris Estreich
Date: Sun, 21 Dec 2025 10:32:08 -0800
Subject: [PATCH 17/18] Custom tools UI (#10244)
Co-authored-by: Matt Rubens
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
---
packages/types/src/experiment.ts | 2 +
.../presentAssistantMessage.ts | 10 +-
src/core/prompts/system.ts | 2 +-
src/core/task/build-tools.ts | 13 +-
src/core/tools/validateToolUse.ts | 8 +-
src/core/webview/webviewMessageHandler.ts | 19 ++
src/shared/ExtensionMessage.ts | 3 +
src/shared/WebviewMessage.ts | 1 +
src/shared/__tests__/experiments.spec.ts | 3 +
src/shared/experiments.ts | 2 +
.../settings/CustomToolsSettings.tsx | 173 ++++++++++++++++++
.../settings/ExperimentalSettings.tsx | 10 +
.../__tests__/ExtensionStateContext.spec.tsx | 2 +
webview-ui/src/i18n/locales/ca/settings.json | 11 ++
webview-ui/src/i18n/locales/de/settings.json | 11 ++
webview-ui/src/i18n/locales/en/settings.json | 11 ++
webview-ui/src/i18n/locales/es/settings.json | 11 ++
webview-ui/src/i18n/locales/fr/settings.json | 11 ++
webview-ui/src/i18n/locales/hi/settings.json | 11 ++
webview-ui/src/i18n/locales/id/settings.json | 11 ++
webview-ui/src/i18n/locales/it/settings.json | 11 ++
webview-ui/src/i18n/locales/ja/settings.json | 11 ++
webview-ui/src/i18n/locales/ko/settings.json | 11 ++
webview-ui/src/i18n/locales/nl/settings.json | 11 ++
webview-ui/src/i18n/locales/pl/settings.json | 11 ++
.../src/i18n/locales/pt-BR/settings.json | 11 ++
webview-ui/src/i18n/locales/ru/settings.json | 11 ++
webview-ui/src/i18n/locales/tr/settings.json | 11 ++
webview-ui/src/i18n/locales/vi/settings.json | 11 ++
.../src/i18n/locales/zh-CN/settings.json | 11 ++
.../src/i18n/locales/zh-TW/settings.json | 11 ++
31 files changed, 431 insertions(+), 15 deletions(-)
create mode 100644 webview-ui/src/components/settings/CustomToolsSettings.tsx
diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts
index cc0aabd6f6b..f6f701a25d3 100644
--- a/packages/types/src/experiment.ts
+++ b/packages/types/src/experiment.ts
@@ -13,6 +13,7 @@ export const experimentIds = [
"imageGeneration",
"runSlashCommand",
"multipleNativeToolCalls",
+ "customTools",
] as const
export const experimentIdsSchema = z.enum(experimentIds)
@@ -30,6 +31,7 @@ export const experimentsSchema = z.object({
imageGeneration: z.boolean().optional(),
runSlashCommand: z.boolean().optional(),
multipleNativeToolCalls: z.boolean().optional(),
+ customTools: z.boolean().optional(),
})
export type Experiments = z.infer
diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts
index 15f298726a4..c1876b8cd08 100644
--- a/src/core/assistant-message/presentAssistantMessage.ts
+++ b/src/core/assistant-message/presentAssistantMessage.ts
@@ -1081,17 +1081,15 @@ export async function presentAssistantMessage(cline: Task) {
break
}
- const customTool = customToolRegistry.get(block.name)
+ const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined
if (customTool) {
try {
- console.log(`executing customTool -> ${JSON.stringify(customTool, null, 2)}`)
let customToolArgs
if (customTool.parameters) {
try {
customToolArgs = customTool.parameters.parse(block.nativeArgs || block.params || {})
- console.log(`customToolArgs -> ${JSON.stringify(customToolArgs, null, 2)}`)
} catch (parseParamsError) {
const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}`
console.error(message)
@@ -1102,13 +1100,15 @@ export async function presentAssistantMessage(cline: Task) {
}
}
- console.log(`${customTool.name}.execute() -> ${JSON.stringify(customToolArgs, null, 2)}`)
-
const result = await customTool.execute(customToolArgs, {
mode: mode ?? defaultModeSlug,
task: cline,
})
+ console.log(
+ `${customTool.name}.execute(): ${JSON.stringify(customToolArgs)} -> ${JSON.stringify(result)}`,
+ )
+
pushToolResult(result)
cline.consecutiveMistakeCount = 0
} catch (executionError: any) {
diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts
index fe2d98504ce..b6543950378 100644
--- a/src/core/prompts/system.ts
+++ b/src/core/prompts/system.ts
@@ -124,7 +124,7 @@ async function generatePrompt(
let customToolsSection = ""
- if (!isNativeProtocol(effectiveProtocol)) {
+ if (experiments?.customTools && !isNativeProtocol(effectiveProtocol)) {
const customTools = customToolRegistry.getAllSerialized()
if (customTools.length > 0) {
diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts
index b7861be3270..b636052341f 100644
--- a/src/core/task/build-tools.ts
+++ b/src/core/task/build-tools.ts
@@ -79,13 +79,16 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise
const mcpTools = getMcpServerTools(mcpHub)
const filteredMcpTools = filterMcpToolsForMode(mcpTools, mode, customModes, experiments)
- // Add custom tools if they are available.
- await customToolRegistry.loadFromDirectoryIfStale(path.join(cwd, ".roo", "tools"))
- const customTools = customToolRegistry.getAllSerialized()
+ // Add custom tools if they are available and the experiment is enabled.
let nativeCustomTools: OpenAI.Chat.ChatCompletionFunctionTool[] = []
- if (customTools.length > 0) {
- nativeCustomTools = customTools.map(formatNative)
+ if (experiments?.customTools) {
+ await customToolRegistry.loadFromDirectoryIfStale(path.join(cwd, ".roo", "tools"))
+ const customTools = customToolRegistry.getAllSerialized()
+
+ if (customTools.length > 0) {
+ nativeCustomTools = customTools.map(formatNative)
+ }
}
return [...filteredNativeTools, ...filteredMcpTools, ...nativeCustomTools]
diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts
index de814f0b3c9..751d164fd26 100644
--- a/src/core/tools/validateToolUse.ts
+++ b/src/core/tools/validateToolUse.ts
@@ -11,13 +11,13 @@ import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } from "../../shared/tools"
* Note: This does NOT check if the tool is allowed for a specific mode,
* only that the tool actually exists.
*/
-export function isValidToolName(toolName: string): toolName is ToolName {
+export function isValidToolName(toolName: string, experiments?: Record): toolName is ToolName {
// Check if it's a valid static tool
if ((validToolNames as readonly string[]).includes(toolName)) {
return true
}
- if (customToolRegistry.has(toolName)) {
+ if (experiments?.customTools && customToolRegistry.has(toolName)) {
return true
}
@@ -40,7 +40,7 @@ export function validateToolUse(
): void {
// First, check if the tool name is actually a valid/known tool
// This catches completely invalid tool names like "edit_file" that don't exist
- if (!isValidToolName(toolName)) {
+ if (!isValidToolName(toolName, experiments)) {
throw new Error(
`Unknown tool "${toolName}". This tool does not exist. Please use one of the available tools: ${validToolNames.join(", ")}.`,
)
@@ -94,7 +94,7 @@ export function isToolAllowedForMode(
// For now, allow all custom tools in any mode.
// As a follow-up we should expand the custom tool definition to include mode restrictions.
- if (customToolRegistry.has(tool)) {
+ if (experiments?.customTools && customToolRegistry.has(tool)) {
return true
}
diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts
index aa72ee253f5..e042c5c1c67 100644
--- a/src/core/webview/webviewMessageHandler.ts
+++ b/src/core/webview/webviewMessageHandler.ts
@@ -16,6 +16,7 @@ import {
Experiments,
ExperimentId,
} from "@roo-code/types"
+import { customToolRegistry } from "@roo-code/core"
import { CloudService } from "@roo-code/cloud"
import { TelemetryService } from "@roo-code/telemetry"
@@ -1725,6 +1726,24 @@ export const webviewMessageHandler = async (
}
break
}
+ case "refreshCustomTools": {
+ try {
+ await customToolRegistry.loadFromDirectory(path.join(getCurrentCwd(), ".roo", "tools"))
+
+ await provider.postMessageToWebview({
+ type: "customToolsResult",
+ tools: customToolRegistry.getAllSerialized(),
+ })
+ } catch (error) {
+ await provider.postMessageToWebview({
+ type: "customToolsResult",
+ tools: [],
+ error: error instanceof Error ? error.message : String(error),
+ })
+ }
+
+ break
+ }
case "saveApiConfiguration":
if (message.text && message.apiConfiguration) {
try {
diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts
index 20bb5759645..093485fa3ee 100644
--- a/src/shared/ExtensionMessage.ts
+++ b/src/shared/ExtensionMessage.ts
@@ -14,6 +14,7 @@ import type {
OrganizationAllowList,
ShareVisibility,
QueuedMessage,
+ SerializedCustomToolDefinition,
} from "@roo-code/types"
import { GitCommit } from "../utils/git"
@@ -133,6 +134,7 @@ export interface ExtensionMessage {
| "browserSessionUpdate"
| "browserSessionNavigate"
| "claudeCodeRateLimits"
+ | "customToolsResult"
text?: string
payload?: any // Add a generic payload for now, can refine later
// Checkpoint warning message
@@ -218,6 +220,7 @@ export interface ExtensionMessage {
browserSessionMessages?: ClineMessage[] // For browser session panel updates
isBrowserSessionActive?: boolean // For browser session panel updates
stepIndex?: number // For browserSessionNavigate: the target step index to display
+ tools?: SerializedCustomToolDefinition[] // For customToolsResult
}
export type ExtensionState = Pick<
diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts
index 970208a2a3e..6c878159940 100644
--- a/src/shared/WebviewMessage.ts
+++ b/src/shared/WebviewMessage.ts
@@ -180,6 +180,7 @@ export interface WebviewMessage {
| "openDebugUiHistory"
| "downloadErrorDiagnostics"
| "requestClaudeCodeRateLimits"
+ | "refreshCustomTools"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts
index 23116b19b20..0b43302611b 100644
--- a/src/shared/__tests__/experiments.spec.ts
+++ b/src/shared/__tests__/experiments.spec.ts
@@ -32,6 +32,7 @@ describe("experiments", () => {
imageGeneration: false,
runSlashCommand: false,
multipleNativeToolCalls: false,
+ customTools: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false)
})
@@ -44,6 +45,7 @@ describe("experiments", () => {
imageGeneration: false,
runSlashCommand: false,
multipleNativeToolCalls: false,
+ customTools: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true)
})
@@ -56,6 +58,7 @@ describe("experiments", () => {
imageGeneration: false,
runSlashCommand: false,
multipleNativeToolCalls: false,
+ customTools: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false)
})
diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts
index 0b11edfcdf0..ad3aeca8634 100644
--- a/src/shared/experiments.ts
+++ b/src/shared/experiments.ts
@@ -7,6 +7,7 @@ export const EXPERIMENT_IDS = {
IMAGE_GENERATION: "imageGeneration",
RUN_SLASH_COMMAND: "runSlashCommand",
MULTIPLE_NATIVE_TOOL_CALLS: "multipleNativeToolCalls",
+ CUSTOM_TOOLS: "customTools",
} as const satisfies Record
type _AssertExperimentIds = AssertEqual>>
@@ -24,6 +25,7 @@ export const experimentConfigsMap: Record = {
IMAGE_GENERATION: { enabled: false },
RUN_SLASH_COMMAND: { enabled: false },
MULTIPLE_NATIVE_TOOL_CALLS: { enabled: false },
+ CUSTOM_TOOLS: { enabled: false },
}
export const experimentDefault = Object.fromEntries(
diff --git a/webview-ui/src/components/settings/CustomToolsSettings.tsx b/webview-ui/src/components/settings/CustomToolsSettings.tsx
new file mode 100644
index 00000000000..176272734cd
--- /dev/null
+++ b/webview-ui/src/components/settings/CustomToolsSettings.tsx
@@ -0,0 +1,173 @@
+import { useState, useEffect, useCallback, useMemo } from "react"
+import { useEvent } from "react-use"
+import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
+import { RefreshCw, Loader2 } from "lucide-react"
+
+import type { SerializedCustomToolDefinition } from "@roo-code/types"
+
+import { useAppTranslation } from "@/i18n/TranslationContext"
+
+import { vscode } from "@/utils/vscode"
+
+import { Button } from "@/components/ui"
+
+interface ToolParameter {
+ name: string
+ type: string
+ description?: string
+ required: boolean
+}
+
+interface ProcessedTool {
+ name: string
+ description: string
+ parameters: ToolParameter[]
+}
+
+interface CustomToolsSettingsProps {
+ enabled: boolean
+ onChange: (enabled: boolean) => void
+}
+
+export const CustomToolsSettings = ({ enabled, onChange }: CustomToolsSettingsProps) => {
+ const { t } = useAppTranslation()
+ const [tools, setTools] = useState([])
+ const [isRefreshing, setIsRefreshing] = useState(false)
+ const [refreshError, setRefreshError] = useState(null)
+
+ useEffect(() => {
+ if (enabled) {
+ vscode.postMessage({ type: "refreshCustomTools" })
+ } else {
+ setTools([])
+ }
+ }, [enabled])
+
+ useEvent("message", (event: MessageEvent) => {
+ const message = event.data
+
+ if (message.type === "customToolsResult") {
+ setTools(message.tools || [])
+ setIsRefreshing(false)
+ setRefreshError(message.error ?? null)
+ }
+ })
+
+ const onRefresh = useCallback(() => {
+ setIsRefreshing(true)
+ setRefreshError(null)
+ vscode.postMessage({ type: "refreshCustomTools" })
+ }, [])
+
+ const processedTools = useMemo(
+ () =>
+ tools.map((tool) => {
+ const params = tool.parameters
+ const properties = (params?.properties ?? {}) as Record
+ const required = (params?.required as string[] | undefined) ?? []
+
+ return {
+ name: tool.name,
+ description: tool.description,
+ parameters: Object.entries(properties).map(([name, def]) => ({
+ name,
+ type: def.type ?? "any",
+ description: def.description,
+ required: required.includes(name),
+ })),
+ }
+ }),
+ [tools],
+ )
+
+ return (
+
+
+
+ onChange(e.target.checked)}>
+ {t("settings:experimental.CUSTOM_TOOLS.name")}
+
+
+
+ {t("settings:experimental.CUSTOM_TOOLS.description")}
+
+
+
+ {enabled && (
+
+
+
+ {t("settings:experimental.CUSTOM_TOOLS.toolsHeader")}
+
+
+
+ {isRefreshing ? (
+
+ ) : (
+
+ )}
+ {isRefreshing
+ ? t("settings:experimental.CUSTOM_TOOLS.refreshing")
+ : t("settings:experimental.CUSTOM_TOOLS.refreshButton")}
+
+
+
+
+ {refreshError && (
+
+ {t("settings:experimental.CUSTOM_TOOLS.refreshError")}: {refreshError}
+
+ )}
+
+ {processedTools.length === 0 ? (
+
+ {t("settings:experimental.CUSTOM_TOOLS.noTools")}
+
+ ) : (
+
+ {processedTools.map((tool) => (
+
+
{tool.name}
+
{tool.description}
+ {tool.parameters.length > 0 && (
+
+
+ {t("settings:experimental.CUSTOM_TOOLS.toolParameters")}:
+
+
+ {tool.parameters.map((param) => (
+
+
+ {param.name}
+
+
+ ({param.type})
+
+ {param.required && (
+
+ required
+
+ )}
+ {param.description && (
+
+ — {param.description}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/webview-ui/src/components/settings/ExperimentalSettings.tsx b/webview-ui/src/components/settings/ExperimentalSettings.tsx
index a1719a954c4..982de949098 100644
--- a/webview-ui/src/components/settings/ExperimentalSettings.tsx
+++ b/webview-ui/src/components/settings/ExperimentalSettings.tsx
@@ -13,6 +13,7 @@ import { SectionHeader } from "./SectionHeader"
import { Section } from "./Section"
import { ExperimentalFeature } from "./ExperimentalFeature"
import { ImageGenerationSettings } from "./ImageGenerationSettings"
+import { CustomToolsSettings } from "./CustomToolsSettings"
type ExperimentalSettingsProps = HTMLAttributes & {
experiments: Experiments
@@ -92,6 +93,15 @@ export const ExperimentalSettings = ({
/>
)
}
+ if (config[0] === "CUSTOM_TOOLS") {
+ return (
+ setExperimentEnabled(EXPERIMENT_IDS.CUSTOM_TOOLS, enabled)}
+ />
+ )
+ }
return (
{
runSlashCommand: false,
nativeToolCalling: false,
multipleNativeToolCalls: false,
+ customTools: false,
} as Record,
checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS + 5,
}
@@ -263,6 +264,7 @@ describe("mergeExtensionState", () => {
runSlashCommand: false,
nativeToolCalling: false,
multipleNativeToolCalls: false,
+ customTools: false,
})
})
})
diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json
index 20b1e0d510f..abcfb3d4c4a 100644
--- a/webview-ui/src/i18n/locales/ca/settings.json
+++ b/webview-ui/src/i18n/locales/ca/settings.json
@@ -824,6 +824,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "Crides paral·leles a eines",
"description": "Quan està activat, el protocol natiu pot executar múltiples eines en un sol torn de missatge de l'assistent."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "Habilitar eines personalitzades",
+ "description": "Quan està habilitat, Roo pot carregar i utilitzar eines TypeScript/JavaScript personalitzades des del directori .roo/tools del vostre projecte.",
+ "toolsHeader": "Eines personalitzades disponibles",
+ "noTools": "No s'han carregat eines personalitzades. Afegiu fitxers .ts o .js al directori .roo/tools del vostre projecte.",
+ "refreshButton": "Actualitzar",
+ "refreshing": "Actualitzant...",
+ "refreshSuccess": "Eines actualitzades correctament",
+ "refreshError": "Error en actualitzar les eines",
+ "toolParameters": "Paràmetres"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json
index d68f7e7d32a..7d9e211b1c6 100644
--- a/webview-ui/src/i18n/locales/de/settings.json
+++ b/webview-ui/src/i18n/locales/de/settings.json
@@ -824,6 +824,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "Parallele Tool-Aufrufe",
"description": "Wenn aktiviert, kann das native Protokoll mehrere Tools in einer einzigen Assistenten-Nachrichtenrunde ausführen."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "Benutzerdefinierte Tools aktivieren",
+ "description": "Wenn aktiviert, kann Roo benutzerdefinierte TypeScript/JavaScript-Tools aus dem .roo/tools-Verzeichnis deines Projekts laden und verwenden.",
+ "toolsHeader": "Verfügbare benutzerdefinierte Tools",
+ "noTools": "Keine benutzerdefinierten Tools geladen. Füge .ts- oder .js-Dateien zum .roo/tools-Verzeichnis deines Projekts hinzu.",
+ "refreshButton": "Aktualisieren",
+ "refreshing": "Aktualisieren...",
+ "refreshSuccess": "Tools erfolgreich aktualisiert",
+ "refreshError": "Fehler beim Aktualisieren der Tools",
+ "toolParameters": "Parameter"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json
index 2396bded682..32a401cd0d6 100644
--- a/webview-ui/src/i18n/locales/en/settings.json
+++ b/webview-ui/src/i18n/locales/en/settings.json
@@ -833,6 +833,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "Parallel tool calls",
"description": "When enabled, the native protocol can execute multiple tools in a single assistant message turn."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "Enable custom tools",
+ "description": "When enabled, Roo can load and use custom TypeScript/JavaScript tools from your project's .roo/tools directory. Note: these tools will automatically be auto-approved.",
+ "toolsHeader": "Available Custom Tools",
+ "noTools": "No custom tools loaded. Add .ts or .js files to your project's .roo/tools directory.",
+ "refreshButton": "Refresh",
+ "refreshing": "Refreshing...",
+ "refreshSuccess": "Tools refreshed successfully",
+ "refreshError": "Failed to refresh tools",
+ "toolParameters": "Parameters"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json
index 6c03855056f..3a83c21a23e 100644
--- a/webview-ui/src/i18n/locales/es/settings.json
+++ b/webview-ui/src/i18n/locales/es/settings.json
@@ -824,6 +824,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "Llamadas paralelas a herramientas",
"description": "Cuando está habilitado, el protocolo nativo puede ejecutar múltiples herramientas en un solo turno de mensaje del asistente."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "Habilitar herramientas personalizadas",
+ "description": "Cuando está habilitado, Roo puede cargar y usar herramientas TypeScript/JavaScript personalizadas desde el directorio .roo/tools de tu proyecto.",
+ "toolsHeader": "Herramientas personalizadas disponibles",
+ "noTools": "No hay herramientas personalizadas cargadas. Añade archivos .ts o .js al directorio .roo/tools de tu proyecto.",
+ "refreshButton": "Actualizar",
+ "refreshing": "Actualizando...",
+ "refreshSuccess": "Herramientas actualizadas correctamente",
+ "refreshError": "Error al actualizar las herramientas",
+ "toolParameters": "Parámetros"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json
index 9fb20036865..99f0406f66b 100644
--- a/webview-ui/src/i18n/locales/fr/settings.json
+++ b/webview-ui/src/i18n/locales/fr/settings.json
@@ -824,6 +824,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "Appels d'outils parallèles",
"description": "Lorsqu'activé, le protocole natif peut exécuter plusieurs outils en un seul tour de message d'assistant."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "Activer les outils personnalisés",
+ "description": "Lorsqu'activé, Roo peut charger et utiliser des outils TypeScript/JavaScript personnalisés à partir du répertoire .roo/tools de votre projet.",
+ "toolsHeader": "Outils personnalisés disponibles",
+ "noTools": "Aucun outil personnalisé chargé. Ajoutez des fichiers .ts ou .js au répertoire .roo/tools de votre projet.",
+ "refreshButton": "Actualiser",
+ "refreshing": "Actualisation...",
+ "refreshSuccess": "Outils actualisés avec succès",
+ "refreshError": "Échec de l'actualisation des outils",
+ "toolParameters": "Paramètres"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json
index ee4f55d243a..65cbee91caf 100644
--- a/webview-ui/src/i18n/locales/hi/settings.json
+++ b/webview-ui/src/i18n/locales/hi/settings.json
@@ -825,6 +825,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "समानांतर टूल कॉल",
"description": "सक्षम होने पर, नेटिव प्रोटोकॉल एकल सहायक संदेश टर्न में एकाधिक टूल निष्पादित कर सकता है।"
+ },
+ "CUSTOM_TOOLS": {
+ "name": "कस्टम टूल्स सक्षम करें",
+ "description": "सक्षम होने पर, Roo आपके प्रोजेक्ट की .roo/tools निर्देशिका से कस्टम TypeScript/JavaScript टूल्स लोड और उपयोग कर सकता है।",
+ "toolsHeader": "उपलब्ध कस्टम टूल्स",
+ "noTools": "कोई कस्टम टूल लोड नहीं हुआ। अपने प्रोजेक्ट की .roo/tools निर्देशिका में .ts या .js फ़ाइलें जोड़ें।",
+ "refreshButton": "रिफ्रेश करें",
+ "refreshing": "रिफ्रेश हो रहा है...",
+ "refreshSuccess": "टूल्स सफलतापूर्वक रिफ्रेश हुए",
+ "refreshError": "टूल्स रिफ्रेश करने में विफल",
+ "toolParameters": "पैरामीटर्स"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json
index 79bf7ba7bff..8cfcbb8f1c7 100644
--- a/webview-ui/src/i18n/locales/id/settings.json
+++ b/webview-ui/src/i18n/locales/id/settings.json
@@ -854,6 +854,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "Panggilan tool paralel",
"description": "Ketika diaktifkan, protokol native dapat mengeksekusi beberapa tool dalam satu giliran pesan asisten."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "Aktifkan tool kustom",
+ "description": "Ketika diaktifkan, Roo dapat memuat dan menggunakan tool TypeScript/JavaScript kustom dari direktori .roo/tools proyek Anda.",
+ "toolsHeader": "Tool Kustom yang Tersedia",
+ "noTools": "Tidak ada tool kustom yang dimuat. Tambahkan file .ts atau .js ke direktori .roo/tools proyek Anda.",
+ "refreshButton": "Refresh",
+ "refreshing": "Merefresh...",
+ "refreshSuccess": "Tool berhasil direfresh",
+ "refreshError": "Gagal merefresh tool",
+ "toolParameters": "Parameter"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json
index a7db28f7513..6967ba6d0c9 100644
--- a/webview-ui/src/i18n/locales/it/settings.json
+++ b/webview-ui/src/i18n/locales/it/settings.json
@@ -825,6 +825,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "Chiamate parallele agli strumenti",
"description": "Quando abilitato, il protocollo nativo può eseguire più strumenti in un singolo turno di messaggio dell'assistente."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "Abilita strumenti personalizzati",
+ "description": "Quando abilitato, Roo può caricare e utilizzare strumenti TypeScript/JavaScript personalizzati dalla directory .roo/tools del tuo progetto.",
+ "toolsHeader": "Strumenti personalizzati disponibili",
+ "noTools": "Nessuno strumento personalizzato caricato. Aggiungi file .ts o .js alla directory .roo/tools del tuo progetto.",
+ "refreshButton": "Aggiorna",
+ "refreshing": "Aggiornamento...",
+ "refreshSuccess": "Strumenti aggiornati con successo",
+ "refreshError": "Impossibile aggiornare gli strumenti",
+ "toolParameters": "Parametri"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json
index b80892cd111..36fc3e3bf72 100644
--- a/webview-ui/src/i18n/locales/ja/settings.json
+++ b/webview-ui/src/i18n/locales/ja/settings.json
@@ -825,6 +825,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "並列ツール呼び出し",
"description": "有効にすると、ネイティブプロトコルは単一のアシスタントメッセージターンで複数のツールを実行できます。"
+ },
+ "CUSTOM_TOOLS": {
+ "name": "カスタムツールを有効化",
+ "description": "有効にすると、Rooはプロジェクトの.roo/toolsディレクトリからカスタムTypeScript/JavaScriptツールを読み込んで使用できます。",
+ "toolsHeader": "利用可能なカスタムツール",
+ "noTools": "カスタムツールが読み込まれていません。プロジェクトの.roo/toolsディレクトリに.tsまたは.jsファイルを追加してください。",
+ "refreshButton": "更新",
+ "refreshing": "更新中...",
+ "refreshSuccess": "ツールが正常に更新されました",
+ "refreshError": "ツールの更新に失敗しました",
+ "toolParameters": "パラメーター"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json
index 85ab220dfea..32c5a4df84f 100644
--- a/webview-ui/src/i18n/locales/ko/settings.json
+++ b/webview-ui/src/i18n/locales/ko/settings.json
@@ -825,6 +825,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "병렬 도구 호출",
"description": "활성화되면 네이티브 프로토콜이 단일 어시스턴트 메시지 턴에서 여러 도구를 실행할 수 있습니다."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "사용자 정의 도구 활성화",
+ "description": "활성화하면 Roo가 프로젝트의 .roo/tools 디렉터리에서 사용자 정의 TypeScript/JavaScript 도구를 로드하고 사용할 수 있습니다.",
+ "toolsHeader": "사용 가능한 사용자 정의 도구",
+ "noTools": "로드된 사용자 정의 도구가 없습니다. 프로젝트의 .roo/tools 디렉터리에 .ts 또는 .js 파일을 추가하세요.",
+ "refreshButton": "새로고침",
+ "refreshing": "새로고침 중...",
+ "refreshSuccess": "도구가 성공적으로 새로고침되었습니다",
+ "refreshError": "도구 새로고침에 실패했습니다",
+ "toolParameters": "매개변수"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json
index be2f2233628..fe93eea35c1 100644
--- a/webview-ui/src/i18n/locales/nl/settings.json
+++ b/webview-ui/src/i18n/locales/nl/settings.json
@@ -825,6 +825,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "Parallelle tool-aanroepen",
"description": "Wanneer ingeschakeld, kan het native protocol meerdere tools uitvoeren in één enkele assistent-berichtbeurt."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "Aangepaste tools inschakelen",
+ "description": "Indien ingeschakeld kan Roo aangepaste TypeScript/JavaScript-tools laden en gebruiken uit de map .roo/tools van je project.",
+ "toolsHeader": "Beschikbare aangepaste tools",
+ "noTools": "Geen aangepaste tools geladen. Voeg .ts- of .js-bestanden toe aan de map .roo/tools van je project.",
+ "refreshButton": "Vernieuwen",
+ "refreshing": "Vernieuwen...",
+ "refreshSuccess": "Tools succesvol vernieuwd",
+ "refreshError": "Fout bij vernieuwen van tools",
+ "toolParameters": "Parameters"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json
index 137bdabf3aa..c1563f2e23f 100644
--- a/webview-ui/src/i18n/locales/pl/settings.json
+++ b/webview-ui/src/i18n/locales/pl/settings.json
@@ -825,6 +825,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "Równoległe wywołania narzędzi",
"description": "Po włączeniu protokół natywny może wykonywać wiele narzędzi w jednej turze wiadomości asystenta."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "Włącz niestandardowe narzędzia",
+ "description": "Gdy włączone, Roo może ładować i używać niestandardowych narzędzi TypeScript/JavaScript z katalogu .roo/tools Twojego projektu.",
+ "toolsHeader": "Dostępne niestandardowe narzędzia",
+ "noTools": "Nie załadowano niestandardowych narzędzi. Dodaj pliki .ts lub .js do katalogu .roo/tools swojego projektu.",
+ "refreshButton": "Odśwież",
+ "refreshing": "Odświeżanie...",
+ "refreshSuccess": "Narzędzia odświeżone pomyślnie",
+ "refreshError": "Nie udało się odświeżyć narzędzi",
+ "toolParameters": "Parametry"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json
index ea459ffdeb0..4df36d14dff 100644
--- a/webview-ui/src/i18n/locales/pt-BR/settings.json
+++ b/webview-ui/src/i18n/locales/pt-BR/settings.json
@@ -825,6 +825,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "Chamadas paralelas de ferramentas",
"description": "Quando habilitado, o protocolo nativo pode executar múltiplas ferramentas em um único turno de mensagem do assistente."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "Ativar ferramentas personalizadas",
+ "description": "Quando habilitado, o Roo pode carregar e usar ferramentas TypeScript/JavaScript personalizadas do diretório .roo/tools do seu projeto.",
+ "toolsHeader": "Ferramentas personalizadas disponíveis",
+ "noTools": "Nenhuma ferramenta personalizada carregada. Adicione arquivos .ts ou .js ao diretório .roo/tools do seu projeto.",
+ "refreshButton": "Atualizar",
+ "refreshing": "Atualizando...",
+ "refreshSuccess": "Ferramentas atualizadas com sucesso",
+ "refreshError": "Falha ao atualizar ferramentas",
+ "toolParameters": "Parâmetros"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json
index 57560a9d33b..1057c86115d 100644
--- a/webview-ui/src/i18n/locales/ru/settings.json
+++ b/webview-ui/src/i18n/locales/ru/settings.json
@@ -825,6 +825,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "Параллельные вызовы инструментов",
"description": "При включении нативный протокол может выполнять несколько инструментов в одном ходе сообщения ассистента."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "Включить пользовательские инструменты",
+ "description": "Если включено, Roo сможет загружать и использовать пользовательские инструменты TypeScript/JavaScript из каталога .roo/tools вашего проекта.",
+ "toolsHeader": "Доступные пользовательские инструменты",
+ "noTools": "Пользовательские инструменты не загружены. Добавьте файлы .ts или .js в каталог .roo/tools вашего проекта.",
+ "refreshButton": "Обновить",
+ "refreshing": "Обновление...",
+ "refreshSuccess": "Инструменты успешно обновлены",
+ "refreshError": "Не удалось обновить инструменты",
+ "toolParameters": "Параметры"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json
index bb2b1d38432..c8bd9888ffe 100644
--- a/webview-ui/src/i18n/locales/tr/settings.json
+++ b/webview-ui/src/i18n/locales/tr/settings.json
@@ -825,6 +825,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "Paralel araç çağrıları",
"description": "Etkinleştirildiğinde, yerel protokol tek bir asistan mesaj turunda birden fazla araç yürütebilir."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "Özel araçları etkinleştir",
+ "description": "Etkinleştirildiğinde, Roo projenizin .roo/tools dizininden özel TypeScript/JavaScript araçlarını yükleyebilir ve kullanabilir.",
+ "toolsHeader": "Kullanılabilir Özel Araçlar",
+ "noTools": "Özel araç yüklenmedi. Projenizin .roo/tools dizinine .ts veya .js dosyaları ekleyin.",
+ "refreshButton": "Yenile",
+ "refreshing": "Yenileniyor...",
+ "refreshSuccess": "Araçlar başarıyla yenilendi",
+ "refreshError": "Araçlar yenilenemedi",
+ "toolParameters": "Parametreler"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json
index 8425d8d6ef5..4f1a38157d5 100644
--- a/webview-ui/src/i18n/locales/vi/settings.json
+++ b/webview-ui/src/i18n/locales/vi/settings.json
@@ -825,6 +825,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "Lệnh gọi công cụ song song",
"description": "Khi được bật, giao thức native có thể thực thi nhiều công cụ trong một lượt tin nhắn của trợ lý."
+ },
+ "CUSTOM_TOOLS": {
+ "name": "Bật công cụ tùy chỉnh",
+ "description": "Khi được bật, Roo có thể tải và sử dụng các công cụ TypeScript/JavaScript tùy chỉnh từ thư mục .roo/tools của dự án của bạn.",
+ "toolsHeader": "Công cụ tùy chỉnh có sẵn",
+ "noTools": "Không có công cụ tùy chỉnh nào được tải. Thêm tệp .ts hoặc .js vào thư mục .roo/tools của dự án của bạn.",
+ "refreshButton": "Làm mới",
+ "refreshing": "Đang làm mới...",
+ "refreshSuccess": "Làm mới công cụ thành công",
+ "refreshError": "Không thể làm mới công cụ",
+ "toolParameters": "Thông số"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json
index 327eb9f8c76..dd88c8fd20b 100644
--- a/webview-ui/src/i18n/locales/zh-CN/settings.json
+++ b/webview-ui/src/i18n/locales/zh-CN/settings.json
@@ -825,6 +825,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "并行工具调用",
"description": "启用后,原生协议可在单个助手消息轮次中执行多个工具。"
+ },
+ "CUSTOM_TOOLS": {
+ "name": "启用自定义工具",
+ "description": "启用后,Roo 可以从项目中的 .roo/tools 目录加载并使用自定义 TypeScript/JavaScript 工具。",
+ "toolsHeader": "可用自定义工具",
+ "noTools": "未加载自定义工具。请向项目的 .roo/tools 目录添加 .ts 或 .js 文件。",
+ "refreshButton": "刷新",
+ "refreshing": "正在刷新...",
+ "refreshSuccess": "工具刷新成功",
+ "refreshError": "工具刷新失败",
+ "toolParameters": "参数"
}
},
"promptCaching": {
diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json
index 4967a7d6791..221ad881a5b 100644
--- a/webview-ui/src/i18n/locales/zh-TW/settings.json
+++ b/webview-ui/src/i18n/locales/zh-TW/settings.json
@@ -825,6 +825,17 @@
"MULTIPLE_NATIVE_TOOL_CALLS": {
"name": "並行工具呼叫",
"description": "啟用後,原生協定可在單個助理訊息輪次中執行多個工具。"
+ },
+ "CUSTOM_TOOLS": {
+ "name": "啟用自訂工具",
+ "description": "啟用後,Roo 可以從專案中的 .roo/tools 目錄載入並使用自訂 TypeScript/JavaScript 工具。",
+ "toolsHeader": "可用自訂工具",
+ "noTools": "未載入自訂工具。請向專案的 .roo/tools 目錄新增 .ts 或 .js 檔案。",
+ "refreshButton": "重新整理",
+ "refreshing": "正在重新整理...",
+ "refreshSuccess": "工具重新整理成功",
+ "refreshError": "工具重新整理失敗",
+ "toolParameters": "參數"
}
},
"promptCaching": {
From 00836911efcd564d4f928404dd9ca0be15db9e31 Mon Sep 17 00:00:00 2001
From: Chris Estreich
Date: Sun, 21 Dec 2025 11:58:19 -0800
Subject: [PATCH 18/18] Add support for global custom tools (#10253)
---
.../__snapshots__/format-native.spec.ts.snap | 12 +++
.../__snapshots__/serialize.spec.ts.snap | 9 ++
.../__tests__/custom-tool-registry.spec.ts | 96 +++++++++++++++++++
.../__tests__/fixtures-override/simple.ts | 11 +++
.../__tests__/fixtures-override/unique.ts | 11 +++
.../src/custom-tools/custom-tool-registry.ts | 54 +++++++++--
packages/core/src/custom-tools/serialize.ts | 10 +-
packages/core/src/custom-tools/types.ts | 8 ++
packages/types/src/custom-tool.ts | 1 +
src/core/task/build-tools.ts | 4 +-
src/core/webview/webviewMessageHandler.ts | 5 +-
.../settings/CustomToolsSettings.tsx | 86 +++++++++--------
webview-ui/src/i18n/locales/ca/settings.json | 4 +-
webview-ui/src/i18n/locales/de/settings.json | 4 +-
webview-ui/src/i18n/locales/en/settings.json | 4 +-
webview-ui/src/i18n/locales/es/settings.json | 4 +-
webview-ui/src/i18n/locales/fr/settings.json | 4 +-
webview-ui/src/i18n/locales/hi/settings.json | 4 +-
webview-ui/src/i18n/locales/id/settings.json | 4 +-
webview-ui/src/i18n/locales/it/settings.json | 4 +-
webview-ui/src/i18n/locales/ja/settings.json | 4 +-
webview-ui/src/i18n/locales/ko/settings.json | 4 +-
webview-ui/src/i18n/locales/nl/settings.json | 4 +-
webview-ui/src/i18n/locales/pl/settings.json | 4 +-
.../src/i18n/locales/pt-BR/settings.json | 4 +-
webview-ui/src/i18n/locales/ru/settings.json | 4 +-
webview-ui/src/i18n/locales/tr/settings.json | 4 +-
webview-ui/src/i18n/locales/vi/settings.json | 4 +-
.../src/i18n/locales/zh-CN/settings.json | 4 +-
.../src/i18n/locales/zh-TW/settings.json | 4 +-
30 files changed, 290 insertions(+), 89 deletions(-)
create mode 100644 packages/core/src/custom-tools/__tests__/fixtures-override/simple.ts
create mode 100644 packages/core/src/custom-tools/__tests__/fixtures-override/unique.ts
create mode 100644 packages/core/src/custom-tools/types.ts
diff --git a/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap b/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap
index d734e60baf9..d4ca0b16832 100644
--- a/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap
+++ b/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap
@@ -11,6 +11,7 @@ exports[`Native Protocol snapshots > should generate correct native definition f
"required": [],
"type": "object",
},
+ "source": undefined,
"strict": true,
},
"type": "function",
@@ -35,6 +36,7 @@ exports[`Native Protocol snapshots > should generate correct native definition f
],
"type": "object",
},
+ "source": undefined,
"strict": true,
},
"type": "function",
@@ -52,6 +54,7 @@ exports[`Native Protocol snapshots > should generate correct native definition f
"required": [],
"type": "object",
},
+ "source": undefined,
"strict": true,
},
"type": "function",
@@ -76,6 +79,7 @@ exports[`Native Protocol snapshots > should generate correct native definition f
],
"type": "object",
},
+ "source": undefined,
"strict": true,
},
"type": "function",
@@ -101,6 +105,7 @@ exports[`Native Protocol snapshots > should generate correct native definitions
],
"type": "object",
},
+ "source": undefined,
"strict": true,
},
"type": "function",
@@ -115,6 +120,7 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"required": [],
"type": "object",
},
+ "source": undefined,
"strict": true,
},
"type": "function",
@@ -136,6 +142,7 @@ exports[`Native Protocol snapshots > should generate correct native definitions
],
"type": "object",
},
+ "source": undefined,
"strict": true,
},
"type": "function",
@@ -150,6 +157,7 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"required": [],
"type": "object",
},
+ "source": undefined,
"strict": true,
},
"type": "function",
@@ -164,6 +172,7 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"required": [],
"type": "object",
},
+ "source": undefined,
"strict": true,
},
"type": "function",
@@ -178,6 +187,7 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"required": [],
"type": "object",
},
+ "source": undefined,
"strict": true,
},
"type": "function",
@@ -197,6 +207,7 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"required": [],
"type": "object",
},
+ "source": undefined,
"strict": true,
},
"type": "function",
@@ -211,6 +222,7 @@ exports[`Native Protocol snapshots > should generate correct native definitions
"required": [],
"type": "object",
},
+ "source": undefined,
"strict": true,
},
"type": "function",
diff --git a/packages/core/src/custom-tools/__tests__/__snapshots__/serialize.spec.ts.snap b/packages/core/src/custom-tools/__tests__/__snapshots__/serialize.spec.ts.snap
index 6b55c3f8e17..6da6a93e9c1 100644
--- a/packages/core/src/custom-tools/__tests__/__snapshots__/serialize.spec.ts.snap
+++ b/packages/core/src/custom-tools/__tests__/__snapshots__/serialize.spec.ts.snap
@@ -19,6 +19,7 @@ exports[`Serialization snapshots > should correctly serialize all fixtures 1`] =
],
"type": "object",
},
+ "source": undefined,
},
{
"description": "Cached tool",
@@ -29,6 +30,7 @@ exports[`Serialization snapshots > should correctly serialize all fixtures 1`] =
"properties": {},
"type": "object",
},
+ "source": undefined,
},
{
"description": "Legacy tool using args",
@@ -47,6 +49,7 @@ exports[`Serialization snapshots > should correctly serialize all fixtures 1`] =
],
"type": "object",
},
+ "source": undefined,
},
{
"description": "Tool A",
@@ -57,6 +60,7 @@ exports[`Serialization snapshots > should correctly serialize all fixtures 1`] =
"properties": {},
"type": "object",
},
+ "source": undefined,
},
{
"description": "Tool B",
@@ -67,6 +71,7 @@ exports[`Serialization snapshots > should correctly serialize all fixtures 1`] =
"properties": {},
"type": "object",
},
+ "source": undefined,
},
{
"description": "Valid",
@@ -77,6 +82,7 @@ exports[`Serialization snapshots > should correctly serialize all fixtures 1`] =
"properties": {},
"type": "object",
},
+ "source": undefined,
},
]
`;
@@ -91,6 +97,7 @@ exports[`Serialization snapshots > should correctly serialize cached tool 1`] =
"properties": {},
"type": "object",
},
+ "source": undefined,
}
`;
@@ -112,6 +119,7 @@ exports[`Serialization snapshots > should correctly serialize legacy tool (using
],
"type": "object",
},
+ "source": undefined,
}
`;
@@ -133,5 +141,6 @@ exports[`Serialization snapshots > should correctly serialize simple tool 1`] =
],
"type": "object",
},
+ "source": undefined,
}
`;
diff --git a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
index 27265db3098..967ae2e8df7 100644
--- a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
+++ b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts
@@ -10,6 +10,7 @@ import { CustomToolRegistry } from "../custom-tool-registry.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const TEST_FIXTURES_DIR = path.join(__dirname, "fixtures")
+const TEST_FIXTURES_OVERRIDE_DIR = path.join(__dirname, "fixtures-override")
describe("CustomToolRegistry", () => {
let registry: CustomToolRegistry
@@ -282,4 +283,99 @@ describe("CustomToolRegistry", () => {
expect(result.loaded).toContain("cached")
}, 30000)
})
+
+ describe.sequential("loadFromDirectories", () => {
+ it("should load tools from multiple directories", async () => {
+ const result = await registry.loadFromDirectories([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])
+
+ // Should load tools from both directories.
+ expect(result.loaded).toContain("simple") // From both directories (override wins).
+ expect(result.loaded).toContain("unique_override") // Only in override directory.
+ expect(result.loaded).toContain("multi_toolA") // Only in fixtures directory.
+ }, 60000)
+
+ it("should allow later directories to override earlier ones", async () => {
+ await registry.loadFromDirectories([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])
+
+ // The simple tool should have the overridden description.
+ const simpleTool = registry.get("simple")
+ expect(simpleTool).toBeDefined()
+ expect(simpleTool?.description).toBe("Simple tool - OVERRIDDEN")
+ }, 60000)
+
+ it("should preserve order: first directory loaded first, second overrides", async () => {
+ // Load in reverse order: override first, then fixtures.
+ await registry.loadFromDirectories([TEST_FIXTURES_OVERRIDE_DIR, TEST_FIXTURES_DIR])
+
+ // Now the original fixtures directory should win.
+ const simpleTool = registry.get("simple")
+ expect(simpleTool).toBeDefined()
+ expect(simpleTool?.description).toBe("Simple tool") // Original wins when loaded second.
+ }, 60000)
+
+ it("should handle non-existent directories in the array", async () => {
+ const result = await registry.loadFromDirectories([
+ "/nonexistent/path",
+ TEST_FIXTURES_DIR,
+ "/another/nonexistent",
+ ])
+
+ // Should still load from the existing directory.
+ expect(result.loaded).toContain("simple")
+ expect(result.failed).toHaveLength(1) // Only the invalid.ts from fixtures.
+ }, 60000)
+
+ it("should handle empty array", async () => {
+ const result = await registry.loadFromDirectories([])
+
+ expect(result.loaded).toHaveLength(0)
+ expect(result.failed).toHaveLength(0)
+ })
+
+ it("should combine results from all directories", async () => {
+ const result = await registry.loadFromDirectories([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])
+
+ // Loaded should include tools from both (with duplicates since simple is loaded twice).
+ // The "simple" tool is loaded from both directories.
+ const simpleCount = result.loaded.filter((name) => name === "simple").length
+ expect(simpleCount).toBe(2) // Listed twice in loaded results.
+ }, 60000)
+ })
+
+ describe.sequential("loadFromDirectoriesIfStale", () => {
+ it("should load tools from multiple directories when stale", async () => {
+ const result = await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])
+
+ expect(result.loaded).toContain("simple")
+ expect(result.loaded).toContain("unique_override")
+ }, 60000)
+
+ it("should not reload if directories are not stale", async () => {
+ // First load.
+ await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR])
+
+ // Clear tools but keep staleness tracking.
+ // (firstLoadSize is captured to document that tools were loaded, then cleared).
+ const _firstLoadSize = registry.size
+ registry.clear()
+
+ // Second load - should return cached tool names without reloading.
+ const result = await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR])
+
+ // Registry was cleared, not stale so no reload.
+ expect(result.loaded).toEqual([])
+ }, 30000)
+
+ it("should handle mixed stale and non-stale directories", async () => {
+ // Load from fixtures first.
+ await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR])
+
+ // Load from both - fixtures is not stale, override is new (stale).
+ const result = await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR])
+
+ // Override directory tools should be loaded (it's stale/new).
+ expect(result.loaded).toContain("simple") // From override (stale).
+ expect(result.loaded).toContain("unique_override") // From override (stale).
+ }, 60000)
+ })
})
diff --git a/packages/core/src/custom-tools/__tests__/fixtures-override/simple.ts b/packages/core/src/custom-tools/__tests__/fixtures-override/simple.ts
new file mode 100644
index 00000000000..9235f4e3337
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/fixtures-override/simple.ts
@@ -0,0 +1,11 @@
+import { parametersSchema, defineCustomTool } from "@roo-code/types"
+
+// This tool has the same name as the one in fixtures/ to test override behavior.
+export default defineCustomTool({
+ name: "simple",
+ description: "Simple tool - OVERRIDDEN",
+ parameters: parametersSchema.object({ value: parametersSchema.string().describe("The input value") }),
+ async execute(args: { value: string }) {
+ return "Overridden Result: " + args.value
+ },
+})
diff --git a/packages/core/src/custom-tools/__tests__/fixtures-override/unique.ts b/packages/core/src/custom-tools/__tests__/fixtures-override/unique.ts
new file mode 100644
index 00000000000..867d2446499
--- /dev/null
+++ b/packages/core/src/custom-tools/__tests__/fixtures-override/unique.ts
@@ -0,0 +1,11 @@
+import { parametersSchema, defineCustomTool } from "@roo-code/types"
+
+// This tool only exists in fixtures-override/ to test combined loading.
+export default defineCustomTool({
+ name: "unique_override",
+ description: "A unique tool only in override directory",
+ parameters: parametersSchema.object({ input: parametersSchema.string().describe("The input") }),
+ async execute(args: { input: string }) {
+ return "Unique: " + args.input
+ },
+})
diff --git a/packages/core/src/custom-tools/custom-tool-registry.ts b/packages/core/src/custom-tools/custom-tool-registry.ts
index a9b2b1e8e12..ee72f68d8e5 100644
--- a/packages/core/src/custom-tools/custom-tool-registry.ts
+++ b/packages/core/src/custom-tools/custom-tool-registry.ts
@@ -15,14 +15,10 @@ import os from "os"
import type { CustomToolDefinition, SerializedCustomToolDefinition, CustomToolParametersSchema } from "@roo-code/types"
+import type { StoredCustomTool, LoadResult } from "./types.js"
import { serializeCustomTool } from "./serialize.js"
import { runEsbuild } from "./esbuild-runner.js"
-export interface LoadResult {
- loaded: string[]
- failed: Array<{ file: string; error: string }>
-}
-
export interface RegistryOptions {
/** Directory for caching compiled TypeScript files. */
cacheDir?: string
@@ -33,7 +29,7 @@ export interface RegistryOptions {
}
export class CustomToolRegistry {
- private tools = new Map()
+ private tools = new Map()
private tsCache = new Map()
private cacheDir: string
private nodePaths: string[]
@@ -78,7 +74,7 @@ export class CustomToolRegistry {
continue
}
- this.tools.set(def.name, def)
+ this.tools.set(def.name, { ...def, source: filePath })
console.log(`[CustomToolRegistry] loaded tool ${def.name} from ${filePath}`)
result.loaded.push(def.name)
}
@@ -113,10 +109,49 @@ export class CustomToolRegistry {
return { loaded: this.list(), failed: [] }
}
+ /**
+ * Load all tools from multiple directories.
+ * Directories are processed in order, so later directories can override tools from earlier ones.
+ * Supports both .ts and .js files.
+ *
+ * @param toolDirs - Array of absolute paths to tools directories
+ * @returns LoadResult with lists of loaded and failed tools from all directories
+ */
+ async loadFromDirectories(toolDirs: string[]): Promise {
+ const result: LoadResult = { loaded: [], failed: [] }
+
+ for (const toolDir of toolDirs) {
+ const dirResult = await this.loadFromDirectory(toolDir)
+ result.loaded.push(...dirResult.loaded)
+ result.failed.push(...dirResult.failed)
+ }
+
+ return result
+ }
+
+ /**
+ * Load all tools from multiple directories if any has become stale.
+ * Directories are processed in order, so later directories can override tools from earlier ones.
+ *
+ * @param toolDirs - Array of absolute paths to tools directories
+ * @returns LoadResult with lists of loaded and failed tools
+ */
+ async loadFromDirectoriesIfStale(toolDirs: string[]): Promise {
+ const result: LoadResult = { loaded: [], failed: [] }
+
+ for (const toolDir of toolDirs) {
+ const dirResult = await this.loadFromDirectoryIfStale(toolDir)
+ result.loaded.push(...dirResult.loaded)
+ result.failed.push(...dirResult.failed)
+ }
+
+ return result
+ }
+
/**
* Register a tool directly (without loading from file).
*/
- register(definition: CustomToolDefinition): void {
+ register(definition: CustomToolDefinition, source?: string): void {
const { name: id } = definition
const validated = this.validate(id, definition)
@@ -124,7 +159,8 @@ export class CustomToolRegistry {
throw new Error(`Invalid tool definition for '${id}'`)
}
- this.tools.set(id, validated)
+ const storedTool: StoredCustomTool = source ? { ...validated, source } : validated
+ this.tools.set(id, storedTool)
}
/**
diff --git a/packages/core/src/custom-tools/serialize.ts b/packages/core/src/custom-tools/serialize.ts
index dc4eceb87e5..c347bf0be12 100644
--- a/packages/core/src/custom-tools/serialize.ts
+++ b/packages/core/src/custom-tools/serialize.ts
@@ -1,17 +1,21 @@
-import { type CustomToolDefinition, type SerializedCustomToolDefinition, parametersSchema } from "@roo-code/types"
+import { type SerializedCustomToolDefinition, parametersSchema } from "@roo-code/types"
+
+import type { StoredCustomTool } from "./types.js"
export function serializeCustomTool({
name,
description,
parameters,
-}: CustomToolDefinition): SerializedCustomToolDefinition {
+ source,
+}: StoredCustomTool): SerializedCustomToolDefinition {
return {
name,
description,
parameters: parameters ? parametersSchema.toJSONSchema(parameters) : undefined,
+ source,
}
}
-export function serializeCustomTools(tools: CustomToolDefinition[]): SerializedCustomToolDefinition[] {
+export function serializeCustomTools(tools: StoredCustomTool[]): SerializedCustomToolDefinition[] {
return tools.map(serializeCustomTool)
}
diff --git a/packages/core/src/custom-tools/types.ts b/packages/core/src/custom-tools/types.ts
new file mode 100644
index 00000000000..fbe871e7893
--- /dev/null
+++ b/packages/core/src/custom-tools/types.ts
@@ -0,0 +1,8 @@
+import { type CustomToolDefinition } from "@roo-code/types"
+
+export type StoredCustomTool = CustomToolDefinition & { source?: string }
+
+export interface LoadResult {
+ loaded: string[]
+ failed: Array<{ file: string; error: string }>
+}
diff --git a/packages/types/src/custom-tool.ts b/packages/types/src/custom-tool.ts
index 2ad40c4d234..ba0bff1c908 100644
--- a/packages/types/src/custom-tool.ts
+++ b/packages/types/src/custom-tool.ts
@@ -59,6 +59,7 @@ export interface SerializedCustomToolDefinition {
name: string
description: string
parameters?: SerializedCustomToolParameters
+ source?: string
}
/**
diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts
index b636052341f..8eea4ace82c 100644
--- a/src/core/task/build-tools.ts
+++ b/src/core/task/build-tools.ts
@@ -6,6 +6,7 @@ import type { ProviderSettings, ModeConfig, ModelInfo } from "@roo-code/types"
import { customToolRegistry, formatNative } from "@roo-code/core"
import type { ClineProvider } from "../webview/ClineProvider"
+import { getRooDirectoriesForCwd } from "../../services/roo-config/index.js"
import { getNativeTools, getMcpServerTools } from "../prompts/tools/native-tools"
import { filterNativeToolsForMode, filterMcpToolsForMode } from "../prompts/tools/filter-tools-for-mode"
@@ -83,7 +84,8 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise
let nativeCustomTools: OpenAI.Chat.ChatCompletionFunctionTool[] = []
if (experiments?.customTools) {
- await customToolRegistry.loadFromDirectoryIfStale(path.join(cwd, ".roo", "tools"))
+ const toolDirs = getRooDirectoriesForCwd(cwd).map((dir) => path.join(dir, "tools"))
+ await customToolRegistry.loadFromDirectoriesIfStale(toolDirs)
const customTools = customToolRegistry.getAllSerialized()
if (customTools.length > 0) {
diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts
index e042c5c1c67..be8e5291df1 100644
--- a/src/core/webview/webviewMessageHandler.ts
+++ b/src/core/webview/webviewMessageHandler.ts
@@ -2,6 +2,7 @@ import { safeWriteJson } from "../../utils/safeWriteJson"
import * as path from "path"
import * as os from "os"
import * as fs from "fs/promises"
+import { getRooDirectoriesForCwd } from "../../services/roo-config/index.js"
import pWaitFor from "p-wait-for"
import * as vscode from "vscode"
@@ -13,7 +14,6 @@ import {
type UserSettingsConfig,
TelemetryEventName,
RooCodeSettings,
- Experiments,
ExperimentId,
} from "@roo-code/types"
import { customToolRegistry } from "@roo-code/core"
@@ -1728,7 +1728,8 @@ export const webviewMessageHandler = async (
}
case "refreshCustomTools": {
try {
- await customToolRegistry.loadFromDirectory(path.join(getCurrentCwd(), ".roo", "tools"))
+ const toolDirs = getRooDirectoriesForCwd(getCurrentCwd()).map((dir) => path.join(dir, "tools"))
+ await customToolRegistry.loadFromDirectories(toolDirs)
await provider.postMessageToWebview({
type: "customToolsResult",
diff --git a/webview-ui/src/components/settings/CustomToolsSettings.tsx b/webview-ui/src/components/settings/CustomToolsSettings.tsx
index 176272734cd..03bff7fc8a4 100644
--- a/webview-ui/src/components/settings/CustomToolsSettings.tsx
+++ b/webview-ui/src/components/settings/CustomToolsSettings.tsx
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from "react"
import { useEvent } from "react-use"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
-import { RefreshCw, Loader2 } from "lucide-react"
+import { RefreshCw, Loader2, FileCode } from "lucide-react"
import type { SerializedCustomToolDefinition } from "@roo-code/types"
@@ -22,6 +22,7 @@ interface ProcessedTool {
name: string
description: string
parameters: ToolParameter[]
+ source?: string
}
interface CustomToolsSettingsProps {
@@ -69,6 +70,7 @@ export const CustomToolsSettings = ({ enabled, onChange }: CustomToolsSettingsPr
return {
name: tool.name,
description: tool.description,
+ source: tool.source,
parameters: Object.entries(properties).map(([name, def]) => ({
name,
type: def.type ?? "any",
@@ -124,47 +126,55 @@ export const CustomToolsSettings = ({ enabled, onChange }: CustomToolsSettingsPr
{t("settings:experimental.CUSTOM_TOOLS.noTools")}
) : (
-
- {processedTools.map((tool) => (
-
+ processedTools.map((tool) => (
+
+
{tool.name}
-
{tool.description}
- {tool.parameters.length > 0 && (
-
-
- {t("settings:experimental.CUSTOM_TOOLS.toolParameters")}:
-
-
- {tool.parameters.map((param) => (
-
-
- {param.name}
-
-
- ({param.type})
-
- {param.required && (
-
- required
-
- )}
- {param.description && (
-
- — {param.description}
-
- )}
-
- ))}
-
+ {tool.source && (
+
+
+
+ {tool.source}
+
)}
- ))}
-
+
{tool.description}
+ {tool.parameters.length > 0 && (
+
+
+ {t("settings:experimental.CUSTOM_TOOLS.toolParameters")}:
+
+
+ {tool.parameters.map((param) => (
+
+
+ {param.name}
+
+
+ ({param.type})
+
+ {param.required && (
+
+ required
+
+ )}
+ {param.description && (
+
+ — {param.description}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ ))
)}
)}
diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json
index abcfb3d4c4a..c64dccde8f1 100644
--- a/webview-ui/src/i18n/locales/ca/settings.json
+++ b/webview-ui/src/i18n/locales/ca/settings.json
@@ -827,9 +827,9 @@
},
"CUSTOM_TOOLS": {
"name": "Habilitar eines personalitzades",
- "description": "Quan està habilitat, Roo pot carregar i utilitzar eines TypeScript/JavaScript personalitzades des del directori .roo/tools del vostre projecte.",
+ "description": "Quan està habilitat, Roo pot carregar i utilitzar eines TypeScript/JavaScript personalitzades des del directori .roo/tools del vostre projecte o ~/.roo/tools per a eines globals. Nota: aquestes eines s'aprovaran automàticament.",
"toolsHeader": "Eines personalitzades disponibles",
- "noTools": "No s'han carregat eines personalitzades. Afegiu fitxers .ts o .js al directori .roo/tools del vostre projecte.",
+ "noTools": "No s'han carregat eines personalitzades. Afegiu fitxers .ts o .js al directori .roo/tools del vostre projecte o ~/.roo/tools per a eines globals.",
"refreshButton": "Actualitzar",
"refreshing": "Actualitzant...",
"refreshSuccess": "Eines actualitzades correctament",
diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json
index 7d9e211b1c6..ced4a8b428e 100644
--- a/webview-ui/src/i18n/locales/de/settings.json
+++ b/webview-ui/src/i18n/locales/de/settings.json
@@ -827,9 +827,9 @@
},
"CUSTOM_TOOLS": {
"name": "Benutzerdefinierte Tools aktivieren",
- "description": "Wenn aktiviert, kann Roo benutzerdefinierte TypeScript/JavaScript-Tools aus dem .roo/tools-Verzeichnis deines Projekts laden und verwenden.",
+ "description": "Wenn aktiviert, kann Roo benutzerdefinierte TypeScript/JavaScript-Tools aus dem .roo/tools-Verzeichnis deines Projekts oder ~/.roo/tools für globale Tools laden und verwenden. Hinweis: Diese Tools werden automatisch genehmigt.",
"toolsHeader": "Verfügbare benutzerdefinierte Tools",
- "noTools": "Keine benutzerdefinierten Tools geladen. Füge .ts- oder .js-Dateien zum .roo/tools-Verzeichnis deines Projekts hinzu.",
+ "noTools": "Keine benutzerdefinierten Tools geladen. Füge .ts- oder .js-Dateien zum .roo/tools-Verzeichnis deines Projekts oder ~/.roo/tools für globale Tools hinzu.",
"refreshButton": "Aktualisieren",
"refreshing": "Aktualisieren...",
"refreshSuccess": "Tools erfolgreich aktualisiert",
diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json
index 32a401cd0d6..a4aeea1ae63 100644
--- a/webview-ui/src/i18n/locales/en/settings.json
+++ b/webview-ui/src/i18n/locales/en/settings.json
@@ -836,9 +836,9 @@
},
"CUSTOM_TOOLS": {
"name": "Enable custom tools",
- "description": "When enabled, Roo can load and use custom TypeScript/JavaScript tools from your project's .roo/tools directory. Note: these tools will automatically be auto-approved.",
+ "description": "When enabled, Roo can load and use custom TypeScript/JavaScript tools from your project's .roo/tools directory or ~/.roo/tools for global tools. Note: these tools will automatically be auto-approved.",
"toolsHeader": "Available Custom Tools",
- "noTools": "No custom tools loaded. Add .ts or .js files to your project's .roo/tools directory.",
+ "noTools": "No custom tools loaded. Add .ts or .js files to your project's .roo/tools directory or ~/.roo/tools for global tools.",
"refreshButton": "Refresh",
"refreshing": "Refreshing...",
"refreshSuccess": "Tools refreshed successfully",
diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json
index 3a83c21a23e..e0e1881d177 100644
--- a/webview-ui/src/i18n/locales/es/settings.json
+++ b/webview-ui/src/i18n/locales/es/settings.json
@@ -827,9 +827,9 @@
},
"CUSTOM_TOOLS": {
"name": "Habilitar herramientas personalizadas",
- "description": "Cuando está habilitado, Roo puede cargar y usar herramientas TypeScript/JavaScript personalizadas desde el directorio .roo/tools de tu proyecto.",
+ "description": "Cuando está habilitado, Roo puede cargar y usar herramientas TypeScript/JavaScript personalizadas desde el directorio .roo/tools de tu proyecto o ~/.roo/tools para herramientas globales. Nota: estas herramientas se aprobarán automáticamente.",
"toolsHeader": "Herramientas personalizadas disponibles",
- "noTools": "No hay herramientas personalizadas cargadas. Añade archivos .ts o .js al directorio .roo/tools de tu proyecto.",
+ "noTools": "No hay herramientas personalizadas cargadas. Añade archivos .ts o .js al directorio .roo/tools de tu proyecto o ~/.roo/tools para herramientas globales.",
"refreshButton": "Actualizar",
"refreshing": "Actualizando...",
"refreshSuccess": "Herramientas actualizadas correctamente",
diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json
index 99f0406f66b..6ff29c77a45 100644
--- a/webview-ui/src/i18n/locales/fr/settings.json
+++ b/webview-ui/src/i18n/locales/fr/settings.json
@@ -827,9 +827,9 @@
},
"CUSTOM_TOOLS": {
"name": "Activer les outils personnalisés",
- "description": "Lorsqu'activé, Roo peut charger et utiliser des outils TypeScript/JavaScript personnalisés à partir du répertoire .roo/tools de votre projet.",
+ "description": "Lorsqu'activé, Roo peut charger et utiliser des outils TypeScript/JavaScript personnalisés à partir du répertoire .roo/tools de votre projet ou ~/.roo/tools pour des outils globaux. Remarque : ces outils seront automatiquement approuvés.",
"toolsHeader": "Outils personnalisés disponibles",
- "noTools": "Aucun outil personnalisé chargé. Ajoutez des fichiers .ts ou .js au répertoire .roo/tools de votre projet.",
+ "noTools": "Aucun outil personnalisé chargé. Ajoutez des fichiers .ts ou .js au répertoire .roo/tools de votre projet ou ~/.roo/tools pour des outils globaux.",
"refreshButton": "Actualiser",
"refreshing": "Actualisation...",
"refreshSuccess": "Outils actualisés avec succès",
diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json
index 65cbee91caf..06efbf63243 100644
--- a/webview-ui/src/i18n/locales/hi/settings.json
+++ b/webview-ui/src/i18n/locales/hi/settings.json
@@ -828,9 +828,9 @@
},
"CUSTOM_TOOLS": {
"name": "कस्टम टूल्स सक्षम करें",
- "description": "सक्षम होने पर, Roo आपके प्रोजेक्ट की .roo/tools निर्देशिका से कस्टम TypeScript/JavaScript टूल्स लोड और उपयोग कर सकता है।",
+ "description": "सक्षम होने पर, Roo आपके प्रोजेक्ट की .roo/tools निर्देशिका या वैश्विक टूल्स के लिए ~/.roo/tools से कस्टम TypeScript/JavaScript टूल्स लोड और उपयोग कर सकता है। नोट: ये टूल्स स्वचालित रूप से स्वत:-अनुमोदित होंगे।",
"toolsHeader": "उपलब्ध कस्टम टूल्स",
- "noTools": "कोई कस्टम टूल लोड नहीं हुआ। अपने प्रोजेक्ट की .roo/tools निर्देशिका में .ts या .js फ़ाइलें जोड़ें।",
+ "noTools": "कोई कस्टम टूल लोड नहीं हुआ। अपने प्रोजेक्ट की .roo/tools निर्देशिका या वैश्विक टूल्स के लिए ~/.roo/tools में .ts या .js फ़ाइलें जोड़ें।",
"refreshButton": "रिफ्रेश करें",
"refreshing": "रिफ्रेश हो रहा है...",
"refreshSuccess": "टूल्स सफलतापूर्वक रिफ्रेश हुए",
diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json
index 8cfcbb8f1c7..6cae436f089 100644
--- a/webview-ui/src/i18n/locales/id/settings.json
+++ b/webview-ui/src/i18n/locales/id/settings.json
@@ -857,9 +857,9 @@
},
"CUSTOM_TOOLS": {
"name": "Aktifkan tool kustom",
- "description": "Ketika diaktifkan, Roo dapat memuat dan menggunakan tool TypeScript/JavaScript kustom dari direktori .roo/tools proyek Anda.",
+ "description": "Ketika diaktifkan, Roo dapat memuat dan menggunakan tool TypeScript/JavaScript kustom dari direktori .roo/tools proyek Anda atau ~/.roo/tools untuk tool global. Catatan: tool ini akan disetujui otomatis.",
"toolsHeader": "Tool Kustom yang Tersedia",
- "noTools": "Tidak ada tool kustom yang dimuat. Tambahkan file .ts atau .js ke direktori .roo/tools proyek Anda.",
+ "noTools": "Tidak ada tool kustom yang dimuat. Tambahkan file .ts atau .js ke direktori .roo/tools proyek Anda atau ~/.roo/tools untuk tool global.",
"refreshButton": "Refresh",
"refreshing": "Merefresh...",
"refreshSuccess": "Tool berhasil direfresh",
diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json
index 6967ba6d0c9..aebc6d9aa22 100644
--- a/webview-ui/src/i18n/locales/it/settings.json
+++ b/webview-ui/src/i18n/locales/it/settings.json
@@ -828,9 +828,9 @@
},
"CUSTOM_TOOLS": {
"name": "Abilita strumenti personalizzati",
- "description": "Quando abilitato, Roo può caricare e utilizzare strumenti TypeScript/JavaScript personalizzati dalla directory .roo/tools del tuo progetto.",
+ "description": "Quando abilitato, Roo può caricare e utilizzare strumenti TypeScript/JavaScript personalizzati dalla directory .roo/tools del tuo progetto o ~/.roo/tools per strumenti globali. Nota: questi strumenti saranno automaticamente approvati.",
"toolsHeader": "Strumenti personalizzati disponibili",
- "noTools": "Nessuno strumento personalizzato caricato. Aggiungi file .ts o .js alla directory .roo/tools del tuo progetto.",
+ "noTools": "Nessuno strumento personalizzato caricato. Aggiungi file .ts o .js alla directory .roo/tools del tuo progetto o ~/.roo/tools per strumenti globali.",
"refreshButton": "Aggiorna",
"refreshing": "Aggiornamento...",
"refreshSuccess": "Strumenti aggiornati con successo",
diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json
index 36fc3e3bf72..44a1da4dc4c 100644
--- a/webview-ui/src/i18n/locales/ja/settings.json
+++ b/webview-ui/src/i18n/locales/ja/settings.json
@@ -828,9 +828,9 @@
},
"CUSTOM_TOOLS": {
"name": "カスタムツールを有効化",
- "description": "有効にすると、Rooはプロジェクトの.roo/toolsディレクトリからカスタムTypeScript/JavaScriptツールを読み込んで使用できます。",
+ "description": "有効にすると、Rooはプロジェクトの.roo/toolsディレクトリまたはグローバルツール用の~/.roo/toolsからカスタムTypeScript/JavaScriptツールを読み込んで使用できます。注意:これらのツールは自動的に承認されます。",
"toolsHeader": "利用可能なカスタムツール",
- "noTools": "カスタムツールが読み込まれていません。プロジェクトの.roo/toolsディレクトリに.tsまたは.jsファイルを追加してください。",
+ "noTools": "カスタムツールが読み込まれていません。プロジェクトの.roo/toolsディレクトリまたはグローバルツール用の~/.roo/toolsに.tsまたは.jsファイルを追加してください。",
"refreshButton": "更新",
"refreshing": "更新中...",
"refreshSuccess": "ツールが正常に更新されました",
diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json
index 32c5a4df84f..a810a6162cb 100644
--- a/webview-ui/src/i18n/locales/ko/settings.json
+++ b/webview-ui/src/i18n/locales/ko/settings.json
@@ -828,9 +828,9 @@
},
"CUSTOM_TOOLS": {
"name": "사용자 정의 도구 활성화",
- "description": "활성화하면 Roo가 프로젝트의 .roo/tools 디렉터리에서 사용자 정의 TypeScript/JavaScript 도구를 로드하고 사용할 수 있습니다.",
+ "description": "활성화하면 Roo가 프로젝트의 .roo/tools 디렉터리 또는 전역 도구를 위한 ~/.roo/tools에서 사용자 정의 TypeScript/JavaScript 도구를 로드하고 사용할 수 있습니다. 참고: 이러한 도구는 자동으로 자동 승인됩니다.",
"toolsHeader": "사용 가능한 사용자 정의 도구",
- "noTools": "로드된 사용자 정의 도구가 없습니다. 프로젝트의 .roo/tools 디렉터리에 .ts 또는 .js 파일을 추가하세요.",
+ "noTools": "로드된 사용자 정의 도구가 없습니다. 프로젝트의 .roo/tools 디렉터리 또는 전역 도구를 위한 ~/.roo/tools에 .ts 또는 .js 파일을 추가하세요.",
"refreshButton": "새로고침",
"refreshing": "새로고침 중...",
"refreshSuccess": "도구가 성공적으로 새로고침되었습니다",
diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json
index fe93eea35c1..10f14ae49de 100644
--- a/webview-ui/src/i18n/locales/nl/settings.json
+++ b/webview-ui/src/i18n/locales/nl/settings.json
@@ -828,9 +828,9 @@
},
"CUSTOM_TOOLS": {
"name": "Aangepaste tools inschakelen",
- "description": "Indien ingeschakeld kan Roo aangepaste TypeScript/JavaScript-tools laden en gebruiken uit de map .roo/tools van je project.",
+ "description": "Indien ingeschakeld kan Roo aangepaste TypeScript/JavaScript-tools laden en gebruiken uit de map .roo/tools van je project of ~/.roo/tools voor globale tools. Opmerking: deze tools worden automatisch goedgekeurd.",
"toolsHeader": "Beschikbare aangepaste tools",
- "noTools": "Geen aangepaste tools geladen. Voeg .ts- of .js-bestanden toe aan de map .roo/tools van je project.",
+ "noTools": "Geen aangepaste tools geladen. Voeg .ts- of .js-bestanden toe aan de map .roo/tools van je project of ~/.roo/tools voor globale tools.",
"refreshButton": "Vernieuwen",
"refreshing": "Vernieuwen...",
"refreshSuccess": "Tools succesvol vernieuwd",
diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json
index c1563f2e23f..c958a6e2895 100644
--- a/webview-ui/src/i18n/locales/pl/settings.json
+++ b/webview-ui/src/i18n/locales/pl/settings.json
@@ -828,9 +828,9 @@
},
"CUSTOM_TOOLS": {
"name": "Włącz niestandardowe narzędzia",
- "description": "Gdy włączone, Roo może ładować i używać niestandardowych narzędzi TypeScript/JavaScript z katalogu .roo/tools Twojego projektu.",
+ "description": "Gdy włączone, Roo może ładować i używać niestandardowych narzędzi TypeScript/JavaScript z katalogu .roo/tools Twojego projektu lub ~/.roo/tools dla narzędzi globalnych. Uwaga: te narzędzia będą automatycznie zatwierdzane.",
"toolsHeader": "Dostępne niestandardowe narzędzia",
- "noTools": "Nie załadowano niestandardowych narzędzi. Dodaj pliki .ts lub .js do katalogu .roo/tools swojego projektu.",
+ "noTools": "Nie załadowano niestandardowych narzędzi. Dodaj pliki .ts lub .js do katalogu .roo/tools swojego projektu lub ~/.roo/tools dla narzędzi globalnych.",
"refreshButton": "Odśwież",
"refreshing": "Odświeżanie...",
"refreshSuccess": "Narzędzia odświeżone pomyślnie",
diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json
index 4df36d14dff..062079ad7e3 100644
--- a/webview-ui/src/i18n/locales/pt-BR/settings.json
+++ b/webview-ui/src/i18n/locales/pt-BR/settings.json
@@ -828,9 +828,9 @@
},
"CUSTOM_TOOLS": {
"name": "Ativar ferramentas personalizadas",
- "description": "Quando habilitado, o Roo pode carregar e usar ferramentas TypeScript/JavaScript personalizadas do diretório .roo/tools do seu projeto.",
+ "description": "Quando habilitado, o Roo pode carregar e usar ferramentas TypeScript/JavaScript personalizadas do diretório .roo/tools do seu projeto ou ~/.roo/tools para ferramentas globais. Nota: estas ferramentas serão aprovadas automaticamente.",
"toolsHeader": "Ferramentas personalizadas disponíveis",
- "noTools": "Nenhuma ferramenta personalizada carregada. Adicione arquivos .ts ou .js ao diretório .roo/tools do seu projeto.",
+ "noTools": "Nenhuma ferramenta personalizada carregada. Adicione arquivos .ts ou .js ao diretório .roo/tools do seu projeto ou ~/.roo/tools para ferramentas globais.",
"refreshButton": "Atualizar",
"refreshing": "Atualizando...",
"refreshSuccess": "Ferramentas atualizadas com sucesso",
diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json
index 1057c86115d..a908bc2ddf6 100644
--- a/webview-ui/src/i18n/locales/ru/settings.json
+++ b/webview-ui/src/i18n/locales/ru/settings.json
@@ -828,9 +828,9 @@
},
"CUSTOM_TOOLS": {
"name": "Включить пользовательские инструменты",
- "description": "Если включено, Roo сможет загружать и использовать пользовательские инструменты TypeScript/JavaScript из каталога .roo/tools вашего проекта.",
+ "description": "Если включено, Roo сможет загружать и использовать пользовательские инструменты TypeScript/JavaScript из каталога .roo/tools вашего проекта или ~/.roo/tools для глобальных инструментов. Примечание: эти инструменты будут одобрены автоматически.",
"toolsHeader": "Доступные пользовательские инструменты",
- "noTools": "Пользовательские инструменты не загружены. Добавьте файлы .ts или .js в каталог .roo/tools вашего проекта.",
+ "noTools": "Пользовательские инструменты не загружены. Добавьте файлы .ts или .js в каталог .roo/tools вашего проекта или ~/.roo/tools для глобальных инструментов.",
"refreshButton": "Обновить",
"refreshing": "Обновление...",
"refreshSuccess": "Инструменты успешно обновлены",
diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json
index c8bd9888ffe..26d433e0af7 100644
--- a/webview-ui/src/i18n/locales/tr/settings.json
+++ b/webview-ui/src/i18n/locales/tr/settings.json
@@ -828,9 +828,9 @@
},
"CUSTOM_TOOLS": {
"name": "Özel araçları etkinleştir",
- "description": "Etkinleştirildiğinde, Roo projenizin .roo/tools dizininden özel TypeScript/JavaScript araçlarını yükleyebilir ve kullanabilir.",
+ "description": "Etkinleştirildiğinde, Roo projenizin .roo/tools dizininden veya global araçlar için ~/.roo/tools dizininden özel TypeScript/JavaScript araçlarını yükleyebilir ve kullanabilir. Not: Bu araçlar otomatik olarak onaylanacaktır.",
"toolsHeader": "Kullanılabilir Özel Araçlar",
- "noTools": "Özel araç yüklenmedi. Projenizin .roo/tools dizinine .ts veya .js dosyaları ekleyin.",
+ "noTools": "Özel araç yüklenmedi. Projenizin .roo/tools dizinine veya global araçlar için ~/.roo/tools dizinine .ts veya .js dosyaları ekleyin.",
"refreshButton": "Yenile",
"refreshing": "Yenileniyor...",
"refreshSuccess": "Araçlar başarıyla yenilendi",
diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json
index 4f1a38157d5..77d3fe7e2a2 100644
--- a/webview-ui/src/i18n/locales/vi/settings.json
+++ b/webview-ui/src/i18n/locales/vi/settings.json
@@ -828,9 +828,9 @@
},
"CUSTOM_TOOLS": {
"name": "Bật công cụ tùy chỉnh",
- "description": "Khi được bật, Roo có thể tải và sử dụng các công cụ TypeScript/JavaScript tùy chỉnh từ thư mục .roo/tools của dự án của bạn.",
+ "description": "Khi được bật, Roo có thể tải và sử dụng các công cụ TypeScript/JavaScript tùy chỉnh từ thư mục .roo/tools của dự án hoặc ~/.roo/tools cho các công cụ toàn cục. Lưu ý: các công cụ này sẽ được tự động phê duyệt.",
"toolsHeader": "Công cụ tùy chỉnh có sẵn",
- "noTools": "Không có công cụ tùy chỉnh nào được tải. Thêm tệp .ts hoặc .js vào thư mục .roo/tools của dự án của bạn.",
+ "noTools": "Không có công cụ tùy chỉnh nào được tải. Thêm tệp .ts hoặc .js vào thư mục .roo/tools của dự án hoặc ~/.roo/tools cho các công cụ toàn cục.",
"refreshButton": "Làm mới",
"refreshing": "Đang làm mới...",
"refreshSuccess": "Làm mới công cụ thành công",
diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json
index dd88c8fd20b..5a70cfd10de 100644
--- a/webview-ui/src/i18n/locales/zh-CN/settings.json
+++ b/webview-ui/src/i18n/locales/zh-CN/settings.json
@@ -828,9 +828,9 @@
},
"CUSTOM_TOOLS": {
"name": "启用自定义工具",
- "description": "启用后,Roo 可以从项目中的 .roo/tools 目录加载并使用自定义 TypeScript/JavaScript 工具。",
+ "description": "启用后 Roo 可从项目中的 .roo/tools 目录或全局工具目录 ~/.roo/tools 加载并使用自定义 TypeScript/JavaScript 工具。注意:这些工具将自动获批。",
"toolsHeader": "可用自定义工具",
- "noTools": "未加载自定义工具。请向项目的 .roo/tools 目录添加 .ts 或 .js 文件。",
+ "noTools": "未加载自定义工具。请向项目的 .roo/tools 目录或全局工具目录 ~/.roo/tools 添加 .ts 或 .js 文件。",
"refreshButton": "刷新",
"refreshing": "正在刷新...",
"refreshSuccess": "工具刷新成功",
diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json
index 221ad881a5b..88ab5a2c094 100644
--- a/webview-ui/src/i18n/locales/zh-TW/settings.json
+++ b/webview-ui/src/i18n/locales/zh-TW/settings.json
@@ -828,9 +828,9 @@
},
"CUSTOM_TOOLS": {
"name": "啟用自訂工具",
- "description": "啟用後,Roo 可以從專案中的 .roo/tools 目錄載入並使用自訂 TypeScript/JavaScript 工具。",
+ "description": "啟用後,Roo 可以從專案中的 .roo/tools 目錄或全域工具目錄 ~/.roo/tools 載入並使用自訂 TypeScript/JavaScript 工具。注意:這些工具將自動獲得核准。",
"toolsHeader": "可用自訂工具",
- "noTools": "未載入自訂工具。請向專案的 .roo/tools 目錄新增 .ts 或 .js 檔案。",
+ "noTools": "未載入自訂工具。請向專案的 .roo/tools 目錄或全域工具目錄 ~/.roo/tools 新增 .ts 或 .js 檔案。",
"refreshButton": "重新整理",
"refreshing": "正在重新整理...",
"refreshSuccess": "工具重新整理成功",