乐于分享
好东西不私藏

Claude Code 源码解读 08:实战构建自己的 Code Agent

Claude Code 源码解读 08:实战构建自己的 Code Agent

读源码是理解系统的最好方式,但动手实现才是真正的掌握。这一章,我们综合前面七章的知识,从零搭建一个简化版的 Code Agent——MiniAgent。它不会取代 Claude Code,但在这个构建过程中,你会真正理解每个子系统是如何协同工作的。

MiniAgent 的设计目标

我们的 MiniAgent 需要具备以下核心能力:

  1. Agent 循环
    :理解任务 → 调用工具 → 收集结果 → 判断是否完成
  2. 工具系统
    :内置 Read、Write、Bash 三个工具,支持扩展
  3. 权限控制
    :基于规则的权限检查
  4. Hooks 机制
    :PreToolUse / PostToolUse 事件处理
  5. MCP 兼容
    :能够连接 MCP 服务器获取额外工具
  6. CLI 界面
    :类似 Claude Code 的交互式终端

这个规模大概是 Claude Code 的 5%,但覆盖了所有核心设计决策。

项目结构

mini-agent/├── src/│   ├── main.ts           # CLI 入口│   ├── agent.ts          # Agent 循环核心│   ├── tools/│   │   ├── index.ts      # 工具注册表│   │   ├── Tool.ts       # Tool 接口定义│   │   ├── FileReadTool.ts│   │   ├── FileWriteTool.ts│   │   └── BashTool.ts│   ├── permissions/│   │   ├── rules.ts      # 权限规则匹配│   │   └── modes.ts      # 权限模式│   ├── hooks/│   │   ├── registry.ts   # Hook 注册表│   │   └── types.ts      # Hook 类型定义│   ├── mcp/│   │   └── client.ts     # MCP 客户端│   └── api/│       └── anthropic.ts  # API 调用封装├── package.json└── tsconfig.json

第一步:Tool 接口定义

一切从 Tool 开始。参考 Claude Code 的设计,我们定义一个最小化的 Tool 接口:

// src/tools/Tool.tsexport interface Tool<Input extends Record<stringunknown>, Output> {  namestring  descriptionstring  // 输入校验  inputSchema: z.ZodType<Input>  // 执行  call(    argsInput,    contextToolUseContext  ): Promise<ToolResult<Output>>  // 权限检查  checkPermissions(argsInput): PermissionResult  // 安全属性  isReadOnly(argsInput): boolean  isConcurrencySafe(argsInput): boolean  // 是否启用  isEnabled(): boolean}export interface ToolUseContext {  cwdstring  messagesMessage[]  abortSignalAbortSignal  permissionsPermissionMode  hooksHookRegistry}export interface ToolResult<T> {  data: T  newMessages?: Message[]  error?: string}export type PermissionResult =   | { allowedtrue }  | { allowedfalsereasonstring }

这里的关键设计:工具描述了它自己的一切——输入格式、执行逻辑、安全属性、权限需求。Claude Code 的 Tool 接口有 45 个成员,我们精简到 8 个,但覆盖了所有必要的能力。

第二步:内置工具实现

FileReadTool

// src/tools/FileReadTool.tsexport class FileReadTool implements Tool<FileReadInputFileReadOutput> {  name = 'Read'  description = 'Read contents of a file'  inputSchema = z.object({    file_path: z.string().describe('Path to the file to read')  })  isReadOnly = () => true  isConcurrencySafe = () => true  isEnabled = () => true  checkPermissions(args: FileReadInput): PermissionResult {    // 权限规则检查    if (isDenied(this.name, args.file_path, 'read')) {      return { allowed: false, reason: 'Path is in denied list' }    }    return { allowed: true }  }  asynccall(args: FileReadInput, ctx: ToolUseContext): Promise<ToolResult<FileReadOutput>> {    try {      const content = await fs.readFile(args.file_path, 'utf-8')      return { data: { content, path: args.file_path } }    } catch (e) {      return { data: { content: '', path: args.file_path }, error: String(e) }    }  }}

BashTool

BashTool 是最复杂的内置工具,需要解析命令判断是否安全:

// src/tools/BashTool.tsexport class BashTool implements Tool<BashInputBashOutput> {  name = 'Bash'  description = 'Execute a bash command'  inputSchema = z.object({    command: z.string().describe('The bash command to execute'),    timeout: z.number().optional().default(30000)  })  // 已知的安全命令(只读)  private readonly READ_COMMANDS = new Set(['ls''cat''grep''find''git''head''tail''wc'])  isReadOnly(argsBashInput): boolean {    const cmd = args.command.trim().split(/\s+/)[0]    return this.READ_COMMANDS.has(cmd)  }  isConcurrencySafe = () => false  // Bash 默认不安全,不并行执行  checkPermissions(argsBashInput): PermissionResult {    // 检查危险命令    if (/sudo|rm\s+-rf|dd\s+of=/.test(args.command)) {      return { allowedfalsereason'Dangerous command blocked' }    }    return { allowedtrue }  }  async call(argsBashInputctxToolUseContext): Promise<ToolResult<BashOutput>> {    return new Promise((resolve) => {      const child = spawn('/bin/bash', ['-c', args.command], {        cwd: ctx.cwd,        signal: ctx.abortSignal      })      let stdout = ''      let stderr = ''      child.stdout.on('data'(data) => { stdout += data.toString() })      child.stderr.on('data'(data) => { stderr += data.toString() })      const timeout = setTimeout(() => {        child.kill()        resolve({ data: { stdout, stderr, exitCode124 }, error'Command timed out' })      }, args.timeout)      child.on('close'(code) => {        clearTimeout(timeout)        resolve({           data: { stdout, stderr, exitCode: code ?? 0 },          error: code !== 0 ? `Exit code: {toolCall.name}`    const args = tool.inputSchema.parse(toolCall.arguments)    const contextToolUseContext = {      cwd: process.cwd(),      messagesthis.messages,      abortSignalnew AbortController().signal,      permissions'default',      hooksthis.hooks    }    // PreToolUse Hook(可修改输入或拒绝)    const hookResult = await this.hooks.emit('PreToolUse', {      tool_name: tool.name,      tool_input: args    })    if (hookResult.blocked) {      return `Blocked: {permResult.reason}`    }    // 执行工具    const result = await tool.call(args, context)    // PostToolUse Hook    await this.hooks.emit('PostToolUse', {      tool_name: tool.name,      tool_input: args,      tool_result: result    })    return result.error ?? JSON.stringify(result.data)  }  private getToolsDescription(): string {    return Array.from(this.tools.values())      .filter(t => t.isEnabled())      .map(t => `{t.description}`)      .join('\n')  }}

这个 Agent 循环包含了所有关键要素:

  • 消息持久化
    :tool_result 作为 user 消息追加,维持对话状态
  • 循环终止
    :模型不返回 tool_calls 时结束
  • Hook 拦截
    :PreToolUse 可以修改参数或直接拒绝
  • 权限兜底
    :Hook 放行后还有工具自身的权限检查

第六步:MCP 客户端(简化版)

MCP 客户端负责与 MCP 服务器通信,把外部工具接入 MiniAgent:

// src/mcp/client.tsexport class MCPClient {  private processChildProcess | null = null  private id = 0  private pending = new Map<number, { resolve(vany) => voidreject(eany) => void }>()  async connect(commandstringargsstring[]): Promise<void> {    this.process = spawn(command, args)    this.process.stdout?.on('data'(data) => {      const lines = data.toString().split('\n').filter(Boolean)      for (const line of lines) {        const msg = JSON.parse(line)        if (msg.id && this.pending.has(msg.id)) {          const { resolve, reject } = this.pending.get(msg.id)!          this.pending.delete(msg.id)          if (msg.errorreject(msg.error)          else resolve(msg.result)        }      }    })    // 初始化    await this.send('initialize', { protocolVersion'2024-11-05'capabilities: {} })    await this.send('notifications/initialized', {})  }  async listTools(): Promise<MCPTool[]> {    const res = await this.send('tools/list', {})    return res.tools ?? []  }  async callTool(namestringargsRecord<stringunknown>) {    const result = await this.send('tools/call', { name, arguments: args })    return result  }  private send(methodstringparamsany): Promise<any> {    return new Promise((resolve, reject) => {      const id = ++this.id      this.pending.set(id, { resolve, reject })      this.process?.stdin?.write(JSON.stringify({ jsonrpc'2.0', id, method, params }) + '\n')    })  }}

MCP 的核心是通过 stdio 的 JSON-RPC 通信。我们在 connect() 时完成握手(initialize),然后通过 send() 发送请求,通过 pending Map 管理异步响应。

第七步:CLI 入口

// src/main.tsasync functionmain() {  const agent new MiniAgent(anthropicModel, `    You are a helpful coding assistant.    You have access to tools: Read, Write, Bash.    Always be concise and practical.`)  // 注册内置工具  agent.registerTool(new FileReadTool())  agent.registerTool(new FileWriteTool())  agent.registerTool(new BashTool())  // 设置权限  agent.setPermissions({    allow: [      { tool'Read', pattern'^/home/.*\\.(ts|js|md){tool_name}" >&2'  })  // 连接 MCP(如果有的话)  const mcp new MCPClient()  try {    await mcp.connect('npx', ['-y''@modelcontextprotocol/server-filesystem''/tmp'])    const mcpTools = await mcp.listTools()    for(const t of mcpTools) {      agent.registerTool(new MCPToolAdapter(t, mcp))    }    console.log(`Connected  npx ts-node src/main.tsMiniAgent ready. Type your request (Ctrl+C to exit):> Read the package.json fileTool: ReadPermission: allowed{  "name""mini-agent",  "version""1.0.0"}> Run git statusTool: BashPermission: allowedOn branch mainnothing to commit, working tree clean> sudo rm -rf /Permission denied: Matched deny rule

哪些我们做到了,哪些没有

做到了:

  • 完整的 Agent 循环(思考→工具→结果→判断)
  • 工具注册与执行管道
  • 权限规则匹配(deny/allow 优先级)
  • Hook 拦截机制(PreToolUse、PostToolUse)
  • MCP 客户端基础框架

没有做到(Claude Code 的复杂度所在):

  • 流式响应(Claude Code 在模型还在输出时就启动工具调用)
  • 14 步精细的执行管道(我们合并成了 3 步)
  • 7 种权限模式(我们只有 deny/allow/ask 三级)
  • 工具的并发安全与 LRU 文件缓存
  • 子 Agent 和上下文隔离
  • 内置的 TUI(我们只是 CLI)
  • Prompt 缓存和上下文窗口管理

最重要的没做到:Claude Code 的工具系统有 40+ 个工具、deferred loading、result budgeting、auto-mode AI 分类器——这些不是写代码能解决的,是 Anthropic 团队数年迭代的工程产物。

写在最后

写这个 MiniAgent 的过程,其实也是一个逆向思考的过程:为什么 Claude Code 要这样设计?为什么 Tool 接口需要 45 个成员而不是 5 个?为什么权限检查要走 7 层而不是 2 层?

当你亲手写出”简化版”之后,这些问题的答案就变得异常清晰了。

这就是源码解读系列的意义——不是为了让你重新实现一个 Claude Code,而是为了让你在动手的过程中,真正理解那些看起来理所当然的设计决策,背后都有什么样的权衡和迭代。

本系列至此完结。如果这个系列让你对 Claude Code 有了新的认识,那我的目的就达到了。