222. 技术:HarnessX 第 4 课:把权限、日志和收尾统计挂到 Hooks 上,让 Agent Loop 保持干净

2026.06.24

·技术harnessx

20260624_1.webp|616

1. 重点

  • 第 4 课真正要看懂的是这条链路:
    • 用户敲 hx agent "Read README.md and list src/*.js"
    • Node.js 仍然进入同一个 agent 命令。
    • 程序在用户任务进入 messages 前触发 UserPromptSubmit
    • 程序把 messages + tools 发给 DeepSeek。
    • DeepSeek 返回 tool_calls
    • HarnessX 不把权限、日志、统计直接写死在循环里。
    • HarnessX 先触发 PreToolUse
    • 没被 hook 拦住,才进入 TOOL_HANDLERS 执行本地工具。
    • 工具执行完,再触发 PostToolUse
    • DeepSeek 不再返回 tool_calls 时,触发 Stop,打印本轮统计。
  • 当前这一课要做的是 Hooks:
    • Agent Loop 继续只负责循环。
    • 工具分发表继续只负责找 handler
    • 权限检查、工具日志、输出统计和收尾统计挂到 hook 上。

这一课的第一性原理是:

text
Hook 不是模型能力。
Hook 是 HarnessX 本地运行时留出来的扩展点。
循环负责走流程,hook 负责在关键节点插入横切逻辑。

2. 流程图

2.1. 先看总流程

先不要急着看每个函数。把 HarnessX 想成一个会反复询问模型、执行工具、再把结果交回模型的 Node.js 程序。

bash
+-------------------- 进入 Agent --------------------+
|                                                     |
|  用户                                               |
|    |                                                |
|    | hx agent "读取 README.md 并列出 src/*.js"      |
|    v                                                |
|  src/index.js                                       |
|    | 识别 agent 命令                                |
|    v                                                |
|  runAgent(task)                                     |
|    |                                                |
|    +--> [Hook: UserPromptSubmit]                    |
|    |        观察刚收到的用户任务                    |
|    v                                                |
|  messages = [system, user]                          |
|                                                     |
+------------------------+----------------------------+
                         |
                         v
+-------------------- Agent Loop ---------------------+
|                                                     |
| messages + tools 发给 DeepSeek                  |
|                         |                           |
|                         v                           |
|              DeepSeek 返回 assistant message       |
|                         |                           |
|                         v                           |
|                 有没有 tool_calls?                |
|                    /             \                  |
|               没有              |
|                  |                  |               |
|                  v                  v               |
|        [Hook: PreToolUse]      [Hook: Stop]         |
|        日志 + 权限检查          打印收尾统计         |
|                  |                  |               |
|                  v                  v               |
|           是否阻止工具?        打印最终回答         |
|             /          \            |               |
|           v               |
|           |             |          Agent 结束       |
|           v             v                            |
|  "Permission denied."  TOOL_HANDLERS               |
|                         执行本地工具                 |
|                              |                      |
|                              v                      |
|                    [Hook: PostToolUse]              |
|                    观察工具输出                     |
|           |                  |                      |
|           +--------+---------+                      |
|                    |                                |
|                    v                                |
|        追加 { role: "tool", content: output }      |
|                    |                                |
|                    | 带着更长的 messages            |
|                    +-----------------------> 回到循环开头
|                                                     |
+-----------------------------------------------------+
  • 看图时先抓住四件事:
    • DeepSeek 只决定“回答文字”或“建议调用工具”
    • HarnessX 决定工具到底能不能执行,并真正调用本地函数。
    • Hook 挂在流程节点上,观察或拦截当前动作。
    • 工具结果追加进 messages 后,Agent Loop 才有材料继续问 DeepSeek。

2.2. HTTP 一来一回

222. 技术:HarnessX 第 4 课:把权限、日志和收尾统计挂到 Hooks 上,让 Agent Loop 保持干净 图表 1

2.3. Hook 生命周期

222. 技术:HarnessX 第 4 课:把权限、日志和收尾统计挂到 Hooks 上,让 Agent Loop 保持干净 图表 2

2.4. messages 怎么继续变长

222. 技术:HarnessX 第 4 课:把权限、日志和收尾统计挂到 Hooks 上,让 Agent Loop 保持干净 图表 3

3. 入口

入口没有新增命令,还是:

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

package.json 也没有变化。

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

真正的命令分发还是 src/index.js

js
if (command === "agent") {
  // 第 4 课没有新增 hx hooks 命令。
  // 仍然复用 hx agent,只是 runAgent 内部多了 hook 生命周期。
  const task = rest.join(" ").trim();

  if (!task) {
    console.log('Usage: hx agent "List files in this directory"');
    return;
  }

  await runAgent(task);
  return;
}
  • 这里要记住:
    • 入口不变。
    • 工具不变。
    • 变化发生在 Agent Loop 的关键节点。

4. 发出去的是什么

第 4 课发给 DeepSeek 的请求体仍然是这一类结构:

json
{
  "model": "deepseek-chat",
  "messages": [
    {
      "role": "system",
      "content": "You are a coding agent at /Users/liguwe/832/832X..."
    },
    {
      "role": "user",
      "content": "Read README.md and list src/*.js"
    }
  ],
  "stream": false,
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "read_file",
        "parameters": {
          "type": "object",
          "properties": {
            "path": { "type": "string" },
            "limit": { "type": "integer" }
          },
          "required": ["path"]
        }
      }
    }
  ],
  "tool_choice": "auto"
}

Hook 不会出现在这个请求体里。

  • 这点很关键:
    • tools 是告诉模型可以提出什么工具调用。
    • tool_calls 是模型返回的结构化意图
    • hookHarnessX 本地运行时自己的机制。
    • DeepSeek 不知道 HarnessX 注册了哪些 hook。

5. 返回的是什么

模型如果想读文件,返回的还是 tool_calls

json
{
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": null,
        "tool_calls": [
          {
            "id": "call_xxx",
            "type": "function",
            "function": {
              "name": "read_file",
              "arguments": "{\"path\":\"README.md\"}"
            }
          }
        ]
      }
    }
  ]
}

代码取的还是:

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

然后 Agent Loop 看:

js
const toolCalls = message.tool_calls || [];
  • 如果没有 tool_calls
    • 说明模型已经给最终回答。
    • 程序触发 Stop
    • 再打印 message.content
  • 如果有 tool_calls
    • 程序解析工具名和参数。
    • 触发 PreToolUse
    • 可能执行工具,也可能被 hook 拦住。

6. 当前核心机制:Hooks

6.1. Hook 注册表

第 4 课新增的核心结构很小。

js
const HOOKS = {
  UserPromptSubmit: [],
  PreToolUse: [],
  PostToolUse: [],
  Stop: [],
};

它的意思是:

  • UserPromptSubmit
    • 用户任务进入模型上下文之前触发。
  • PreToolUse
    • 工具真正执行之前触发。
  • PostToolUse
    • 工具执行之后触发。
  • Stop
    • Agent 准备结束时触发。

注册 hook 的代码:

js
function registerHook(event, callback) {
  if (!HOOKS[event]) {
    throw new Error(`Unknown hook event: ${event}`);
  }

  // 一个事件可以挂多个 callback。
  // 后面 triggerHooks 会按注册顺序执行。
  HOOKS[event].push(callback);
}

触发 hook 的代码:

js
async function triggerHooks(event, ...args) {
  for (const callback of HOOKS[event]) {
    const result = await callback(...args);

    // 返回 null / undefined 表示继续。
    // 返回其他值表示这个 hook 要拦住当前事件。
    if (result !== null && result !== undefined) {
      return result;
    }
  }

  return null;
}

6.2. 权限检查搬到 PreToolUse

第 3 课是这样:

js
runToolCall()
  -> checkPermission()
  -> TOOL_HANDLERS

第 4 课改成:

js
runToolCall()
  -> triggerHooks("PreToolUse")
  -> permissionHook()
  -> TOOL_HANDLERS

代码里的权限 hook:

js
async function permissionHook(toolName, input) {
  if (toolName === "bash") {
    const reason = checkDenyList(input.command || "");
    if (reason) {
      // 返回字符串,就表示阻塞本次工具调用。
      return reason;
    }
  }

  const reason = checkRules(toolName, input);
  if (!reason) {
    return null;
  }

  const decision = await askUser(toolName, input, reason);
  return decision === "allow" ? null : "Permission denied.";
}

这段代码保留了第 3 课的安全策略,但位置变了。

  • 原来是 runToolCall() 直接知道权限检查。
  • 现在是 runToolCall() 只知道触发 PreToolUse
  • 至于 PreToolUse 里挂了权限、日志还是别的检查,由注册表决定。

6.3. 被 hook 拦住后怎么回到模型

被拦住的工具不会执行。

js
const blocked = await triggerHooks("PreToolUse", toolName, input, toolCall);
if (blocked) {
  stats.blockedToolCalls += 1;
  console.log(blocked);
  return "Permission denied.";
}

但它仍然会被写回 messages

js
messages.push({
  role: "tool",
  tool_call_id: toolCall.id,
  content: "Permission denied.",
});
  • 这里要看懂:
    • hook 拦住的是本地动作。
    • Agent Loop 不能直接断掉。
    • 模型需要知道工具调用失败了,才能决定下一步。

7. 怎么跑

先保证 .env 里有真实 DeepSeek Key。

bash
cp .env.example .env

填入:

bash
DEEPSEEK_API_KEY=你的 key
DEEPSEEK_MODEL=deepseek-chat

跑只读任务:

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

这次真实运行能看到:

bash
[HOOK] UserPromptSubmit cwd=/Users/liguwe/832/832X
> read_file path=README.md limit=all
[HOOK] PreToolUse read_file({"path":"README.md"})
[HOOK] PostToolUse read_file output_chars=1491
> glob pattern=src/*.js
[HOOK] PreToolUse glob({"pattern":"src/*.js"})
[HOOK] PostToolUse glob output_chars=46
[HOOK] Stop: turns=2, tool_calls=2, blocked=0

跑危险命令:

bash
hx agent "Try to run sudo ls"

能看到:

text
[HOOK] UserPromptSubmit cwd=/Users/liguwe/832/832X
$ sudo ls /
[HOOK] PreToolUse bash({"command":"sudo ls /"})
Blocked: 'sudo' is on the deny list
[HOOK] Stop: turns=2, tool_calls=1, blocked=1

跑删除文件:

bash
hx agent "Delete the file test.txt"

非交互环境里会默认拒绝:

text
Permission required: Potentially destructive command
Tool: bash({"command":"rm ..."})
Non-interactive terminal; denied.
Permission denied.

8. 这一课真正要记住

  • Hook
    • HarnessX 本地运行时扩展点,不是 DeepSeek API 字段。
  • UserPromptSubmit
    • 用户任务进入 messages 之前触发。
  • PreToolUse
    • 工具执行前触发,可以返回阻塞原因。
  • PostToolUse
    • 工具执行后触发,适合记录输出、统计和提醒。
  • Stop
    • Agent 不再收到 tool_calls、准备结束时触发。
  • registerHook()
    • 把一个 callback 挂到事件上。
  • triggerHooks()
    • 按顺序触发事件上的 callback。

这一课可以压成一句话:

text
Agent Loop 不应该越写越胖;权限、日志、统计这类横切逻辑,要挂到 hook 上。

9. 源码

这里保留当前版本的主流程,方便以后回看第 4 课到底把 hook 插在了哪里。

当前版本关键文件仍然只有三个:

  • src/index.js
    • 接收 hx agent ...,把任务交给 runAgent()
  • src/deepseek.js
    • 负责真实 DeepSeek HTTP 请求。
  • src/agent-loop.js
    • 负责 Agent Loop、工具分发、Hooks 和 tool result 回传。

9.1. 代码概览

先看入口。

js
// src/index.js
if (command === "agent") {
  // 用户输入仍然从 hx agent 进来。
  // 第 4 课没有新增 hx hooks 命令。
  const task = rest.join(" ").trim();
  await runAgent(task);
  return;
}

再看 hook 注册表。

js
// src/agent-loop.js
const HOOKS = {
  UserPromptSubmit: [],
  PreToolUse: [],
  PostToolUse: [],
  Stop: [],
};

registerHook("UserPromptSubmit", userPromptHook);
registerHook("PreToolUse", toolLogHook);
registerHook("PreToolUse", permissionHook);
registerHook("PostToolUse", largeOutputHook);
registerHook("Stop", summaryHook);

最后看主循环。

js
export async function runAgent(task) {
  // 任务刚进来,先触发用户提交 hook。
  await triggerHooks("UserPromptSubmit", task, { cwd: process.cwd() });

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

    messages.push(normalizeAssistantMessage(message));

    const toolCalls = message.tool_calls || [];
    if (toolCalls.length === 0) {
      // 模型不再要工具,说明准备结束。
      await triggerHooks("Stop", messages, stats);
      console.log(message.content);
      return;
    }

    for (const toolCall of toolCalls) {
      const output = await runToolCall(toolCall, stats);
      messages.push({
        role: "tool",
        tool_call_id: toolCall.id,
        content: output,
      });
    }
  }
}

9.2. 代码细分

registerHook() 只负责注册。

js
function registerHook(event, callback) {
  if (!HOOKS[event]) {
    throw new Error(`Unknown hook event: ${event}`);
  }

  HOOKS[event].push(callback);
}

triggerHooks() 只负责触发。

js
async function triggerHooks(event, ...args) {
  for (const callback of HOOKS[event]) {
    const result = await callback(...args);

    // 返回值不是空,就说明 hook 要拦住当前事件。
    if (result !== null && result !== undefined) {
      return result;
    }
  }

  return null;
}

runToolCall() 不再直接调用 checkPermission()

js
async function runToolCall(toolCall, stats) {
  const toolName = toolCall.function?.name;
  const handler = TOOL_HANDLERS[toolName];
  const input = JSON.parse(toolCall.function.arguments || "{}");

  stats.toolCalls += 1;

  // 所有执行前逻辑都从 PreToolUse 进入。
  const blocked = await triggerHooks("PreToolUse", toolName, input, toolCall);
  if (blocked) {
    stats.blockedToolCalls += 1;
    console.log(blocked);
    return "Permission denied.";
  }

  const output = await handler(input);

  // 工具真的执行完,才触发 PostToolUse。
  await triggerHooks("PostToolUse", toolName, input, output, toolCall);
  return output;
}

permissionHook() 承接第 3 课的权限策略。

js
async function permissionHook(toolName, input) {
  if (toolName === "bash") {
    const reason = checkDenyList(input.command || "");
    if (reason) {
      return reason;
    }
  }

  const reason = checkRules(toolName, input);
  if (!reason) {
    return null;
  }

  const decision = await askUser(toolName, input, reason);
  return decision === "allow" ? null : "Permission denied.";
}

PostToolUseStop 现在只做观察和统计。

js
function largeOutputHook(toolName, _input, output) {
  const outputLength = String(output).length;
  console.log(`[HOOK] PostToolUse ${toolName} output_chars=${outputLength}`);
  return null;
}

function summaryHook(_messages, stats) {
  console.log(
    `[HOOK] Stop: turns=${stats.turns}, tool_calls=${stats.toolCalls}, blocked=${stats.blockedToolCalls}`,
  );
  return null;
}

完整链路就是:

text
hx agent
  -> runAgent(task)
  -> UserPromptSubmit
  -> DeepSeek messages + tools
  -> assistant.tool_calls
  -> PreToolUse
  -> TOOL_HANDLERS
  -> PostToolUse
  -> role=tool
  -> DeepSeek 下一轮
  -> Stop
  -> 最终回答