教程整体目标
通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。
读懂仓库
提供受控的文件搜索与读取能力。
预计 25 分钟本页目录
本章任务让 Agent 读取 package.json,回答项目使用的包管理器,并拒绝工作区外路径。
章节目标Agent 能在工作区内搜索文本和读取文件,但不能通过路径逃逸读取外部内容。
开始之前
第 04 章已经建立 ToolRegistry、Executor 与 function_call_output 回传协议。
本章涉及文件
src/workspace.tssrc/tools/read-file.tssrc/tools/search-text.tssrc/index.ts
Step 1限制所有路径在工作区内
创建 src/workspace.ts。只比较 resolve() 后的字符串挡不住工作区内的符号链接指向外部;读取已有对象时必须比较 realpath() 后的真实路径。
typescript
import { realpath } from "node:fs/promises";import { isAbsolute, relative, resolve, sep } from "node:path";export function assertInside(root: string, target: string): void { const pathFromRoot = relative(root, target); if (pathFromRoot === ".." || pathFromRoot.startsWith(`..${sep}`) || isAbsolute(pathFromRoot)) { throw new Error("path_outside_workspace"); }}export async function resolveExistingWorkspacePath(cwd: string, input: string): Promise<string> { const root = await realpath(resolve(cwd)); const target = await realpath(resolve(root, input)); assertInside(root, target); return target;}这里先解析目标,再检查它与真实工作区根目录的相对关系。因此 workspace/link -> /tmp/secret 会被拒绝。不存在的新文件不能直接 realpath;下一章会真实解析父目录、拒绝符号链接父级,再创建文件。
Step 2实现 read_file
创建 src/tools/read-file.ts。限制单次读取长度,并明确告诉模型内容是否被截断。
typescript
const inputSchema = z.object({ path: z.string().min(1) });export const readFileTool = defineTool({ name: "read_file", description: "Read one UTF-8 text file inside the workspace", schema: inputSchema, async execute(input, context) { const path = await resolveExistingWorkspacePath(context.cwd, input.path); const content = await readFile(path, "utf8"); const limit = 20_000; return { content: content.slice(0, limit), truncated: content.length > limit }; },});Step 3实现 search_text
创建 src/tools/search-text.ts,通过 spawn("rg", args, { shell: false }) 执行搜索。固定最大结果数并排除 .git、node_modules 和构建目录。
typescript
const inputSchema = z.object({ query: z.string().min(1) });const args = [ "-n", "--fixed-strings", "--max-count", "50", "--glob", "!.git/**", "--glob", "!node_modules/**", "--glob", "!dist/**", "--glob", "!build/**", "--glob", "!.next/**", "--", input.query, ".",];const cwd = await realpath(context.cwd);const child = spawn("rg", args, { cwd, shell: false });const matches = await collectLines(child.stdout, 50, () => child.kill("SIGTERM"));-- 明确结束选项解析,所以 -g、--help 之类的查询不会被当成 ripgrep 参数。--max-count 是“每个文件”的上限,不是全局上限,因此 Executor 仍要在收集到 50 行后停止子进程并标记结果被截断。Glob 必须与正文承诺逐项一致;新增构建目录时,同时更新代码与测试。
Step 4注册只读工具并收紧提示词
在 CLI 装配阶段注册两个工具,并告诉模型先搜索、后读取;找不到证据时继续调查,不要猜测仓库内容。
typescript
const tools = new ToolRegistry();tools.register(searchTextTool);tools.register(readFileTool);