你的第一批工具
上一节课,你把Agent read交给了聊天机器人,变成了有用的东西。有用,但有限。你的Agent只能打开它已经知道名字的文件。让它“查找所有TODO注释”,它开始猜哪些文件可能有注释,然后逐条阅读。
那不是搜索。那是礼貌地挥舞。
加上grep,Agent就有了真正的搜索工具。但现在你有了新的问题。模型每次接手任务时都要在read和 grep之间做选择。除非你告诉它怎么做,否则它就会选错。
学习成果
你拥有一个grep工具,描述丰富且能塑造行为。模型使用grep进行搜索,read用于文件检查,路径完全由描述决定。
快速路径
- 添加一个带有正则表达式模式、可选的球状过滤器和50匹配上限的
grep工具 - 用WHEN TO USE、WHEN NOT TO USE、DO NOT USE FOR 和 EXAMPLES 来写描述
- 更新
read的描述以匹配相同的契约
动手练习 1.2
先构建grep工具,然后重写两个描述,直到模型正确路由。
要求:
- 添加一个带有
pattern、可选path和可选globZod schema的grep工具 - 使用
execSync实现execute``grep -rn,排除node_modules和.git - 输出上限为50匹配并报告总数量
- 使用四部分的契约描述来
read和grep:WHEN TO USE,WHEN NOT TO USE,DO NOT USE FOR,EXAMPLES
实现提示:
- 从
node:child_process进口execSync - 引用输入 shell 命令以避免在特殊字符上中断
- 将
grep的非零退出(未找到匹配)视为成功,而非错误 - 描述是模型选择工具的用API。写给模型看,而不是读者
看错工具赢了
先做个简短的描述,看看情况有多糟:
const grep = tool({
description: "Search files.",
inputSchema: z.object({
pattern: z.string(),
glob: z.string().optional(),
}),
// ... execute with execSync grep
});现在问问Agent:
bun run index.ts . "Find all TODO comments in this project"模型无视grep,伸手拿起read,打开随机文件,希望有个TODO。如果你已经添加了bash,它会尝试那个。两个词的描述无法给模型任何可用的信息,所以它只能猜测。
这是第一次工具选择重要。这也是它第一次断裂。
描述提示词
修复并不是更好的实现。这样描述更准确:
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:
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场比赛上限
grep和read一样,得到了同样有上下文意识的处理。没有上限,在大型代码库中搜索import会淹没数百行导入,Agent不需要。50匹配就足够回答这个问题了。500是污染。
动手试试
用重写的描述运行搜索提示词:
bun run index.ts . "Find all TODO comments in this project"你应该直接看到模型调用 grep,有像TODO这样的模式和像*.ts那样的团块。在一个小文件里放几条// TODO:评论,结果就很明显了。排除node_modules能让输出聚焦在你的代码上,而不是依赖关系。
现在运行文件检查提示词:
bun run index.ts . "Read the tsconfig.json"这仍然使用read,而不是grep。这些描述引导模型向两个方向发展。搜索提示词向grep拉。已知档案提示词向read拉。
npx tsc --noEmit**注意:故意让验证变得无聊**
真实的代码库匹配度太多,难以用眼法判断。在你控制的小文件里放两个// TODO:评论,然后运行搜索。关键是验证路由,而不是发现漏洞。让测试变得明显。
提交
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场,截断后报告总数 - [ ]
read和grep都使用WHEN TO USE,WHEN NOT to USE,并且确实使用NOT USE FOR - [ ]
npx tsc --noEmit
**注意:把描述推到破碎为止**
开始逐节削弱grep的描述。先把EXAMPLES放下。那就NOT USE FOR。然后WHEN NOT TO USE。模型在什么时候会切换回bash或read?Haiku、Sonnet和Opus之间的阈值会变化吗?
参考实现
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)`);