Claude Code 源码解读 08:实战构建自己的 Code Agent
读源码是理解系统的最好方式,但动手实现才是真正的掌握。这一章,我们综合前面七章的知识,从零搭建一个简化版的 Code Agent——MiniAgent。它不会取代 Claude Code,但在这个构建过程中,你会真正理解每个子系统是如何协同工作的。
MiniAgent 的设计目标
我们的 MiniAgent 需要具备以下核心能力:
- Agent 循环
:理解任务 → 调用工具 → 收集结果 → 判断是否完成 - 工具系统
:内置 Read、Write、Bash 三个工具,支持扩展 - 权限控制
:基于规则的权限检查 - Hooks 机制
:PreToolUse / PostToolUse 事件处理 - MCP 兼容
:能够连接 MCP 服务器获取额外工具 - 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<string, unknown>, Output> {name: stringdescription: string// 输入校验inputSchema: z.ZodType<Input>// 执行call(args: Input,context: ToolUseContext): Promise<ToolResult<Output>>// 权限检查checkPermissions(args: Input): PermissionResult// 安全属性isReadOnly(args: Input): booleanisConcurrencySafe(args: Input): boolean// 是否启用isEnabled(): boolean}export interface ToolUseContext {cwd: stringmessages: Message[]abortSignal: AbortSignalpermissions: PermissionModehooks: HookRegistry}export interface ToolResult<T> {data: TnewMessages?: Message[]error?: string}export type PermissionResult =| { allowed: true }| { allowed: false; reason: string }
这里的关键设计:工具描述了它自己的一切——输入格式、执行逻辑、安全属性、权限需求。Claude Code 的 Tool 接口有 45 个成员,我们精简到 8 个,但覆盖了所有必要的能力。
第二步:内置工具实现
FileReadTool
// src/tools/FileReadTool.tsexport class FileReadTool implements Tool<FileReadInput, FileReadOutput> {name = 'Read'description = 'Read contents of a file'inputSchema = z.object({file_path: z.string().describe('Path to the file to read')})isReadOnly = () => trueisConcurrencySafe = () => trueisEnabled = () => truecheckPermissions(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<BashInput, BashOutput> {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(args: BashInput): boolean {const cmd = args.command.trim().split(/\s+/)[0]return this.READ_COMMANDS.has(cmd)}isConcurrencySafe = () => false // Bash 默认不安全,不并行执行checkPermissions(args: BashInput): PermissionResult {// 检查危险命令if (/sudo|rm\s+-rf|dd\s+of=/.test(args.command)) {return { allowed: false, reason: 'Dangerous command blocked' }}return { allowed: true }}async call(args: BashInput, ctx: ToolUseContext): 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, exitCode: 124 }, 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 context: ToolUseContext = {cwd: process.cwd(),messages: this.messages,abortSignal: new AbortController().signal,permissions: 'default',hooks: this.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 Hookawait 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 process: ChildProcess | null = nullprivate id = 0private pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>()async connect(command: string, args: string[]): 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.error) reject(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(name: string, args: Record<string, unknown>) {const result = await this.send('tools/call', { name, arguments: args })return result}private send(method: string, params: any): Promise<any> {return new Promise((resolve, reject) => {const id = ++this.idthis.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 有了新的认识,那我的目的就达到了。
夜雨聆风