
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 一来一回
2.3. Hook 生命周期
2.4. messages 怎么继续变长
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是模型返回的结构化意图。hook是HarnessX本地运行时自己的机制。- 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、准备结束时触发。
- Agent 不再收到
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.";
}PostToolUse 和 Stop 现在只做观察和统计。
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
-> 最终回答