LinOnward / Agent TutorialAgent Tutorial
章节6 / 18
教程整体目标

通过逐章可运行的增量,理解 Agent 的模型、上下文、任务状态、规划、工具、Skills 与 Agent Loop 等关键组件,并从零构建一个由 Harness 约束、不依赖 Agent 框架的可用 Agent。

05

读懂仓库

提供受控的文件搜索与读取能力。

本页目录
本章任务让 Agent 读取 package.json,回答项目使用的包管理器,并拒绝工作区外路径。
章节目标Agent 能在工作区内搜索文本和读取文件,但不能通过路径逃逸读取外部内容。
开始之前

第 04 章已经建立 ToolRegistry、Executor 与 function_call_output 回传协议。

本章涉及文件
  • src/workspace.ts
  • src/tools/read-file.ts
  • src/tools/search-text.ts
  • src/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 }) 执行搜索。固定最大结果数并排除 .gitnode_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);