第 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
三个关键边界:
- Assistant
message_end先于工具准备; tool_execution_end在最终 ToolResult Message 之前;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,
}
partialResult 是 AgentToolResult,不是字符串 delta。工具可以每次发送一份完整的“当前部分结果”。
Pi 不规定 details 的结构。桌面应用应为自己的工具约定可判别字段,例如 phase、scanned、total。
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 可以替换:
contentdetailsusageisErrorterminate
之后才发出:
{
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 数组仍可能随流继续变化。工具的 details 和 partialResult 也是应用定义的任意结构。
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、工具异常以及 beforeToolCall、afterToolCall 和 shouldStopAfterTurn。
17. 自测
- 为什么
tool_execution_start更适合映射成 preparing? - start 事件中的 args 是否已经通过 Schema 验证?
message_update与tool_execution_update分别表示什么?- Tool 的
onUpdate在 execute 结束后再调用会怎样? tool_execution_end.result为什么可能不同于 execute 的返回值?- 为什么 UI 不能直接永久保存 Assistant 更新事件中的 message?
- 并行执行时,Tool Result Message 和 execution end 的顺序有何不同?
AgentState.pendingToolCalls在订阅者收到 start 时是否已经更新?- ToolResult Message、工具卡片和
turn_end.toolResults各服务什么用途? - 为什么一个工具失败不一定意味着 Agent 整体结束?
本章源码依据
packages/agent/src/types.tspackages/agent/src/agent.tspackages/agent/src/agent-loop.tspackages/agent/README.mdpackages/agent/test/agent-loop.test.tspackages/agent/test/agent.test.tspackages/ai/src/types.ts