从零开始拆解Pi系列——(5)hook 机制

一、引言:为什么需要 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 执行过程中追加指令------需要中途插入新消息
  • 某些工具调用有安全风险------需要在执行前拦截
  • 工具返回结果里有敏感信息------需要在回填前脱敏

这些干预点如果直接写进 runLoopexecuteToolCalls,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 类的结构

classDiagram class Agent { -_state: MutableAgentState +convertToLlm: (messages) => Message[] +streamFn: StreamFn +beforeToolCall?: BeforeToolCall +afterToolCall?: AfterToolCall +prepareNextTurn?: PrepareNextTurn +steeringQueue: PendingMessageQueue +followUpQueue: PendingMessageQueue +sessionId?: string +transport: Transport -activeRun?: ActiveRun +createLoopConfig(): AgentLoopConfig +createContextSnapshot(): AgentContext } class AgentLoopConfig { +model: Model +convertToLlm: (messages) => Message[] +beforeToolCall?: BeforeToolCall +afterToolCall?: AfterToolCall +prepareNextTurn?: PrepareNextTurn +getSteeringMessages?: () => Promise~AgentMessage[]~ +getFollowUpMessages?: () => Promise~AgentMessage[]~ +shouldStopAfterTurn?: ShouldStopAfterTurn } class PendingMessageQueue { +drain(): AgentMessage[] +push(msg: AgentMessage): void } Agent --> AgentLoopConfig : createLoopConfig() 构造 Agent --> PendingMessageQueue : steeringQueue Agent --> PendingMessageQueue : followUpQueue

Agent 类持有两类东西:

  • 可赋值的钩子属性beforeToolCall / afterToolCall / prepareNextTurn------外部直接 agent.beforeToolCall = ... 赋值
  • 消息队列steeringQueue / followUpQueue------不直接暴露给外部,而是通过 steer() / followUp() 方法 push,createLoopConfig 时包装成 drain 函数

2. createLoopConfig:组装 config

每次启动循环时,AgentcreateLoopConfig() 把自己的属性组装成 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 / prepareNextTurnAgent 类的可赋值属性,createLoopConfig 直接把引用拷进 config。
  • 包装成函数getSteeringMessages / getFollowUpMessages 不是 Agent 的属性------Agent 持有的是 steeringQueue / followUpQueue 两个队列,createLoopConfig 把它们的 drain() 方法包装成 config 里的 async 函数。还加了 skipInitialSteeringPoll 逻辑------首次启动循环时跳过一次 steering 检查,避免刚启动就收到旧消息。

shouldStopAfterTurncreateLoopConfig完全没出现 ------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 的实现

AgentHarnessagent-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
  };
},

三步:

flushPendingSessionWritesagent-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 流式响应。

createTurnStateagent-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() 就是这个函数)。

createContextagent-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 的 getSteeringMessagesagent.ts:440-446):

typescript 复制代码
getSteeringMessages: async () => {
  if (skipInitialSteeringPoll) {         // 首轮跳过
    skipInitialSteeringPoll = false;
    return [];
  }
  return this.steeringQueue.drain();
},

runLoop 在两个地方调它(文章 3 讲过):

  1. 内层循环开头 :每圈开始前 drain,取出的消息 push 到 context.messages,LLM 下一轮调用时就能看到
  2. 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 执行链里的位置------beforeToolCallprepareToolCall 里调、afterToolCallfinalizeExecutedToolCall 里调。本章先回顾调用逻辑,再展开 pi coding-agent 真正填入的实现。

1. 回顾:agent-loop.ts 的调用逻辑

beforeToolCallagent-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 } 就放行。

afterToolCallagent-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)}`);
  }
};

逻辑:

  1. 检查有没有扩展注册了 tool_call handler------没有就直接 return undefined(放行)
  2. 有就调 runner.emitToolCall(...),把工具名、调用 ID、参数传给所有注册的 handler
  3. handler 可以返回 { block: true, reason: "..." } 拦截------emitToolCall 会把这个返回值传回来
  4. 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,
  };
};

逻辑:

  1. 检查有没有扩展注册了 tool_result handler------没有就直接 return undefined
  2. 有就把完整的工具结果(content / details / isError)传给 handler
  3. handler 返回修改后的结果------按字段覆盖回 result
  4. handler 返回 undefined------保留原始结果

3. 设计要点:延迟绑定

注意 _installAgentToolHooks 的注释(agent-session.ts:397-401):

The callbacks read this._extensionRunner at 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 持有的不是队列本身,而是一个闭包
  • 每次 runLoopconfig.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_result handler ,不是替换 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 继续"活着"。返回空 → 外层循环 breakagent_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 的能力边界。

相关推荐
阿里云大数据AI技术2 小时前
从三个月到两周:DataWorks Data Agent 重构信飞科技多国数仓交付链路
人工智能·agent
殷紫川2 小时前
AI Agent让人等得想砸键盘?流式交互与实时体验的工程实战
agent·ai编程
leeyi3 小时前
Agent 怎么测试:mock LLM + 状态机表驱动,不调真模型的测试策略(第96篇-E82)
llm·aigc·agent
prog_61033 小时前
【笔记】用cursor手搓cursor(九)
人工智能·笔记·大语言模型·agent
程序猿DD3 小时前
豆包工作送 30 天会员!我把每天要盯的事情都交给了它,结果...
agent
殷紫川4 小时前
长程任务 Agent 为什么总半途而废?PLAN-AND-ACT 用"先规划后动手"给出 SOTA 答案
llm·agent·ai编程
SelectDB4 小时前
Apache Doris 在内容 AI 生产链路中的实践:从内容打标到可追溯数据链路
大数据·agent·图片资源
宋哥转AI5 小时前
深入理解 AI Agent · 多 Agent 编排 #01:多 Agent 编排的四种核心模式
人工智能·agent·ai编程
今日无bug5 小时前
从零实现 Mini-Cursor 编程助手:Agent 开发实战指南
agent·cursor