返回课程首页

27

通过 Extension 改变 Agent 行为

为 Prompt、输入、模型、压缩和会话选择扩展点。

源码基线:Pi v0.82.0 · commit 518855d

前两章讲了 Event 和 Tool。本章回答更实用的问题:

我想改 Agent 行为,应该选哪个扩展点?

关键不是“哪个 Event 看起来最接近”,而是你想修改的数据处于哪一层。

1. 先按数据层选择

flowchart TD
    Q{"要改什么?"}
    Q -->|用户原始输入| I["input"]
    Q -->|本次系统提示| B["before_agent_start"]
    Q -->|每次模型消息| C["context"]
    Q -->|HTTP Header| H["before_provider_headers"]
    Q -->|序列化 Payload| P["before_provider_request"]
    Q -->|工具许可| T["tool_call"]
    Q -->|模型/Thinking| M["Runtime Action"]
    Q -->|压缩| X["compact + session_before_compact"]
    Q -->|会话状态| S["appendEntry"]

越靠后修改,越接近 Provider/Tool 的最终形态;越靠前,越能保持高层语义。

2. 修改 System Prompt

对每个用户 Run 增加规则:

pi.on("before_agent_start", (event) => ({
  systemPrompt:
    event.systemPrompt +
    "\n\nUse concise, evidence-backed answers.",
}));

它覆盖当前用户提交触发的整个 Agent Run,包括其中多个 Tool-loop Turn。多个 Handler 链式 处理;下一次用户提交/Agent Run 没有 Override 时恢复 Base Prompt。

若规则来自静态应用配置,直接构建 Base System Prompt 更简单;Extension 适合按项目、 用户输入或运行状态动态改变。

3. 修改用户输入

input 看见 Skill/Template 展开前的 Raw Text:

pi.on("input", (event) => {
  if (!event.text.startsWith("?brief ")) {
    return { action: "continue" };
  }
  return {
    action: "transform",
    text: `请简短回答:${event.text.slice(7)}`,
    images: event.images,
  };
});

返回 Handled 可完全绕过 Agent。Extension Command 比 Input 更早;命中 Command 时 Input 不会触发。

4. 增加或过滤 Context

每次模型调用前:

pi.on("context", (event) => ({
  messages: [
    {
      role: "custom",
      customType: "runtime-facts",
      content: "Current incident: INC-42",
      display: false,
      timestamp: Date.now(),
    },
    ...event.messages,
  ],
}));

Context Event 的修改不自动写 Session。适合:

  • 临时 Runtime Fact;
  • 对模型隐藏某些 Custom Message;
  • Redact 发送给 Provider 的内容;
  • 控制本次 Context Window。

需要审计和恢复的业务状态,应另写 Session Entry。

5. before_agent_start 与 context 怎么选

需求 选择
根据用户这次 Prompt 增加规则 before_agent_start
每一轮 Tool Loop 前都重新计算 context
修改 System Prompt before_agent_start
修改 Message Array context
写入可持久化 Custom Message before_agent_start 返回 message
仅影响 Provider 可见 Context context

一次 Agent Run 可有多个 LLM Turn,所以 context 可能比 before_agent_start 多次触发。

6. 修改 Provider Request

增加 Trace Header:

pi.on("before_provider_headers", (event, ctx) => {
  event.headers["x-session-id"] =
    ctx.sessionManager.getSessionId();
});

修改 Payload:

pi.on("before_provider_request", (event) => {
  const payload = event.payload as Record<string, unknown>;
  return { ...payload, temperature: 0 };
});

Payload Shape 由 Provider/API 决定,不是统一稳定 Schema。能在 System Prompt、Context 或 Provider Config 解决的问题,不要无谓地下沉到 Payload Rewrite。

7. 添加请求头的边界

Header Handler 原地修改:

event.headers.authorization = null; // 删除

它适合 Trace、Gateway Attribution 或受控 Header。不要把 Secret 写进日志。

Provider Retry 可能复用同一 Headers,不重新触发 Hook;不能用它实现“每次 Retry 都生成 不同 Nonce”的假设。

8. 切换模型

绑定完成后:

const model = ctx.modelRegistry.find(
  "provider",
  "model-id",
);
if (model) {
  const changed = await pi.setModel(model);
  if (!changed) {
    // 没有可用认证
  }
}

setModel() 返回 Boolean;缺少 API Key 时为 False。只有模型实际变化时才触发 model_select;若切换导致 Clamp 后的有效 Thinking Level 也实际变化,才另触发 thinking_level_select

模型对象应来自当前 Model Registry,不要凭字符串构造缺字段对象。

9. 控制 Thinking Level

pi.setThinkingLevel("high");

Runner 委托 AgentSession;实际 Level 会按 Model Capability Clamp。需要显示最终值:

pi.getThinkingLevel();

thinking_level_select 是通知,不是可取消 Event。

10. 自动触发 Compaction

pi.on("agent_settled", (_event, ctx) => {
  const usage = ctx.getContextUsage();
  if (
    typeof usage?.percent === "number" &&
    usage.percent > 80
  ) {
    ctx.compact({
      customInstructions: "Preserve decisions and tool evidence.",
    });
  }
});

ctx.compact() 不返回待 Await 的 Compaction Result。用 onComplete/onError Callback 或 session_compact Event 观察结果。

必须防递归:Compaction 后的事件链不应无条件再次触发 Compaction。

11. 自定义 Compaction

session_before_compact 可以:

  • Cancel;
  • 返回完整 compaction
  • 观察 Reason、WillRetry、Signal;
  • 使用 Branch Entries 和 Preparation。

多个 Extension 都返回 Custom Compaction 时会形成 Owner 冲突。生产应用应只启用一个 Compaction Provider,其他 Extension 只观察。

12. 添加自定义会话数据

pi.appendEntry("desktop-policy", {
  schemaVersion: 1,
  mode: "readonly",
});

Custom Entry:

  • 写入当前 Session;
  • 不进入模型 Context;
  • 可在同一持久化 Session Resume 时重建状态;
  • 不自动跨无关 New Session。

State Schema 要 Versioned;Reload 会重跑 Factory,闭包状态需要从 Entry 恢复。

13. 任务完成后执行操作

发送通知、刷新文件索引或更新外部状态,应监听:

pi.on("agent_settled", async () => {
  await updateTaskStatus();
});

不是 agent_end:后者之后可能 Retry、Auto-compaction Retry 或处理 Follow-up。

仍要保证 Idempotency。Extension 触发的新 Run 可能再次 Settled;使用 Run/Session Entry 或 业务 Idempotency Key 去重。

14. 实现 Read-only Mode

最小方案:

const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"];
let mode: "normal" | "readonly" = "normal";

pi.registerCommand("readonly", {
  description: "Enable read-only mode",
  handler: async (_args, ctx) => {
    await ctx.waitForIdle();
    mode = "readonly";
    pi.setActiveTools(READ_ONLY_TOOLS);
    pi.appendEntry("desktop-mode", {
      mode: "readonly",
    });
  },
});

再加最终 Gate:

pi.on("tool_call", (event) => {
  if (mode !== "readonly") return;
  if (!READ_ONLY_TOOLS.includes(event.toolName)) {
    return {
      block: true,
      reason: "Read-only mode blocks this tool.",
    };
  }
});

session_start 中还应从当前 Branch 的 desktop-mode Entry 恢复 mode;切回 Normal 时 同时恢复此前保存的 Active Tool Set。这里的 Closure State 只服务当前 Extension Instance。

只改 Prompt 不够;只改 Active Set 也不能撤回本 Turn 已生成的 Call。最终执行 Backend 还要 做 Filesystem/Network Policy。

15. 实现 Plan Mode

Plan Mode 通常需要三层:

System Prompt:只分析和规划
Active Tools:只保留读取/搜索
Final Policy:拒绝写入和执行

Extension 可用 Command 切换 Mode、保存 Entry、修改 Tool Set,并在 before_agent_start 追加 Plan Instructions。

Pi 没有一个通用的“PlanMode=true” Core Flag。这里是由现有扩展点组合出的产品模式。

16. Mode 状态机

stateDiagram-v2
    [*] --> Normal
    Normal --> Plan: command / policy
    Plan --> ReadOnly: lock
    ReadOnly --> Normal: explicit unlock

    state Plan {
      [*] --> ReadTools
      ReadTools --> ProducePlan
    }

每次切换:

  1. 等待 Idle;
  2. 持久化 Mode;
  3. 更新 Active Tools;
  4. 更新 UI Store;
  5. 下一次用户提交/Agent Run 由 before_agent_start 注入规则;
  6. Final Policy 按当前 Mode 检查。

17. 修改 Tool Result

Redaction:

pi.on("tool_result", (event) => ({
  content: redactSecrets(event.content),
}));

它发生在 Tool Execute 后、模型继续前。适合保护 Provider Context,但不能撤销 Tool 已经 执行的副作用,也不一定清除 Backend/Console 中已记录的数据。

18. 添加 Custom Message

before_agent_start 返回 Message 会:

  • 形成 Session 中的 Custom Message;
  • 送入 Agent Context;
  • 可控制 Display。

pi.sendMessage() 可以选择:

  • 是否 Trigger Turn;
  • Streaming 时 Steer/FollowUp/NextTurn。

发送消息可能形成递归 Event Chain,必须标记 CustomType 并检查来源。

19. 为需求选 Hook 的检查表

  1. 修改对象是 Prompt、Message、Payload、Tool,还是 Session?
  2. 要持久化还是只影响一次 Provider Call?
  3. 要观察、修改、Block,还是启动新动作?
  4. 同一 Run 会触发几次?
  5. 多个 Extension 时如何合并?
  6. Handler Error 应 Fail Open 还是 Fail Closed?
  7. 是否需要 Signal/Timeout?
  8. 是否会递归触发自己?
  9. 是否有最终宿主安全检查?

20. 常见误区

“所有 Prompt 修改都放 context”

不对。System Prompt 用 before_agent_start 更直接;Message Array 才用 Context。

“before_provider_request 有稳定通用 Payload”

错误。它取决于 Provider API。

“agent_end 就代表业务完成”

不一定。用 agent_settled,并做 Idempotency。

“Plan Mode 只是一句 System Prompt”

不安全。还要 Tool Set 与 Final Policy。

“appendEntry 会自动把 Mode 告诉模型”

错误。Custom Entry 不进入 LLM Context;需要另行注入。

21. 本章小结

  • 扩展点应按被修改的数据层选择;
  • Input 改 Raw Text,before_agent_start 改当前 Agent Run 的 System Prompt,Context 改每次 LLM Messages;
  • Headers 与 Provider Payload 是更底层、Provider-sensitive 的 Hook;
  • Model 与 Thinking 通过 Runtime Action 改变;只有对应有效值实际变化时才发 Notification;
  • Compaction Action、Before Event 和 After Event 分别负责触发、定制和观察;
  • Custom Entry 保存当前 Session 状态,但不进入模型 Context;
  • 任务真正稳定结束看 agent_settled,并保证 Side Effect Idempotent;
  • Read-only/Plan Mode 是 Prompt、Active Tools 与 Final Policy 的组合;
  • Tool Result Redaction 不能撤销已发生副作用;
  • 所有会启动新 Run 的 Handler 都要防递归。

22. 自测

  1. before_agent_start 与 context 各适合修改什么?
  2. Provider Payload Rewrite 为什么应谨慎使用?
  3. setModel 返回 False 代表什么?
  4. ctx.compact 怎样观察 Completion/Error?
  5. appendEntry 为什么不能单独让模型知道 Mode?
  6. 任务完成通知为什么用 agent_settled?
  7. Read-only Mode 至少需要哪三层?
  8. Plan Mode 是 Pi 内置 Flag 吗?
  9. Tool Result Redaction 能否撤销 Tool 副作用?
  10. 怎样避免 Extension 自己触发无限 Run?