数据截至 (上游 commit 0a27a45390b4)
钩子、 确认与可扩展性:护栏与插件如何挂上去
30 秒导读: gptme 的主循环(→ 01)本身很瘦,真正让它"有护栏、能扩展"的是一套钩子系统。生命周期的每个关键时刻——会话开始、每一步生成前后、工具执行前后、文件保存、循环是否继续——都会
trigger_hook(...)喊一嗓子;凡是想插一手的能力(要不要确认、要不要自动 git 提交、要不要注入成本提示、要不要拦截提示注入)都写成一个钩子挂上去。第三方插件也用同一套机制把自己的工具/钩子/命令带进来。这一章讲这套"挂载点"是怎么设计的。
1. 这是什么(零基础也能懂)
一句话定义
钩子(hook)= 一个"注册在某个时刻上、到点就被回调"的函数。 gptme 预先在生命周期里埋了一堆时刻(叫 HookType),你把函数 register_hook 到某个时刻,循环跑到那里就用 trigger_hook 把所有注册者依次叫起来。
它解决什么问题
主循环要保持简单,但一个真实的 agent 需要一大堆"横切"能力:
- 执行危险命令前问一句 y/n(确认护栏);
- 每一轮结束后自动 git 提交改动;
- 生成前注入一条成本/token 提示给用户;
- 工具输出里混进了可疑的"提示注入"文本时贴个警告;
- 换了工作目录就把新目录的
AGENTS.md注入上下文。
如果把这些全写进主循环,循环会膨胀成一坨谁也读不懂的东西。gptme 的选择是:循环只负责在正确的时刻喊一声,具体做什么由挂在那个时刻上的钩子决定。 这就是"控制反转"——把可变的策略从固定的骨架里抽出来。
一句话直觉
把它想成前端的事件监听:循环 = DOM,HookType = 事件名(click、submit……),register_hook = addEventListener,trigger_hook = dispatchEvent。区别只在于:这里的"事件"是 agent 生命周期里的时刻,而监听器可以改写数据、甚至叫停后续监听器。
用起来什么样
一个最小的钩子长这样(风格取自内置钩子,# 示意,非源码):
from gptme.hooks import HookType, register_hook
from gptme.message import Message
# 在"每一步生成之前"注入一条系统消息
def remind_time(messages, **kwargs):
yield Message("system", "现在是深夜,注意别写破坏性命令") # 钩子用 yield 产出消息
def register():
register_hook("time_reminder", HookType.GENERATION_PRE, remind_time)
钩子产出(yield)消息,循环把这些消息灌回上下文;或者什么都不产出,只做副作用(比如提交、发通知)。就这么简单——难的是在哪些时刻埋点、按什么顺序叫、谁能叫停谁,这正是下面要讲的。
2. 顶层全景(它大概怎么转)
整套机制只有三个动词和一张枚举:
| 部件 | 干什么 | 在哪 |
|---|---|---|
HookType | 所有"可挂载时刻"的枚举(会话/回合/步/生成/工具/文件/确认/征询…) | gptme/hooks/types.py:66 |
register_hook | 把函数按 HookType + 优先级登记进注册表 | gptme/hooks/registry.py:623 |
trigger_hook | 到点触发某类钩子,按优先级依次跑、yield 出消息 | gptme/hooks/registry.py:654 |
HookRegistry | 每个 HookType 一个有序钩子列表;线程本地 | gptme/hooks/registry.py:109 |
| 内置钩子(一堆文件) | 确认 / autocommit / 成本 / 缓存 / 上下文注入 / 注入拦截 … | gptme/hooks/*.py |
| 插件系统 | 让第三方把工具/钩子/命令带进来 | gptme/plugins/ |
怎么读下面这张图: 竖着是主循环的时间线(自上而下走一遍),每个 trigger_hook 落点右边挂着"到这里会被叫起来的那类钩子"。循环骨架细节见 → 01,这里只标"钩子在哪落地"。
主循环时间线 trigger_hook 落点 挂在上面的内置钩子(举例)
───────────── ─────────────── ──────────────────────
会话开始 ──▶ SESSION_START cost_awareness(初始化预算)
│
├─ 每个回合(turn)开始 ──▶ TURN_PRE
│ │
│ ├─ 每一步(step)开始 ──▶ STEP_PRE
│ │ │
│ │ ├─ 生成前 ──▶ GENERATION_PRE active_context / cost 提示注入
│ │ │ ▼ LLM 生成
│ │ ├─ 生成后 ──▶ GENERATION_POST cache_awareness(记录耗时)
│ │ │
│ │ └─ 执行工具 ─────────────────────────────┐
│ │ ├─ TOOL_EXECUTE_PRE │ auto_snapshots(快照)
│ │ ├─ [工具内部] TOOL_CONFIRM ◀── 确认护栏(cli/server/auto/allowlist)
│ │ └─ TOOL_EXECUTE_POST │ injection_screening / 结果注入
│ │
│ └─ 回合结束 ──▶ TURN_POST autocommit(自动 git 提交)
│
└─ 该不该再转一圈? ──▶ LOOP_CONTINUE
会话结束 ──▶ SESSION_END cost_awareness(打印总花费)
一句话把大盘说清:循环是"骨",钩子是"肉"。 骨头只在固定关节处 trigger_hook,肉挂在哪个关节、几块肉 、谁先谁后,全由注册决定。工具确认是唯一"下沉到关节内部"的特例(§6)。
3. HookType 全景:一共有哪些"时刻"
HookType 是个 str 枚举,命名沿用 OpenCode 风格的点号分层 <category>.<event>(gptme/hooks/types.py:66-137)。术语上要先分清两个词(枚举 docstring 里也定义了):
- 回合(turn): 一次完整的"用户↔助手"交换,可能含多步。
- 步(step): 一次"LLM 生成 + 工具执行"循环。
按类别把成员列全(值即字符串名):
| 类别 | 成员(枚举名 = 值) | 触发时刻 |
|---|---|---|
| 步/回合 | STEP_PRE=step.pre / STEP_POST=step.post | 每一步前 / 后 |
TURN_PRE=turn.pre / TURN_POST=turn.post | 每回合前 / 全部步跑完后 | |
MESSAGE_TRANSFORM=message.transform | 改写并持久化助手消息内容 | |
| 工具 | TOOL_EXECUTE_PRE / TOOL_EXECUTE_POST | 任一工具执行前 / 后 |
TOOL_TRANSFORM=tool.transform | 改写工具的输入/输出 | |
TOOL_CONFIRM=tool.confirm | 执行前确认(特殊,见 §6) | |
| 文件 | FILE_SAVE_PRE/POST、FILE_PATCH_PRE/POST | 保存/打补丁前后 |
| 会话 | SESSION_START / SESSION_END | 会话起止(长事件用 START/END) |
| 生成 | GENERATION_PRE / GENERATION_POST | 生成响应前 / 后 |
GENERATION_CHUNK=generation.chunk | 流式响应每一行 | |
GENERATION_INTERRUPT | 打断生成 | |
| 循环 | LOOP_CONTINUE=loop.continue | 决定"是否/如何再转一圈" |
| 目录 | CWD_CHANGED=cwd.changed | 工具执行中工作目录变了 |
| 缓存 | CACHE_INVALIDATED=cache.invalidated | 提示缓存失效(如压缩后) |
| 确认 | TOOL_CONFIRM | (同上) |
| 征询 | ELICIT=elicit | agent 反向向用户要结构化输入 |
命名约定值得记一下(源码注释明说):PRE/POST 一律表示"某事件前后"的时序;START/END 只留给会话级的长事件。这让 agent 光看名字就能判断一个钩子是"包在某动作两侧"还是"整段会话的边界"。
每个 HookType 都配了一个 Protocol 类声明它的调用签名(gptme/hooks/types.py:140-359)。例如 SessionStartHook 收 (logdir, workspace, initial_msgs),FilePostSaveHook 收 (log, workspace, path, content, created)。这些 Protocol 不强制运行时校验,但配合下面的多签名重载给静态检查用。
4. 注册表:优先级、叫停、同步/异步
这节讲 HookRegistry(gptme/hooks/registry.py:109)怎么管钩子。核心就三件事:排序、遍历、容错。
4.1 注册即排序:优先级高者先跑
register(registry.py:120)把钩子按 HookType 塞进 self.hooks[hook_type] 列表,同名先删旧再加新(可热替换),然后 list.sort()(registry.py:161)。排序规则藏在 Hook.__lt__(gptme/hooks/types.py:393-395):
def __lt__(self, other):
# 注意方向反了:priority 大的排前面,同优先级按 name
return (self.priority, self.name) > (other.priority, other.name)
所以 priority 越大越先跑。内置钩子正是靠这个编排先后,例如 injection_screening 用 priority=100 抢在别的 TOOL_EXECUTE_POST 前面把警告贴到工具输出旁(gptme/hooks/injection_screening.py:189-193)。
4.2 触发:依 次跑、yield 消息、可叫停
trigger(registry.py:182)是心脏。它做的事:
- 取出该类型下所有
enabled的钩子,分成 sync / async 两拨(registry.py:203-204)。 - async 钩子:每个用
contextvars.copy_context()拷一份上下文,丢进daemon=True后台线程 fire-and-forget(registry.py:217-226)——适合日志、遥测、通知这类"不该阻塞主流程"的副作用,它们产出的消息只记 log、不回灌。 - sync 钩子:按序调用,
hook.func(*args, **过滤后的kwargs)。钩子既可以是"返回生成器逐个yield消息"的,也可以直接返回一个Message(registry.py:234-279)。
叫停机制是这套设计的关键一笔。钩子可以 yield 一个 StopPropagation() 哨兵(gptme/hooks/types.py:54),trigger 一旦见到它就 return,同类型里优先级更低的钩子全部不再执行(registry.py:258-271)。这让"前置校验失败就短路后续"成为可能——典型用法:precommit 检查失败时 yield StopPropagation(),把同挂在 TURN_POST 但优先级更低的 autocommit 拦下(见 §6.4 的 autocommit 注释)。
容错:单个 sync 钩子抛异常会被 logger.exception 记下然后 continue(registry.py:308-325),不拖垮整条链;唯一例外是 SessionCompleteException 会被重新抛出以传播"会话结束"