跳到正文

你的第一批工具

上一节课,你把Agent read交给了聊天机器人,变成了有用的东西。有用,但有限。你的Agent只能打开它已经知道名字的文件。让它“查找所有TODO注释”,它开始猜哪些文件可能有注释,然后逐条阅读。

那不是搜索。那是礼貌地挥舞。

加上grep,Agent就有了真正的搜索工具。但现在你有了新的问题。模型每次接手任务时都要在readgrep之间做选择。除非你告诉它怎么做,否则它就会选错。

学习成果

你拥有一个grep工具,描述丰富且能塑造行为。模型使用grep进行搜索,read用于文件检查,路径完全由描述决定。

快速路径

  1. 添加一个带有正则表达式模式、可选的球状过滤器和50匹配上限的 grep 工具
  2. 用WHEN TO USE、WHEN NOT TO USE、DO NOT USE FOR 和 EXAMPLES 来写描述
  3. 更新read的描述以匹配相同的契约

动手练习 1.2

先构建grep工具,然后重写两个描述,直到模型正确路由。

要求:

  1. 添加一个带有pattern、可选path和可选globZod schema的grep工具
  2. 使用execSync实现execute``grep -rn,排除node_modules.git
  3. 输出上限为50匹配并报告总数量
  4. 使用四部分的契约描述来readgrep:WHEN TO USE,WHEN NOT TO USE,DO NOT USE FOR,EXAMPLES

实现提示:

  • node:child_process进口execSync
  • 引用输入 shell 命令以避免在特殊字符上中断
  • grep的非零退出(未找到匹配)视为成功,而非错误
  • 描述是模型选择工具的用API。写给模型看,而不是读者

看错工具赢了

先做个简短的描述,看看情况有多糟:

ts
const grep = tool({
  description: "Search files.",
  inputSchema: z.object({
    pattern: z.string(),
    glob: z.string().optional(),
  }),
  // ... execute with execSync grep
});

现在问问Agent:

bash
bun run index.ts . "Find all TODO comments in this project"

模型无视grep,伸手拿起read,打开随机文件,希望有个TODO。如果你已经添加了bash,它会尝试那个。两个词的描述无法给模型任何可用的信息,所以它只能猜测。

这是第一次工具选择重要。这也是它第一次断裂。

描述提示词

修复并不是更好的实现。这样描述更准确:

ts
const grep = tool({
  description: `Search file contents using regex. Returns matching lines with file paths.
WHEN TO USE: finding patterns across multiple files, locating function definitions,
  searching for imports, finding TODOs or error messages.
WHEN NOT TO USE: reading a known file (use read instead).
DO NOT USE FOR: running commands, listing directories.
EXAMPLES:
  - Find all TODO comments: pattern "TODO" glob "*.ts"
  - Find function definitions: pattern "function \\\\w+" glob "*.ts"`,
  inputSchema: z.object({
    pattern: z.string().describe("Regex pattern to search for"),
    path: z.string().optional().describe("Directory to search (default: working dir)"),
    glob: z.string().optional().describe("File glob filter, e.g. '*.ts'"),
  }),
  execute: async ({ pattern, path: searchPath, glob: globFilter }) => {
    const dir = resolve(cwd, searchPath || ".");
    const escapedPattern = pattern.replace(/'/g, `'\\''`);
    const escapedGlob = (globFilter || "*").replace(/'/g, `'\\''`);
    const cmd = `grep -rn --exclude-dir=node_modules --exclude-dir=.git --include='${escapedGlob}' -E '${escapedPattern}' '${dir}' 2>/dev/null`;

    try {
      const stdout = execSync(cmd, { encoding: "utf-8", timeout: 10_000 });
      const lines = stdout.trim().split("\\n").filter(Boolean);

      const MAX_MATCHES = 50;
      const truncated = lines.length > MAX_MATCHES;
      const result = truncated ? lines.slice(0, MAX_MATCHES) : lines;

      return truncated
        ? result.join("\\n") + `\\n... (${lines.length} total, showing first ${MAX_MATCHES})`
        : result.join("\\n") || "No matches found.";
    } catch (error: any) {
      const stdout = String(error?.stdout || "").trim();
      if (stdout) {
        const lines = stdout.split("\\n").filter(Boolean);
        const MAX_MATCHES = 50;
        const truncated = lines.length > MAX_MATCHES;
        const result = truncated ? lines.slice(0, MAX_MATCHES) : lines;
        return truncated
          ? result.join("\\n") + `\\n... (${lines.length} total, showing first ${MAX_MATCHES})`
          : result.join("\\n");
      }
      return "No matches found.";
    }
  },
});

这描述中发生了两件事。WHEN TO USE 使工具的工作变得清晰可读。WHEN NOT要USE并且确实NOT USE FOR引导模型避免默认使用它最喜欢的工具,而(根据我们的经验)通常是bash

**注意:巴什引力**

我们测试过的每个模型(Haiku、Sonnet、Opus)在工具描述薄弱时默认为bash。生产线增强了负转向,每个工具都WHEN NOTTO和DO的“USE”和DO的NOT USE FOR,因为说一次还不够。本课的模式源自真实的Harness,观察Agent不断错误拨弦。

现在对read做同样的治疗。read上的描述同样强烈反驳grep

ts
const read = tool({
  description: `Read a file from the project. Returns numbered lines.
WHEN TO USE: viewing file contents, checking configs, reading source code.
WHEN NOT TO USE: searching across files (use grep instead).
DO NOT USE FOR: running commands, listing directories.`,
  // ... rest unchanged
});

为什么要有50场比赛上限

grepread一样,得到了同样有上下文意识的处理。没有上限,在大型代码库中搜索import会淹没数百行导入,Agent不需要。50匹配就足够回答这个问题了。500是污染。

动手试试

用重写的描述运行搜索提示词:

bash
bun run index.ts . "Find all TODO comments in this project"

你应该直接看到模型调用 grep,有像TODO这样的模式和像*.ts那样的团块。在一个小文件里放几条// TODO:评论,结果就很明显了。排除node_modules能让输出聚焦在你的代码上,而不是依赖关系。

现在运行文件检查提示词:

bash
bun run index.ts . "Read the tsconfig.json"

这仍然使用read,而不是grep。这些描述引导模型向两个方向发展。搜索提示词向grep拉。已知档案提示词向read拉。

bash
npx tsc --noEmit

**注意:故意让验证变得无聊**

真实的代码库匹配度太多,难以用眼法判断。在你控制的小文件里放两个// TODO:评论,然后运行搜索。关键是验证路由,而不是发现漏洞。让测试变得明显。

提交

bash
git add index.ts
git commit -m "feat(tools): add grep with behavioral description contract"

完成标准

  • [ ] "Find all TODO comments" 调用 grep,不是 read 也不是 bash
  • [ ] "Read the tsconfig.json" 调用 read,不是grep
  • [ ] grep最多匹配50场,截断后报告总数
  • [ ] readgrep都使用WHEN TO USE,WHEN NOT to USE,并且确实使用NOT USE FOR
  • [ ] npx tsc --noEmit

**注意:把描述推到破碎为止**

开始逐节削弱grep的描述。先把EXAMPLES放下。那就NOT USE FOR。然后WHEN NOT TO USE。模型在什么时候会切换回bashread?Haiku、Sonnet和Opus之间的阈值会变化吗?

参考实现

ts
import { ToolLoopAgent, stepCountIs, tool } from "ai";
import { z } from "zod";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { execSync } from "node:child_process";

const cwd = resolve(process.argv[2] || process.cwd());

const read = tool({
  description: `Read a file from the project. Returns numbered lines.
WHEN TO USE: viewing file contents, checking configs, reading source code.
WHEN NOT TO USE: searching across files (use grep instead).
DO NOT USE FOR: running commands, listing directories.`,
  inputSchema: z.object({
    path: z.string().describe("File path relative to working directory"),
    offset: z.number().optional().describe("Start line (1-indexed)"),
    limit: z.number().optional().describe("Max lines to return"),
  }),
  execute: async ({ path: filePath, offset, limit }) => {
    const abs = resolve(cwd, filePath);
    const content = readFileSync(abs, "utf-8");
    let lines = content.split("\n");

    if (offset) lines = lines.slice(offset - 1);
    if (limit) lines = lines.slice(0, limit);

    const MAX_LINES = 500;
    const truncated = lines.length > MAX_LINES;
    if (truncated) lines = lines.slice(0, MAX_LINES);

    const numbered = lines.map((l, i) => `${(offset || 1) + i}: ${l}`);
    return truncated
      ? numbered.join("\n") + `\n... (truncated at ${MAX_LINES} lines)`
      : numbered.join("\n");
  },
});

const grep = tool({
  description: `Search file contents using regex. Returns matching lines with file paths.
WHEN TO USE: finding patterns across multiple files, locating function definitions,
  searching for imports, finding TODOs or error messages.
WHEN NOT TO USE: reading a known file (use read instead).
DO NOT USE FOR: running commands, listing directories.
EXAMPLES:
  - Find all TODO comments: pattern "TODO" glob "*.ts"
  - Find function definitions: pattern "function \\\\w+" glob "*.ts"`,
  inputSchema: z.object({
    pattern: z.string().describe("Regex pattern to search for"),
    path: z.string().optional().describe("Directory to search (default: working dir)"),
    glob: z.string().optional().describe("File glob filter, e.g. '*.ts'"),
  }),
  execute: async ({ pattern, path: searchPath, glob: globFilter }) => {
    const dir = resolve(cwd, searchPath || ".");
    const escapedPattern = pattern.replace(/'/g, `'\\''`);
    const escapedGlob = (globFilter || "*").replace(/'/g, `'\\''`);
    const cmd = `grep -rn --exclude-dir=node_modules --exclude-dir=.git --include='${escapedGlob}' -E '${escapedPattern}' '${dir}' 2>/dev/null`;

    try {
      const stdout = execSync(cmd, { encoding: "utf-8", timeout: 10_000 });
      const lines = stdout.trim().split("\\n").filter(Boolean);

      const MAX_MATCHES = 50;
      const truncated = lines.length > MAX_MATCHES;
      const result = truncated ? lines.slice(0, MAX_MATCHES) : lines;

      return truncated
        ? result.join("\\n") + `\\n... (${lines.length} total, showing first ${MAX_MATCHES})`
        : result.join("\\n") || "No matches found.";
    } catch (error: any) {
      const stdout = String(error?.stdout || "").trim();
      if (stdout) {
        const lines = stdout.split("\\n").filter(Boolean);
        const MAX_MATCHES = 50;
        const truncated = lines.length > MAX_MATCHES;
        const result = truncated ? lines.slice(0, MAX_MATCHES) : lines;
        return truncated
          ? result.join("\\n") + `\\n... (${lines.length} total, showing first ${MAX_MATCHES})`
          : result.join("\\n");
      }
      return "No matches found.";
    }
  },
});

const agent = new ToolLoopAgent({
  model: "anthropic/claude-haiku-4-5",
  instructions: `You are a coding agent.\nWorking directory: ${cwd}`,
  tools: { read, grep },
  stopWhen: stepCountIs(10),
});

const prompt = process.argv.slice(3).join(" ") || "Hello!";
const { text, steps } = await agent.generate({ prompt });
console.log(text);
console.log(`\n(${steps.length} steps)`);

非官方简体中文翻译 · 原课程来自 Vercel Academy