实现一个 Agent 插件

77 阅读7分钟

在 AI 编程助手爆发的时代,理解 Agent 的底层原理比使用工具更重要。本项目实现了一个从零构建的 VSCode Agent 插件,包含:

  • ✅ 大模型意图拆解(Plan-and-Execute)
  • ✅ 动态工具调用(Function Calling)
  • ✅ 安全的文件操作与授权
  • ✅ 流式内容展示(SSE + Markdown)
  • ✅ 终端命令执行
  • ✅ Diff 预览与确认机制

架构设计

image.png

数据流向:

  1. 用户输入 → React UI → Extension → Server
  2. 意图拆解 → Server 调用 OpenAI(带工具定义)
  3. 工具执行 → Server 下发 Tool Call → Extension 执行 → 结果回传
  4. 循环迭代 → Server 再次请求 OpenAI → 直到任务完成
  5. 流式展示 → Server 通过 SSE 推送内容 → React 实时渲染

1. 工具调用

Function Calling 是 OpenAI 提供的一种机制,允许模型在需要时调用外部函数来获取信息或执行操作。它不是让模型直接执行代码,而是让模型生成结构化的函数调用请求,由开发者执行后返回结果。

定义可用工具列表(Tool Definitions)

// src/tools/definitions.ts
export const TOOLS = [
  {
    type: "function",
    function: {
      name: "read_file",
      description: "读取指定路径的文件内容。用于获取现有文件的内容进行分析或修改。",
      parameters: {
        type: "object",
        properties: {
          path: { 
            type: "string", 
            description: "文件的绝对路径。建议使用工作区内的相对路径或绝对路径。" 
          }
        },
        required: ["path"]
      }
    }
  },
  {
    type: "function",
    function: {
      name: "write_file",
      description: "写入内容到文件。会覆盖原文件内容。修改前建议先读取原文件进行对比。",
      parameters: {
        type: "object",
        properties: {
          path: { 
            type: "string", 
            description: "文件的绝对路径。" 
          },
          content: { 
            type: "string", 
            description: "要写入的文件内容。确保内容格式正确。" 
          }
        },
        required: ["path", "content"]
      }
    }
  },
  {
    type: "function",
    function: {
      name: "run_command",
      description: "在终端执行 Shell 命令。用于运行构建、测试、安装依赖等操作。",
      parameters: {
        type: "object",
        properties: {
          command: { 
            type: "string", 
            description: "要执行的 Shell 命令。确保命令安全且可执行。" 
          }
        },
        required: ["command"]
      }
    }
  },
  {
    type: "function",
    function: {
      name: "search_web",
      description: "搜索网页获取信息。当需要最新信息或不确定答案时使用。",
      parameters: {
        type: "object",
        properties: {
          query: { 
            type: "string", 
            description: "搜索关键词" 
          }
        },
        required: ["query"]
      }
    }
  }
] as const;

工具注入OpenAI

// 🔥 核心:调用 OpenAI 并传入工具定义
const stream = await openai.chat.completions.create({
  model: 'gpt-4o-2024-08-06',
  messages: messages as any,
  tools: TOOLS,  // 传入工具定义
  tool_choice: 'auto',  // 让模型自动决定是否调用
  stream: true,
});

获取OpenAI返回的工具调用列表

let toolCalls: any[] = [];

// 处理流式响应
for await (const chunk of stream) {
  const delta = chunk.choices[0]?.delta;
  
  // 🔧 处理 Tool Calls(关键!)
  if (delta?.tool_calls) {
    for (const tc of delta.tool_calls) {
      const index = tc.index || 0;
      if (!toolCalls[index]) {
        toolCalls[index] = { 
          id: tc.id || `call_${Date.now()}_${index}`,
          function: { name: '', arguments: '' } 
        };
      }
      // 累积参数(流式返回可能分多次)
      if (tc.id) toolCalls[index].id = tc.id;
      if (tc.function?.name) toolCalls[index].function.name = tc.function.name;
      if (tc.function?.arguments) {
        toolCalls[index].function.arguments += tc.function.arguments;
      }
    }
  }
}

解析工具调用配置信息

const parsedToolCalls = toolCalls.map((tc: any) => {
  try {
    return {
      id: tc.id,
      name: tc.function.name,
      arguments: JSON.parse(tc.function.arguments || '{}')
    };
  } catch (e) {
    return {
      id: tc.id,
      name: tc.function.name,
      arguments: {}
    };
  }
});

通知 Extension 执行工具

res.write(`data: ${JSON.stringify({ 
   type: 'tool_calls', 
   data: parsedToolCalls 
})}\n\n`);

Extension 端执行工具

// 🔥 核心:执行工具
async execute(toolName: string, args: any): Promise<string> {
 switch (toolName) {
   case 'read_file':
     return await this.readFile(args.path);
   case 'write_file':
     return await this.writeFile(args.path, args.content);
   case 'run_command':
     return await this.runCommand(args.command);
   case 'search_web':
     return await this.searchWeb(args.query);
   default:
     return `❌ 未知工具: ${toolName}`;
 }
}

工具调用流程图

image.png

2. 文件系统操作与授权机制

步骤 1: 路径规范化和安全检查

  • vscode.Uri.file(path)获取虚拟路径
  • vscode.workspace.getWorkspaceFolder(uri)检查是否在工作区
const normalizedPath = this.normalizePath(filePath);
const uri = vscode.Uri.file(normalizedPath);

// 安全检查:是否在工作区内
const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri);
if (!workspaceFolder) {
  return `❌ 文件不在工作区内,拒绝修改: ${normalizedPath}`;
}

// 安全检查:文件类型
const fileExt = path.extname(normalizedPath).toLowerCase();
const dangerousExts = ['.exe', '.dll', '.so', '.dylib', '.bin'];
if (dangerousExts.includes(fileExt)) {
  return `❌ 拒绝修改二进制文件: ${normalizedPath}`;
}

步骤 2: 检查文件是否存在并读取原内容

  • vscode.workspace.fs.stat(uri)检查文件/目录是否存在和获取元数据
  • vscode.workspace.fs.readFile(uri)读取文件内容
const stat = await vscode.workspace.fs.stat(uri);
// 返回: { type: FileType, size: number, mtime: number, ... }
// 如果文件不存在,抛出错误:FileNotFoundError
const fileStat = await vscode.workspace.fs.stat(uri);
const fileExists = true;

// 检查是否是文件(不是文件夹)
if (fileStat.type !== vscode.FileType.File) {
  return `❌ ${normalizedPath} 是文件夹,不是文件`;
}
        
// 检查文件大小(防止读取超大文件)
const maxSize = 10 * 1024 * 1024; // 10MB
if (fileStat.size > maxSize) {
  return `❌ 文件过大 (${(fileStat.size / 1024 / 1024).toFixed(2)}MB),拒绝修改`;
}

// 读取原内容
const data = await vscode.workspace.fs.readFile(uri);
originalContent = Buffer.from(data).toString('utf-8');

步骤 3: 内容变更检测

if (fileExists && originalContent === newContent) {
  return `⏭️ 文件内容未变化,无需修改: ${normalizedPath}`;
}

步骤 4: 写权限检查与授权

  • vscode.workspace.fs.writeFile()写入文件
if (fileExists) {
  // 尝试写入测试(实际权限检查)
  try {
    // 创建临时文件测试写入权限
    const testUri = vscode.Uri.file(path.join(path.dirname(normalizedPath), `.tmp_${Date.now()}`));
    await vscode.workspace.fs.writeFile(testUri, Buffer.from('test', 'utf-8'));
    await vscode.workspace.fs.delete(testUri);
  } catch (error) {
    // 没有写入权限,请求用户授权
    const granted = await this.requestWritePermission(normalizedPath);
    if (!granted) {
        return `⛔ 用户拒绝了写权限`;
    }
  }
}

步骤 6: 执行写入(含备份)

//1 创建备份(可选)
let backupPath: string | undefined;
if (fileExists) {
  backupPath = await this.createBackup(normalizedPath, originalContent);
}

// 2 实际写入
try {
  await vscode.workspace.fs.writeFile(uri, Buffer.from(newContent, 'utf-8'));
} catch (writeError) {
  // 写入失败,恢复备份
  if (backupPath) {
    await this.restoreBackup(normalizedPath, backupPath);
  }
  throw writeError;
}

// 3 清理备份(成功后删除)
if (backupPath) {
  try {
    await vscode.workspace.fs.delete(vscode.Uri.file(backupPath));
  } catch (e) {
    // 备份删除失败不影响主流程
  }
}

授权流程

image.png

3. VSCode 授权机制

VSCode 的权限模型是:

  1. 声明权限(package.json 的 capabilities)
  2. 用户运行时授权(通过 Dialog 或 Workspace Trust)

方式一:Workspace Trust(工作区信任)

// package.json
{
  "capabilities": {
    "workspace": {
      "read": true,
      "write": true,
      "trusted": true  // 需要用户信任工作区
    }
  }
}

用户第一次打开文件夹时,VSCode 会询问是否信任:

// 检查工作区是否被信任
const isTrusted = vscode.workspace.isTrusted;
if (!isTrusted) {
  const result = await vscode.window.showInformationMessage(
    '需要信任工作区才能访问文件',
    '信任工作区',
    '取消'
  );
  if (result === '信任工作区') {
    await vscode.commands.executeCommand('vscode.trustWorkspace');
  }
}

方式二:用户选择授权(最常用)

// ✅ 正确方式:让用户主动选择文件
const result = await vscode.window.showOpenDialog({
  defaultUri: vscode.Uri.file(path),
  canSelectFiles: true,
  canSelectFolders: false,
  openLabel: '选择并授权文件'
});

if (result && result.length > 0) {
  // 用户选择了文件,URI 自动获得访问权限
  const selectedUri = result[0];
  const content = await vscode.workspace.fs.readFile(selectedUri);
  return content;
}

为什么选择后就获得权限?

  • VSCode 在 showOpenDialog 时,会通过系统文件选择器获取权限
  • 返回的 URI 被标记为 "已授权",后续操作可以正常访问

方式三:全局文件系统 API(最稳健)

// 使用 Node.js fs 模块(需要额外权限)
import * as fs from 'fs';

// 但这样会绕过 VSCode 的权限系统!
// ❌ 不推荐:在 VSCode 插件中使用 Node.js fs
const content = fs.readFileSync(path, 'utf-8');

4. 大模型意图拆解与多步规划

复杂的用户请求(如 "重构整个项目的 API 调用")需要拆解为多个步骤:

  1. 读取所有 API 文件
  2. 分析调用模式
  3. 生成新的代码
  4. 逐个文件替换

System Prompt 设计

const SYSTEM_PROMPT = `你是一个专业的代码助手 Agent。

## 核心能力
你可以使用以下工具来完成任务:
1. **read_file** - 读取文件内容
2. **write_file** - 写入内容到文件(会覆盖)
3. **run_command** - 执行 Shell 命令
4. **search_web** - 搜索网页获取信息

## 工作流程
1. **理解意图**:分析用户想要什么
2. **拆解步骤**:将复杂任务分解为可执行的小步骤
3. **执行计划**:按步骤调用工具,每一步都验证结果
4. **迭代优化**:如果结果不理想,调整计划继续执行

## 规则
- 每次调用工具后,等待工具结果再继续
- 如果结果已满足用户需求,立即返回最终答案
- 最多调用 10 次工具
- 修改文件前,先读取原内容确认
- 对于不确定的操作,先用 search_web 查询

## 示例
用户:"重构 config.json 中的 API 地址"
你的思考:
1. 读取 config.json → 了解当前配置
2. 分析需要修改哪些 URL
3. 生成新的配置内容
4. 写入文件(显示 Diff 预览)
5. 确认修改完成

开始工作前,先思考并制定计划。`;

多步规划实现(Plan-and-Execute)

// 用户提示词前追加一条 role = 'system' 的message
export class TaskPlanner {
  static async planTask(userPrompt: string, openai: OpenAI): Promise<PlanStep[]> {
    const response = await openai.chat.completions.create({
      model: 'gpt-4o-2024-08-06',
      messages: [
        {
          role: 'system',
          content: `你将用户的请求拆解为可执行的步骤列表。
          输出格式为 JSON 数组:[{ "step": 1, "action": "描述", "tool": "工具名", "params": {} }]
          工具名:read_file, write_file, run_command, search_web`
        },
        { role: 'user', content: userPrompt }
      ],
      response_format: { type: 'json_object' }
    });

    const result = JSON.parse(response.choices[0].message.content || '{"steps": []}');
    return result.steps || [];
  }
}

// 在 AgentController 中使用
private async executePlan(userPrompt: string): Promise<void> {
  // 1️⃣ 生成计划
  const steps = await TaskPlanner.planTask(userPrompt, this.openai);
  
  // 2️⃣ 展示计划给用户
  this.webviewManager.sendMessage({
    type: 'plan',
    data: { steps }
  });

  // 3️⃣ 逐步执行
  for (const step of steps) {
    this.webviewManager.sendMessage({
      type: 'stepStart',
      data: { step }
    });

    try {
      // 执行工具
      const result = await this.executor.execute(step.tool, step.params);
      
      this.webviewManager.sendMessage({
        type: 'stepComplete',
        data: { step, result }
      });

      // 检查是否需要继续
      const shouldContinue = await this.checkContinuation(result);
      if (!shouldContinue) break;
    } catch (error) {
      this.webviewManager.sendMessage({
        type: 'stepError',
        data: { step, error: String(error) }
      });
      // 询问用户是否继续
      const continue = await this.askUserContinue();
      if (!continue) break;
    }
  }

  // 4️⃣ 总结
  await this.generateSummary();
}

5. 变更内容预览(Diff Preview)

Diff 数据生成与传输

// src/agent/ToolExecutor.ts
private async showDiffPreview(path: string, original: string, modified: string): Promise<boolean> {
  const stepId = `diff_${Date.now()}`;
  
  // 发送 Diff 数据到 Webview
  this.webviewManager.sendMessage({
    type: 'showDiff',
    data: { path, original, modified, stepId }
  });

  // 等待用户确认
  return new Promise((resolve) => {
    const handler = (msg: any) => {
      if (msg.type === 'diffConfirmed' && msg.payload.stepId === stepId) {
        resolve(msg.payload.confirmed);
        this.webviewManager.offMessage('diffConfirmed', handler);
      }
    };
    this.webviewManager.onMessage('diffConfirmed', handler);
    
    // 超时
    setTimeout(() => {
      resolve(false);
    }, 60000);
  });
}

Diff 预览效果

image.png

6. 终端执行与命令管理

终端创建与命令执行

private async runCommand(command: string): Promise<string> {
  // 1️⃣ 安全检查
  const dangerousCommands = [
    'rm -rf', 'sudo', 'dd if', 'mkfs', 
    'chmod 777', 'chown', 'kill -9'
  ];
  
  if (dangerousCommands.some(cmd => command.includes(cmd))) {
    return `❌ 检测到危险命令,已阻止执行: ${command}`;
  }

  // 2️⃣ 用户确认
  const confirm = await vscode.window.showWarningMessage(
    `Agent 要执行命令: ${command}`,
    { modal: true },
    '✅ 允许执行',
    '❌ 拒绝'
  );

  if (confirm !== '✅ 允许执行') {
    return `⛔ 用户拒绝了命令执行`;
  }

  // 3️⃣ 创建终端
  const terminal = vscode.window.createTerminal({
    name: 'Agent Command',
    cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath,
    iconPath: new vscode.ThemeIcon('terminal')
  });

  // 4️⃣ 显示终端
  terminal.show(true);
  
  // 5️⃣ 执行命令
  terminal.sendText(command);

  // 6️⃣ 捕获输出(需要 Shell Integration)
  let output = '';
  const outputDisposable = vscode.window.onDidWriteTerminalData((event) => {
    if (event.terminal === terminal) {
      output += event.data;
    }
  });

  // 7️⃣ 等待命令完成(简化版)
  await new Promise(resolve => setTimeout(resolve, 3000));
  
  outputDisposable.dispose();

  return `✅ 命令已执行: ${command}\n\n📋 输出(部分):\n${output.slice(-500)}`;
}

7. 授权UI

方式一、全局弹框模式

image.png

方式二、内联交互模式

image.png

操作流如下所示:

image.png