一、引言:为什么需要 hook
前四篇文章拆解了 pi agent 的核心骨架:completeSimple 调 LLM、streamSimple 流式消费事件、runLoop 循环驱动 agent、executeToolCalls 执行工具。这些代码定义了 agent 的基础行为------什么时候调 LLM、什么时候执行工具、什么时候停。
但 agent loop 的行为是写死的。runLoop 每一轮固定走"stream → 检查 toolCalls → execute → 回填 → turn_end",executeToolCalls 固定走"prepare → execute → finalize"。真实场景里,agent 需要外部干预:
- 对话太长撑爆 context window------需要在每轮结束后裁剪旧消息
- token 预算快耗尽------需要提前终止循环
- 用户在 agent 执行过程中追加指令------需要中途插入新消息
- 某些工具调用有安全风险------需要在执行前拦截
- 工具返回结果里有敏感信息------需要在回填前脱敏
这些干预点如果直接写进 runLoop 或 executeToolCalls,agent loop 的代码会膨胀到不可维护。pi 的做法是把它们抽成六个正式的 hook 接口,全部可选、不传就退化为基础行为:
| 类别 | hook | 什么时候调 |
|---|---|---|
| 循环控制 | prepareNextTurn |
每轮 turn_end 后 |
| 循环控制 | shouldStopAfterTurn |
prepareNextTurn 后 |
| 循环控制 | getSteeringMessages |
内层循环每圈开头 |
| 循环控制 | getFollowUpMessages |
内层循环退出后 |
| 工具执行 | beforeToolCall |
tool.execute 前 |
| 工具执行 | afterToolCall |
tool.execute 后 |
文章 3 讲了循环 hook 在 runLoop 里的调用点和控制流影响,文章 4 讲了工具 hook 在执行链里的位置和签名。但两篇文章都把它们当黑盒------"传了就在这里被调用"。本章拆开这些黑盒,看 pi 的 Agent 类和 AgentHarness / AgentSession 是怎么实现它们的。
二、Agent 类如何持有钩子
文章 3、4 讲的六个 hook 都挂在 AgentLoopConfig 上,runAgentLoop 从 config 里读它们。但 AgentLoopConfig 是一个临时对象------每次启动循环时构造,跑完就丢。真正持有钩子状态的是 Agent 类。
1. Agent 类的结构
Agent 类持有两类东西:
- 可赋值的钩子属性 :
beforeToolCall/afterToolCall/prepareNextTurn------外部直接agent.beforeToolCall = ...赋值 - 消息队列 :
steeringQueue/followUpQueue------不直接暴露给外部,而是通过steer()/followUp()方法 push,createLoopConfig时包装成 drain 函数
2. createLoopConfig:组装 config
每次启动循环时,Agent 调 createLoopConfig() 把自己的属性组装成 AgentLoopConfig:
typescript
// agent.ts:422-449
private createLoopConfig(options: { skipInitialSteeringPoll?: boolean } = {}): AgentLoopConfig {
let skipInitialSteeringPoll = options.skipInitialSteeringPoll === true;
return {
model: this._state.model,
reasoning: this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel,
sessionId: this.sessionId,
// ... 其他 stream 选项
toolExecution: this.toolExecution,
// ─── 可赋值钩子直接拷贝 ───
beforeToolCall: this.beforeToolCall,
afterToolCall: this.afterToolCall,
// prepareNextTurn 包一层 signal 传递
prepareNextTurn: this.prepareNextTurn
? async () => await this.prepareNextTurn?.(this.signal)
: undefined,
// ─── 消息队列包装成 drain 函数 ───
getSteeringMessages: async () => {
if (skipInitialSteeringPoll) { // 首轮跳过------避免刚启动就收到旧消息
skipInitialSteeringPoll = false;
return [];
}
return this.steeringQueue.drain();
},
getFollowUpMessages: async () => this.followUpQueue.drain(),
// shouldStopAfterTurn 不在这里设------Agent 类不持有它
};
}
两种钩子的传递方式不同:
- 直接拷贝 :
beforeToolCall/afterToolCall/prepareNextTurn是Agent类的可赋值属性,createLoopConfig直接把引用拷进 config。 - 包装成函数 :
getSteeringMessages/getFollowUpMessages不是Agent的属性------Agent持有的是steeringQueue/followUpQueue两个队列,createLoopConfig把它们的drain()方法包装成 config 里的 async 函数。还加了skipInitialSteeringPoll逻辑------首次启动循环时跳过一次 steering 检查,避免刚启动就收到旧消息。
shouldStopAfterTurn 在 createLoopConfig 里完全没出现 ------Agent 类不持有它。它只存在于 AgentLoopConfig 接口定义里,agent-loop.ts 会检查 config.shouldStopAfterTurn?.(),但 Agent 不设值,所以默认是 undefined(不生效)。
3. 谁来填这些钩子
Agent 类只是"持有"钩子属性,默认全是 undefined。真正给它们赋值的是两层:
| hook | 谁填 | 在哪 | 填什么 |
|---|---|---|---|
beforeToolCall |
AgentSession |
agent-session.ts:404 |
委托给 extension runner 的 emitToolCall------第 5 章详讲 |
afterToolCall |
AgentSession |
agent-session.ts:425 |
委托给 extension runner 的 emitToolResult------第 5 章详讲 |
prepareNextTurn |
AgentHarness |
agent-harness.ts:457 |
flush pending session writes + create turn state------第 3 章详讲 |
shouldStopAfterTurn |
没人填 | --- | 纯接口,留给第三方扩展使用 |
getSteeringMessages |
Agent 类自身 |
agent.ts:440 |
包装 steeringQueue.drain()------第 4 章详讲 |
getFollowUpMessages |
Agent 类自身 |
agent.ts:447 |
包装 followUpQueue.drain()------第 4 章详讲 |
AgentSession 是 pi coding-agent 的 session 管理器,它直接给 agent.beforeToolCall / agent.afterToolCall 赋值。AgentHarness 是 agent 包的通用封装器,它通过构造 AgentLoopConfig 的方式填 prepareNextTurn(如果用 Harness 的话,它覆盖 Agent 的 createLoopConfig)。
shouldStopAfterTurn 是一个"预留接口"------pi 自己不用,但接口定义里有,agent-loop 会调用。第三方扩展可以填它来实现"token 预算耗尽时优雅停止"之类的逻辑。
三、turn 级循环钩子:prepareNextTurn + shouldStopAfterTurn
文章 3 讲了这两个钩子在 runLoop 里的调用点------turn_end 之后、下一轮 turn_start 之前。本章展开它们的内部实现。
1. 调用顺序
回顾 runLoop 内层循环末尾(agent-loop.ts:218-251):
ini
emit(turn_end, message, toolResults)
# 1. prepareNextTurn------调整下一轮状态
nextTurn = config.prepareNextTurn?.({ message, toolResults, context, newMessages })
if nextTurn:
context = nextTurn.context ?? context
config.model = nextTurn.model ?? config.model
# 2. shouldStopAfterTurn------判断是否提前终止
if config.shouldStopAfterTurn?.({ message, toolResults, context, newMessages }):
emit(agent_end, newMessages)
return
# 3. getSteeringMessages------检查插话(第 4 章讲)
pendingMessages = config.getSteeringMessages?.() ?? []
两个钩子串联调用:先 prepareNextTurn 调整状态,再 shouldStopAfterTurn 决定是否停。这个顺序有讲究------shouldStopAfterTurn 看到的是 prepareNextTurn 调整后 的 context。如果 prepareNextTurn 裁剪了旧消息,shouldStopAfterTurn 检查 token 预算时看到的是裁剪后的状态。
2. prepareNextTurn 的实现
AgentHarness 在 agent-harness.ts:457-466 实现了 prepareNextTurn:
typescript
// agent-harness.ts:457-466
prepareNextTurn: async () => {
await this.flushPendingSessionWrites(); // 1. 刷写待持久化的 session 变更
const nextTurnState = await this.createTurnState(); // 2. 重新构造 turn 状态
setTurnState(nextTurnState); // 3. 更新当前 turn 状态引用
return {
context: this.createContext(nextTurnState), // 下一轮用的 context
model: nextTurnState.model, // 下一轮用的 model
thinkingLevel: nextTurnState.thinkingLevel, // 下一轮用的 thinking level
};
},
三步:
flushPendingSessionWrites (agent-harness.ts:484-508)------把这一轮运行中累积的、还没持久化的 session 写入刷到磁盘:
typescript
private async flushPendingSessionWrites(): Promise<void> {
while (this.pendingSessionWrites.length > 0) {
const write = this.pendingSessionWrites[0]!;
if (write.type === "message") {
await this.session.appendMessage(write.message);
} else if (write.type === "model_change") {
await this.session.appendModelChange(write.provider, write.modelId);
} else if (write.type === "active_tools_change") {
await this.session.appendActiveToolsChange(write.activeToolNames);
}
// ... 还有 thinking_level_change / custom / label / leaf 等类型
this.pendingSessionWrites.shift();
}
}
运行中如果 agent 调了 setTools / setModel 等方法,变更不会立即写 session------而是 push 到 pendingSessionWrites 队列,等 prepareNextTurn 时统一 flush。这是为了防止 IO 抖动打断 LLM 流式响应。
createTurnState (agent-harness.ts:331-363)------从 session 重新构造下一轮的完整状态:
typescript
private async createTurnState(): Promise<AgentHarnessTurnState> {
const context = await this.session.buildContext(); // 从 session 拿最新 messages
const resources = this.getResources();
const tools = [...this.tools.values()];
const activeTools = this.activeToolNames
.map((name) => this.tools.get(name))
.filter((tool): tool is TTool => tool !== undefined);
// systemPrompt:可能是字符串、可能是函数(动态构造)
let systemPrompt = "You are a helpful assistant.";
if (typeof this.systemPrompt === "string") {
systemPrompt = this.systemPrompt;
} else if (this.systemPrompt) {
systemPrompt = await this.systemPrompt({
env: this.env, session: this.session, model: this.model,
thinkingLevel: this.thinkingLevel, activeTools, resources,
});
}
return { messages: context.messages, resources, systemPrompt, model, thinkingLevel, tools, activeTools };
}
关键:systemPrompt 可以是函数 ------每次 prepareNextTurn 都重新调一次。这支持动态 system prompt(如根据当前时间、工具集、session 状态生成不同 prompt)。pi 的 coding-agent 就用了这个机制(文章 2 讲的 buildSystemPrompt() 就是这个函数)。
createContext (agent-harness.ts:365-374)------从 turn state 构造 AgentContext:
typescript
private createContext(turnState: AgentHarnessTurnState): AgentContext {
return {
systemPrompt: turnState.systemPrompt,
messages: turnState.messages.slice(), // slice 拷贝------防止原地修改
tools: turnState.activeTools.slice(),
};
}
注意 messages.slice()------每轮都拷贝一份新的 messages 数组。这防止 runLoop 里的 context.messages.push(...) 改到 Harness 内部的原始数据。
3. shouldStopAfterTurn 的实现
pi 自己不实现这个钩子。
shouldStopAfterTurn 只存在于:
AgentLoopConfig接口定义(types.ts:208)runLoop的调用点(agent-loop.ts:242)
但 Agent 类的 createLoopConfig 不设它,AgentHarness 不填它,AgentSession 也不填它。默认 undefined------runLoop 检查 config.shouldStopAfterTurn?.() 时 ?. 直接跳过。
这是一个预留接口。pi 的设计逻辑是:
- pi 自己用
shouldStopAfterTurn管理停止------通过消息队列(没有 follow-up 消息就停)和 error/aborted 退出路径,不需要显式的 shouldStop 判断 - 但某些第三方场景需要"提前停止"------如按 token 预算限制、按时间限制、按用户显式指令停止。这些逻辑 pi 不内置,但接口留好了
如果第三方要用,在 AgentLoopConfig 里传入即可:
typescript
const config: AgentLoopConfig = {
// ...
shouldStopAfterTurn: ({ message, toolResults, newMessages }) => {
const totalTokens = newMessages.reduce((sum, m) =>
sum + (m.role === "assistant" ? m.usage.totalTokens : 0), 0);
return totalTokens > 50000; // 超 50k token 就停
},
};
四、消息队列钩子:getSteeringMessages + getFollowUpMessages
文章 3 讲了这两个钩子的调用点------steering 在内层循环每圈开头,followUp 在内层循环退出后。它们的实现都依赖 Agent 类的两个消息队列。
1. PendingMessageQueue 机制
Agent 类内部有两个 PendingMessageQueue 实例(agent.ts:169-170):
typescript
class Agent {
private readonly steeringQueue: PendingMessageQueue;
private readonly followUpQueue: PendingMessageQueue;
constructor(options) {
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time");
}
}
PendingMessageQueue 本身很简单(agent.ts:118-152):
typescript
class PendingMessageQueue {
private messages: AgentMessage[] = [];
public mode: QueueMode; // "all" | "one-at-a-time"
enqueue(message: AgentMessage): void {
this.messages.push(message);
}
drain(): AgentMessage[] {
if (this.mode === "all") {
const drained = this.messages.slice();
this.messages = [];
return drained; // 一次取出全部
}
const first = this.messages[0];
if (!first) return [];
this.messages = this.messages.slice(1);
return [first]; // 一次只取一条
}
}
两种 drain 模式:
"all":一次取出全部排队消息。用户连发三条"做 A / 做 B / 做 C"→ 三条一起取出,下一轮 LLM 全看到。"one-at-a-time"(默认):一次只取一条。用户连发三条 → 每轮只注入一条,LLM 处理完再注入下一条。这让 agent 能"逐条消化"用户指令,而不是被一堆消息淹没。
外部通过 steer() / followUp() 往队列里塞消息(agent.ts:264-271):
typescript
steer(message: AgentMessage): void {
this.steeringQueue.enqueue(message);
}
followUp(message: AgentMessage): void {
this.followUpQueue.enqueue(message);
}
这两个方法是公开 API ------UI 或上层代码在 agent 运行时调 agent.steer(msg) 插话,或 agent.followUp(msg) 追加任务。
2. getSteeringMessages 的实现
Agent 类在 createLoopConfig 里把 steeringQueue.drain() 包装成 config 的 getSteeringMessages(agent.ts:440-446):
typescript
getSteeringMessages: async () => {
if (skipInitialSteeringPoll) { // 首轮跳过
skipInitialSteeringPoll = false;
return [];
}
return this.steeringQueue.drain();
},
runLoop 在两个地方调它(文章 3 讲过):
- 内层循环开头 :每圈开始前 drain,取出的消息 push 到
context.messages,LLM 下一轮调用时就能看到 shouldStopAfterTurn之后:检查有没有新的插话------有就继续转,没有就进 follow-up 检查
skipInitialSteeringPoll 是一个细节------agent 刚启动时(第一次进内层循环),用户可能还没来得及发 steering 消息,这时 drain 会返回空。但更重要的原因是:如果用户在启动前就 steer 了一条消息,这条消息应该在启动 prompt 里处理,而不是被 steering 机制重复注入。所以首轮跳过一次 steering 检查。
3. getFollowUpMessages 的实现
同样包装 followUpQueue.drain()(agent.ts:447):
typescript
getFollowUpMessages: async () => this.followUpQueue.drain(),
runLoop 只在一个地方调它------内层循环退出后(agent-loop.ts:257-262):
bash
# 内层循环退出(hasMoreToolCalls == false 且无 steering)
while true: # 外层循环
while hasMoreToolCalls or pendingMessages: # 内层循环
...
# 内层停了------检查有没有后续任务
followUpMessages = config.getFollowUpMessages?() ?? []
if followUpMessages.length > 0:
pendingMessages = followUpMessages # 变成 pending,重新进入内层循环
continue
else:
break # 真的停了
如果 getFollowUpMessages 返回非空,消息变成 pendingMessages,外层循环 continue 回到内层循环开头------内层循环的条件 pendingMessages.length > 0 满足,重新开始转。
4. steering vs followUp 的区别
两个队列的实现完全一样(都是 PendingMessageQueue),差异全在调用时机:
| getSteeringMessages | getFollowUpMessages | |
|---|---|---|
| 何时 drain | 内层循环每圈开头 + shouldStopAfterTurn 后 | 内层循环退出后 |
| agent 状态 | 正在跑(内层循环还在转) | 本来要停了 |
| 语义 | 中途插话------"顺便也做一下这个" | 追加任务------"做完了?再做这个" |
| 对 LLM 的影响 | 消息注入到下一轮调用的 context 里 | 消息重启循环,开始新一轮 |
一句话:steering 是"趁 agent 还在干活,往它手里塞新指令";followUp 是"agent 准备下班了,告诉它还有活要干"。
5. AgentHarness 怎么填这两个钩子
AgentHarness 不覆盖 getSteeringMessages / getFollowUpMessages------它直接用 Agent 类在 createLoopConfig 里包装的版本。但 Harness 提供了额外的队列管理(agent-harness.ts:467-468):
typescript
// AgentHarness 自己的 createLoopConfig(覆盖 Agent 的版本)
getSteeringMessages: async () => this.drainQueuedMessages(this.steerQueue, this.steeringQueueMode),
getFollowUpMessages: async () => this.drainQueuedMessages(this.followUpQueue, this.followUpQueueMode),
drainQueuedMessages 是 Harness 自己的方法,和 Agent 类的 PendingMessageQueue.drain() 逻辑一致------支持 "all" / "one-at-a-time" 两种模式。差异在于 Harness 的队列和 Agent 的队列是两套独立实例------用 Harness 的话走 Harness 的队列,用 Agent 的话走 Agent 的队列。
pi 的 coding-agent 直接用 Agent(不用 AgentHarness),所以实际运行时走的是 Agent 类的 steeringQueue / followUpQueue。
五、工具钩子:beforeToolCall + afterToolCall
文章 4 讲了这两个钩子在 executeToolCalls 执行链里的位置------beforeToolCall 在 prepareToolCall 里调、afterToolCall 在 finalizeExecutedToolCall 里调。本章先回顾调用逻辑,再展开 pi coding-agent 真正填入的实现。
1. 回顾:agent-loop.ts 的调用逻辑
beforeToolCall (agent-loop.ts:581-604)------在 tool.execute 之前调用,能拦截工具调用:
yaml
# prepareToolCall 内部(伪代码)
validatedArgs = validateToolArguments(tool, toolCall)
if config.beforeToolCall:
beforeResult = await config.beforeToolCall({
assistantMessage, # 触发这次工具调用的 assistant 消息
toolCall, # 工具调用块(id / name / arguments)
args: validatedArgs, # 校验后的参数
context, # 当前 agent context
}, signal)
if beforeResult?.block: # 拦截!
return { kind: "immediate",
result: errorToolResult(beforeResult.reason),
isError: true } # 不执行,直接返回错误结果
返回 { block: true, reason: "..." } 就拦截------工具不执行,LLM 收到一条错误结果说"工具被阻止了,原因是 ..."。返回 undefined 或 { block: false } 就放行。
afterToolCall (agent-loop.ts:676-700)------在 tool.execute 之后调用,能修改结果:
ini
# finalizeExecutedToolCall 内部(伪代码)
result = executed.result # 工具执行后的原始结果
isError = executed.isError
if config.afterToolCall:
afterResult = await config.afterToolCall({
assistantMessage,
toolCall,
args,
result, # 原始结果
isError,
context,
}, signal)
if afterResult:
# 字段级覆盖,不深合并
result = {
content: afterResult.content ?? result.content,
details: afterResult.details ?? result.details,
terminate: afterResult.terminate ?? result.terminate,
}
isError = afterResult.isError ?? isError
返回 AfterToolCallResult 就按字段覆盖------content / details / terminate / isError 各自独立替换,不提供就保留原值。返回 undefined 就不修改。
2. AgentSession 的实现
pi coding-agent 的 AgentSession 在 _installAgentToolHooks()(agent-session.ts:403-451)里给 agent.beforeToolCall / agent.afterToolCall 赋值。两个钩子都委托给 Extension Runner------pi 的扩展系统。
beforeToolCall 实现 (agent-session.ts:404-423):
typescript
this.agent.beforeToolCall = async ({ toolCall, args }) => {
const runner = this._extensionRunner;
if (!runner.hasHandlers("tool_call")) {
return undefined; // 没有扩展注册 tool_call handler → 放行
}
try {
return await runner.emitToolCall({
type: "tool_call",
toolName: toolCall.name,
toolCallId: toolCall.id,
input: args as Record<string, unknown>,
});
} catch (err) {
if (err instanceof Error) {
throw err; // 扩展抛异常 → 阻止执行
}
throw new Error(`Extension failed, blocking execution: ${String(err)}`);
}
};
逻辑:
- 检查有没有扩展注册了
tool_callhandler------没有就直接return undefined(放行) - 有就调
runner.emitToolCall(...),把工具名、调用 ID、参数传给所有注册的 handler - handler 可以返回
{ block: true, reason: "..." }拦截------emitToolCall会把这个返回值传回来 - handler 抛异常→整个工具调用被阻止(异常被
prepareToolCall的 try-catch 接住,转成 error result)
afterToolCall 实现 (agent-session.ts:425-450):
typescript
this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => {
const runner = this._extensionRunner;
if (!runner.hasHandlers("tool_result")) {
return undefined; // 没有扩展注册 tool_result handler → 不修改
}
const hookResult = await runner.emitToolResult({
type: "tool_result",
toolName: toolCall.name,
toolCallId: toolCall.id,
input: args as Record<string, unknown>,
content: result.content, // 原始结果
details: result.details,
isError,
});
if (!hookResult) {
return undefined; // 扩展不修改 → 保留原结果
}
return {
content: hookResult.content, // 覆盖
details: hookResult.details,
isError: hookResult.isError ?? isError,
};
};
逻辑:
- 检查有没有扩展注册了
tool_resulthandler------没有就直接return undefined - 有就把完整的工具结果(content / details / isError)传给 handler
- handler 返回修改后的结果------按字段覆盖回
result - handler 返回
undefined------保留原始结果
3. 设计要点:延迟绑定
注意 _installAgentToolHooks 的注释(agent-session.ts:397-401):
The callbacks read
this._extensionRunnerat execution time, so extension reload swaps in the new runner without reinstalling hooks.
钩子赋值只在启动时做一次,但钩子内部读的是 this._extensionRunner------这是当前引用,不是赋值时的快照。如果用户中途重载了扩展(_extensionRunner 被替换),下一次工具调用自动走新 runner,不需要重新安装钩子。
这是 pi 的"热重载"设计------扩展可以随时增删,不影响正在运行的 agent。
4. 谁来注册 tool_call / tool_result handler
runner.hasHandlers("tool_call") 检查的是 Extension API 注册的 handler。扩展在 .pi/extensions/ 里声明:
typescript
// .pi/extensions/my-guard/extension.ts
export default {
hooks: {
tool_call: async ({ toolName, input }) => {
if (toolName === "bash" && input.command.includes("rm -rf")) {
return { block: true, reason: "Dangerous command blocked" };
}
return undefined; // 放行
},
tool_result: async ({ toolName, content, isError }) => {
if (isError) {
console.log(`Tool ${toolName} failed`);
}
return undefined; // 不修改结果
},
},
};
这就是 pi "扩展无需 fork" 的落地------beforeToolCall / afterToolCall 是 agent loop 预留的正式接口,扩展通过 Extension API 注册 handler,handler 的逻辑由扩展自己定义。agent loop 源码不需要任何改动。
六、Q&A
Q1:pi 这里的 hook 概念和 TS 编程中的 hook 概念是一致的吗?
概念相近但不完全一致。
相同点------都是"在某个执行点插入外部逻辑,不修改原代码":
| TS hook(如 React hooks) | pi hook | |
|---|---|---|
| 本质 | 在组件生命周期点插入逻辑 | 在 agent loop 执行点插入逻辑 |
| 不改源码 | 组件代码不需要知道 hook 做了什么 | agent loop 代码不需要知道 hook 做了什么 |
| 可组合 | 多个 hook 可叠加 | 多个 hook 可叠加(如 beforeToolCall + afterToolCall) |
差异------方向相反:
- TS hook 是"调用方主动拉" :组件代码里显式写
useEffect(...)/useState(...),hook 是组件主动调用的函数。调用方知道自己用了什么 hook。 - pi hook 是"被调用方被动推" :agent loop 在固定位置调
config.beforeToolCall?.(),调用方(agent loop)不知道谁填了这个钩子、填了什么逻辑。被调用方(外部代码)通过 config 注入逻辑。
更接近的类比是 Web 开发中的中间件 / 拦截器:
- Express 的
app.use((req, res, next) => ...)------在请求处理链里插入逻辑,不改路由代码 - axios 的
interceptors.request.use(...)------在 HTTP 请求发出前插入逻辑 - pi 的
beforeToolCall------在工具执行前插入逻辑
所以 pi 的 hook 更准确的叫法是 拦截器 / 钩子点------框架预留的正式扩展点,外部代码通过它们注入逻辑,框架源码不变。和 React hooks 的"组合复用"是不同的设计理念。
Q2:直接拷贝和包装成函数这两种 hook 传递方式的具体差异是什么?
差异在"谁拥有控制权"和"何时求值"。
直接拷贝 (beforeToolCall / afterToolCall / prepareNextTurn):
typescript
beforeToolCall: this.beforeToolCall, // 引用拷贝
- Agent 把自己的属性引用塞进 config。config 持有的就是同一个函数对象。
- 如果 Agent 的
beforeToolCall属性被重新赋值(agent.beforeToolCall = newFn),旧的 config 不受影响------因为 config 持有的是赋值时的旧引用。 - 但实际上 pi 不在运行中重新赋值这些属性(
_installAgentToolHooks只在启动时调一次),所以直接拷贝够用。
包装成函数 (getSteeringMessages / getFollowUpMessages):
typescript
getSteeringMessages: async () => {
if (skipInitialSteeringPoll) { skipInitialSteeringPoll = false; return []; }
return this.steeringQueue.drain(); // 每次调用时读 this.steeringQueue
},
- config 持有的不是队列本身,而是一个闭包。
- 每次
runLoop调config.getSteeringMessages()时,闭包实时读this.steeringQueue------如果队列被替换了,下次调用走新队列。 - 闭包还能携带额外逻辑(
skipInitialSteeringPoll),这是直接拷贝做不到的。
为什么混用两种方式:
| 直接拷贝 | 包装成函数 | |
|---|---|---|
| 何时求值 | 赋值时固定 | 每次调用时实时 |
| 能携带额外逻辑 | 不能 | 能(如 skipInitialSteeringPoll) |
| 能感知属性替换 | 不能 | 能(闭包读 this) |
| 适用场景 | 钩子本身是完整函数,不需要额外包装 | 钩子依赖 Agent 内部状态(队列),需要每次实时读取 |
一句话:直接拷贝传的是"一个函数",包装传的是"一个读 Agent 状态的策略"。steering/followUp 需要每次调用时读队列当前状态,所以必须包装。
Q3:两种传递方式都可以被外部 extension 重新定义吗?
不能。两种传递方式都不能被 extension 重新定义钩子本身。
Extension API 只能注册 handler,不能替换钩子函数。
直接拷贝的三个钩子 (beforeToolCall / afterToolCall / prepareNextTurn):
AgentSession._installAgentToolHooks()在启动时给agent.beforeToolCall赋值一次- 赋的值是一个固定函数 ,内部委托给
this._extensionRunner - Extension 注册的是
tool_call/tool_resulthandler ,不是替换beforeToolCall函数本身
Extension 不能做 agent.beforeToolCall = myNewFn------它没有 agent 实例的引用,也不应该有。
包装成函数的两个钩子 (getSteeringMessages / getFollowUpMessages):
Agent.createLoopConfig内部构造闭包- Extension 完全不接触这两个钩子------它们是 Agent 类的内部实现,和 Extension API 无关
Extension 能做什么:
| 钩子 | Extension 能做的 | Extension 不能做的 |
|---|---|---|
beforeToolCall |
注册 tool_call handler,返回 { block: true, reason } 拦截 |
替换 beforeToolCall 函数 |
afterToolCall |
注册 tool_result handler,返回修改后的结果 |
替换 afterToolCall 函数 |
prepareNextTurn |
无 | 无 |
getSteeringMessages |
无 | 无 |
getFollowUpMessages |
无 | 无 |
shouldStopAfterTurn |
无 | 无 |
为什么这么设计:pi 的钩子分两类------对内 和对外:
- 对内钩子 (四个循环钩子 +
prepareNextTurn):由Agent/AgentHarness/AgentSession实现,控制 agent loop 的运转。Extension 不碰。 - 对外钩子 (
beforeToolCall/afterToolCall的 handler):Extension 能注册 handler,但 handler 的调用框架是AgentSession写死的。
Extension 的扩展边界是"工具调用的拦截和修改"------通过 handler 注入逻辑,不替换框架代码。如果要改循环行为(如自定义 prepareNextTurn),得直接用 Agent 类的 API 赋值,不走 Extension API。
Q4:通过重写 hook,可以对 agent loop 做什么样的定制?这些变更如何影响整个 loop?
1. prepareNextTurn------定制"下一轮的状态"
重写它能控制三件事:context(对话历史)、model(模型)、thinkingLevel(推理等级)。
| 定制场景 | 怎么做 | 对 loop 的影响 |
|---|---|---|
| context window 管理 | 裁剪旧消息,只保留最近 N 轮 + system prompt | LLM 下一轮看到的 context 变短,不会超限,但可能丢失早期上下文 |
| 动态切换模型 | 对话简单时用便宜模型,复杂时用强模型 | 下一轮的 LLM 调用走不同 provider,token 成本和回复质量变化 |
| 调整推理深度 | 简单问题 thinkingLevel: "off",复杂问题 "high" |
LLM 下一轮的 thinking 预算变化,影响响应速度和质量 |
| 注入外部上下文 | 从文件系统 / 数据库 / API 拉取信息塞进 messages | LLM 下一轮看到额外信息,回复基于新数据 |
| 强制重置对话 | 清空 messages,只保留 system prompt + 最新 user 消息 | agent "忘记"之前所有对话,相当于重启 |
关键影响点:prepareNextTurn 的返回值直接影响 streamAssistantResponse 的输入------LLM 下一轮看到什么、用什么模型、怎么思考,全由这个 hook 决定。如果返回 undefined,保持当前状态不变。
2. shouldStopAfterTurn------定制"什么时候停"
重写它能注入任意的停止条件,agent loop 在每轮结束后检查。
| 定制场景 | 怎么做 | 对 loop 的影响 |
|---|---|---|
| token 预算 | 累积 newMessages 里的 usage,超限就停 |
循环优雅退出------当前轮的工具调用已完成,但不再启动新一轮 |
| 时间限制 | 记录启动时间,超时就停 | 防止 agent 跑太久,长任务被截断 |
| 轮次限制 | 统计 turn 次数,超 N 轮就停 |
防止 agent 无限循环(LLM 不断调工具但完不成任务) |
| 条件完成 | 检查 message.content 里的文本是否包含"完成"标记 |
agent 自主判断完成 + 外部确认,双重保险 |
| 安全熔断 | 检查 toolResults 里有没有太多 isError: true |
连续工具失败时停止,避免无意义重试 |
关键影响点:返回 true → 直接 agent_end,跳过所有后续检查 (steering / followUp 都不查)。这是"硬停止"------比"没有工具调用"和"没有 follow-up"更优先。返回 false 或不实现 → 继续正常流程。
注意:shouldStopAfterTurn 看到的是 prepareNextTurn 调整后 的 context。如果 prepareNextTurn 裁剪了消息,token 计算会基于裁剪后的状态------这可能让 token 预算检查不准确(裁剪前超限、裁剪后不超)。
3. getSteeringMessages------定制"中途插什么话"
重写它能控制 agent 运行中注入什么消息、什么时候注入。
| 定制场景 | 怎么做 | 对 loop 的影响 |
|---|---|---|
| 外部消息源 | 从 WebSocket / 消息队列 / 文件系统读消息 | 用户不通过 UI 也能插话------如 CI/CD 系统注入构建结果 |
| 条件注入 | 只在特定 turn 注入(如第 3 轮后) | 控制插话时机,避免过早干扰 |
| 自动 steering | 检测 LLM 输出里的关键词,自动注入修正指令 | 如 LLM 输出"我不确定"时自动注入"请用 read 工具查看" |
| 消息过滤 | 只注入符合安全策略的消息 | 防止恶意 steering 消息劫持 agent |
| 频率控制 | 每隔 N 轮才检查一次 | 减少 IO 开销 |
关键影响点:返回的消息 push 到 context.messages,LLM 下一轮调用时看到。返回空数组 → 不插话,循环正常继续。返回非空 → 消息注入,LLM 的下一轮回复会基于这些新消息调整方向。
和 followUp 的关键差异:steering 是内层循环还在转时 注入------agent 正在干活,新消息插进来改变当前任务方向。followUp 是内层循环已经停了才检查------agent 准备下班了,追加新任务。
4. getFollowUpMessages------定制"结束后追什么任务"
重写它能控制 agent 本来要停时,是否继续跑新任务。
| 定制场景 | 怎么做 | 对 loop 的影响 |
|---|---|---|
| 任务队列 | 从外部任务队列读下一个任务 | agent 变成"任务处理器"------完成一个任务自动取下一个 |
| 条件续跑 | 只在上一轮成功(无 isError)时续跑 | 失败不续跑,避免连锁错误 |
| 批量处理 | 一次返回多个 followUp 消息 | 下一轮 LLM 同时看到多个任务,自己决定处理顺序 |
| 定时触发 | 检查时间,到点就注入定时任务 | agent 可以做周期性任务 |
| 优雅退出 | 检查外部"停止"标志,返回空 | 比正常退出多一层外部控制 |
关键影响点:返回非空 → 外层循环 continue,消息变成 pendingMessages 重新进入内层循环------agent 继续"活着"。返回空 → 外层循环 break,agent_end,agent 停下。
5. beforeToolCall------定制"工具能不能执行"
重写它能在工具执行前拦截、修改、记录。
| 定制场景 | 怎么做 | 对 loop 的影响 |
|---|---|---|
| 权限控制 | 按工具名 / 参数判断,危险操作返回 { block: true } |
工具不执行,LLM 收到错误结果,需要换策略 |
| 审计日志 | 记录 toolName / args / 时间戳,然后 return undefined |
不影响执行,但留了审计记录 |
| 频率限制 | 检查该工具近期调用次数,超限就 block | 防止 LLM 重复调用同一工具 |
| 动态启停 | 检查外部标志,维护期间 block 所有写操作 | 系统维护时 agent 只读不写 |
关键影响点:返回 { block: true, reason } → 工具不执行,prepareToolCall 返回 { kind: "immediate", isError: true },LLM 收到 "Tool X was blocked: reason"。返回 undefined → 放行,正常执行。
不能做什么:beforeToolCall 不能修改工具参数------它拿到的是校验后的 args,但返回值只有 block / reason 两个字段。改参数要用 AgentTool.prepareArguments(在 schema 校验前做预处理)。
6. afterToolCall------定制"工具结果怎么处理"
重写它能在工具执行后修改结果、记录、强制终止。
| 定制场景 | 怎么做 | 对 loop 的影响 |
|---|---|---|
| 敏感信息脱敏 | 替换 content 里的密码 / token 为 *** |
LLM 看到脱敏后的结果,但 details 可以保留原始数据给 UI |
| 结果截断 | 工具输出太长时截断 content |
防止爆 context,但 LLM 可能因信息不全做错决策 |
| 错误恢复 | isError: true 时修改 content 提供重试建议 |
LLM 看到错误 + 建议,可能换方式重试 |
| 强制终止 | 返回 { terminate: true } |
如果这批所有工具都 terminate,循环停止 |
| 审计日志 | 记录 result / isError / 耗时,不修改结果 | 不影响执行,留审计记录 |
| 结果增强 | 在 content 里追加额外信息(如时间戳、文件路径提示) |
LLM 看到增强后的结果,回复更精准 |
关键影响点:返回 AfterToolCallResult → 按字段覆盖(content / details / terminate / isError 各自独立)。返回 undefined → 不修改。
和 beforeToolCall 的关键差异:beforeToolCall 是"能不能执行"------二值决策(block / 放行)。afterToolCall 是"结果怎么处理"------连续修改(字段级覆盖)。一个在执行前,一个在执行后。
Q5:使用 hook 注入上下文和使用 tool 的差异是什么?
时机、来源、LLM 的角色完全不同。
| hook 注入上下文 | tool 获取上下文 | |
|---|---|---|
| 谁触发 | 框架(agent loop 在固定点调 hook) | LLM(模型决定调不调工具、调哪个) |
| 何时 | 轮次边界(prepareNextTurn / steering) |
轮次中间(LLM 在回复里产出 toolCall) |
| LLM 能拒绝吗 | 不能------注入的消息直接进 context.messages,LLM 下一轮必须看到 |
能------LLM 可以选择不调工具 |
| LLM 能控制参数吗 | 不能------注入什么内容由 hook 完全决定 | 能------LLM 生成 toolCall 的 arguments |
| 消耗一轮吗 | 不消耗------注入后 LLM 在同一轮里处理 | 消耗------调工具 → 回填 → 下一轮才处理结果 |
| 适合什么 | 系统级信息(时间、环境、session 状态、安全约束) | 探索性信息(读文件、搜索、执行命令) |
场景对比:agent 需要知道当前 Git 分支。
用 hook 注入(prepareNextTurn):读 git branch → 塞进 messages → LLM 下一轮直接看到。每轮都带,LLM 不用主动获取。但如果 LLM 不需要这个信息,它还是在 context 里占 token。
用 tool 获取(bash 工具):LLM → "我需要知道分支" → bash("git branch") → 结果回填 → LLM 看到。按需获取,不浪费 token。但消耗了一轮,且 LLM 可能忘记调。
选择原则:hook 注入适合"agent 必须知道的"------安全约束、session 状态、外部指令。框架决定,不由 LLM 判断。tool 适合"agent 可能需要的"------文件内容、命令输出、搜索结果。LLM 根据任务自己判断要不要获取。
一句话:hook 是"框架替 LLM 做决定",tool 是"LLM 自己做决定"。hook 适合必选项,tool 适合可选项。
Q6:使用 hook 注入和直接使用 system prompt 的差异是什么?
时机、灵活性、token 成本不同。
| system prompt | hook 注入(prepareNextTurn / steering) | |
|---|---|---|
| 何时构造 | agent 启动时构造一次(或每次 prepareNextTurn 时重新调 buildSystemPrompt) |
每轮运行中动态注入 |
| 位置 | context.systemPrompt------每次 LLM 调用固定带在最前面 |
context.messages 末尾------作为对话历史的一部分 |
| LLM 权重 | 最高------system prompt 是最高优先级指令 | 按对话顺序------越晚注入的权重越高(LLM 更关注最近的消息) |
| 能改吗 | pi 的 systemPrompt 可以是函数,每次 prepareNextTurn 重新调 |
随时注入新消息,不受 system prompt 重建限制 |
| token 成本 | 每轮都带,不变 | 注入多少算多少,可以按需控制 |
| 语义 | "你是谁、你能做什么、你的规则" | "现在发生了一件事,请处理" |
场景对比:告诉 agent 当前是生产环境,不能写文件。
用 system prompt:"你是一个 coding agent。注意:当前是生产环境,禁止使用 write/edit 工具。"------LLM 从第一轮就知道规则。但如果环境在运行中变化,要等 prepareNextTurn 重建 system prompt 才能生效。占 system prompt 的 token,每轮都带。
用 hook 注入(steering):agent.steer({ role: "user", content: "警告:现在切到生产环境了,不要再写文件" })------随时注入,立即生效。作为对话历史,LLM 看到后调整行为。但可能被后续对话"遗忘"------LLM 几轮后可能不再遵守。
选择原则:system prompt 适合持久规则------"你是什么 agent"、"你的行为准则"、"可用工具列表"。贯穿整个 session 不变。hook 注入适合临时状态------"用户刚追加了指令"、"环境刚变了"、"session 刚加载了新配置"。某一轮需要 LLM 知道,但不一定是永久规则。
一句话:system prompt 是"宪法",hook 注入是"临时通知"。宪法稳定持久,通知灵活但可能被遗忘。最佳实践是两者配合------规则写进 system prompt,变化通过 hook 注入。
Q7:beforeToolCall 和 afterToolCall 是全局的还是逐工具定义的?
全局的,不是逐工具的。
beforeToolCall / afterToolCall 挂在 AgentLoopConfig 上,所有工具共用一套 。agent loop 调用时拿到的是 toolCall.name------钩子内部自己判断要不要对特定工具做处理。
typescript
// AgentSession 的实现(agent-session.ts:404)
this.agent.beforeToolCall = async ({ toolCall, args }) => {
// 所有工具调用都走这里------toolCall.name 区分是哪个工具
if (toolCall.name === "bash" && args.command.includes("rm -rf")) {
return { block: true, reason: "Dangerous command" };
}
if (toolCall.name === "write") {
// 对 write 工具的处理
}
return undefined; // 其他工具放行
};
为什么不是逐工具定义:
- 逐工具定义意味着每个
AgentTool要带beforeToolCall/afterToolCall字段------但工具定义在 ai 层(Tool接口),那里不该有执行策略逻辑 - 一个钩子统一处理所有工具,更容易做跨工具策略------如"这批工具调用里有没有 bash + write 的危险组合",这需要看到所有工具调用,逐工具定义做不到
pi 的折中:
| 需求 | 在哪做 |
|---|---|
| 全局策略(所有工具统一的拦截 / 修改) | beforeToolCall / afterToolCall |
| 逐工具参数预处理 | AgentTool.prepareArguments(每个工具自己的字段) |
| 逐工具执行模式 | AgentTool.executionMode("sequential" / "parallel") |
| 逐工具自定义逻辑 | tool.execute 内部自己实现(如 bash 的 spawnHook) |
所以 pi 的设计是:全局钩子管策略,工具自身字段管特性,execute 内部管实现。三层各管各的,不混。
七、下一章预告
下一篇文章将进入 pi 的 skills 机制------.pi/skills/ 目录下的 SKILL.md 如何被 agent 加载、skill 描述如何注入 system prompt、以及 skills 如何作为"按需加载的指令包"让 agent 在不膨胀 system prompt 的前提下获得领域知识。skills 机制是 pi "扩展无需 fork" 理念的另一个落地------不改源码、不注册工具,仅通过 Markdown 文件就能扩展 agent 的能力边界。