219. 技术:HarnessX 第 2 课:把工具调用从一个 bash 扩展成工具分发表,让 Agent 能读写和查找文件

2026.06.23

·技术harnessx

1. 重点

20260623_1.webp|520

  • 第 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 课扩展成 bashread_filewrite_fileedit_fileglob
    • Agent Loop 主结构不变。
    • 变化集中在工具定义工具分发表

这一课的第一性原理是:---> 结构化意图

text
工具调用不是让模型真的执行工具。
模型只返回结构化意图。
HarnessX 用工具名查表,执行本地函数,再把结果喂回模型。

2. 流程图

2.1. 整体流程

219. 技术:HarnessX 第 2 课:把工具调用从一个 bash 扩展成工具分发表,让 Agent 能读写和查找文件 图表 1

2.2. HTTP 一来一回加工具分发

219. 技术:HarnessX 第 2 课:把工具调用从一个 bash 扩展成工具分发表,让 Agent 能读写和查找文件 图表 2

2.3. 工具分发表

219. 技术:HarnessX 第 2 课:把工具调用从一个 bash 扩展成工具分发表,让 Agent 能读写和查找文件 图表 3

2.4. messages 怎么变长

219. 技术:HarnessX 第 2 课:把工具调用从一个 bash 扩展成工具分发表,让 Agent 能读写和查找文件 图表 4

3. 入口

用户敲的是:

bash
hx agent "Read README.md and list src/*.js"

package.json 还是同一个入口。

json
{
  "bin": {
    "hx": "./src/index.js"
  }
}

真正进入业务代码的是:

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

js
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 查找文件。

核心请求体长这样:

json
{
  "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 课变复杂。

js
// 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 发送方式还是同一套。

7. 返回的是什么

如果模型决定调用工具,DeepSeek 返回的是 tool_calls

假设它想读取 README,再列出 src/*.js,返回形状大概是:

关键是 tool_calls 字段

json
{
  "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\"}"
            }
          }
        ]
      }
    }
  ]
}

代码会取出这一段:

js
const message = payload?.choices?.[0]?.message;

然后进入 Agent Loop:

js
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 可以直接理解成:

text
observation = 工具执行结果 = 模型下一轮要看的现实反馈

它不是 DeepSeek API 里的固定字段名。

在当前代码里,它对应的是 runToolCall() 返回的 output

js
const output = await runToolCall(toolCall);

messages.push({
  role: "tool",
  tool_call_id: toolCall.id,
  content: output,
});

这里的 content: output,就是这次工具调用的观察结果。

比如模型发出工具调用:

json
{
  "name": "read_file",
  "arguments": "{\"path\":\"README.md\"}"
}

HarnessX 本地执行 read_file 后,可能得到:

text
# HarnessX

用 Node.js 从零写一个最小 Agent 课程。

这段文件内容就是 observation

再比如模型想读工作区外的文件:

json
{
  "name": "read_file",
  "arguments": "{\"path\":\"../outside.txt\"}"
}

HarnessX 执行 safePath() 后拒绝,得到:

text
Error: Path escapes workspace: ../outside.txt

这段错误文本也是 observation

所以模型并不是凭空知道文件内容,也不是自己真的读了文件。它只是下一轮看到了 HarnessX 回填的 role: "tool" 消息,然后基于这个观察结果继续回答。

8. 当前核心机制:工具分发表

第 1 课的代码里,工具执行层可以直接写死:

js
// 第 1 课只有 bash,所以可以硬编码。
if (toolCall.function?.name !== "bash") {
  return `Error: Unknown tool`;
}

const output = await runBash(command);

第 2 课不能再这么写。

因为现在模型可能返回:

这里其实都是我告诉模型应该返什么。即我本地有这些工具,我本地有这些软件,我本地有这些能力。你看看我的诉求,然后看看应该调我这个什么工具、什么软件。

  • bash
  • read_file
  • write_file
  • edit_file
  • glob

所以要把工具名映射成本地函数。

js
const TOOL_HANDLERS = {
  bash: runBashTool,
  read_file: runReadFile,
  write_file: runWriteFile,
  edit_file: runEditFile,
  glob: runGlob,
};

真正执行时只做查表。

js
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.md
    • src/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

  • 解析后还是一个绝对路径。
  • 相对当前工作区计算时,也会落到外部路径。
  • 代码会把它识别成路径逃逸。

所以判断标准可以压缩成一句话:

text
先把路径算成绝对路径,再看它相对工作区的位置;只要相对路径往上走,就是逃出工作区。
js
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;
}
  • 这段代码的判断标准:
    • 工作区内路径可以继续执行。
    • 工作区外路径返回错误。
    • 错误也会作为工具结果喂给模型。

工具执行结果可能是:

text
Error: Path escapes workspace: ../outside.txt

这比让模型自由拼 shell 命令更可控。

10. 五个工具各自负责什么

bash 负责执行命令。

js
function runBashTool(input) {
  if (typeof input.command !== "string" || !input.command.trim()) {
    return "Error: Missing command";
  }

  return runBash(input.command);
}

read_file 负责读文件。

js
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 负责写文件。

js
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 负责精确替换。

js
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 负责找文件。

js
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. 怎么跑

第一次安装本地命令:

bash
npm link

准备 DeepSeek 环境变量:

bash
cp .env.example .env

填入:

bash
DEEPSEEK_API_KEY=你的 key
DEEPSEEK_MODEL=deepseek-chat

运行第二课能力:

bash
hx agent "Read README.md and list src/*.js"

如果模型选择工具,终端会先看到工具日志:

text
> read_file path=README.md limit=all
# HarnessX
...
> glob pattern=src/*.js
src/agent-loop.js
src/deepseek.js
src/index.js

最后会看到模型根据工具结果生成的中文回答。

如果想观察路径边界,直接让真实模型尝试读工作区外路径:

bash
hx agent "Try to read ../outside.txt and tell me what happened"

如果模型调用 read_file,工具层会返回类似结果:

text
> read_file path=../outside.txt limit=all
Error: Path escapes workspace: ../outside.txt

12. 这一课真正要记住

  • tools
    • 发给模型的工具说明,告诉模型可以返回哪些结构化工具调用。
  • tool_calls
    • 模型返回的动作计划,里面有工具名和 JSON 字符串参数。
  • TOOL_HANDLERS
    • 本地工具分发表,把工具名映射到真正执行函数。
  • role: "tool"
    • 工具执行结果回填给模型的消息角色。
  • safePath
    • 文件工具的工作区边界,防止模型读写当前目录外的文件。
  • observation
    • 本地工具执行后的字符串结果,模型下一轮要根据它继续回答。

这一课一句话:

text
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()

js
// src/index.js
if (command === "agent") {
  const task = rest.join(" ").trim();
  await runAgent(task);
}

工具定义发给模型,工具分发表留在本地执行。

js
// 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,执行工具,再把结果回填。

js
// 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。

js
// 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(),再碰真实文件。

js
// 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. 代码细分

入口缩略代码:

js
// 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;
}

模型请求缩略代码:

js
// 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;
}

工具定义缩略代码:

js
// 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 */ } } },
];

工具分发表缩略代码:

js
// src/agent-loop.js

const TOOL_HANDLERS = {
  bash: runBashTool,
  read_file: runReadFile,
  write_file: runWriteFile,
  edit_file: runEditFile,
  glob: runGlob,
};

Agent Loop 缩略代码:

js
// 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,
      });
    }
  }
}

执行工具缩略代码:

js
// 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;
}

文件工具边界缩略代码:

js
// 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;
}

完整逻辑链路:

text
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 根据工具结果生成最终回答