diff --git a/README.md b/README.md index 4f32b44..de10738 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 你将从 0 开始,一步步做出一个可运行、可扩展、可发布的 Agent CLI,不只会“调用模型”,还会完整打通 REPL、Agentic Loop、上下文构建、工具系统和工程化发布。 ---- +------ ## 教程结构(共 28 章) @@ -18,14 +18,14 @@ 第 6 部分:综合实战 (第 28 章) ``` ---- +------ ## 第 0 章:开始之前 - 实现目标:明确教程定位、环境准备、项目结构和学习方式。 - 里程碑:`bun init -y && bun add @anthropic-ai/sdk ink react commander lodash-es` ---- +------ ## 第一部分:核心骨架 @@ -74,7 +74,7 @@ 输入行/状态提示),支持流式文本渲染、工具进行中提示、基础键位交互 (如 `/clear`、`/help`、滚动或焦点切换预留)。 ---- +------ ## 第二部分:工具系统 @@ -113,7 +113,7 @@ - 实现目标:实现 MCP 客户端并动态加载外部工具。 - 里程碑:`bun run src/index.ts "列出这个仓库最新的 5 个 PR"` ---- +------ ## 第三部分:高级特性 @@ -142,7 +142,7 @@ - 实现目标:实现 Pre/Post/Session 三级 Hook。 - 里程碑:工具执行后自动触发自定义命令(如 `git add`)。 ---- +------ ## 第四部分:工程化 @@ -166,7 +166,7 @@ - 实现目标:支持本地打包、npm 发布、多平台分发。 - 里程碑:`npm i -g myagent && myagent --version` ---- +------ ## 第五部分:扩展生态 @@ -185,7 +185,7 @@ - 实现目标:统一 Provider 抽象,支持多云模型接入。 - 里程碑:切换环境变量即可切换 Provider。 ---- +------ ## 第六部分:综合实战 @@ -194,7 +194,7 @@ - 实现目标:从空目录到可发布的 `myagent v1.0.0` 全流程演练。 - 里程碑:完成一次可复现的构建、测试、发布流程。 ---- +------ ## 学习路径建议 @@ -203,7 +203,7 @@ - 生产可用:第 1-23 章 - 完整版本:全部 28 章 ---- +------ ## 学习代码仓库地址 @@ -211,14 +211,14 @@ 每章的代码按照分支存放在仓库中, 分支名称为 `chapter-xxx`。 ---- +------ ### A. Claude Code 核心文件速查 [Claude Code 代码仓库](https://github.com/ricoNext/claude-code) | 文件 | 行数 | 对应章节 | -| --- | --- | --- | +| ------ | ------ | ------ | | `src/entrypoints/cli.tsx` | 320 | 第 2 章 | | `src/main.tsx` | 4,683 | 第 2 章 | | `src/screens/REPL.tsx` | 5,009 | 第 3 / 7 / 8 章 | @@ -243,7 +243,7 @@ ### C. 学习路径建议 | 目标 | 需完成 | 耗时 | -| --- | --- | --- | +| ------ | ------ | ------ | | 快速上手,能聊天 | 第 1-3 章 | 1-2 天 | | 能干活的 Agent | 第 1-12 章 | 3-5 天 | | 生产可用 | 第 1-23 章 | 2-3 周 | diff --git a/src/agent/loop.ts b/src/agent/loop.ts index 3eacbb7..e51f51d 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -19,7 +19,7 @@ export interface RunAgentConversationOptions { } const BASE_SYSTEM = - "你是命令行里的编码助手。需要列文件、统计数量、跑测试时,优先用工具获取真实输出,不要编造结果。若用户明确要求「只转大小写、不访问磁盘」,优先使用 `uppercase` 工具"; + "你是命令行里的编码助手。需要列文件、统计数量、跑测试时,优先用工具获取真实输出,不要编造结果。若用户明确要求「只转大小写、不访问磁盘」,优先使用 `uppercase` 工具。如果你需要修改文件,请先使用 `read_file` 工具读取文件,然后使用 `edit_file` 工具修改文件,最后使用 `write_file` 工具写入文件。"; let cachedSystemPrompt: string | null = null; diff --git a/src/tools/edit-file-tool.ts b/src/tools/edit-file-tool.ts new file mode 100644 index 0000000..9804d42 --- /dev/null +++ b/src/tools/edit-file-tool.ts @@ -0,0 +1,120 @@ +import fs from "node:fs/promises"; + +import { + assertPathInsideCwd, + markFileAsRead, + toWorkspaceAbsolutePath, + wasFileReadInSession, +} from "./file-session"; +import type { AgentTool } from "./types"; + +// 编辑文件工具 +export const editFileTool: AgentTool = { + name: "edit_file", + // 转换为 OpenAI 工具格式 + toOpenAI: () => ({ + type: "function", + function: { + name: "edit_file", + description: + "在已读取过的文本文件内做精确字符串替换。" + + "old_string 须唯一,除非 replace_all 为 true。" + + "不要包含 read_file 返回的行号前缀。", + parameters: { + type: "object", + properties: { + file_path: { type: "string" }, + old_string: { + type: "string", + description: "要被替换的原文(唯一匹配)", + }, + new_string: { type: "string", description: "替换后的内容" }, + replace_all: { + type: "boolean", + description: "为 true 时替换所有 old_string", + }, + }, + required: ["file_path", "old_string", "new_string"], + }, + }, + }), + // 执行工具 + async execute(args: unknown) { + // 解析参数 file_path, old_string, new_string, replace_all + const a = args as { + file_path?: unknown; + old_string?: unknown; + new_string?: unknown; + replace_all?: unknown; + }; + const filePath = typeof a.file_path === "string" ? a.file_path : ""; + const oldStr = typeof a.old_string === "string" ? a.old_string : ""; + const newStr = typeof a.new_string === "string" ? a.new_string : ""; + const replaceAll = a.replace_all === true; + if (!filePath.trim()) { + return "错误:file_path 为空"; + } + if (oldStr === newStr) { + return "错误:old_string 与 new_string 相同,无需修改"; + } + + // 转换为绝对路径 + const abs = toWorkspaceAbsolutePath(filePath); + // 断言路径是否在当前工作区之内 + const guard = assertPathInsideCwd(abs); + // 如果路径不在当前工作区之内,则返回错误 + if (guard) { + return guard; + } + // 如果文件未被读取,则返回错误 + if (!wasFileReadInSession(abs)) { + return "错误:请先用 read_file 读取该文件,再调用 edit_file"; + } + + // 读取文件 + let raw: string; + try { + raw = await fs.readFile(abs, "utf8"); + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return "错误:文件不存在"; + } + const msg = e instanceof Error ? e.message : String(e); + return `错误:读取失败 — ${msg}`; + } + // 如果 replace_all 为 true,则替换所有 old_string + if (replaceAll) { + if (!raw.includes(oldStr)) { + return "错误:找不到任何 old_string"; + } + // 替换所有 old_string + raw = raw.split(oldStr).join(newStr); + } else { + // 如果 replace_all 为 false,则替换第一个 old_string + const first = raw.indexOf(oldStr); + // 如果找不到 old_string,则返回错误 + if (first === -1) { + return "错误:找不到 old_string,请与磁盘一致(可重新 read_file)"; + } + // 查找第二个 old_string + const second = raw.indexOf(oldStr, first + oldStr.length); + // 如果找到第二个 old_string,则返回错误 + if (second !== -1) { + return ( + "错误:old_string 出现多次,请扩大上下文使片段唯一," + + "或设置 replace_all: true" + ); + } + // 得到新的文件内容 + raw = `${raw.slice(0, first)}${newStr}${raw.slice(first + oldStr.length)}`; + } + + // 写入文件 + await fs.writeFile(abs, raw, "utf8"); + // 标记文件为已读 + markFileAsRead(abs); + // 返回结果 + return JSON.stringify({ ok: true, path: abs }, null, 2); + }, +}; diff --git a/src/tools/file-session.ts b/src/tools/file-session.ts new file mode 100644 index 0000000..270bd7c --- /dev/null +++ b/src/tools/file-session.ts @@ -0,0 +1,33 @@ +import { relative, resolve } from "node:path"; + +const readPaths = new Set(); + +/** 相对路径相对于 process.cwd() */ +export function toWorkspaceAbsolutePath(filePath: string): string { + return resolve(process.cwd(), filePath); +} + +// 标记文件为已读 +export function markFileAsRead(absPath: string): void { + readPaths.add(absPath); +} + +// 清除文件的已读标记 +export function clearReadMark(absPath: string): void { + readPaths.delete(absPath); +} + +// 判断文件是否已读 +export function wasFileReadInSession(absPath: string): boolean { + return readPaths.has(absPath); +} + +// 断言路径是否在当前工作区之内 +export function assertPathInsideCwd(absPath: string): string | null { + const cwd = resolve(process.cwd()); + const rel = relative(cwd, absPath); + if (rel.startsWith("..")) { + return "错误:禁止访问工作区外的路径"; + } + return null; +} diff --git a/src/tools/read-file-tool.ts b/src/tools/read-file-tool.ts new file mode 100644 index 0000000..cb0e90c --- /dev/null +++ b/src/tools/read-file-tool.ts @@ -0,0 +1,119 @@ +import fs from "node:fs/promises"; + +import { + assertPathInsideCwd, + markFileAsRead, + toWorkspaceAbsolutePath, +} from "./file-session"; + +import type { AgentTool } from "./types"; + +// 行分隔符正则表达式 +const LINE_ENDING_REGEXP = /\r?\n/; + +// 读取文件工具 +export const readFileTool: AgentTool = { + name: "read_file", + // 转换为 OpenAI 工具格式 + toOpenAI: () => ({ + type: "function", + function: { + name: "read_file", + description: + "读取工作区内文本文件。返回带行前缀的行文本,便于 edit_file 精确匹配。" + + "可选 offset/limit。", + parameters: { + type: "object", + properties: { + file_path: { + type: "string", + description: "相对或绝对路径(相对则相对当前工作目录)", + }, + offset: { + type: "integer", + description: "起始行号(从 1 开始)。省略则从文件开头读。", + }, + limit: { + type: "integer", + description: "最多读取行数。省略则读到末尾(受 maxLines 截断)。", + }, + }, + required: ["file_path"], + }, + }, + }), + // 执行工具 + async execute(args: unknown) { + // 解析参数 file_path, offset, limit + const a = args as { + file_path?: unknown; + offset?: unknown; + limit?: unknown; + }; + const filePath = typeof a.file_path === "string" ? a.file_path : ""; + if (!filePath.trim()) { + return "错误:file_path 为空"; + } + // 转换为绝对路径 + const abs = toWorkspaceAbsolutePath(filePath); + // 断言路径是否在当前工作区之内 + const guard = assertPathInsideCwd(abs); + if (guard) { + return guard; + } + // 读取文件最大行数 + const maxLines = 2000; + + // 解析起始行号:省略则从文件开头读。 + let offset = + typeof a.offset === "number" && Number.isFinite(a.offset) + ? Math.trunc(a.offset) + : 1; + // 解析最多读取行数:省略则读到末尾(受 maxLines 截断)。 + let limit = + typeof a.limit === "number" && Number.isFinite(a.limit) + ? Math.trunc(a.limit) + : maxLines; + + // 如果起始行号小于 1,则从文件开头读。 + if (offset < 1) { + offset = 1; + } + // 如果最多读取行数小于 1,则读到末尾。 + if (limit < 1) { + limit = maxLines; + } + + // 读取文件 + let raw: string; + try { + raw = await fs.readFile(abs, "utf8"); + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return "错误:文件不存在"; + } + const msg = e instanceof Error ? e.message : String(e); + return `错误:读取失败 — ${msg}`; + } + + // 按行分隔 + const lines = raw.split(LINE_ENDING_REGEXP); + // 截取指定行数 + const slice = lines.slice(offset - 1, offset - 1 + limit); + // 格式化输出 + const out = slice + .map((line, i) => { + const n = offset + i; + return `${String(n).padStart(6, " ")}→${line}`; + }) + .join("\n"); + + // 标记文件为已读 + markFileAsRead(abs); + // 截取最大内容: 取 10⁵ 量级 比较常见(约几十到一百多 KB 量级的 UTF-8 文本) + const cap = 120_000; + // 返回结果 + return out.length > cap ? `${out.slice(0, cap)}\n...(truncated)` : out; + }, +}; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 356b80e..c54531e 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -1,10 +1,19 @@ import type { ChatCompletionTool } from "openai/resources/chat/completions"; import { bashTool } from "./bash-tool"; +import { editFileTool } from "./edit-file-tool"; +import { readFileTool } from "./read-file-tool"; import type { AgentTool } from "./types"; import { uppercaseTool } from "./uppercase-tool"; +import { writeFileTool } from "./write-file-tool"; // 工具列表 -export const AGENT_TOOLS: readonly AgentTool[] = [bashTool, uppercaseTool]; +export const AGENT_TOOLS: readonly AgentTool[] = [ + bashTool, + uppercaseTool, + editFileTool, + readFileTool, + writeFileTool, +]; // 工具名称匹配工具 export function toolMatchesName( diff --git a/src/tools/write-file-tool.ts b/src/tools/write-file-tool.ts new file mode 100644 index 0000000..03fde75 --- /dev/null +++ b/src/tools/write-file-tool.ts @@ -0,0 +1,82 @@ +import fs from "node:fs/promises"; +import { dirname } from "node:path"; + +import { + assertPathInsideCwd, + clearReadMark, + toWorkspaceAbsolutePath, + wasFileReadInSession, +} from "./file-session"; +import type { AgentTool } from "./types"; + +export const writeFileTool: AgentTool = { + name: "write_file", + toOpenAI: () => ({ + type: "function", + function: { + name: "write_file", + description: + "向工作区写入文本(整文件覆盖)。若路径已存在须先 read_file;" + + "新建可直接写入。大段修改更推荐 edit_file。", + parameters: { + type: "object", + properties: { + file_path: { type: "string", description: "目标路径(相对或绝对)" }, + content: { type: "string", description: "完整文件内容" }, + }, + required: ["file_path", "content"], + }, + }, + }), + // 执行工具 + async execute(args: unknown) { + // 解析参数 file_path, content + const a = args as { file_path?: unknown; content?: unknown }; + // 解析文件路径 + const filePath = typeof a.file_path === "string" ? a.file_path : ""; + // 解析文件内容 + const content = typeof a.content === "string" ? a.content : ""; + // 如果文件路径为空,则返回错误 + if (!filePath.trim()) { + return "错误:file_path 为空"; + } + // 转换为绝对路径 + const abs = toWorkspaceAbsolutePath(filePath); + // 断言路径是否在当前工作区之内 + const guard = assertPathInsideCwd(abs); + // 如果路径不在当前工作区之内,则返回错误 + if (guard) { + return guard; + } + + // 检查文件是否存在 + let existed = false; + // 尝试访问文件 + try { + await fs.access(abs); + existed = true; + } catch { + existed = false; + } + + // 如果文件存在且未被读取,则返回错误 + // 这个错误主要是用来告诉模型,文件如果存在需要先调用 read_file 读取后再 write_file + if (existed && !wasFileReadInSession(abs)) { + return ( + "错误:目标文件已存在,请先用 read_file 读取后再 write_file" + + "(与先读后写策略一致)" + ); + } + + // 创建目录 + await fs.mkdir(dirname(abs), { recursive: true }); + // 写入文件 + await fs.writeFile(abs, content, "utf8"); + // 清除文件的已读标记 + clearReadMark(abs); + // 计算文件字节数 + const bytes = Buffer.byteLength(content, "utf8"); + // 返回结果 + return JSON.stringify({ ok: true, path: abs, bytes }, null, 2); + }, +};