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}`) + } + } + + lines.push(``) + 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 && ( +
+
+ + +
+ + {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": "工具重新整理成功",