1. 重点

- 第 2 课真正要看懂的是这条链路:
- 用户敲
hx agent "Read README.md and list src/*.js"。 - Node.js 收到
agent命令和任务文本。 - 代码把任务变成
messages。 - 代码把
messages和 5 个工具定义 Schema一起发给 DeepSeek。 - DeepSeek 如果返回
tool_calls,程序先看工具名。 - 程序用工具名去
TOOL_HANDLERS里查本地函数。 - 本地函数执行完,返回一段字符串观察结果。
- 程序把观察结果作为
role: "tool"消息追加回messages。 - 再次请求 DeepSeek,直到模型给出最终回答。
- 用户敲
- 当前这一课要做的是工具分发:
- 第 1 课只有一个
bash工具 + 循环 - 第 2 课扩展成
bash、read_file、write_file、edit_file、glob。 - Agent Loop 主结构不变。
- 变化集中在
工具定义和工具分发表。
- 第 1 课只有一个
这一课的第一性原理是:---> 结构化意图
工具调用不是让模型真的执行工具。
模型只返回结构化意图。
HarnessX 用工具名查表,执行本地函数,再把结果喂回模型。2. 流程图
2.1. 整体流程
2.2. HTTP 一来一回加工具分发
2.3. 工具分发表
2.4. messages 怎么变长
3. 入口
用户敲的是:
hx agent "Read README.md and list src/*.js"package.json 还是同一个入口。
{
"bin": {
"hx": "./src/index.js"
}
}真正进入业务代码的是:
runCli(process.argv.slice(2));
// 用户输入:
// hx agent "Read README.md and list src/*.js"
//
// 业务代码看到:
// ["agent", "Read README.md and list src/*.js"]4. 命令分发
第 2 课没有新增命令,仍然复用 hx agent。
if (command === "agent") {
// hx agent 后面的内容就是用户任务。
const task = rest.join(" ").trim();
if (!task) {
console.log('Usage: hx agent "List files in this directory"');
return;
}
// 第 2 课的变化在 runAgent 里面:
// 模型返回 tool_calls 后,不再只执行 bash,
// 而是通过工具分发表找到对应 handler。
await runAgent(task);
return;
}- 这里要看懂:
agent仍然是课程入口。runAgent()仍然负责 Agent Loop。- 第 2 课没有把入口变复杂,只把工具执行层扩展了。
5. 发出去的是什么
第 1 课发给 DeepSeek 的工具只有 bash。
第 2 课发的是 5 个工具:
bash- 执行 shell 命令。
read_file- 读取工作区内文件。
write_file- 写入或创建工作区内文件。
edit_file- 精确替换文件里的第一处文本。
glob- 按
pattern查找文件。
- 按
核心请求体长这样:
{
"model": "deepseek-chat",
"messages": [
{
"role": "system",
"content": "You are a coding agent at /current/path. Use tools to solve the user's task."
},
{
"role": "user",
"content": "Read README.md and list src/*.js"
}
],
"stream": false,
// 5 个 工具
"tools": [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read text file contents from the current working directory.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative file path to read."
},
"limit": {
"type": "integer",
"description": "Optional maximum number of lines to return."
}
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "glob",
"description": "Find files matching a glob pattern in the current working directory.",
"parameters": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern, for example src/*.js."
}
},
"required": ["pattern"]
}
}
}
],
"tool_choice": "auto"
}- 这段请求体的重点:
tools只是说明模型可以调用什么。parameters是模型要返回的参数形状。- 模型不会真的读文件。
- 真正读文件的是
本地 handler。
6. 怎么发出去
发 HTTP 请求的代码没有因为第 2 课变复杂。
// options 里会带上 tools 和 tool_choice。
const requestBody = {
model,
messages,
stream: false,
...options,
};
const response = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
});- 这里要记住:
- 第 0 课只发
messages。 - 第 1 课开始发
tools。 - 第 2 课把
tools从 1 个扩展成 5 个。 - HTTP 发送方式还是同一套。
- 第 0 课只发
7. 返回的是什么
如果模型决定调用工具,DeepSeek 返回的是 tool_calls。
假设它想读取 README,再列出 src/*.js,返回形状大概是:
关键是
tool_calls字段
{
"choices": [
{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_read",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\":\"README.md\",\"limit\":20}"
}
},
{
"id": "call_glob",
"type": "function",
"function": {
"name": "glob",
"arguments": "{\"pattern\":\"src/*.js\"}"
}
}
]
}
}
]
}代码会取出这一段:
const message = payload?.choices?.[0]?.message;然后进入 Agent Loop:
const toolCalls = message.tool_calls || [];
for (const toolCall of toolCalls) {
const output = await runToolCall(toolCall);
// 这条 role=tool 消息会进入下一轮模型请求。
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: output,
});
}- 这里要看懂:
tool_calls是模型的结构化动作计划。tool_call.id用来把工具结果和这次调用对应起来。content是工具执行后的观察结果。- 下一轮模型看到
role=tool,再决定怎么回答用户。
7.1. observation 是什么意思
observation 可以直接理解成:
observation = 工具执行结果 = 模型下一轮要看的现实反馈它不是 DeepSeek API 里的固定字段名。
在当前代码里,它对应的是 runToolCall() 返回的 output:
const output = await runToolCall(toolCall);
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: output,
});这里的 content: output,就是这次工具调用的观察结果。
比如模型发出工具调用:
{
"name": "read_file",
"arguments": "{\"path\":\"README.md\"}"
}HarnessX 本地执行 read_file 后,可能得到:
# HarnessX
用 Node.js 从零写一个最小 Agent 课程。这段文件内容就是 observation。
再比如模型想读工作区外的文件:
{
"name": "read_file",
"arguments": "{\"path\":\"../outside.txt\"}"
}HarnessX 执行 safePath() 后拒绝,得到:
Error: Path escapes workspace: ../outside.txt这段错误文本也是 observation。
所以模型并不是凭空知道文件内容,也不是自己真的读了文件。它只是下一轮看到了 HarnessX 回填的 role: "tool" 消息,然后基于这个观察结果继续回答。
8. 当前核心机制:工具分发表
第 1 课的代码里,工具执行层可以直接写死:
// 第 1 课只有 bash,所以可以硬编码。
if (toolCall.function?.name !== "bash") {
return `Error: Unknown tool`;
}
const output = await runBash(command);第 2 课不能再这么写。
因为现在模型可能返回:
这里其实都是我告诉模型应该返什么。即我本地有这些工具,我本地有这些软件,我本地有这些能力。你看看我的诉求,然后看看应该调我这个什么工具、什么软件。
bashread_filewrite_fileedit_fileglob
所以要把工具名映射成本地函数。
const TOOL_HANDLERS = {
bash: runBashTool,
read_file: runReadFile,
write_file: runWriteFile,
edit_file: runEditFile,
glob: runGlob,
};真正执行时只做查表。
async function runToolCall(toolCall) {
const toolName = toolCall.function?.name;
const handler = TOOL_HANDLERS[toolName];
if (!handler) {
return `Error: Unknown tool ${toolName || "(missing)"}`;
}
// DeepSeek 返回的 arguments 是 JSON 字符串。
// 本地执行前必须先 parse 成对象。
const input = JSON.parse(toolCall.function.arguments || "{}");
// 所有 handler 都遵守同一个约定:
// input object in -> string observation out
const output = await handler(input);
return output;
}- 工具分发表的好处:
- Agent Loop 不需要知道每个工具怎么实现。
- 新增工具时,只补工具定义和 handler 映射。
- 多个工具调用可以按模型返回顺序逐个执行。
- 未知工具和参数 JSON 错误可以在统一入口处理。
9. 文件工具为什么要有 safePath
第 2 课开始,模型能读写文件了。
这件事比执行 cat README.md 更清楚,但也带来边界问题。
- 模型可以请求读:
README.mdsrc/index.js
- 模型也可能请求读:
../outside.txt/Users/liguwe/.ssh/id_rsa
所以文件工具必须先过 safePath()。
9.1. safePath 怎么判断路径有没有逃出去
safePath() 的核心不是判断字符串里有没有 ..。
它做的是两步:
- 第一步,把用户传进来的路径变成绝对路径。
- 当前工作区是
/Users/liguwe/832/832X。 - 输入
README.md。 path.resolve(workdir, inputPath)得到/Users/liguwe/832/832X/README.md。
- 当前工作区是
- 第二步,再问这个绝对路径相对工作区在哪里。
path.relative(workdir, resolved)得到README.md。- 说明目标还在工作区里面。
如果输入是 ../outside.txt:
- 第一步解析绝对路径:
/Users/liguwe/832/outside.txt
- 第二步计算相对路径:
../outside.txt
- 这个相对路径以
..开头:- 说明目标已经跑到工作区外面。
- 所以直接抛错。
如果输入是 /Users/liguwe/.ssh/id_rsa:
- 解析后还是一个绝对路径。
- 相对当前工作区计算时,也会落到外部路径。
- 代码会把它识别成路径逃逸。
所以判断标准可以压缩成一句话:
先把路径算成绝对路径,再看它相对工作区的位置;只要相对路径往上走,就是逃出工作区。function safePath(inputPath) {
if (typeof inputPath !== "string" || !inputPath.trim()) {
throw new Error("path must be a non-empty string");
}
const workdir = process.cwd();
const resolved = path.resolve(workdir, inputPath);
const relative = path.relative(workdir, resolved);
// 只要解析后的路径逃出当前工作目录,就拒绝。
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Path escapes workspace: ${inputPath}`);
}
return resolved;
}- 这段代码的判断标准:
- 工作区内路径可以继续执行。
- 工作区外路径返回错误。
- 错误也会作为工具结果喂给模型。
工具执行结果可能是:
Error: Path escapes workspace: ../outside.txt这比让模型自由拼 shell 命令更可控。
10. 五个工具各自负责什么
bash 负责执行命令。
function runBashTool(input) {
if (typeof input.command !== "string" || !input.command.trim()) {
return "Error: Missing command";
}
return runBash(input.command);
}read_file 负责读文件。
function runReadFile(input) {
const filePath = safePath(input.path);
const text = readFileSync(filePath, "utf8");
const lines = text.split(/\r?\n/);
// limit 让模型可以先读文件头部,不必一次塞满上下文。
if (Number.isInteger(input.limit) && input.limit >= 0 && input.limit < lines.length) {
return truncateOutput(
[...lines.slice(0, input.limit), `... (${lines.length - input.limit} more lines)`].join("\n"),
);
}
return truncateOutput(text);
}write_file 负责写文件。
function runWriteFile(input) {
if (typeof input.content !== "string") {
return "Error: write_file.content must be a string";
}
const filePath = safePath(input.path);
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, input.content, "utf8");
return `Wrote ${input.content.length} bytes to ${input.path}`;
}edit_file 负责精确替换。
function runEditFile(input) {
const filePath = safePath(input.path);
const text = readFileSync(filePath, "utf8");
if (!text.includes(input.old_text)) {
return `Error: text not found in ${input.path}`;
}
// 教学版只替换第一处,先保持行为简单。
writeFileSync(filePath, text.replace(input.old_text, input.new_text), "utf8");
return `Edited ${input.path}`;
}glob 负责找文件。
function runGlob(input) {
const workdir = process.cwd();
const patternMatcher = globPatternToRegex(input.pattern);
const matches = listFiles(workdir)
.filter((relativePath) => patternMatcher.test(relativePath))
.sort();
return matches.length ? truncateOutput(matches.join("\n")) : "(no matches)";
}- 当前
glob是教学版最小实现:- 支持
src/*.js这种课程示例。 - 支持星号、问号、深层目录匹配。
- 不引入新依赖。
- 不用 Node 当前会打印 warning 的实验性
globSync。
- 支持
11. 怎么跑
第一次安装本地命令:
npm link准备 DeepSeek 环境变量:
cp .env.example .env填入:
DEEPSEEK_API_KEY=你的 key
DEEPSEEK_MODEL=deepseek-chat运行第二课能力:
hx agent "Read README.md and list src/*.js"如果模型选择工具,终端会先看到工具日志:
> read_file path=README.md limit=all
# HarnessX
...
> glob pattern=src/*.js
src/agent-loop.js
src/deepseek.js
src/index.js最后会看到模型根据工具结果生成的中文回答。
如果想观察路径边界,直接让真实模型尝试读工作区外路径:
hx agent "Try to read ../outside.txt and tell me what happened"如果模型调用 read_file,工具层会返回类似结果:
> read_file path=../outside.txt limit=all
Error: Path escapes workspace: ../outside.txt12. 这一课真正要记住
tools- 发给模型的工具说明,告诉模型可以返回哪些结构化工具调用。
tool_calls- 模型返回的动作计划,里面有工具名和 JSON 字符串参数。
TOOL_HANDLERS- 本地工具分发表,把工具名映射到真正执行函数。
role: "tool"- 工具执行结果回填给模型的消息角色。
safePath- 文件工具的工作区边界,防止模型读写当前目录外的文件。
observation- 本地工具执行后的字符串结果,模型下一轮要根据它继续回答。
这一课一句话:
s02 的核心不是多了几个工具,而是 Agent Loop 开始通过工具名查表执行本地能力。13. 源码
保留这一节,是为了以后代码继续变复杂时,还能回看第 2 课的最小主流程。
当前版本的关键文件:
src/index.js- 负责
hx命令入口和子命令分发。
- 负责
src/deepseek.js- 负责组织 HTTP 请求,调用 DeepSeek Chat Completions。
src/agent-loop.js- 负责 Agent Loop、工具定义、工具分发表、工具执行、文件工具。
13.1. 代码概览
先用几个短代码块看完整调用链。
入口只做一件事:把 agent 后面的任务交给 runAgent()。
// src/index.js
if (command === "agent") {
const task = rest.join(" ").trim();
await runAgent(task);
}工具定义发给模型,工具分发表留在本地执行。
// src/agent-loop.js
const TOOL_DEFINITIONS = [
{ type: "function", function: { name: "bash" } },
{ type: "function", function: { name: "read_file" } },
{ type: "function", function: { name: "write_file" } },
{ type: "function", function: { name: "edit_file" } },
{ type: "function", function: { name: "glob" } },
];
const TOOL_HANDLERS = {
bash: runBashTool,
read_file: runReadFile,
write_file: runWriteFile,
edit_file: runEditFile,
glob: runGlob,
};Agent Loop 负责一来一回:发给 DeepSeek,执行工具,再把结果回填。
// src/agent-loop.js
export async function runAgent(task) {
const messages = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: task },
];
while (true) {
// src/deepseek.js
// 真实请求 DeepSeek:model + messages + tools + tool_choice。
const message = await callDeepSeekMessage(messages, {
tools: TOOL_DEFINITIONS,
tool_choice: "auto",
});
messages.push(normalizeAssistantMessage(message));
const toolCalls = message.tool_calls || [];
if (toolCalls.length === 0) {
console.log(message.content);
return;
}
for (const toolCall of toolCalls) {
const output = await runToolCall(toolCall);
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: output,
});
}
}
}工具执行层只关心一件事:用工具名查 handler。
// src/agent-loop.js
async function runToolCall(toolCall) {
const toolName = toolCall.function?.name;
const handler = TOOL_HANDLERS[toolName];
const input = JSON.parse(toolCall.function.arguments || "{}");
return handler ? handler(input) : `Error: Unknown tool ${toolName}`;
}文件工具先过 safePath(),再碰真实文件。
// src/agent-loop.js
function runReadFile(input) {
const filePath = safePath(input.path);
return readFileSync(filePath, "utf8");
}
function safePath(inputPath) {
const resolved = path.resolve(process.cwd(), inputPath);
const relative = path.relative(process.cwd(), resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Path escapes workspace: ${inputPath}`);
}
return resolved;
}13.2. 代码细分
入口缩略代码:
// src/index.js
if (command === "agent") {
const task = rest.join(" ").trim();
// 输入为空就停在入口层,不进入模型循环。
if (!task) {
console.log('Usage: hx agent "List files in this directory"');
return;
}
// 第二课的核心能力都在 runAgent 里。
await runAgent(task);
return;
}模型请求缩略代码:
// src/deepseek.js
export async function callDeepSeekMessage(messages, options = {}) {
const requestBody = {
model,
messages,
stream: false,
// agent-loop 会通过 options 塞入 tools 和 tool_choice。
...options,
};
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
});
const payload = await response.json();
// Agent Loop 需要完整 message,因为 tool_calls 不在 content 里。
return payload?.choices?.[0]?.message;
}工具定义缩略代码:
// src/agent-loop.js
const TOOL_DEFINITIONS = [
// 发给模型:我支持 bash。
{ type: "function", function: { name: "bash", parameters: { /* command */ } } },
// 发给模型:我支持读取文件。
{ type: "function", function: { name: "read_file", parameters: { /* path, limit */ } } },
// 发给模型:我支持写文件。
{ type: "function", function: { name: "write_file", parameters: { /* path, content */ } } },
// 发给模型:我支持精确替换文件文本。
{ type: "function", function: { name: "edit_file", parameters: { /* path, old_text, new_text */ } } },
// 发给模型:我支持按 pattern 查找文件。
{ type: "function", function: { name: "glob", parameters: { /* pattern */ } } },
];工具分发表缩略代码:
// src/agent-loop.js
const TOOL_HANDLERS = {
bash: runBashTool,
read_file: runReadFile,
write_file: runWriteFile,
edit_file: runEditFile,
glob: runGlob,
};Agent Loop 缩略代码:
// src/agent-loop.js
export async function runAgent(task) {
const messages = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: task },
];
for (let turn = 1; turn <= AGENT_MAX_TURNS; turn += 1) {
const message = await callDeepSeekMessage(messages, {
tools: TOOL_DEFINITIONS,
tool_choice: "auto",
});
// assistant 这轮说了什么,要先放回 messages。
messages.push(normalizeAssistantMessage(message));
const toolCalls = message.tool_calls || [];
// 没有工具调用,说明模型已经给最终回答。
if (toolCalls.length === 0) {
console.log(message.content);
return;
}
// 有工具调用,就逐个执行,再把结果作为 role=tool 喂回模型。
for (const toolCall of toolCalls) {
const output = await runToolCall(toolCall);
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: output,
});
}
}
}执行工具缩略代码:
// src/agent-loop.js
async function runToolCall(toolCall) {
const toolName = toolCall.function?.name;
const handler = TOOL_HANDLERS[toolName];
if (!handler) {
return `Error: Unknown tool ${toolName || "(missing)"}`;
}
// DeepSeek 返回的是 JSON 字符串。
// 本地 handler 要的是 JS object。
const input = JSON.parse(toolCall.function.arguments || "{}");
// 这里就是 s02 的核心动作:按名字查表,执行本地函数。
const output = await handler(input);
// output 会作为 role=tool 的 content 回到下一轮 messages。
return output;
}文件工具边界缩略代码:
// src/agent-loop.js
function safePath(inputPath) {
const workdir = process.cwd();
const resolved = path.resolve(workdir, inputPath);
const relative = path.relative(workdir, resolved);
// 任何逃出当前工作目录的路径,都直接拒绝。
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Path escapes workspace: ${inputPath}`);
}
return resolved;
}完整逻辑链路:
hx agent "任务"
-> src/index.js 识别 agent 命令
-> runAgent(task)
-> messages + TOOL_DEFINITIONS 发给 DeepSeek
-> DeepSeek 返回 tool_calls
-> runToolCall(toolCall)
-> TOOL_HANDLERS[toolName](input)
-> 工具执行结果变成 role=tool
-> messages 继续变长
-> 下一轮 DeepSeek 根据工具结果生成最终回答