Claude-skill-registry copilot-tool-design
Guide to designing and implementing tools for the vscode-copilot-chat extension, including patterns, testing, and best practices
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/copilot-tool-design" ~/.claude/skills/majiayu000-claude-skill-registry-copilot-tool-design && rm -rf "$T"
skills/data/copilot-tool-design/SKILL.mdThis skill provides comprehensive guidance on designing, implementing, and testing tools for the vscode-copilot-chat extension.
What are Tools?
Tools are capabilities that Copilot agents can invoke to interact with the system. They enable agents to:
- Read and write files
- Execute commands
- Search the codebase
- Interact with VS Code APIs
- Call external services
Tool Architecture
Tool Interface
Every tool implements this interface:
export interface IToolName { invoke( options: LanguageModelToolInvocationOptions, token: CancellationToken ): Promise<LanguageModelToolResult>; }
Key components:
- Contains input parametersLanguageModelToolInvocationOptions
- For cancellable operationsCancellationToken
- Structured responseLanguageModelToolResult
Tool Registration
Tools are registered in
src/extension/tools/vscode-node/tools.ts:
tools.push({ name: ToolName.MyTool, // Enum value description: 'What the tool does', // For LLM understanding inputSchema: { /* JSON schema */ }, // Expected input format invoke: async (options, token) => { const tool = instantiationService.createInstance(MyTool); return tool.invoke(options, token); } });
Tool Design Principles
1. Single Responsibility
Each tool should do ONE thing well:
// ✅ GOOD - Single purpose class ReadFileTool { async invoke(options, token) { const { filePath } = options.input; const content = await this.fs.readFile(filePath); return this.formatResponse(content); } } // ❌ BAD - Multiple responsibilities class FileOperationsTool { async invoke(options, token) { const { operation, filePath, content } = options.input; switch (operation) { case 'read': return this.read(filePath); case 'write': return this.write(filePath, content); case 'delete': return this.delete(filePath); case 'search': return this.search(filePath); } } }
Why: Single-purpose tools are easier to understand, test, and compose.
2. Clear Input Schema
Define precise, well-documented schemas:
inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Absolute path to the file to read' }, encoding: { type: 'string', description: 'File encoding (default: utf-8)', enum: ['utf-8', 'ascii', 'base64'], default: 'utf-8' }, maxLines: { type: 'number', description: 'Maximum number of lines to read', minimum: 1 } }, required: ['filePath'] }
Best practices:
- Describe every property clearly
- Use
for fixed choicesenum - Mark required fields
- Provide defaults when sensible
- Use specific types (not just 'string')
3. Structured Output
Return well-formatted, parseable results:
// ✅ GOOD - Structured JSON return new LanguageModelToolResult([ new LanguageModelTextPart(JSON.stringify({ success: true, filePath: path, content: fileContent, lines: lineCount, encoding: 'utf-8' }, null, 2)) ]); // ❌ BAD - Unstructured text return new LanguageModelToolResult([ new LanguageModelTextPart( `File: ${path}\nContent: ${fileContent}\nLines: ${lineCount}` ) ]);
Why: Structured output is easier for the LLM to parse and use.
4. Error Handling
Handle errors gracefully with informative messages:
async invoke(options, token) { try { const { filePath } = options.input; // Validate input if (!filePath) { return this.errorResponse('filePath is required'); } if (!path.isAbsolute(filePath)) { return this.errorResponse('filePath must be absolute'); } // Perform operation const content = await this.fs.readFile(filePath, 'utf-8'); return this.successResponse({ filePath, content, size: content.length }); } catch (error) { // Specific error messages if (error.code === 'ENOENT') { return this.errorResponse(`File not found: ${filePath}`); } if (error.code === 'EACCES') { return this.errorResponse(`Permission denied: ${filePath}`); } return this.errorResponse(`Error reading file: ${error.message}`); } } private successResponse(data: any) { return new LanguageModelToolResult([ new LanguageModelTextPart(JSON.stringify({ success: true, ...data }, null, 2)) ]); } private errorResponse(message: string) { return new LanguageModelToolResult([ new LanguageModelTextPart(JSON.stringify({ success: false, error: message }, null, 2)) ]); }
5. Cancellation Support
Respect the CancellationToken:
async invoke(options, token: CancellationToken) { // Check cancellation before expensive operations if (token.isCancellationRequested) { return this.errorResponse('Operation cancelled'); } const files = await this.findFiles(pattern); // Check again during long operations for (const file of files) { if (token.isCancellationRequested) { return this.errorResponse('Operation cancelled'); } await this.processFile(file); } return this.successResponse({ processedCount: files.length }); }
Tool Implementation Pattern
Complete Example
// 1. Define the interface export interface IMyTool { invoke( options: LanguageModelToolInvocationOptions, token: CancellationToken ): Promise<LanguageModelToolResult>; } export const IMyTool = createDecorator<IMyTool>('myTool'); // 2. Implement the tool export class MyTool implements IMyTool { static readonly TOOL_ID = 'myTool'; constructor( @IFileSystemService private readonly fs: IFileSystemService, @ILogService private readonly log: ILogService ) {} async invoke( options: LanguageModelToolInvocationOptions, token: CancellationToken ): Promise<LanguageModelToolResult> { try { this.log.info(`MyTool invoked with: ${JSON.stringify(options.input)}`); // Validate input const input = this.validateInput(options.input); // Check cancellation if (token.isCancellationRequested) { return this.errorResponse('Cancelled'); } // Perform operation const result = await this.performOperation(input, token); // Return success return this.successResponse(result); } catch (error) { this.log.error(`MyTool error:`, error); return this.errorResponse(error.message); } } private validateInput(input: any): ValidatedInput { // Validation logic if (!input.requiredParam) { throw new Error('requiredParam is required'); } return input as ValidatedInput; } private async performOperation( input: ValidatedInput, token: CancellationToken ): Promise<OperationResult> { // Implementation } private successResponse(data: any) { return new LanguageModelToolResult([ new LanguageModelTextPart(JSON.stringify({ success: true, ...data }, null, 2)) ]); } private errorResponse(error: string) { return new LanguageModelToolResult([ new LanguageModelTextPart(JSON.stringify({ success: false, error }, null, 2)) ]); } } // 3. Register in tools.ts tools.push({ name: ToolName.MyTool, description: 'Does something useful', inputSchema: { type: 'object', properties: { requiredParam: { type: 'string', description: 'A required parameter' } }, required: ['requiredParam'] }, invoke: async (options, token) => { const tool = instantiationService.createInstance(MyTool); return tool.invoke(options, token); } }); // 4. Add to toolNames.ts export enum ToolName { // ... existing tools MyTool = 'myTool', }
Testing Tools
Unit Test Pattern
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { MyTool } from '../../node/myTool.js'; import { MockFileSystemService } from '../mocks/mockFileSystem.js'; describe('MyTool', () => { let tool: MyTool; let mockFs: MockFileSystemService; let mockToken: CancellationToken; beforeEach(() => { mockFs = new MockFileSystemService(); mockToken = { isCancellationRequested: false } as CancellationToken; tool = new MyTool(mockFs, mockLog); }); it('should perform operation successfully', async () => { const options = { input: { requiredParam: 'value' } }; const result = await tool.invoke(options, mockToken); const parsed = JSON.parse(result.content[0].value); expect(parsed.success).toBe(true); expect(parsed.data).toBeDefined(); }); it('should handle missing required parameter', async () => { const options = { input: {} // Missing requiredParam }; const result = await tool.invoke(options, mockToken); const parsed = JSON.parse(result.content[0].value); expect(parsed.success).toBe(false); expect(parsed.error).toContain('required'); }); it('should respect cancellation token', async () => { mockToken.isCancellationRequested = true; const options = { input: { requiredParam: 'value' } }; const result = await tool.invoke(options, mockToken); const parsed = JSON.parse(result.content[0].value); expect(parsed.success).toBe(false); expect(parsed.error).toContain('cancel'); }); });
Common Tool Patterns
File Operations
// Read file class ReadFileTool { async invoke(options, token) { const { filePath } = options.input; const content = await this.fs.readFile(filePath, 'utf-8'); return this.successResponse({ filePath, content }); } } // Write file class WriteFileTool { async invoke(options, token) { const { filePath, content } = options.input; await this.fs.writeFile(filePath, content, 'utf-8'); return this.successResponse({ filePath, written: true }); } }
Search Operations
class SearchFilesTool { async invoke(options, token) { const { pattern, includePattern, excludePattern } = options.input; const files = await this.workspace.findFiles( includePattern || '**/*', excludePattern || '**/node_modules/**' ); const matches = []; for (const file of files) { if (token.isCancellationRequested) break; const content = await this.fs.readFile(file.fsPath, 'utf-8'); if (content.includes(pattern)) { matches.push(file.fsPath); } } return this.successResponse({ matches, count: matches.length }); } }
Command Execution
class RunCommandTool { async invoke(options, token) { const { command, cwd } = options.input; const process = this.processService.spawn(command, { cwd, shell: true }); const output = await new Promise<string>((resolve, reject) => { let stdout = ''; process.stdout.on('data', (data) => stdout += data); process.on('close', (code) => { if (code === 0) resolve(stdout); else reject(new Error(`Command failed with code ${code}`)); }); token.onCancellationRequested(() => { process.kill(); reject(new Error('Cancelled')); }); }); return this.successResponse({ output, exitCode: 0 }); } }
Best Practices Summary
- Single Responsibility: One tool, one purpose
- Clear Schemas: Document every input parameter
- Structured Output: Return JSON with success/error
- Error Handling: Specific, helpful error messages
- Cancellation: Check token during long operations
- Validation: Validate inputs before processing
- Logging: Log invocations and errors
- Testing: Comprehensive unit tests
- Documentation: Clear descriptions for LLM
- Security: Validate file paths, sanitize inputs
Remember: Tools are how agents interact with the world. Well-designed tools enable powerful agent capabilities!