从零实现 Agent Harness 系列 · 第 03 篇
前面我们已经把 Agent 的最小循环和任务推进方式讲清楚了。
但当 Agent 真开始执行工具后,很快还会碰到另一类问题:权限检查、审计日志、大输出截断,到底该写在哪里?
前言
到这一步,Agent 已经能调用工具、维护计划了。
很多人接下来会很自然地把权限检查、日志记录、输出截断这类"和工具相关的附加处理"塞进 agent_loop():
python
for tool_call in response.tool_calls:
if not check_permission(tool_call):
...
log_tool(tool_call)
output = run_tool(tool_call)
if len(output) > 100000:
warn(...)
这些逻辑单看都合理,但问题在于:它们并不属于 Loop 的核心职责。
Loop 本来只该做三件事:
text
1. 调模型
2. 有 tool_calls 就执行并回填
3. 直到模型不再要工具
一旦权限、日志、审计、截断、补充上下文都开始往里面堆,Loop 很快就会从"主干"变成"杂物间"。
这就是 Hook 要解决的问题。
一句话说:
Hook 的作用,是把权限检查、日志记录、输出截断这类公共处理,从
agent_loop()里拿出来,放到它该出现的时机去执行。
一、Hook 要解决什么问题
这一篇要解决的问题很直接:
text
Agent 做动作的时候,能不能被拦、被看见、被约束?
比如模型请求执行一个 bash 命令时,程序常常不只想知道"能不能执行",还会关心:
- 这条命令是否危险
- 这次调用要不要记录日志
- 输出是不是太长,不能原样塞回上下文
- 这次任务开始前要不要补一点环境信息
这些逻辑有个共同点:它们不是某一个工具自己的业务逻辑,而是跨越很多工具的公共规则。
也正因为这样,如果把它们分别塞进 run_bash、read_file、write_file、edit_file,代码会越来越散。
二、为什么这些问题更适合交给 Hook
最直接的写法,是在工具函数里自己处理。
比如最小版本里,run_bash 已经做过一点危险命令拦截。这种做法能工作,但继续扩展时会越来越别扭。
第一,策略会重复
如果今天要限制 bash,明天还要限制 write_file、edit_file 写出工作区,那你就得在每个 run_* 函数里各写一遍相似判断。
第二,语义会混在一起
工具函数本来负责"真正执行动作",但一旦里面既有权限规则、又有日志、又有输出裁剪,它就不再只是执行器,而变成执行器加策略层的混合体。
第三,模型收到的反馈也会不够清楚
如果你在 run_bash 里直接返回一段错误字符串,模型并不一定知道这是"策略拒绝",还是"命令本身运行失败"。
所以更清楚的分层应该是:
text
模型请求工具
→ 先经过策略层判断
→ 再真正执行工具
→ 执行完成后再经过观测层处理
→ 最后把结果回填给模型
Hook 做的,就是把这层"策略与观测"的公共机制,从工具函数和主循环里抽出来。
三、Hook 如何接入 Agent Loop
这一章不需要先记很多代码,先抓住一个核心:Hook 不是替换 Agent Loop,而是插在 Loop 的几个关键节点上。
3.1 Hook 插在哪几个位置
这份代码里定义了四个触发点:
UserPromptSubmit:用户输入刚进入系统时PreToolUse:工具真正执行之前PostToolUse:工具执行完成之后Stop:这一轮准备结束时
可以先把它理解成一句话:
text
主循环负责往前跑
Hook 负责在固定节点插入附加逻辑
先看流程图,会更直观。
3.2 一次工具调用会怎么经过这些 Hook
当 Agent 跑起来时,流程大致会经过下面这些位置:
text
用户输入
↓
UserPromptSubmit
↓
LLM 决定是否调用工具
↓
PreToolUse
↓
执行工具
↓
PostToolUse
↓
回填结果
↓
若本轮不再调用工具,则进入 Stop
这张图想表达的就是:Loop 仍然是主线,Hook 只是插在主线上的几个检查点。
流程讲完之后,再看 Hook 具体在前后两端分别做什么。
3.3 代码里怎么把这些 Hook 组织起来
在实现上,这份代码用了一个很朴素的注册表:
python
HOOKS = {
"UserPromptSubmit": [],
"PreToolUse": [],
"PostToolUse": [],
"Stop": [],
}
def register_hook(event: str, callback):
HOOKS[event].append(callback)
意思很简单:
- 先按事件名分出几个桶
- 每个桶里放这一类 Hook
- 跑到对应节点时,就把这个桶里的 Hook 依次执行一遍
所以这一版 Hook 示例的落地方式其实就是:先定义触发点,再把权限检查、日志记录、输出截断这些处理挂到对应触发点上,最后由框架按顺序执行。
3.4 Hook 返回结果后,框架怎么处理
HookResult 解决的是:回调执行完之后,框架怎么理解它的结果。
python
@dataclass
class HookResult:
action: Literal["allow", "deny", "ask", "modify"]
message: str | None = None
这里最关键的是四种动作语义:
allow:明确放行deny:拒绝继续执行ask:先向用户确认modify:修改原本的数据或输出
这比 True / False 更适合真实系统,因为 Agent 的拦截逻辑不只有"让不让过"两种。
3.5 PreToolUse 和 PostToolUse 分别负责什么
在这份实现里,PreToolUse 和 PostToolUse 分别负责两类事情:
text
PreToolUse 更像"门卫"
PostToolUse 更像"审计和整理层"
一个负责"先看能不能过",一个负责"执行完后怎么收拾结果"。
3.6 Hook 为什么先拿到 ToolCallContext
还有一个实现细节是:Hook 不直接操作 SDK 返回的原始对象,而是先把它归一成 ToolCallContext。
python
@dataclass
class ToolCallContext:
id: str
name: str
arguments: dict
这样 Hook 作者只需要关心:
- 这次调用的是哪个工具
- 参数里有哪些字段
而不用反复处理对象访问、JSON 解析、异常分支。
四、Hook 在 Agent 架构中的位置
如果把 Agent 的结构拆开看,Loop 负责把流程跑起来,Plan 负责把任务推进下去,Hook 负责在关键节点补上拦截、确认、日志和收尾这些控制逻辑。
也正因为 Hook 专门处理这一层,后面无论是补审批、补审计,还是让不同 Agent 共用同一套工具策略,都会更容易接上去。
小结
Hook 解决的,不是"让 Agent 多一个能力",而是"让 Agent 在调用工具时保持可控"。
这一篇最值得带走的,是这条工程判断:
当一段逻辑会同时作用于很多工具,又不属于主循环核心职责时,它往往就该长成 Hook。