返回课程首页

7

在界面中显示工具执行过程

把 Agent 事件映射成清晰的界面状态。

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

第 6 章已经让 Agent 自动调用工具,但用户只看到最终文字。真实桌面 Agent 还需要回答:

  • 当前是模型在生成,还是工具在执行?
  • 调用了哪个工具?
  • 参数是什么?
  • 工具有没有进度?
  • 成功还是失败?
  • 多个工具同时运行时,哪一个先完成?

Pi 不要求 UI 读取 Agent Loop 内部变量,而是通过统一 AgentEvent 暴露运行过程。

1. 先建立独立的 UI View Model

不要把 AgentState 直接当成界面 Store。Agent State 是运行时真相,界面还需要标题、进度文案、展开状态等展示数据。

type ToolRunView = {
  id: string;
  name: string;
  status:
    | "preparing"
    | "running"
    | "done"
    | "error";
  rawArgs: unknown;
  progressText?: string;
  resultText?: string;
};

type DesktopAgentView = {
  runStatus: "idle" | "running" | "settling";
  turn: number;
  activeAssistant?: {
    turn: number;
    text: string;
  };
  assistantMessages: Array<{
    turn: number;
    text: string;
  }>;
  tools: Record<string, ToolRunView>;
};

这是课程的 UI 类型,不是 Pi 内置接口。

工具运行记录必须用 toolCallId 作为主键。工具名会重复,多个相同工具也可能同时执行。

2. Agent Event 的三条时间线

AgentEvent 可以按用途分成三组:

flowchart TD
    A["AgentEvent"] --> L["Agent / Turn 生命周期"]
    A --> M["消息生命周期"]
    A --> T["工具执行生命周期"]

    L --> L1["agent_start / agent_end"]
    L --> L2["turn_start / turn_end"]
    M --> M1["message_start / update / end"]
    T --> T1["tool_execution_start / update / end"]

完整联合类型在 packages/agent/src/types.ts 中定义。

事件 核心含义
agent_start 本次运行开始
agent_end Loop 不再发出新事件
turn_start 新的 Assistant Turn 开始
turn_end Assistant 与本轮 Tool Result 都已完成
message_start 任意 AgentMessage 开始;内置消息包括 User、Assistant、ToolResult
message_update 仅 Assistant 的流式更新
message_end 一条消息完成
tool_execution_start 一项工具调用进入准备阶段
tool_execution_update 工具主动报告部分结果
tool_execution_end 工具已完成最终处理

消息流和工具执行流有关联,但不是同一条流。message_update 不会承载工具执行进度。

3. 一次工具调用的精确事件顺序

顺序执行一个成功工具时:

sequenceDiagram
    participant Model
    participant Agent
    participant UI
    participant Tool

    Model-->>Agent: AssistantMessage with ToolCall
    Agent-->>UI: message_end(assistant)
    Agent-->>UI: tool_execution_start
    Agent->>Agent: 查找、预处理、验证、beforeToolCall
    Agent->>Tool: execute(...)
    Tool-->>Agent: onUpdate(partial)
    Agent-->>UI: tool_execution_update
    Tool-->>Agent: final result
    Agent->>Agent: afterToolCall
    Agent-->>UI: tool_execution_end
    Agent-->>UI: message_start(toolResult)
    Agent-->>UI: message_end(toolResult)
    Agent-->>UI: turn_end

三个关键边界:

  1. Assistant message_end 先于工具准备;
  2. tool_execution_end 在最终 ToolResult Message 之前;
  3. turn_end 在本轮所有 Tool Result Message 之后。

Agent 的外部监听器会被等待。因此 Assistant message_end 是工具 preflight 前的真实 Barrier:它的内部状态归约和所有监听器完成后,才进入工具阶段。

这项“等待订阅者”的保证来自 Agent.processEvents()。直接消费低层 agentLoop() 返回的 EventStream 时,消费者读取事件的速度不会反向成为同样的执行 Barrier。

4. tool_execution_start 还不代表通过验证

源码在查找工具、参数预处理和 Schema 验证之前就发出:

{
  type: "tool_execution_start",
  toolCallId: toolCall.id,
  toolName: toolCall.name,
  args: toolCall.arguments,
}

因此这个事件更准确的 UI 状态是 "preparing",不是“执行成功启动”。

事件中的 args 是模型产生的原始参数:

  • 还没有经过 prepareArguments
  • 还没有经过 validateToolArguments
  • 可能对应不存在的工具。

UI 可以把它作为受限预览,但不能把它当成已验证业务数据。

5. beforeToolCall 之后才真正执行

准备阶段:

flowchart LR
    S["tool_execution_start"] --> F["查找 AgentTool"]
    F --> P["prepareArguments"]
    P --> V["Schema 验证"]
    V --> B["beforeToolCall"]
    B --> E["execute"]

找不到工具、验证失败、被 Hook 阻止或已经取消,都会跳过 execute(),但仍产生:

  • tool_execution_end,其中 isError: true
  • 对应的错误 ToolResult Message。

所以 UI 不能用“有没有 start”判断本地函数是否真的被调用。

Pi 也没有单独的“execute 函数此刻真正开始”事件。若工具从不调用 onUpdate,卡片可以从 "preparing" 直接进入 "done""error"

6. 让工具主动报告进度

AgentTool.execute() 的第四个参数是 onUpdate

async execute(_id, args, signal, onUpdate) {
  onUpdate?.({
    content: [
      {
        type: "text",
        text: "正在扫描下载目录…",
      },
    ],
    details: {
      phase: "scanning",
      scanned: 0,
    },
  });

  const files = await scanFiles(args, signal, (scanned) => {
    onUpdate?.({
      content: [
        {
          type: "text",
          text: `已检查 ${scanned} 个文件`,
        },
      ],
      details: {
        phase: "scanning",
        scanned,
      },
    });
  });

  return {
    content: [
      {
        type: "text",
        text: JSON.stringify({ files }),
      },
    ],
    details: {
      phase: "done",
      files,
    },
  };
}

每次回调都会形成 tool_execution_update

{
  type: "tool_execution_update",
  toolCallId,
  toolName,
  args,
  partialResult,
}

partialResultAgentToolResult,不是字符串 delta。工具可以每次发送一份完整的“当前部分结果”。

Pi 不规定 details 的结构。桌面应用应为自己的工具约定可判别字段,例如 phasescannedtotal

7. Update 回调的生命周期

onUpdate 只在当前 execute() Promise 尚未结束时有效。

源码用 acceptingUpdates 保护:

  • Promise resolve 或 reject 后设为 false
  • 之后再调用旧回调会被忽略;
  • 已经触发的 update 事件会在 tool_execution_end 前等待完成。
stateDiagram-v2
    [*] --> Accepting
    Accepting --> Accepting: onUpdate
    Accepting --> Settled: execute resolve/reject
    Settled --> Settled: late onUpdate ignored
    Settled --> [*]: tool_execution_end

这防止工具保存旧回调后,在下一次任务中意外污染 UI。

源码位置:packages/agent/src/agent-loop.ts 中的 executePreparedToolCall

8. tool_execution_end 是最终化后的结果

工具 Promise 真正执行完成后,Loop 还可能运行 afterToolCall。这个 Hook 可以替换:

  • content
  • details
  • usage
  • isError
  • terminate

之后才发出:

{
  type: "tool_execution_end",
  toolCallId,
  toolName,
  result,
  isError,
}

因此,对已经进入 execute() 的调用,tool_execution_end.result 是最终化结果,不一定与 execute() 原始返回值完全相同。

找不到工具、参数验证失败、被 beforeToolCall 阻止、执行前已取消,以及 length 截断调用都属于 immediate outcome:它们没有执行工具,也不会运行 afterToolCall,但仍会发出错误 tool_execution_end

随后 createToolResultMessage() 才把结果转换成模型可见消息。

9. 最小 UI Reducer

下面只投影 UI 真正需要的字段:

function textFromContent(
  content: Array<{ type: string; text?: string }>,
) {
  return content
    .filter(
      (
        block,
      ): block is { type: "text"; text: string } =>
        block.type === "text" &&
        typeof block.text === "string",
    )
    .map((block) => block.text)
    .join("\n");
}

function assistantText(message: AgentMessage) {
  if (message.role !== "assistant") return "";
  return textFromContent(message.content);
}

function reduceAgentEvent(
  state: DesktopAgentView,
  event: AgentEvent,
): DesktopAgentView {
  switch (event.type) {
    case "agent_start":
      return {
        ...state,
        runStatus: "running",
      };

    case "turn_start":
      return {
        ...state,
        turn: state.turn + 1,
      };

    case "message_start":
      if (event.message.role !== "assistant") {
        return state;
      }
      return {
        ...state,
        activeAssistant: {
          turn: state.turn,
          text: "",
        },
      };

    case "message_update":
      if (
        event.assistantMessageEvent.type !== "text_delta"
      ) {
        return state;
      }
      return {
        ...state,
        activeAssistant: {
          turn:
            state.activeAssistant?.turn ??
            state.turn,
          text:
            (state.activeAssistant?.text ?? "") +
            event.assistantMessageEvent.delta,
        },
      };

    case "message_end":
      if (event.message.role !== "assistant") {
        return state;
      }
      return {
        ...state,
        activeAssistant: undefined,
        assistantMessages: [
          ...state.assistantMessages,
          {
            turn:
              state.activeAssistant?.turn ??
              state.turn,
            // 使用最终消息作为权威值,也覆盖没有 delta、
            // 直接 done/error 的 Provider 路径。
            text: assistantText(event.message),
          },
        ],
      };

    case "tool_execution_start":
      return {
        ...state,
        tools: {
          ...state.tools,
          [event.toolCallId]: {
            id: event.toolCallId,
            name: event.toolName,
            status: "preparing",
            rawArgs: event.args,
          },
        },
      };

    case "tool_execution_update": {
      const current = state.tools[event.toolCallId];
      if (!current) return state;

      return {
        ...state,
        tools: {
          ...state.tools,
          [event.toolCallId]: {
            ...current,
            status: "running",
            progressText: textFromContent(
              event.partialResult.content ?? [],
            ),
          },
        },
      };
    }

    case "tool_execution_end": {
      const current = state.tools[event.toolCallId];
      if (!current) return state;

      return {
        ...state,
        tools: {
          ...state.tools,
          [event.toolCallId]: {
            ...current,
            status: event.isError ? "error" : "done",
            resultText: textFromContent(
              event.result.content ?? [],
            ),
          },
        },
      };
    }

    case "agent_end":
      return {
        ...state,
        runStatus: "settling",
      };

    default:
      return state;
  }
}

await agent.prompt() 返回后,再把 runStatus 设为 "idle",原因已在第 6 章解释。

这个 Reducer 为每个 Turn 建立独立 Assistant 气泡。message_update 只更新当前气泡;message_end 使用最终 Assistant Message 作为权威内容,因此不会把工具前后的多条 Assistant Message 粘成一段,也不会漏掉没有 text delta 的直接 done/error 路径。

10. 为什么 UI 不应直接保存事件对象

Assistant 流更新中的 message 是顶层浅拷贝,内部 Content 数组仍可能随流继续变化。工具的 detailspartialResult 也是应用定义的任意结构。

UI Reducer 应:

  • 根据 delta 更新自己的文本;
  • 只投影所需 Tool 字段;
  • 对跨进程数据做明确序列化;
  • 不把事件对象当成永久不可变快照。

这也是 UI 状态与 Agent 状态解耦的核心。

Reducer 本身应保持同步、纯粹。日志写入、IPC、通知等异步副作用放在 Reducer 外执行,避免并发工具事件让状态提交顺序变得不可预测。

flowchart LR
    E["AgentEvent"] --> R["UI Reducer / Projector"]
    R --> V["稳定 View Model"]
    V --> C1["聊天气泡"]
    V --> C2["工具卡片"]
    V --> C3["运行状态栏"]

11. 多个工具不能依赖数组位置

默认 toolExecution"parallel"

在并行模式中:

  • Tool Call 按 Assistant Message 源顺序做 start 和 preflight;
  • 允许执行的工具并发运行;
  • tool_execution_end 按实际完成顺序发出;
  • ToolResult Message 和 turn_end.toolResults 最终仍按 Assistant 源顺序发出。

例如第二个工具先完成:

start: tool-1
start: tool-2
end:   tool-2
end:   tool-1
ToolResultMessage: tool-1
ToolResultMessage: tool-2

这个序列假定两个调用都通过 preflight。Immediate failure 会在准备时立刻发出 end,因此也可能出现 start-1 → end-1 → start-2

因此 UI 必须使用 toolCallId 更新记录,不能假设“第一个 start 对应第一个 end”。

并行策略的设计原因和取消行为会在下一章深入。

12. Agent 内部状态何时先于 UI 更新

Agent.processEvents() 先归约内部状态,再按注册顺序等待外部监听器:

  • message_start/update 更新 streamingMessage
  • message_end 清空 Streaming Message,并追加完整消息;
  • tool_execution_start 把 ID 加入 pendingToolCalls
  • tool_execution_end 移除 ID;
  • turn_end 更新 errorMessage
  • 然后才调用订阅者。

所以在 tool_execution_start 监听器里:

agent.state.pendingToolCalls.has(event.toolCallId);

已经是 true

这使 AgentState 适合做运行时查询,UI Event Reducer 则适合做历史和展示。

13. Tool Result Message 与工具卡片不是一回事

tool_execution_end 面向运行过程,ToolResult Message 面向对话和下一次模型请求。

一个桌面界面可以:

  • 用 Tool Event 更新顶部工具卡片;
  • message_end(toolResult) 把结果落入对话时间线;
  • turn_end.toolResults 做整轮统计。

不要因为三处都有结果就重复创建三张卡片。它们是同一工具结果在三个不同观察层的表达。

建议关系:

flowchart TD
    S["tool_execution_start/update/end"] --> CARD["运行中工具卡片"]
    MR["message_end(toolResult)"] --> HISTORY["对话/持久记录"]
    TE["turn_end.toolResults"] --> TURN["Turn 汇总"]

14. 错误状态怎样显示

找不到工具、参数验证失败、Hook 阻止、工具抛错或输出被截断,都会产生 tool_execution_end

UI 统一根据:

event.isError

设置成功或失败状态,再从最终 result.content 提取最终化后的文案。

Pi 基础 Loop 不保证这段文案已经过安全裁剪:Tool 或 Hook 抛出的 error.message 默认可能直接进入内容。桌面宿主必须在 Tool 内或 afterToolCall 等边界主动移除绝对路径、堆栈和秘密。

不要根据文案中是否包含“error”判断失败,也不要把错误 Tool Result 当成 Agent 整体崩溃。Loop 通常会把它反馈给模型并继续下一 Turn。

15. 一张完整的事件—界面映射表

Agent Event UI 动作 不应该做什么
agent_start 锁定发送按钮,显示运行中 清空历史消息
turn_start 增加 Turn 计数 创建空工具卡
message_start 按 AgentMessage role 准备对应容器 假设只有三种内置消息或一定是 Assistant
message_update 消费 Assistant delta 当成工具执行进度
message_end 落定消息 重复追加已由 delta 构建的全文
tool_execution_start 按 ID 创建 preparing 卡片 信任原始 args
tool_execution_update 更新同 ID 的进度 把 partial 当最终结果
tool_execution_end 标记 done/error 按数组下标配对
turn_end 完成本轮汇总 假设整个 Agent 已结束
agent_end 标记 settling 立刻假设异步监听器均完成

16. 本章小结

  • Agent Event 分为 Agent/Turn、Message 和 Tool Execution 三条时间线。
  • message_update 只用于 Assistant 流,不是工具进度。
  • tool_execution_start 在查找和参数验证前发出,args 仍是不可信原始输入。
  • Tool 可以用 onUpdate 主动发送部分 AgentToolResult
  • execute Promise 结束后的晚到 Update 会被忽略。
  • 已触发的 Update 会在 tool_execution_end 前完成事件交付。
  • 已进入 execute 的调用,其 tool_execution_end 包含 afterToolCall 最终化后的结果;immediate outcome 不运行该 Hook。
  • ToolResult Message 在 execution end 之后产生,用于对话和模型 Context。
  • UI 应把 Agent Event 投影成自己的稳定 View Model。
  • 并行工具的 end 顺序可能与 Tool Call 源顺序不同,必须按 toolCallId 关联。
  • Agent 内部 State 在外部订阅者收到事件前已经归约。
  • turn_end 汇总一个 Turn,不表示整个 Agent 已结束。

下一章会深入默认并行模式、Abort、工具异常以及 beforeToolCallafterToolCallshouldStopAfterTurn

17. 自测

  1. 为什么 tool_execution_start 更适合映射成 preparing?
  2. start 事件中的 args 是否已经通过 Schema 验证?
  3. message_updatetool_execution_update 分别表示什么?
  4. Tool 的 onUpdate 在 execute 结束后再调用会怎样?
  5. tool_execution_end.result 为什么可能不同于 execute 的返回值?
  6. 为什么 UI 不能直接永久保存 Assistant 更新事件中的 message?
  7. 并行执行时,Tool Result Message 和 execution end 的顺序有何不同?
  8. AgentState.pendingToolCalls 在订阅者收到 start 时是否已经更新?
  9. ToolResult Message、工具卡片和 turn_end.toolResults 各服务什么用途?
  10. 为什么一个工具失败不一定意味着 Agent 整体结束?

本章源码依据

  • packages/agent/src/types.ts
  • packages/agent/src/agent.ts
  • packages/agent/src/agent-loop.ts
  • packages/agent/README.md
  • packages/agent/test/agent-loop.test.ts
  • packages/agent/test/agent.test.ts
  • packages/ai/src/types.ts