Article Detail

从零构建终端 SWE Agent:10 轮迭代完整指南

2026-05-14MDX POCzh

从零构建终端 SWE Agent:10 轮迭代完整指南

有一类软件,你觉得它很神奇,直到理解了它的原理,然后发现一切都很自然。AI 编程助手就是这类软件。看着 Claude Code 这样的工具根据一句自然语言描述来读写文件、运行命令、编辑代码——看起来像是黑魔法。

其实不是。底层就是一个循环:调用 LLM,检查它是否想用工具,执行工具,把结果喂回去,重复直到模型满意。复杂性藏在细节里——流式处理、工具定义、权限控制、状态管理,以及把一切串起来的终端 UI。

这篇指南通过 10 轮迭代,一步步构建一个终端 SWE Agent。你会从 bun init 开始,最终得到一个可运行的 Agent,具备流式 AI 响应、工具执行、斜杠命令、权限系统和状态管理。目标读者是有 React 经验但没有 AI Agent 开发经验的工程师。

完整实现的代码规模约 15,000-20,000 行 TypeScript,单人全职预计 2-3 个月。

我们要构建什么

项目名叫 my-swe-agent。它在终端中运行,使用 React 19 + Ink 5 做 UI,通过 Anthropic Messages API(或本地代理)连接 LLM,在与模型的反馈循环中执行文件读取、Shell 命令、代码搜索等工具。

架构遵循一个简单的循环:

用户输入 → API 调用 → 流式响应流 → 工具执行 → 结果回传 → 重复

每一轮迭代都在前一轮的基础上增加一个核心能力,最终汇聚成一个完整的 AI 编程助手。

前置知识要求

在开始之前,你应该已经掌握:

技能 熟练度要求
React 基础 函数组件、useState、useEffect、useContext
TypeScript 类型定义、泛型、utility types
异步编程 Promise、async/await、for await…of
Git 基本操作
终端 基本命令行操作

不需要提前掌握的(我们会在过程中一起学习):

  • Ink 终端渲染框架
  • Anthropic API(我们通过兼容层调用,格式一致)
  • CLI 工具设计模式
  • AI Agent 循环架构

API 兼容层说明

本项目通过 @anthropic-ai/sdk 调用 API,但将 baseURL 指向本地代理(cc-switcher),由代理将 Anthropic 格式的请求转换为 OpenAI 格式发往 DeepSeek 或其他后端。这意味着:

  • 代码中写的全是 Anthropic SDK 的调用方式
  • 实际的模型可以是 DeepSeek、OpenAI、或其他任意通过代理桥接的模型
  • 不需要真正的 Anthropic API Key
  • 代理在后台常驻:python3 ~/Desktop/code/cc-switcher/app.py

这种设计的好处是:你写的代码与 Claude Code 使用的 SDK 完全一致,但实际调用的模型可以灵活切换。

开发策略

在开始编码之前,先确立几条核心策略:

先写核心,再补外围——先让最基本的功能跑通(REPL + API 调用),再逐步添加工具和优化。不要在第一天就想实现权限系统。

每次迭代都可运行——每一轮迭代结束时,程序都应该能运行并看到可见的成果。这能保持动力,也方便 debug——如果某一步出了问题,你知道是刚加的代码导致的。

遇到困难先简化——如果某个功能太复杂,先做 80% 的版本,后续迭代再完善。一个能用的简化版比一个永远在开发中的完美版更有价值。

渐近式复杂性——不要试图一次性实现所有功能。先做最常用的 70%,再逐步完善剩下的 30%。


迭代 1:最小 REPL 外壳

目标:搭建最基本的终端交互框架——用户输入文字,程序显示在屏幕上。

预计时间:第 1-2 天

创建项目

mkdir my-swe-agent
cd my-swe-agent
bun init -y
bun add react ink
bun add -d @types/react typescript

为什么选择 Ink?

Ink 是 React 的终端渲染器。它与 React DOM 类似,但组件渲染的是终端字符而非 HTML。关键概念:

  • <Text> = 终端的 <span>
  • <Box> = 终端的 <div>(支持 flexbox 布局)
  • useInput = 键盘事件处理
  • render() 返回 { rerender, unmount, waitUntilExit }

实现最简单的外壳

src/main.tsx

#!/usr/bin/env bun
import React, { useState } from "react";
import { render, Text, Box, useInput, useApp } from "ink";

function REPL() {
  const { exit } = useApp();
  const [input, setInput] = useState("");
  const [history, setHistory] = useState<string[]>([]);

  useInput((_input, key) => {
    if (key.return && input.trim()) {
      setHistory((h) => [...h, `> ${input}`, `[Echo] ${input}`]);
      setInput("");
    } else if (key.escape) {
      exit();
    } else if (key.backspace || key.delete) {
      setInput((i) => i.slice(0, -1));
    } else if (_input) {
      setInput((i) => i + _input);
    }
  });

  return (
    <Box flexDirection="column" height="100%">
      <Box flexDirection="column" flexGrow={1}>
        {history.map((line, i) => (
          <Text key={i}>{line}</Text>
        ))}
      </Box>
      <Box>
        <Text>&gt; {input}</Text>
      </Box>
    </Box>
  );
}

render(<REPL />);

运行:bun src/main.tsx

useInput 的深层理解

useInput 的回调接收两个参数:inputkey

  • input 是按键对应的实际字符(字母数字等)
  • key 对象包含修饰键信息:returnescapebackspacedeleteupArrowdownArrowtabctrlmeta

重要的细节:key.returnkey.escape 不会产生 input 字符(它们是控制键),所以判断逻辑是 if (key.return) 而不是 if (_input === '\n')

验证标准

  • [x] 启动后显示空白 TUI 界面
  • [x] 打字显示在底部提示符后
  • [x] Enter 提交输入 → 显示回显
  • [x] Backspace 删除字符
  • [x] 上箭头/下箭头浏览历史(可选)
  • [x] Escape 退出程序

调试技巧

如果 Ink 应用卡住了,可以用 process.exit(1) 强制退出。开发时也可以先注释掉 render(),用 console.log 测试逻辑。


迭代 2:Anthropic API 集成与流式对话

目标:用户输入发送到 LLM,流式显示响应内容,一个字一个字地出现。

预计时间:第 3-5 天

配置代理

在开始编码之前,确保代理在后台运行:

python3 ~/Desktop/code/cc-switcher/app.py &
# 验证代理是否运行
curl http://127.0.0.1:5001/proxy/opencode-go/health

代理的作用:它监听在 http://127.0.0.1:5001,接受 Anthropic Messages API 格式的请求,转换为 OpenAI 格式发往 DeepSeek(或其他上游),将 OpenAI 的 SSE 流式响应转换回 Anthropic 的 content_block_delta 格式。

安装依赖

bun add @anthropic-ai/sdk

创建 API 客户端

src/services/api/claude.ts

import Anthropic from "@anthropic-ai/sdk";

const PROXY_BASE_URL = "http://127.0.0.1:5001/proxy/opencode-go";
const DEFAULT_MODEL = "claude-opencode-go-flash";

let client: Anthropic | null = null;

export function getClient(): Anthropic {
  if (!client) {
    client = new Anthropic({
      apiKey: process.env.ANTHROPIC_API_KEY || "sk-proxy-managed",
      baseURL: process.env.ANTHROPIC_BASE_URL || PROXY_BASE_URL,
      timeout: 5 * 60 * 1000,
    });
  }
  return client;
}

export async function* queryModel(params: {
  system: string;
  messages: Array<{ role: "user" | "assistant"; content: string }>;
  tools?: any[];
  maxTokens?: number;
}): AsyncGenerator<string> {
  const anthropic = getClient();
  const stream = await anthropic.messages.create({
    model: process.env.SWE_MODEL || DEFAULT_MODEL,
    max_tokens: params.maxTokens ?? 8192,
    system: params.system,
    messages: params.messages,
    tools: params.tools,
    stream: true,
  });

  for await (const event of stream) {
    if (
      event.type === "content_block_delta" &&
      event.delta.type === "text_delta"
    ) {
      yield event.delta.text;
    }
  }
}

为什么用 AsyncGenerator?

queryModel 使用 async function*(AsyncGenerator),因为它天然适合流式场景:

  • 调用者可以用 for await...of 消费流
  • 每次 yield 返回一个文本块
  • 不需要事件回调或手动状态管理
  • 调用者控制消费的速度(背压)

更新 REPL 以支持流式响应

function REPL() {
  const [input, setInput] = useState("");
  const [messages, setMessages] = useState<Message[]>([]);
  const [streamingText, setStreamingText] = useState("");
  const [isProcessing, setIsProcessing] = useState(false);

  const handleSubmit = async () => {
    if (!input.trim() || isProcessing) return;

    const userMsg: Message = { role: "user", content: input };
    const updatedMessages = [...messages, userMsg];
    setMessages(updatedMessages);
    setInput("");
    setIsProcessing(true);
    setStreamingText("...");

    try {
      let fullResponse = "";
      const stream = queryModel({
        system: "You are a helpful coding assistant called SWE Agent.",
        messages: updatedMessages.map((m) => ({
          role: m.role,
          content: typeof m.content === "string" ? m.content : "",
        })),
      });

      for await (const chunk of stream) {
        fullResponse += chunk;
        setStreamingText(fullResponse);
      }

      setMessages((prev) => [
        ...prev,
        { role: "assistant", content: fullResponse },
      ]);
      setStreamingText("");
    } catch (err) {
      setStreamingText(`[Error] ${err}`);
    } finally {
      setIsProcessing(false);
    }
  };

  useInput((_input, key) => {
    if (key.return && input.trim() && !isProcessing) {
      handleSubmit();
    } else if (key.escape) {
      exit();
    } else if (key.backspace || key.delete) {
      setInput((i) => i.slice(0, -1));
    } else if (_input) {
      setInput((i) => i + _input);
    }
  });

  return (
    <Box flexDirection="column">
      <Box flexDirection="column">
        {messages.map((msg, i) => (
          <Box key={i} flexDirection="column">
            <Text bold color={msg.role === "user" ? "green" : "cyan"}>
              {msg.role === "user" ? "> " : "AI: "}
            </Text>
            <Text>{msg.content}</Text>
          </Box>
        ))}
        {streamingText && (
          <Box flexDirection="column">
            <Text bold color="cyan">AI: </Text>
            <Text>{streamingText}</Text>
          </Box>
        )}
      </Box>
      <Box marginTop={1}>
        <Text>{"> "}{input}</Text>
        {isProcessing && <Text dimColor> (processing...)</Text>}
      </Box>
    </Box>
  );
}

流式响应的用户体验设计

流式响应的渲染有几个关键设计考虑:

  1. 打字机效果——文字逐字出现让用户感觉到模型正在"思考"和"输出",相比一次性显示全部响应,用户体验更好
  2. 处理中的状态提示——isProcessing 让用户知道系统正在工作,避免重复提交
  3. 错误处理——网络错误、API 错误、超时等都需要优雅处理,显示在界面上而不是直接崩溃
  4. 输入锁定——处理中时禁用输入,防止用户在模型响应时提交新消息

验证标准

  • [x] 启动前确保 cc-switcher proxy 运行在后台
  • [x] 输入问题后看到模型流式响应(逐字出现)
  • [x] 响应完成后可以继续对话(上下文保持)
  • [x] 响应中出现 error 时显示错误信息
  • [x] 处理中时输入框被禁用

常见问题排查

问题 可能原因 解决方法
连接被拒绝 proxy 未启动 运行 python3 ~/Desktop/code/cc-switcher/app.py
空响应 消息格式不对 检查 system 和 messages 参数
超时错误 网络问题或模型负载高 增加 timeout,检查网络连接
对话不连贯 没有发送完整历史 确保 messages 数组包含所有历史消息

迭代 3:BuildTool 工厂与基础工具集

目标:实现 buildTool 工厂模式和基础工具定义。这是整个工具系统的基础设施。

预计时间:第 6-9 天

为什么需要 buildTool 工厂?

如果没有工厂模式,每个工具都需要重复实现大量样板代码——描述、权限检查、并发安全、输入验证等。buildTool 工厂通过提供智能默认值解决了这个问题:工具定义只需要关注核心差异,自动获得标准化的接口。

定义 Tool 类型和 buildTool

src/tools/Tool.ts

import { z } from "zod";

export type ToolContext = {
  cwd: string;
};

export type ToolResult<T = unknown> = {
  data: T;
  isError?: boolean;
};

export type ToolDef = {
  name: string;
  inputSchema: z.ZodType;
  outputSchema?: z.ZodType;
  description?: () => Promise<string>;
  prompt?: () => Promise<string>;
  call: (input: unknown, context: ToolContext) => Promise<ToolResult>;
  isReadOnly?: () => boolean;
  isConcurrencySafe?: () => boolean;
  isEnabled?: () => boolean;
  validateInput?: (input: unknown, ctx: ToolContext) => ValidationResult;
};

export type Tool = {
  name: string;
  inputSchema: z.ZodType;
  outputSchema?: z.ZodType;
  description: () => Promise<string>;
  prompt: () => Promise<string>;
  call: (input: unknown, context: ToolContext) => Promise<ToolResult>;
  isReadOnly: () => boolean;
  isConcurrencySafe: () => boolean;
  isEnabled: () => boolean;
  validateInput?: (input: unknown, ctx: ToolContext) => ValidationResult;
};

type ValidationResult =
  | { result: true }
  | { result: false; message: string };

const TOOL_DEFAULTS = {
  description: async () => "",
  prompt: async () => "",
  isReadOnly: () => false,
  isConcurrencySafe: () => false,
  isEnabled: () => true,
};

export function buildTool<D extends ToolDef>(def: D): Tool {
  return {
    ...TOOL_DEFAULTS,
    ...def,
  };
}

export function createToolContext(cwd?: string): ToolContext {
  return { cwd: cwd ?? process.cwd() };
}

关于 TOOL_DEFAULTS 的思考

TOOL_DEFAULTS 的设计体现了"约定优于配置"的原则:

  • 大多数工具不是只读的,所以 isReadOnly 默认为 false
  • 大多数工具不是并发安全的(串行执行更安全),所以 isConcurrencySafe 默认为 false
  • 描述和提示词是可选的,默认为空
  • 工具默认启用

当这些默认值不适用时,工具定义可以覆盖它们。例如 Read 工具是只读和并发安全的,就显式覆写这两个属性。

深入理解 Zod Schema

Zod 是 TypeScript 生态中流行的 Schema 验证库。在工具系统中,每个工具的 inputSchema 使用 Zod 定义,作用包括:

  • 运行时验证——当模型调用工具时,参数必须通过 Zod 验证
  • 类型推断——z.infer<typeof tool.inputSchema> 可以从 Schema 中提取出 TypeScript 类型
  • JSON Schema 生成——通过 zod-to-json-schema,可以将 Zod Schema 转换为 API 需要的 JSON Schema 格式

实现 BashTool

src/tools/BashTool/BashTool.ts

import { execa } from "execa";
import { buildTool } from "../Tool.js";

export const BashTool = buildTool({
  name: "Bash",
  inputSchema: z.object({
    command: z.string().describe("The shell command to execute"),
    description: z.string().optional().describe("What this command does"),
    timeout: z.number().optional().describe("Timeout in milliseconds"),
  }),
  outputSchema: z.object({
    stdout: z.string(),
    stderr: z.string(),
    exitCode: z.number(),
  }),
  isConcurrencySafe: () => false,
  isReadOnly: () => false,

  async prompt() {
    return `## Bash
Executes shell commands. Use this to run code, build, test, etc.
Use full paths and proper quoting. Long-running commands should use &.

Parameters:
- command (required): The shell command to execute
- description (optional): What this command does
- timeout (optional): Timeout in ms (default: 120000)`;
  },

  async call({ command, timeout }) {
    const result = await execa("bash", ["-c", command], {
      timeout: timeout ?? 120_000,
      reject: false,
      all: true,
    });
    return {
      data: {
        stdout: result.stdout ?? "",
        stderr: result.stderr ?? "",
        exitCode: result.exitCode ?? 0,
      },
    };
  },
});

BashTool 的设计要点

  1. 使用 execa 而非 child_process——execa 提供了更好的 API(Promise 返回、默认转义、更好的错误处理)
  2. reject: false——不因为非零退出码而抛出异常,让模型自己判断命令是否成功
  3. all: true——同时捕获 stdout 和 stderr
  4. isConcurrencySafe: false——Shell 有状态(cwd、环境变量),不能并行执行
  5. prompt() 返回自然语言描述——这会被注入到系统提示词中,帮助模型理解何时使用这个工具

实现 FileReadTool

src/tools/FileReadTool/FileReadTool.ts

import { readFile } from "fs/promises";
import { buildTool } from "../Tool.js";

export const FileReadTool = buildTool({
  name: "Read",
  inputSchema: z.object({
    file_path: z.string().describe("Absolute path to the file"),
    offset: z.number().optional().describe("Line number to start from (0-based)"),
    limit: z.number().optional().describe("Maximum number of lines to read"),
  }),
  isReadOnly: () => true,
  isConcurrencySafe: () => true,

  async call({ file_path, offset, limit }) {
    const content = await readFile(file_path, "utf-8");
    const lines = content.split("\n");
    const start = offset ?? 0;
    const end = limit ? start + limit : lines.length;
    return {
      data: {
        content: lines.slice(start, end).join("\n"),
        totalLines: lines.length,
        startLine: start,
        endLine: end,
      },
    };
  },
});

FileReadTool 的两个关键属性:

  • isReadOnly: true——这告诉权限系统不需要每次都询问用户
  • isConcurrencySafe: true——多个文件可以同时读取

实现 FileWriteTool

src/tools/FileWriteTool/FileWriteTool.ts

import { writeFile, mkdir } from "fs/promises";
import { dirname } from "path";
import { buildTool } from "../Tool.js";

export const FileWriteTool = buildTool({
  name: "Write",
  inputSchema: z.object({
    file_path: z.string().describe("Absolute path to the file"),
    content: z.string().describe("The complete new file content"),
  }),
  isReadOnly: () => false,
  isConcurrencySafe: () => false,

  async call({ file_path, content }) {
    await mkdir(dirname(file_path), { recursive: true });
    await writeFile(file_path, content, "utf-8");
    return {
      data: {
        message: `Written ${content.length} bytes to ${file_path}`,
      },
    };
  },
});

实现 GlobTool

src/tools/GlobTool/GlobTool.ts

import { glob } from "fs/promises";
import { buildTool } from "../Tool.js";

export const GlobTool = buildTool({
  name: "Glob",
  inputSchema: z.object({
    pattern: z.string().describe("Glob pattern to search (e.g., '**/*.ts')"),
    path: z.string().optional().describe("Directory to search in"),
  }),
  isReadOnly: () => true,
  isConcurrencySafe: () => true,

  async call({ pattern, path }) {
    const cwd = path ?? process.cwd();
    const results: string[] = [];
    for await (const file of glob(pattern, { cwd })) {
      results.push(file);
    }
    return { data: { files: results, count: results.length } };
  },
});

实现 GrepTool

src/tools/GrepTool/GrepTool.ts

import { execa } from "execa";
import { buildTool } from "../Tool.js";

export const GrepTool = buildTool({
  name: "Grep",
  inputSchema: z.object({
    pattern: z.string().describe("Search pattern (regex supported)"),
    path: z.string().optional().describe("Directory to search in"),
    include: z.string().optional().describe("File glob pattern to include"),
    maxResults: z.number().optional().default(50),
  }),
  isReadOnly: () => true,
  isConcurrencySafe: () => true,

  async call({ pattern, path, include, maxResults }) {
    const args = ["--color", "never", "--line-number", "--with-filename"];
    if (include) args.push("--glob", include);
    args.push("--max-count", String(maxResults ?? 50));
    args.push(pattern);
    if (path) args.push(path);

    const { stdout, exitCode } = await execa("rg", args, {
      reject: false,
      timeout: 30000,
    });

    if (exitCode === 2) return { isError: true, data: { message: "rg failed" } };
    const results = stdout ? stdout.split("\n").filter(Boolean) : [];
    return { data: { results, count: results.length, truncated: results.length >= (maxResults ?? 50) } };
  },
});

工具注册表

src/tools/registry.ts

import { BashTool } from "./BashTool/BashTool.js";
import { FileReadTool } from "./FileReadTool/FileReadTool.js";
import { FileWriteTool } from "./FileWriteTool/FileWriteTool.js";
import { GlobTool } from "./GlobTool/GlobTool.js";
import { GrepTool } from "./GrepTool/GrepTool.js";
import type { Tool } from "./Tool.js";

export function getAllTools(): Tool[] {
  return [BashTool, FileReadTool, FileWriteTool, GlobTool, GrepTool];
}

工具目录结构

每个工具都有自己的目录,包含核心实现、UI 组件和提示词:

src/tools/BashTool/
├── BashTool.ts     ← 核心实现(buildTool 调用)
├── UI.tsx          ← 终端渲染组件
└── index.ts        ← 统一导出

这种自包含的结构使得添加新工具只需创建一个目录,然后在注册表中注册即可。

验证标准

  • [x] BashTool 可以执行 shell 命令并返回输出
  • [x] FileReadTool 可以读取文件内容
  • [x] FileWriteTool 可以写入文件
  • [x] GlobTool 可以搜索匹配的文件
  • [x] GrepTool 可以搜索文件内容
  • [x] 工具注册表返回所有已注册的工具
  • [x] 所有工具通过 Zod Schema 验证输入参数

测试工具

可以在 test.js 中单独测试每个工具:

import { BashTool } from "./src/tools/BashTool/BashTool.js";
const result = await BashTool.call(
  { command: "echo hello", timeout: 5000 },
  { cwd: process.cwd() }
);
console.log(result);

迭代 4:工具执行循环

目标:实现完整的 Agent 循环——API 调用 → 工具执行 → 结果回传 → 继续推理。

预计时间:第 10-14 天

这是整个 Agent 的心脏。理解这个循环是理解 AI Agent 工作原理的关键。

理解 Stream 事件结构

Anthropic SDK 的流式响应包含多种事件类型:

type StreamEvent =
  | { type: "message_start"; message: { id: string; content: ContentBlock[] } }
  | { type: "content_block_start"; index: number; content_block: ContentBlock }
  | { type: "content_block_delta"; index: number; delta: TextDelta | ToolUseDelta | ThinkingDelta }
  | { type: "content_block_stop"; index: number }
  | { type: "message_delta"; delta: { stop_reason: string }; usage: Usage }
  | { type: "message_stop" };

type ContentBlock =
  | { type: "text"; text: string }
  | { type: "tool_use"; id: string; name: string; input: object }
  | { type: "thinking"; thinking: string };

关键理解点:

  • tool_use 块的 input 在流中是不完整的!必须等到 content_block_stop 事件后才能使用完整的 input
  • text_delta 是逐渐到达的,需要累积拼接成完整的文本
  • stop_reason 告诉你模型为什么停止——end_turn(自然结束)、tool_use(想用工具)、max_tokens(达到上限)

实现核心查询循环

src/query.ts

import Anthropic from "@anthropic-ai/sdk";
import { getClient } from "./services/api/claude.js";

export interface QueryParams {
  system: string;
  messages: any[];
  tools: Tool[];
  model: string;
  maxTokens?: number;
}

export type ToolUseBlock = {
  id: string;
  name: string;
  input: Record<string, unknown>;
};

export type ToolResultBlock = {
  type: "tool_result";
  tool_use_id: string;
  content: string;
  is_error?: boolean;
};

export type ContentBlock =
  | { type: "text"; text: string }
  | { type: "tool_use"; id: string; name: string; input: object }
  | { type: "thinking"; thinking: string };

export async function* queryLoop(
  params: QueryParams
): AsyncGenerator<any> {
  const { system, messages, tools, model } = params;
  const anthropic = getClient();

  // 这是 Agent 循环——while(true) 直到模型不再调用工具
  while (true) {
    const toolUseBlocks: ToolUseBlock[] = [];
    let currentText = "";

    // 阶段 1:API 调用
    const stream = await anthropic.messages.create({
      model,
      max_tokens: params.maxTokens ?? 8192,
      system,
      messages,
      tools: tools.map(formatToolForAPI),
      stream: true,
    });

    // 阶段 2:处理流式事件
    for await (const event of stream) {
      switch (event.type) {
        case "content_block_start":
          if (event.content_block.type === "tool_use") {
            toolUseBlocks.push({
              id: event.content_block.id,
              name: event.content_block.name,
              input: event.content_block.input as Record<string, unknown>,
            });
          }
          break;

        case "content_block_delta":
          if (event.delta.type === "text_delta") {
            currentText += event.delta.text;
            yield { type: "text", text: event.delta.text };
          }
          break;

        case "message_delta":
          yield { type: "stop_reason", reason: event.delta.stop_reason };
          break;
      }
    }

    // 阶段 3:如果没有工具调用 → 完成
    if (toolUseBlocks.length === 0) {
      messages.push({ role: "assistant", content: currentText });
      return;
    }

    // 阶段 4:构建 assistant 消息(包含文本和工具调用)
    const assistantContent: ContentBlock[] = [];
    if (currentText) {
      assistantContent.push({ type: "text", text: currentText });
    }
    for (const tb of toolUseBlocks) {
      assistantContent.push({
        type: "tool_use",
        id: tb.id,
        name: tb.name,
        input: tb.input,
      });
    }
    messages.push({ role: "assistant", content: assistantContent });

    // 阶段 5:执行每个工具
    const toolResults: ToolResultBlock[] = [];
    for (const tb of toolUseBlocks) {
      const tool = findTool(tb.name);
      if (!tool) {
        toolResults.push({
          type: "tool_result",
          tool_use_id: tb.id,
          content: `Unknown tool: ${tb.name}`,
          is_error: true,
        });
        continue;
      }

      const result = await tool.call(tb.input, { cwd: process.cwd() });
      yield { type: "tool_result", toolUseId: tb.id, data: result.data };

      toolResults.push({
        type: "tool_result",
        tool_use_id: tb.id,
        content: typeof result.data === "string"
          ? result.data
          : JSON.stringify(result.data, null, 2),
      });
    }

    // 阶段 6:工具结果回传 → 继续循环
    // 关键:tool_result 以 user 角色消息的格式发回给 API
    messages.push({ role: "user", content: toolResults });
    // 回到 while(true) 开头,发送新一轮 API 请求
  }
}

function formatToolForAPI(tool: Tool) {
  return {
    name: tool.name,
    description: tool.description,
    input_schema: tool.inputSchema,
  };
}

function findTool(name: string): Tool | undefined {
  return getAllTools().find((t) => t.name === name);
}

循环的六阶段详解

阶段 1:API 调用——发送当前消息数组(包含所有历史)到 LLM,请求流式响应。

阶段 2:事件处理——遍历流事件:

  • text_delta → 累加文本,yield 给 UI 渲染
  • tool_use 块开始 → 记录工具调用信息(名称、参数)
  • message_delta → 拿到 stop_reason,判断模型是否想用工具

阶段 3:判断是否需要继续——如果没有工具调用,模型是在正常回答,输出最终结果,结束循环。

阶段 4:保存 assistant 消息——将模型的文本回复和工具调用请求一起保存到消息历史中。

阶段 5:执行工具——遍历模型请求的工具调用,逐一执行。结果格式化为 tool_result 内容块。

阶段 6:反馈结果——将工具执行结果以 user 角色的消息推入历史。回到循环开头,模型基于工具结果继续推理。

<>

这是 Anthropic API 的约定:tool_result 内容块必须包含在 role: "user" 的消息中。这反映了"工具的运行结果是环境对 Agent 的反馈"这个概念——就像用户在说话。

并行工具执行

对于 isConcurrencySafe() 返回 true 的工具,可以并行执行以提升效率:

async function executeToolsConcurrently(
  toolCalls: ToolUseBlock[],
): Promise<ToolResultBlock[]> {
  // 按并发安全分两组
  const safe = toolCalls.filter(
    (tb) => findTool(tb.name)?.isConcurrencySafe() ?? false
  );
  const unsafe = toolCalls.filter(
    (tb) => !(findTool(tb.name)?.isConcurrencySafe() ?? false)
  );

  // 并发安全的工具并行执行
  const safeResults = await Promise.all(
    safe.map((tb) => executeSingleTool(tb))
  );

  // 不安全的串行执行
  const unsafeResults = [];
  for (const tb of unsafe) {
    unsafeResults.push(await executeSingleTool(tb));
  }

  return [...safeResults, ...unsafeResults];
}

更新 REPL 以使用查询循环

function REPL() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [streamingText, setStreamingText] = useState("");
  const [isProcessing, setIsProcessing] = useState(false);

  const handleSubmit = async (input: string) => {
    if (!input.trim() || isProcessing) return;

    setMessages((m) => [...m, { role: "user", content: input }]);
    setIsProcessing(true);
    setStreamingText("");

    // 维护 API 消息列表
    const apiMessages: ApiMessage[] = [];

    try {
      for await (const event of queryLoop({
        system: "You are a helpful coding assistant.",
        messages: apiMessages,
        tools: getAllTools(),
        model: process.env.SWE_MODEL || "deepseek-v4-flash",
      })) {
        if (event.type === "text") {
          setStreamingText((prev) => prev + event.text);
        }
        if (event.type === "tool_result") {
          setStreamingText(
            (prev) => prev + `\n[Tool ${event.toolUseId} completed]\n`
          );
        }
        if (event.type === "stop_reason") {
          // 模型停止原因
        }
      }
    } catch (err) {
      setStreamingText(`Error: ${err}`);
    }

    setIsProcessing(false);
  };

  // ... 渲染
}

验证标准

  • [x] 用户输入 → 模型返回工具调用 → 执行 → 结果回传 → 模型继续推理
  • [x] 流式文本渲染平滑(逐字出现)
  • [x] 多个工具调用正确处理
  • [x] 未知工具名优雅处理错误
  • [x] 没有工具调用时正常结束回复
  • [x] 并发安全的工具并行执行

常见调试方法

Agent 循环是最容易出现 bug 的地方。推荐这些调试方法:

  1. 打印消息历史——每次循环迭代后,打印当前消息数组的长度和最后几条消息的角色
  2. 记录工具调用——在 tool.call() 前后打印日志,记录输入和输出
  3. 模拟 LLM 响应——在测试时,可以用 mock 来替代真实的 API 调用,返回预定义的工具调用序列
  4. 限制循环次数——开发时设置 maxTurns: 5,防止无限循环浪费 token

迭代 5:输入处理与命令系统

目标:实现 /slash 命令和输入预处理。不是所有输入都需要发给 LLM。

预计时间:第 15-19 天

三种命令类型

从 Claude Code 的架构中借鉴了三种命令类型的设计:

type CommandBase = {
  name: string;
  description: string;
  aliases?: string[];
};

// PromptCommand — 生成提示词发送给 LLM
// 例如 /commit 生成 commit message 模板,然后让模型创建 commit
type PromptCommand = CommandBase & {
  type: "prompt";
  getPromptForCommand(args: string): ContentBlockParam;
};

// LocalCommand — 进程内同步执行,返回纯文本
// 例如 /clear 清空对话,/version 显示版本号
type LocalCommand = CommandBase & {
  type: "local";
  call(args: string): Promise<string | undefined>;
};

// LocalJSXCommand — 返回 React 组件
// 例如 /config 渲染交互式设置编辑器
type LocalJSXCommand = CommandBase & {
  type: "local-jsx";
  render(onDone: () => void): React.ReactNode;
};

type Command = PromptCommand | LocalCommand | LocalJSXCommand;

实现命令解析

function processUserInput(input: string, commands: Command[]): ProcessResult {
  // 检查是不是 slash 命令
  if (input.startsWith("/")) {
    const [cmdName, ...args] = input.slice(1).split(" ");
    const cmd = findCommand(cmdName, commands);
    if (!cmd) {
      return { type: "error", message: `Unknown command: /${cmdName}` };
    }

    if (cmd.type === "local") {
      const result = await cmd.call(args.join(" "));
      return { type: "local_result", content: result, shouldQuery: false };
    }

    if (cmd.type === "prompt") {
      const prompt = await cmd.getPromptForCommand(args.join(" "));
      return { type: "prompt", content: prompt, shouldQuery: true };
    }

    // LocalJSXCommand → 渲染交互面板
    return { type: "jsx", component: cmd.render(onDone), shouldQuery: false };
  }

  // 不是 slash 命令,当做普通查询发给 LLM
  return { type: "query", content: input, shouldQuery: true };
}

<>

最简单的 LocalCommand:

export const clearCommand: Command = {
  type: "local",
  name: "clear",
  description: "Clear the conversation",
  async call() {
    return "CLEAR_CONVERSATION"; // 特殊标记,告诉 REPL 清空状态
  },
};

命令注册表

src/commands/registry.ts

import type { Command } from "./types.js";

const commands: Command[] = [];

export function registerCommand(cmd: Command): void {
  commands.push(cmd);
}

export function getCommands(): Command[] {
  return [...commands];
}

export function findCommand(name: string): Command | undefined {
  return commands.find(
    (c) => c.name === name || c.aliases?.includes(name)
  );
}

// 注册内置命令
registerCommand(clearCommand);
registerCommand(helpCommand);

验证标准

  • [x] 输入 /clear 清空对话
  • [x] 输入 /help 显示命令列表
  • [x] 未知命令显示错误提示
  • [x] 普通输入正常发送给模型
  • [x] 命令可以带参数(如 /model gpt-4o

迭代 6:系统提示词构建与上下文管理

目标:构建有效的系统提示词,管理 CLAUDE.md 记忆和 Git 上下文。

预计时间:第 20-23 天

为什么系统提示词很重要

系统提示词决定了 LLM 的行为方式。好的系统提示词可以显著提高工具调用的准确性和代码质量。它不是一段静态文本,而是根据当前上下文动态构建的。

系统提示词构建函数

src/prompts/system.ts

export function buildSystemPrompt(context: PromptContext): string {
  return [
    getIntroduction(),
    getBehaviorRules(),
    getTaskGuidelines(),
    getToolDescriptions(context.tools),
    getEnvironmentInfo(),
    getMemoryContent(context.cwd),
  ].join("\n\n");
}

function getIntroduction(): string {
  return `You is an interactive agent that helps users with software engineering tasks.
You operate in a terminal environment and have access to various tools.`;
}

function getBehaviorRules(): string {
  return `## Core Rules
1. Think step by step before taking action
2. Read files before editing them
3. After editing files, read them again to verify changes
4. Handle errors gracefully and suggest fixes
5. Prefer minimal changes - only modify what's necessary
6. Ask the user before destructive operations
7. If unsure, use available tools to gather more information`;
}

function getTaskGuidelines(): string {
  return `## Task Execution
- Break down complex tasks into smaller steps
- After each step, verify the result before proceeding
- Use Git commands for version control operations
- Write clear, documented code following project conventions`;
}

function getToolDescriptions(tools: Tool[]): string {
  return tools
    .filter((t) => t.isEnabled())
    .map((t) => t.prompt())
    .join("\n\n");
}

function getEnvironmentInfo(): string {
  return `## Environment
- OS: ${process.platform}
- Shell: ${process.env.SHELL || "bash"}
- Working Directory: ${process.cwd()}`;
}

function getMemoryContent(cwd: string): string {
  // 读取 CLAUDE.md 文件(项目级记忆)
  const claudeMdPath = join(cwd, "CLAUDE.md");
  try {
    return readFileSync(claudeMdPath, "utf-8");
  } catch {
    return "";
  }
}

<>

系统提示词可以用模板文件(如 prompts/system.txt)配合变量替换来实现,也可以完全在代码中动态构建。两种方式各有优劣:

  • 模板方式——提示词内容与代码分离,非开发者也可以修改,但在代码中嵌入变量替换逻辑较复杂
  • 动态构建——完全类型安全,灵活,但提示词内容分散在代码中

本项目采用动态构建方式,因为提示词需要根据当前注册的工具列表动态生成。

工具提示词

每个工具通过 prompt() 方法返回自然语言描述,这些描述被注入到系统提示词中。工具提示词应该包含:

  1. 工具做什么——简明扼要的描述
  2. 何时使用——与其他工具的区分
  3. 参数说明——如何构建参数的值
  4. 示例——可选的使用示例

CLAUDE.md 记忆机制

CLAUDE.md 文件是项目级的记忆(类似于 .env 但针对 AI 助手)。Agent 在启动时读取该文件的内容,将其注入系统提示词。常见的用途包括:

  • 项目结构说明
  • 代码命名约定
  • 常用命令和脚本
  • 依赖关系说明

验证标准

  • [x] 系统提示词包含角色定义和行为规则
  • [x] 工具描述自动从工具定义中获取
  • [x] 环境信息(操作系统、目录、Shell)动态注入
  • [x] CLAUDE.md 内容被读取并注入
  • [x] 提示词随工具注册表变化而更新

迭代 7:权限系统

目标:在工具执行前进行权限检查,保护用户数据安全。

预计时间:第 24-28 天

为什么需要权限系统

Agent 可以执行 shell 命令、读写文件——这本质上是让 AI 控制你的电脑。如果没有权限控制,一个错误的命令可能导致数据丢失。权限系统是安全的最外层防线。

权限模型

权限系统基于规则评估:

type PermissionAction = "allow" | "deny" | "ask";
type PermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "dontAsk" | "plan" | "auto";

interface PermissionRule {
  permission: string;  // 工具名或 "*"(通配)
  pattern: string;     // 通配符模式
  action: PermissionAction;
}

权限检查链

每个工具调用在真正执行前,都经过以下检查链:

function hasPermissionsToUseTool(tool, input, context): PermissionResult {
  // 步骤 1a: 全局拒绝规则 → 立即拒绝
  // 例如 "Bash(rm *)" → 所有 rm 命令都被拦截

  // 步骤 1b: 全局询问规则 → 需要用户确认
  // 例如 "Edit" → 所有编辑工具都提示用户

  // 步骤 1c: 工具特定权限 → tool.checkPermissions()
  // BashTool 检查命令是否危险

  // 步骤 1d: 模式检查
  // bypassPermissions → 自动允许
  // plan → 暂停所有执行

  // 步骤 1e: 总是允许规则 → 自动通过

  // 步骤 1f: 默认 → 询问用户
}

权限模式

模式 行为 适用场景
default 标准模式,破坏性操作需要确认 正常交互式使用
acceptEdits 当前目录的文件编辑自动允许 开发工作流
bypassPermissions 自动批准所有操作(危险) 自动化/CI 脚本
dontAsk 所有 ASK 视为 DENY 受限环境
plan 暂停所有工具执行 规划阶段
auto AI 分类器决定(实验性) 实验性自动批准

权限规则配置

权限规则支持通配符模式:

Bash(git *)           # 允许所有 git 命令
FileEdit(/src/*)      # 允许编辑 src/ 下的文件
FileRead(*)           # 允许读取任何文件

规则来源(按优先级从高到低):

  1. 企业策略(MDM)
  2. CLI 标志 --permission
  3. 项目设置 .claude/settings.json
  4. 用户设置 ~/.claude/settings.json
  5. 运行时决策(如"本次会话始终允许")

BashTool 的权限检查

BashTool 实现 checkPermissions 来阻止危险命令:

async checkPermissions({ command }) {
  const dangerous = ["rm -rf /", "mkfs", "dd if=", ":(){ :|:& };:"];
  for (const pattern of dangerous) {
    if (command.includes(pattern)) {
      return { allowed: false, reason: `Dangerous command pattern: ${pattern}` };
    }
  }
  return { allowed: true };
}

权限 UI

当工具需要用户确认时,在 REPL 中显示权限对话框:

function PermissionDialog({ toolName, input, onAllow, onDeny }) {
  return (
    <Box borderStyle="round" borderColor="yellow" padding={1}>
      <Text bold color="yellow">⚠ Tool Permission Request</Text>
      <Text>Tool: {toolName}</Text>
      <Text>Input: {JSON.stringify(input)}</Text>
      <Box marginTop={1}>
        <Text>Press </Text><Text bold>y</Text><Text> to allow, </Text>
        <Text bold>n</Text><Text> to deny</Text>
      </Box>
    </Box>
  );
}

验证标准

  • [x] 危险命令被拦截(如 rm -rf /
  • [x] 需要确认的操作显示权限对话框
  • [x] 用户可以选择允许/拒绝
  • [x] 不同的权限模式产生不同的行为
  • [x] 权限规则可配置

迭代 8:状态管理与 UI 增强

目标:用自定义 Store 管理应用状态,增强 UI 组件。

预计时间:第 29-33 天

为什么不用 Redux 或 Zustand?

Claude Code 没有使用任何第三方状态管理库。它使用了一个极简的自定义 Store(约 30 行代码),配合 React 18+ 的 useSyncExternalStore。理由:

  • 依赖越少越好
  • 状态管理逻辑简单直接(不需要 middleware、devtools、reducers)
  • useSyncExternalStore 提供了原生 React 集成

Store 实现

src/state/store.ts

type Listener = () => void;

export interface Store<T> {
  getState: () => T;
  setState: (updater: (prev: T) => T) => void;
  subscribe: (listener: Listener) => () => void;
}

export function createStore<T>(initialState: T): Store<T> {
  let state = initialState;
  const listeners = new Set<Listener>();

  return {
    getState: () => state,
    setState: (updater) => {
      const next = updater(state);
      if (Object.is(next, state)) return;  // 没有变化 → 跳过通知
      state = next;
      listeners.forEach((fn) => fn());
    },
    subscribe: (listener) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

AppState 定义

src/state/AppState.ts

import React, { createContext, useContext, useSyncExternalStore } from "react";
import { createStore, type Store } from "./store.js";

export interface AppState {
  sessionID: string | null;
  messages: Message[];
  streamingText: string;
  isStreaming: boolean;
  isProcessing: boolean;
  settings: {
    providerID: string;
    modelID: string;
    maxTurns: number;
    permissionMode: string;
    cwd: string;
  };
  pendingPermissionRequest: PermissionRequest | null;
  cost: {
    sessionCost: number;
    totalInputTokens: number;
    totalOutputTokens: number;
  };
}

export function getDefaultAppState(): AppState {
  return {
    sessionID: null,
    messages: [],
    streamingText: "",
    isStreaming: false,
    isProcessing: false,
    settings: {
      providerID: process.env.SWE_DEFAULT_PROVIDER || "anthropic",
      modelID: process.env.SWE_DEFAULT_MODEL || "claude-sonnet-4-20250514",
      maxTurns: 50,
      permissionMode: "default",
      cwd: process.cwd(),
    },
    pendingPermissionRequest: null,
    cost: { sessionCost: 0, totalInputTokens: 0, totalOutputTokens: 0 },
  };
}

React 集成

const store = createStore(getDefaultAppState());
const StoreContext = createContext(store);

export function AppStateProvider({ children }: { children: React.ReactNode }) {
  return React.createElement(StoreContext.Provider, { value: store }, children);
}

// 选择器 Hook — 只关心状态的某一部分
export function useAppState<T>(selector: (state: AppState) => T): T {
  const s = useContext(StoreContext);
  return useSyncExternalStore(s.subscribe, () => selector(s.getState()));
}

// 更新函数
export function setState(updater: (prev: AppState) => AppState): void {
  store.setState(updater);
}

useSyncExternalStore 的核心作用

useSyncExternalStore 是 React 18 引入的 Hook,专门用于连接外部状态存储和 React。它的关键特性:

  • 选择器订阅——只有当选中的值发生变化时,组件才会重新渲染
  • 并发模式安全——在 React 的并发渲染中也能正确工作
  • 无额外渲染——避免了 Context 引发的 Provider 下所有组件的不必要渲染

这就是为什么 Store 模式比 React Context 更适合高频更新的场景(如流式文本渲染)——流式更新每次 yield 都修改 streamingText,但只有使用了 useAppState(s => s.streamingText) 的组件会重新渲染。

UI 组件结构

src/components/
├── App.tsx              ← 顶层 App(Provider 包装)
├── REPL.tsx             ← 主界面布局
├── Messages.tsx         ← 消息列表
├── MessageBubble.tsx    ← 单条消息(用户/助手/系统/工具)
├── PromptInput.tsx      ← 输入框(历史导航、自动补全)
├── Spinner.tsx          ← 加载动画
├── StatusBar.tsx        ← 底栏(状态、模型信息)
├── ToolCallDisplay.tsx  ← 工具调用可视化
├── PermissionDialog.tsx ← 权限对话框
└── ErrorBoundary.tsx    ← 错误边界

MessageBubble 组件

const ROLE_COLORS: Record<string, string> = {
  user: "green",
  assistant: "cyan",
  system: "yellow",
  tool: "magenta",
};

const ROLE_LABELS: Record<string, string> = {
  user: "You",
  assistant: "Assistant",
  system: "System",
  tool: "Tool",
};

export function MessageBubble({ role, content, isStreaming }: MessageBubbleProps) {
  return (
    <Box flexDirection="column" marginY={0}>
      <Text color={ROLE_COLORS[role] || "white"}>
        ▌ {ROLE_LABELS[role] || role}
      </Text>
      <Box marginLeft={2}>
        <Text>{content}</Text>
        {isStreaming && <Text dimColor>▊</Text>}
      </Box>
    </Box>
  );
}

验证标准

  • [x] Store 可以存储和更新状态
  • [x] React 组件通过 useAppState 选择器订阅状态
  • [x] 流式更新只影响消息列表组件
  • [x] 状态栏显示当前的 provider/model
  • [x] 权限对话框正确显示和交互

迭代 9:文件编辑和项目管理工具

目标:实现精确文件编辑和任务管理功能。

预计时间:第 34-38 天

FileEditTool 实现

精确字符串替换是最常见的编辑操作——比整个文件重写更高效、更安全:

export const FileEditTool = buildTool({
  name: "Edit",
  inputSchema: z.object({
    file_path: z.string().describe("Absolute path to the file"),
    old_string: z.string().describe("The exact text to replace (must appear exactly once)"),
    new_string: z.string().describe("The replacement text"),
  }),

  async call({ file_path, old_string, new_string }) {
    const content = await readFile(file_path, "utf-8");
    const count = content.split(old_string).length - 1;

    if (count === 0) {
      return {
        isError: true,
        data: { message: `String not found. Read the file first to see exact content.` },
      };
    }

    if (count > 1) {
      return {
        isError: true,
        data: { message: `Found ${count} occurrences. Include more surrounding context.` },
      };
    }

    const newContent = content.replace(old_string, new_string);
    await writeFile(file_path, newContent, "utf-8");
    return { data: { message: `Applied edit to ${file_path}` } };
  },
});

编辑工具的设计哲学

FileEditTool 的严格约束(必须精确匹配且只出现一次)是有意为之的:

  • 精确匹配防止意外修改——如果模型提供的字符串稍有偏差,工具会报错而不是乱改
  • 唯一性约束防止歧义——如果同一段代码出现多次,模型需要提供更多上下文来定位正确的那个
  • 错误引导——当编辑失败时,错误消息引导模型重新读取文件,提供更精确的匹配

TodoWriteTool

export const TodoWriteTool = buildTool({
  name: "TodoWrite",
  inputSchema: z.object({
    task: z.string().describe("Task description"),
    status: z.enum(["pending", "in_progress", "completed"]).default("pending"),
  }),
  isReadOnly: () => false,

  async call({ task, status }) {
    // 将任务写入 todo 文件
    const todoFile = join(process.cwd(), ".swe-tasks.json");
    let todos: any[] = [];
    try { todos = JSON.parse(await readFile(todoFile, "utf-8")); } catch {}
    todos.push({ task, status, createdAt: new Date().toISOString() });
    await writeFile(todoFile, JSON.stringify(todos, null, 2));
    return { data: { message: `Task created: ${task}` } };
  },
});

验证标准

  • [x] Edit 工具支持精确字符串替换
  • [x] 字符串不存在时返回明确的错误信息
  • [x] 字符串出现多次时返回明确的错误信息
  • [x] 编辑成功时确认修改
  • [x] 工具调用后验证文件内容

迭代 10:高级功能

目标:实现 Token 计数、上下文压缩、子智能体、MCP 集成。

预计时间:第 39-45 天

Token 计数和成本追踪

export class CostTracker {
  private totalInputTokens = 0;
  private totalOutputTokens = 0;
  private modelRates: Record<string, { input: number; output: number }> = {
    "claude-sonnet-4-20250514": { input: 3.0, output: 15.0 },
    "claude-haiku-3-5-20241022": { input: 0.25, output: 1.25 },
  };

  addUsage(inputTokens: number, outputTokens: number, model: string): void {
    this.totalInputTokens += inputTokens;
    this.totalOutputTokens += outputTokens;
  }

  getTotalCost(model: string): number {
    const rates = this.modelRates[model] || { input: 0, output: 0 };
    return (this.totalInputTokens / 1000000) * rates.input +
           (this.totalOutputTokens / 1000000) * rates.output;
  }

  getStats(): { inputTokens: number; outputTokens: number; totalCost: number } {
    return {
      inputTokens: this.totalInputTokens,
      outputTokens: this.totalOutputTokens,
      totalCost: this.getTotalCost("claude-sonnet-4-20250514"),
    };
  }
}

上下文压缩

随着对话进行,消息数组不断增长,token 消耗也随之增加。上下文压缩在 token 数量超过阈值时自动触发:

async function compactMessages(
  messages: Message[],
  threshold: number = 80000,
): Promise<Message[]> {
  const totalTokens = estimateTokens(messages);
  if (totalTokens <= threshold) return messages;

  // 压缩策略:将早期的对话轮次汇总为摘要
  const summaryModel = "claude-haiku-3-5-20241022"; // 使用更便宜的模型
  const earlyMessages = messages.slice(0, Math.floor(messages.length / 2));
  const summary = await generateSummary(earlyMessages, summaryModel);

  return [
    { role: "system", content: `[Previous conversation summary: ${summary}]` },
    ...messages.slice(Math.floor(messages.length / 2)),
  ];
}

压缩策略的选择:

  • 自动压缩——超过阈值时自动触发
  • 微压缩——裁剪过长的工具输出
  • 上下文折叠——将多次对话合并为一条摘要

MCP 协议集成

MCP(Model Context Protocol)允许 Agent 连接外部工具服务器:

interface MCPConfig {
  name: string;
  command: string;
  args?: string[];
  env?: Record<string, string>;
}

class MCPManager {
  private servers: Map<string, MCPConnection> = new Map();

  async connect(config: MCPConfig): Promise<void> {
    // 启动 MCP 服务器子进程
    // 通过 stdio 传输通信
    // 发现可用工具
    // 注册到工具注册表
  }

  async callTool(serverName: string, toolName: string, args: unknown): Promise<unknown> {
    // 向 MCP 服务器发送工具调用请求
  }
}

MCP 工具的命名约定:mcp__${serverName}__${toolName},防止来自不同服务器的工具名称冲突。

子智能体系统

AgentTool 支持启动独立的子智能体:

export const AgentTool = buildTool({
  name: "AgentTool",
  inputSchema: z.object({
    prompt: z.string().describe("Task for the sub-agent"),
    background: z.boolean().optional().default(false).describe("Run in background"),
  }),

  async call({ prompt, background }) {
    // 创建新的 AgentEngine 实例作为子智能体
    const subAgent = new AgentEngine({
      provider: process.env.SWE_DEFAULT_PROVIDER || "anthropic",
      model: process.env.SWE_DEFAULT_MODEL || "claude-sonnet-4-20250514",
      systemPrompt: "You are a helpful sub-agent.",
      maxTurns: 20,
    });

    if (background) {
      // 后台运行,不阻塞主 Agent
      subAgent.submitMessage([{ role: "user", content: prompt }]);
      return { data: { status: "running_in_background" } };
    }

    // 前台运行
    const result: string[] = [];
    for await (const event of subAgent.submitMessage([
      { role: "user", content: prompt },
    ])) {
      if (event.type === "text-delta") result.push(event.textDelta);
      else if (event.type === "tool-call") {
        result.push(`\n[Tool: ${event.toolName}]\n`);
      }
    }
    return { data: { status: "completed", result: result.join("") } };
  },
});

验证标准

  • [x] Token 计数准确
  • [x] 成本追踪可用
  • [x] 上下文压缩正常工作
  • [x] 子 Agent 可独立执行任务
  • [x] MCP 服务器可连接、工具可调用

测试策略

这个项目的一个优势是——Claude Code 的泄露源码没有测试套件,而我们可以从一开始就写好测试。

单元测试

// 测试 BashTool 的安全检查
describe("BashTool", () => {
  it("blocks dangerous commands", async () => {
    const result = await BashTool.checkPermissions(
      { command: "rm -rf /", description: "", timeout: 5000 },
      { cwd: "/tmp" }
    );
    expect(result.allowed).toBe(false);
  });

  it("allows safe commands", async () => {
    const result = await BashTool.checkPermissions(
      { command: "ls -la", description: "", timeout: 5000 },
      { cwd: "/tmp" }
    );
    expect(result.allowed).toBe(true);
  });
});

集成测试

// 测试完整的 Agent 循环(mock LLM)
describe("AgentEngine", () => {
  it("executes tool calls in sequence", async () => {
    // mock 一个返回 tool_use 的 LLM
    // 验证工具被执行
    // 验证结果被反馈给模型
  });
});

测试要点

  1. 每个工具单独测试——提供 mock 文件系统,测试正常路径和错误路径
  2. 权限系统测试——测试规则匹配、优先级排序、边界情况
  3. 消息格式化测试——验证 ProviderTransform 正确处理各提供商的差异
  4. Store 测试——验证状态更新和订阅通知

常见问题与调试指南

问题 可能原因 解决方法
启动后黑屏 Ink 渲染错误 检查 main.tsx 的入口文件路径
按 Enter 没反应 useInput 未正确处理 检查 key.return 的判断逻辑
API 报错 401 认证问题 检查 proxy 是否运行和 baseURL 配置
模型不调用工具 系统提示词中工具描述不够清晰 检查 prompt() 方法返回的内容
工具执行报错 参数验证失败 检查 Zod Schema 定义
流式渲染卡顿 React 状态更新过于频繁 考虑使用 useCallbackReact.memo
内存持续增长 消息数组无限增长 实现上下文压缩

调试技巧

  1. 使用 console.log 和 stderr——在开发阶段,用 console.error 打印调试信息(不会干扰 Ink 的渲染)
  2. 单步调试工具——直接从 Node.js 调用工具函数,绕过整个 Agent 循环
  3. 模拟 LLM——在测试中用预定义的响应序列代替真实的 API 调用,这样测试更快、更可控、更便宜
  4. 测试 Agent 循环——写一个简化的版本,只处理文本响应(不用工具),验证循环逻辑正确后再加入工具支持

项目结构总结

完整项目的目录结构:

my-swe-agent/
├── src/
│   ├── main.tsx                  ← CLI 入口
│   ├── repl.tsx                  ← REPL 交互会话
│   ├── query.ts                  ← 核心查询循环
│   ├── QueryEngine.ts            ← 查询引擎封装
│   │
│   ├── services/api/claude.ts    ← Anthropic API 客户端
│   │
│   ├── tools/
│   │   ├── Tool.ts               ← buildTool 工厂 + 类型
│   │   ├── registry.ts           ← 工具注册表
│   │   ├── BashTool/             ← Shell 执行
│   │   ├── FileReadTool/         ← 文件读取
│   │   ├── FileWriteTool/        ← 文件写入
│   │   ├── FileEditTool/         ← 文件编辑
│   │   ├── GlobTool/             ← 文件搜索
│   │   └── GrepTool/             ← 内容搜索
│   │
│   ├── commands/
│   │   ├── registry.ts           ← 命令注册表
│   │   ├── help.ts               ← /help 命令
│   │   └── clear.ts              ← /clear 命令
│   │
│   ├── components/
│   │   ├── App.tsx               ← 顶层应用
│   │   ├── REPL.tsx              ← 主界面
│   │   ├── Messages.tsx          ← 消息列表
│   │   ├── MessageBubble.tsx     ← 消息气泡
│   │   ├── PromptInput.tsx       ← 输入框
│   │   ├── Spinner.tsx           ← 加载指示器
│   │   ├── StatusBar.tsx         ← 状态栏
│   │   └── ErrorBoundary.tsx     ← 错误边界
│   │
│   ├── state/
│   │   ├── store.ts              ← Store 工厂
│   │   └── AppState.ts           ← 应用状态
│   │
│   ├── prompts/system.ts         ← 系统提示词构建
│   │
│   └── utils/
│       ├── config.ts             ← 配置管理
│       └── messages.ts           ← 消息工具

├── package.json
├── tsconfig.json
└── README.md

从这份指南中学到的核心经验

回顾整个构建过程,最重要的经验可以总结为三条:

1. buildTool 工厂模式是值得投资的抽象。

buildTool 工厂只用了大约 30 行代码,但它定义了整个工具系统的接口规范。每个新工具都遵循相同的模式——nameinputSchemacallpromptisReadOnlyisConcurrencySafe——这使得添加新工具变得可预测且低风险。工具总数从 5 个增长到 10 个、20 个时,这种一致性带来的收益是复利增长的。

2. 权限系统要早设计,不要后期追加。

如果先绕过权限系统快速实现功能,后期再想加上权限检查,会面临两个问题:一是每个工具已经习惯了"直接执行",需要逐个修改;二是用户已经适应了没有权限提示的工作流,突然加入需要适应。从一开始就在工具调用链中预留权限检查点,可以避免这种问题。

3. 引擎循环和 UI 之间的状态同步是最大的 Bug 来源。

Store 模式(pub/sub + useSyncExternalStore)可以干净地处理这个问题,但需要精心设计——引擎 yield 事件流、UI 订阅状态变化、保持两者同步需要纪律。最容易出 Bug 的地方是流式更新和工具执行状态的同步——比如工具正在执行时用户尝试提交新消息、或者流式文本更新与消息历史更新之间的时序问题。

解决方法是:在 Store 中明确区分"进行中的状态"(streamingText、isProcessing)和"已提交的状态"(messages),UI 组件根据各自关心的状态选择不同的 selector。


项目配置参考

项目支持以下环境变量进行配置:

环境变量 说明 默认值
SWE_DEFAULT_PROVIDER 默认 LLM 提供商 anthropic
SWE_DEFAULT_MODEL 默认模型 claude-sonnet-4-20250514
SWE_MODEL 覆盖模型 ID -
SWE_MAX_TURNS 最大工具循环轮数 50
ANTHROPIC_API_KEY Anthropic API 密钥(或 proxy 任意值) -
ANTHROPIC_BASE_URL API 基础地址(可指向 proxy) http://127.0.0.1:5001/proxy/opencode-go
SWE_ENABLE_WEB_SEARCH 启用网络搜索工具 false

项目演进路线图

完成 10 轮迭代只是起点。以下是完整的项目演进路线:

短期(完成基础功能)

  • 完善测试覆盖率达到 80%+
  • 添加更多工具(WebSearch、LSP、ApplyDiff)
  • 实现配置文件的加载和保存
  • 支持会话的持久化和恢复

中期(增强能力)

  • 多提供商支持——集成 Vercel AI SDK,支持 OpenAI、Google、Groq、Mistral 等 20+ 提供商
  • 上下文压缩——长对话的自动压缩和摘要,减少 token 消耗
  • MCP 协议——连接外部工具服务器(数据库、云服务、API 网关)
  • 子智能体编排——AgentTool 启动独立的子智能体,并行处理复杂任务

长期(生产化)

  • Web 界面——将 Agent 从终端搬到浏览器(React 网页版)
  • 沙盒容器——在 Docker 容器中执行工具以隔离风险(浏览器沙盒版)
  • 持续学习——Agent 记录和记忆用户的偏好和代码风格
  • 协作模式——多人共享同一个 Agent 会话
  • 插件生态——开放插件 API,让社区贡献自定义工具和命令

与 Claude Code 的定位差异

这个项目不是要克隆 Claude Code。以下是我们与 Claude Code 的关键差异:

方面 Claude Code 本项目
代码规模 ~512,000 行 ~15,000-20,000 行
工具数量 ~40 个 ~10-15 个
提供商支持 仅 Anthropic 可扩展
测试 无(泄露源码) 完整的测试套件
代码质量 单文件 ~46K 行 模块化、职责清晰
可定制性 闭源 完全可定制
学习成本 无法学习内部实现 1++

重要的是,通过构建这个 Agent,你获得了对 AI Agent 内部原理的深入理解——这比使用任何一个现有工具都有价值得多。

从这份指南中学到的核心经验

回顾整个构建过程,最重要的经验可以总结为三条:

1. buildTool 工厂模式是值得投资的抽象。

buildTool 工厂只用了大约 30 行代码,但它定义了整个工具系统的接口规范。每个新工具都遵循相同的模式——nameinputSchemacallpromptisReadOnlyisConcurrencySafe——这使得添加新工具变得可预测且低风险。工具总数从 5 个增长到 10 个、20 个时,这种一致性带来的收益是复利增长的。

更重要的是,这种一致的接口让 LLM 更容易理解和使用每个工具。因为每个工具在系统提示词中都用相同的结构描述,模型可以更快地学会什么时候该用什么工具。

2. 权限系统要早设计,不要后期追加。

如果先绕过权限系统快速实现功能,后期再想加上权限检查,会面临两个问题:一是每个工具已经习惯了"直接执行",需要逐个修改;二是用户已经适应了没有权限提示的工作流,突然加入需要适应。从一开始就在工具调用链中预留权限检查点,可以避免这种问题。

权限系统设计的核心问题是:如何在安全性和易用性之间找到平衡?太严格会烦人(每次读文件都要确认),太宽松就失去了意义。好的方案是分层设计——只读操作默认允许,破坏性操作默认询问,用户可以根据自己的信任程度选择权限模式。

3. 引擎循环和 UI 之间的状态同步是最大的 Bug 来源。

Store 模式(pub/sub + useSyncExternalStore)可以干净地处理这个问题,但需要精心设计——引擎 yield 事件流、UI 订阅状态变化、保持两者同步需要纪律。最容易出 Bug 的地方是流式更新和工具执行状态的同步——比如工具正在执行时用户尝试提交新消息、或者流式文本更新与消息历史更新之间的时序问题。

解决方法是:在 Store 中明确区分"进行中的状态"(streamingText、isProcessing)和"已提交的状态"(messages),UI 组件根据各自关心的状态选择不同的 selector。另一个关键实践是:让引擎成为唯一的状态生产者,UI 只消费和展示——避免 UI 组件直接修改引擎内部的 messages 数组。

写在最后

构建一个 AI 编程助手是一次难得的端到端系统设计体验。它涉及 CLI 框架、异步流处理、React 终端渲染、LLM API 集成、安全权限模型、状态管理——可以说是一个"微缩版的完整软件工程实践"。

这份指南的 10 轮迭代遵循了"渐进式构建"的原则:每一轮都增加一个核心能力,每一轮结束时的程序都是可运行的。如果你跟着做到了第 10 轮,你拥有的不仅仅是一个工具——你理解了一个正在重塑软件开发方式的系统的内在工作原理。

每一条扩展方向(多提供商、MCP、Web 界面、沙盒容器)都可以单独写一篇深入的技术文章。但在那之前,先让你的 Agent 跑起来,然后看看它能在哪些地方帮到你——只有真正使用它,才能真正理解它。

而且谁知道呢——也许你构建的东西,会成为下一个 159K stars 的开源项目。