返回课程首页

26

通过 Extension 添加和包装工具

理解 Tool Definition、Wrapper、审批与审计。

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

桌面 Agent 现在要增加数据库查询能力:

模型可以执行只读 SQL;任何写操作必须进入审批,所有调用都要留下审计记录。

这一需求会串起 Tool Definition、Schema、Wrapper、Active Tool、Event Gate 和 Audit。

1. Tool 从哪里进入

flowchart LR
    F["Extension Factory"] --> D["ToolDefinition"]
    D --> G["RegisteredTool<br/>+ SourceInfo"]
    G --> W["wrapRegisteredTool"]
    W --> A["AgentTool"]
    A --> R["AgentSession Tool Registry"]
    R --> S["Agent State Active Tools"]
    S --> M["下一次模型请求"]

pi.registerTool() 会刷新 Registry;启动时 Extension Tool 默认进入 Active Set,运行期间 新注册的 Tool 通常也会自动激活,并从下一次模型请求可用。Allow/Exclude 或宿主显式 Active Set 仍可能限制它。

2. defineTool 做什么

const queryDatabase = defineTool({
  name: "query_database",
  label: "Query database",
  description: "Run SQL. Write operations require approval.",
  parameters: Type.Object({
    sql: Type.String(),
  }),
  async execute(_id, params, signal, onUpdate, ctx) {
    // ...
  },
});

defineTool() 主要保留 TypeBox Parameter 的 Type Inference,尤其当 Definition 先赋给变量或 放进 Array 时。它不会注册 Tool,也不会增加运行时 Security。

真正注册仍是:

pi.registerTool(queryDatabase);

3. ToolDefinition 的核心字段

字段 谁使用
name 模型 Tool Call、Registry Key
label 宿主展示
description 模型理解能力
parameters Tool Argument Schema
execute 实际执行
executionMode Parallel / Sequential
prepareArguments Validation 前兼容转换
promptSnippet Active 时的默认 Prompt Tool Section
promptGuidelines Active 时的默认 Prompt Guideline
constrainedSampling Provider 侧 Tool Sampling 请求

课程不使用自定义 TUI Renderer;桌面 UI 从 AgentSession Event 建立自己的 Tool Card。

4. 参数从模型到 execute

模型 Raw Arguments
→ prepareArguments(可选)
→ TypeBox Validation
→ beforeToolCall / tool_call
→ execute(validatedArgs)

一个重要边界:tool_call Handler 可以在 Validation 后原地改 Input,Core 不再次校验。 因此审批后若还有不可信 Handler,最终执行参数仍可能变化。

桌面 Agent 的最后一道 Policy 应位于受控的最终执行层,检查真正交给 Backend 的参数。

5. 完整数据库 Tool:默认只读,写操作需 Capability

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

export default function databaseExtension(pi: ExtensionAPI) {
  pi.registerTool(
    defineTool({
      name: "query_database",
      label: "Query database",
      description: "Run SQL. Write operations require approval.",
      parameters: Type.Object({
        sql: Type.String({ minLength: 1 }),
      }),
      executionMode: "sequential",
      async execute(toolCallId, { sql }, signal, onUpdate) {
        const capability = consumeQueryCapability(
          toolCallId,
          canonicalizeSql(sql),
        );
        assertAuthorizedSql(sql, capability);
        onUpdate?.({
          content: [{ type: "text", text: "Querying…" }],
          details: { phase: "query" },
        });
        return await runAuthorizedQuery(sql, signal);
      },
    }),
  );
}

这些 Capability、SQL 校验与 Query Backend 都是宿主业务实现,不是 Pi 内置函数。只读 SQL 可获得隐式 Read Capability;写 SQL 只有在审批 Gate 为同一 toolCallId + canonical sql 记录一次性 Capability 后才通过 Backend Final Check。

6. execute 的五个参数

toolCallId
params
signal
onUpdate
ctx
  • ID:关联 Event、UI 和 Audit;
  • Params:Schema Validation 后的参数,但可能已被 Hook 修改;
  • Signal:当前 Run 的 Abort Signal;
  • onUpdate:发送 Partial Result;
  • Context:当前 CWD、Model、Session Read View、Abort/Compact 等。

Tool 应把 Signal 继续传给 Database Driver、Fetch 或 Child Process。只接收 Signal 而不转发, 用户中断仍无法停止底层工作。

7. Tool Result 的形状

return {
  content: [
    { type: "text", text: JSON.stringify(rows) },
  ],
  details: {
    rowCount: rows.length,
    durationMs,
  },
};
  • content 会形成给模型的 Tool Result;
  • details 主要给应用层/Renderer/Event 使用;
  • usage 可记录 Tool 内嵌模型调用;
  • terminate 可参与终止整个 Tool Batch 的判断;
  • Wrapper 还可能添加 addedToolNames

不要把 Secret 放入 Content;它会进入模型 Context 和 Session Message。

8. Progress Update

onUpdate(partialResult) 产生 tool_execution_update

sequenceDiagram
    participant T as Tool
    participant C as Agent Core
    participant U as Desktop Store

    T->>C: onUpdate(progress)
    C->>U: tool_execution_update
    T->>C: final result
    C->>U: tool_execution_end

Execute 返回后,Core 不再接受迟到 Update,并等待已经发出的 Update Event 完成。桌面 Store 应按 toolCallId 合并 Partial State,而不是把每次 Update 当新 Tool。

9. RegisteredTool 与 Wrapper

pi.registerTool() 保存:

{
  definition,
  sourceInfo: extension.sourceInfo,
}

wrapRegisteredTool()

  • 调用通用 wrapToolDefinition()
  • 在执行时注入 runner.createContext()
  • 比较执行前后 Active Tool Set;
  • 若执行后只新增 Active Tool、没有移除任何旧 Active Tool,将 Name 写进 Result 的 addedToolNames;若同一次变化移除了旧 Tool,则不添加该字段。

Tool Call/Result Interception 不在这个 Wrapper 中,而在 AgentSession 安装到 Agent Core 的 Before/After Tool Hook 中。

10. 动态启用和禁用

const current = pi.getActiveTools();
pi.setActiveTools(
  current.filter((name) => name !== "query_database"),
);

setActiveToolsByName()

  1. 只保留 Registry 中存在的 Name;
  2. Unknown Name 静默忽略;
  3. 替换 Agent State Tools;
  4. 按新 Tool Set 重建 Base System Prompt;
  5. 改变从下一 Agent Turn 生效。

如果当前 Turn 已经把 Tool Schema 发给模型,动态禁用不能撤回已经生成并进入 Preflight 的 Call;最终 Policy 仍要检查。

11. Allowed 与 Excluded Tool

AgentSession Config 可提供:

  • allowedToolNames
  • excludedToolNames

Registry Refresh 时,Tool 必须在 Allowlist(若存在)且不在 Exclude Set。这个过滤同时作用 于 Built-in、Extension 与 SDK Custom Tool。

Extension 的 setActiveTools() 只能在已经被配置允许的 Registry 内选择,不能绕过宿主 Allow/Exclude。

12. 同名 Tool 的覆盖顺序

内部 Registry 先放 Built-in,再放 Extension Tool,最后放 SDK customTools。Map 后写覆盖:

SDK customTools
> Extension Tool
> Built-in Tool

而多个 Extension 之间先注册者胜出;Resource Loader 为跨 Extension 同名加诊断。

因此注册一个名为 read 的 Extension Tool 可以替换 Built-in Read 的执行 Definition,但 不是自动调用原实现的 Decorator。其他展示层可能按自己的规则回退 Built-in Metadata/ Renderer,不能把执行覆盖泛化成所有层都完整替换。

13. 两种“包装”

Event 包装

tool_call → 前置检查/改参数
原 Tool execute
tool_result → 脱敏/补信息

不需要获得原 Tool Execute Reference,适合 Approval、Audit、Redaction。

同名替换

注册相同 Name
→ Registry 使用 Custom Definition

适合完全提供新 Backend。ExtensionAPI 的 getAllTools() 返回 Metadata,不提供原 execute,所以不能假设同名 Definition 自动帮你调用 Built-in。

14. 数据库审批 Gate

pi.on("tool_call", async (event, ctx) => {
  if (event.toolName !== "query_database") return;

  const sql =
    typeof event.input.sql === "string"
      ? event.input.sql
      : "";

  if (isReadOnlySql(sql)) return;
  if (!ctx.hasUI) {
    return {
      block: true,
      reason: "Write query requires interactive approval.",
    };
  }

  const approved = await ctx.ui.confirm(
    "Database write",
    sql,
    { signal: ctx.signal, timeout: 30_000 },
  );
  if (!approved) {
    return { block: true, reason: "Rejected by user." };
  }
  grantOneShotQueryCapability(
    event.toolCallId,
    canonicalizeSql(sql),
  );
});

在桌面应用中,ExtensionUIContext 必须由宿主适配为桌面 Dialog;本章不依赖 Pi TUI。

审批后,最终 Database Backend 仍应验证 SQL/Capability。UI Approval 是用户意图,不是 Parser 或 Authorization。

15. 审批内容必须绑定最终参数

Approval Record 至少保存:

toolCallId
toolName
canonical arguments hash
decision
user/session
timestamp
policy version

真正执行前再计算 Canonical Argument Hash。若与批准版本不一致,重新审批或 Block。

这是桌面宿主的安全设计,不是 Pi 已内置的 Approval Record Schema。

16. Audit Event

观察三类 Event:

pi.on("tool_execution_start", recordStart);
pi.on("tool_execution_update", recordProgress);
pi.on("tool_execution_end", recordEnd);

还可监听:

  • tool_call:当前 Handler 所见的已校验 Input,后续 Handler 仍可能修改;
  • tool_result:实际执行后的 Input 与送回模型前 Result;Block、查找或校验失败时没有该 Extension Event;
  • Extension Error Listener:记录 Runner 捕获的普通/Result Handler Error;tool_call Handler Error 直接传播,不会由 emitToolCall() 发给这个 Listener。

最终参数的权威审计应位于最后一个受控 Gate 或 Backend 执行层;tool_result.input 只能做 执行后的补充记录。tool_execution_start.args 是 Prepare/Validation 前的 Raw Arguments。

审计日志应对 Secret 和大 Result 做 Redaction/Truncation。不要因为“审计”复制一份完整 Credential。

17. Tool Error

以下情况产生 Error Tool Result:

  • Name 不存在;
  • Argument Preparation/Validation 失败;
  • Before Tool Hook Block 或抛错;
  • Execute 抛错;
  • Agent Core After Tool Hook 抛错;
  • 执行前检测到 Abort,或 Tool 自己响应 Signal 并抛错。

Extension 的 tool_result Handler 抛错会被 Runner 记录并隔离,保留当前 Result,不会自动 变成 isError=true。自定义 Tool 成功 Return 一个写着 { error: ... } 的 Details,也不会 自动设置 isError。需要失败语义就让 Execute Throw,或让成功运行的 Result Handler明确 返回 isError: true

若 Tool 忽略 Signal 并最终成功返回,Core 不能强行抢占它;执行期 Abort 是否成为 Error 取决于底层是否真正响应取消。

18. Sequential 与 Parallel

Definition 可设置:

executionMode: "sequential"

若 Batch 中任何 Tool 标记 Sequential,整个 Batch 走 Sequential;Agent Loop Config 也可 全局指定 Sequential。否则默认 Parallel。

数据库 Transaction、会修改共享文件或依赖兄弟 Result 的 Tool,应慎重选择 Sequential。 但 Sequential 只规定这一 Batch 的执行顺序,不自动提供 Transaction Rollback。

19. Prompt 中怎样出现 Custom Tool

Active Tool 的完整 Schema 通过 Provider Request 提供。coding-agent 的 Base System Prompt 还可使用:

  • promptSnippet:Available Tools 的一行说明;
  • promptGuidelines:Guidelines Bullet。

未设置 Snippet 的 Custom Tool 不出现在默认 Prompt 的那段文本中,但仍可通过 Provider Tool Schema 被模型调用。只有 Active Tool 的 Prompt Metadata 会参与构建。

20. 一个完整链路

sequenceDiagram
    participant M as Model
    participant C as Agent Core
    participant G as Approval Gate
    participant T as Database Tool
    participant A as Audit

    M->>C: query_database(sql)
    C->>A: execution_start
    C->>G: tool_call(current mutable input)
    G-->>C: allow/block
    C->>T: execute(params, signal, update)
    T->>A: execution_update
    T-->>C: result
    C->>C: tool_result patches
    C->>A: execution_end
    C-->>M: ToolResult Message

21. 常见误区

“defineTool 会自动注册”

错误。它帮助类型推导,仍要 registerTool()

“Schema Validation 后参数不会再变”

错误。tool_call 可以改,而且不重新校验。

“禁用 Tool 能撤销本 Turn 已生成的调用”

错误。Active Set 主要影响后续 Turn;Final Policy 仍要检查。

“同名 Tool 自动包装 Built-in”

错误。它是替换;Event 才是非侵入式前后包装。

“Approval 通过就等于 SQL 安全”

错误。Backend 仍需 Parser、Authorization 和 Final Argument Binding。

22. 本章小结

  • defineTool 保留 Parameter Inference,不负责注册或安全;
  • ToolDefinition 经 RegisteredTool、Wrapper 和 Registry 成为 AgentTool;
  • Argument 先 Prepare/Validate,再经过可变的 tool_call Chain;
  • Execute 要转发 Abort Signal,并可通过 onUpdate 报 Progress;
  • Result Content 进入模型 Context,Details 服务应用层;
  • Active Tool 改变会重建 Prompt,并从下一 Turn 生效;
  • Allowed/Excluded 是宿主 Registry 边界;
  • SDK Custom > Extension > Built-in(执行 Definition);Extension 间 First Wins;
  • Event 包装与同名替换是两种不同机制;
  • 审批要绑定最终 Canonical Arguments,Backend 做最终 Policy;
  • Execution/Call/Result Event 共同构成审计链;
  • Sequential 不等于 Transaction,也不提供副作用回滚。

23. 自测

  1. defineTool 与 registerTool 分别做什么?
  2. Tool Argument 在哪个阶段校验,之后还能否被修改?
  3. RegisteredTool 为什么保存 SourceInfo?
  4. onUpdate 怎样映射到桌面状态?
  5. setActiveTools 对 Unknown Name 怎样处理?
  6. SDK Custom、Extension 与 Built-in 同名时谁胜出?
  7. Event 包装与同名替换有什么区别?
  8. Approval 为什么要绑定 Canonical Argument Hash?
  9. Tool 成功返回带 error 字段的 Details 会自动 isError 吗?
  10. Sequential Tool 能否自动回滚之前的副作用?