上一章的 answerWithOneToolRound() 只能处理一轮工具调用:
模型 → 工具 → 模型
如果模型还要读取某个 PDF,再查询另一个目录,应用就得继续手写同一套判断。现在我们把它替换成 Pi 的 Agent。
本章要建立一个核心认识:
Agent 不是一次模型调用,而是“模型判断—工具执行—结果反馈”的循环。
1. 先把上一章的 Tool 升级成 AgentTool
上一章已经定义了:
const listDownloadFilesTool = {
name: "list_download_files",
description:
"列出当前用户下载目录中的文件。用户询问下载目录内容时使用。",
parameters: ListDownloadFilesParameters,
};
pi-agent 的 AgentTool 继承这些 Provider 可见字段,再增加本地执行函数:
import type {
AgentTool,
} from "@earendil-works/pi-agent-core";
const listDownloadFilesAgentTool: AgentTool<
typeof ListDownloadFilesParameters,
{ files: string[] }
> = {
...listDownloadFilesTool,
label: "查询下载目录",
async execute(_toolCallId, args) {
try {
const files = await listDownloadFiles(
desktopPaths.downloads,
args,
);
return {
content: [
{
type: "text",
text: JSON.stringify({ files }),
},
],
details: { files },
};
} catch (error) {
desktopDiagnostics.capture(error);
throw new Error(
"无法查询下载目录:目录暂时不可访问。",
);
}
},
};
execute() 返回 AgentToolResult,其中:
content是模型可见的文字或图片;details是应用可保存和渲染的结构化数据;- 还可以带
usage、addedToolNames和terminate。
工具执行失败时应 throw。Agent Loop 会把异常转换成错误 Tool Result,而不是让整个应用因未捕获异常退出。
2. 最小 Agent
把 Model、Tool 和 Stream Function 交给 Agent。下面是建立在前五章代码之上的累计示例,不是一个脱离前文的单文件:
import {
Agent,
} from "@earendil-works/pi-agent-core";
const agent = new Agent({
initialState: {
systemPrompt: [
"你是一个桌面助手。",
"需要本机信息时使用工具,不要猜测。",
].join("\n"),
model,
tools: [listDownloadFilesAgentTool],
},
streamFn: models.streamSimple.bind(models),
});
agent.subscribe((event) => {
if (
event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta"
) {
appendAnswerText(event.assistantMessageEvent.delta);
}
});
await agent.prompt("我的下载目录里有哪些 PDF?");
这里的 model 和 models 来自前四章,工具和 desktopPaths 来自上一章的桌面宿主。
与上一章相比,应用没有再手工:
- 找 Tool Call;
- 验证参数;
- 调用执行函数;
- 创建 Tool Result;
- 判断是否再次请求模型。
这些工作进入 Agent Loop。
3. streamFn 是 Agent 与模型层的接缝
Agent 不直接依赖某个 Provider,也不自己创建 Models。构造时必须给它一个符合 StreamFn 的函数:
type StreamFn = (
model: Model<Api>,
context: Context,
options?: SimpleStreamOptions,
) =>
| AssistantMessageEventStream
| Promise<AssistantMessageEventStream>;
models.streamSimple.bind(models) 正好符合这个形状。
flowchart LR
A["Agent"] --> F["StreamFn"]
F --> M["Models.streamSimple"]
M --> P["Provider"]
P --> API["目标模型 API"]
这种接缝带来两个结果:
- Agent Loop 只关心“怎样获得一条统一 Assistant Message Stream”;
- 模型目录、认证和 Provider 协议仍由
pi-ai的 Models 处理。
StreamFn 的契约还要求请求、模型和运行时失败进入 Stream error,而不是用 rejected Promise 表达普通模型失败。Models.streamSimple() 通过 lazyStream() 满足这个边界。
stream-fn.ts 还提供 setDefaultStreamFn(),宿主可以注册一个运行时默认值。不过当前 AgentOptions 类型要求显式传入 streamFn;本书也始终显式传入,依赖关系更清楚。
源码位置:
packages/agent/src/types.ts中的StreamFnpackages/agent/src/stream-fn.tspackages/ai/src/models.ts中的streamSimple
4. Agent State 保存什么
Agent 是有状态包装。它公开的 AgentState 包括:
interface AgentState {
systemPrompt: string;
model: Model<Api>;
thinkingLevel: ThinkingLevel;
tools: AgentTool[];
messages: AgentMessage[];
readonly isStreaming: boolean;
readonly streamingMessage?: AgentMessage;
readonly pendingToolCalls: ReadonlySet<string>;
readonly errorMessage?: string;
}
这些字段分成三类:
| 类型 | 字段 | 含义 |
|---|---|---|
| 下次运行配置 | systemPrompt、model、thinkingLevel、tools |
下一次运行创建快照时使用什么 |
| 对话记录 | messages |
当前 Agent Transcript |
| 运行时状态 | isStreaming、streamingMessage、pendingToolCalls、errorMessage |
当前执行到了哪里 |
桌面界面不需要再自己猜“Agent 是否忙”。可以读取:
sendButton.disabled = agent.state.isStreaming;
类型注释把 streamingMessage 描述为当前流式 Assistant Message。当前 Agent.processEvents() 的实际 reducer 更宽:
- 任意
message_start都把它设为当前消息,包括 User 和 ToolResult; message_update再用最新 Assistant partial 更新;message_end清空,并把完整消息加入state.messages。
User 和 ToolResult 的 start/end 紧邻,但监听器会被等待,因此事件监听器中仍可能短暂观察到它们。桌面 UI 应先检查消息 role,不能无条件把 streamingMessage 当作 Assistant partial。
给 state.tools 或 state.messages 赋新数组时,Agent 会复制顶层数组。但读取后直接修改返回数组,会修改当前状态。它不是深度不可变 Store。
Agent 开始一次运行时,createContextSnapshot() 和 createLoopConfig() 会复制当前配置。运行中直接修改 agent.state.model、tools 或 systemPrompt,不会自动改变当前运行的下一 Turn;若要在同一次 Loop 中更新 Context、Model 或 Thinking Level,应使用 prepareNextTurn 返回替换快照。
源码位置:
packages/agent/src/types.ts中的AgentStatepackages/agent/src/agent.ts中的createMutableAgentState
5. 一次 Prompt 怎样进入 Loop
agent.prompt("...") 的调用链:
flowchart TD
P["agent.prompt(text)"] --> N["normalizePromptInput()"]
N --> RPM["runPromptMessages()"]
RPM --> L["runWithLifecycle()"]
L --> R["runAgentLoop()"]
R --> E1["agent_start"]
E1 --> E2["turn_start"]
E2 --> U["user message_start / end"]
U --> LOOP["runLoop()"]
normalizePromptInput() 把字符串变成 User Message,并可附加图片。
runPromptMessages() 建立当前 State 的 Context 快照和 Loop Config,再通过 runWithLifecycle() 开始本次运行。
runWithLifecycle():
- 拒绝同一个 Agent 同时开始第二个运行;
- 创建本次运行的
AbortController; - 设置
isStreaming = true; - 调用 Loop;
- 捕获 Loop 层意外抛出的失败并转换成错误 Assistant Message;
- 最后清理运行时状态。
低层 transformContext、convertToLlm、shouldStopAfterTurn 等回调的源码契约要求调用者不要抛异常。若它们违反契约,低层 Loop 会直接 reject;使用 Agent 包装时,runWithLifecycle() 才会把这类意外失败转换成错误 Assistant Message,并补齐 message_start/end、turn_end、agent_end 生命周期事件。
runAgentLoop() 会复制已有 Context,加上新 Prompt,然后发出 Agent 和第一轮事件。
源码位置:packages/agent/src/agent.ts 的 prompt、runPromptMessages、runWithLifecycle
6. Turn 到底是什么
Pi 的定义是:
一个 Turn = 一次 Assistant 响应 + 由该响应触发的全部 Tool Call 与 Tool Result。
User Message 在第一次 turn_start 后发出消息事件,但它不是 turn_end.message;turn_end.message 是这一轮的 Assistant Message。
有工具时,事件大致是:
sequenceDiagram
participant UI
participant Agent
participant Model
participant Tool
Agent-->>UI: turn_start
Agent->>Model: Context + Tools
Model-->>Agent: AssistantMessage(ToolCall)
Agent-->>UI: message_end
Agent->>Tool: execute(validated args)
Tool-->>Agent: AgentToolResult
Agent-->>UI: ToolResult message
Agent-->>UI: turn_end
Agent-->>UI: turn_start
Agent->>Model: Context + ToolResult
Model-->>Agent: 最终 AssistantMessage
Agent-->>UI: turn_end
Agent-->>UI: agent_end
一次用户 Prompt 可以包含多个 Turn。不要把“Turn”理解成一整次用户任务。
7. runLoop() 为什么有两层循环
源码中的结构可以简化为:
while (true) {
while (hasMoreToolCalls || pendingMessages.length > 0) {
const message = await streamAssistantResponse(...);
const toolResults = await executeToolCalls(...);
// 追加结果,完成当前 Turn
pendingMessages = await getSteeringMessages();
}
const followUps = await getFollowUpMessages();
if (followUps.length > 0) {
pendingMessages = followUps;
continue;
}
break;
}
内层循环负责:
- 模型生成;
- 工具执行;
- 工具结果反馈;
- 运行途中注入的 Steering Message。
外层循环负责:
- Agent 原本准备结束后,再处理 Follow-up Message。
Steering 和 Follow-up 会在第 14 章细讲。本章只需知道,双层循环不是为了“无限调用模型”,而是表达两种不同的继续来源。
8. 一轮模型调用发生了什么
streamAssistantResponse() 在每轮请求前:
- 可选执行
transformContext(); - 用
convertToLlm()把 AgentMessage 转成标准 Message; - 组装
Context,带上 System Prompt 和 Tools; - 可选解析本轮动态 API Key;
- 调用
streamFn; - 把 pi-ai 内容事件转换成 Agent
message_update; - 把最终 Assistant Message 写进当前 Context。
flowchart LR
AM["AgentMessage[]"] --> TC["transformContext"]
TC --> CL["convertToLlm"]
CL --> LM["Message[]"]
LM --> CTX["Context + Tools"]
CTX --> SF["streamFn"]
SF --> EV["Agent message events"]
默认 convertToLlm() 只保留 User、Assistant 和 ToolResult。应用自定义消息需要提供自己的转换函数。
源码位置:
packages/agent/src/agent-loop.ts的streamAssistantResponsepackages/agent/src/agent.ts的defaultConvertToLlm
9. Tool Call 怎样进入执行阶段
模型响应结束后,Loop 从 message.content 找出全部 Tool Call。
如果消息以 "length" 结束,Loop 不执行任何调用,而是为每个调用生成错误 Tool Result。
否则进入:
flowchart TD
C["ToolCall"] --> F{"按名称找到 AgentTool?"}
F -->|否| NF["错误 Tool Result"]
F -->|是| PA["prepareArguments 可选预处理"]
PA --> V["validateToolArguments"]
V --> B["beforeToolCall 可选拦截"]
B --> E["tool.execute"]
E --> A["afterToolCall 可选后处理"]
A --> R["ToolResultMessage"]
本章只关注主路径。并行执行、取消和两个 Hook 会在第 8 章展开。
执行函数抛出的异常会被 executePreparedToolCall() 捕获,并转换成:
{
content: [{ type: "text", text: message }],
details: {},
}
同时对应 Tool Result 的 isError 为 true。
这保证“单个工具失败”通常会成为模型可理解的结果。但工具作者仍应避免在异常消息中包含秘密,因为基础 Loop 会使用异常文本。coding-agent 或桌面宿主可以通过 Hook 做安全裁剪;第 8、9 章会处理。
10. 为什么 Tool Result 会触发下一轮
执行结束后,Loop 把每个 Tool Result 同时加入:
- 当前 Context;
- 本次运行的
newMessages。
如果这批工具没有请求提前终止:
hasMoreToolCalls = true;
内层循环因此开始下一 Turn,模型看到刚才的 Tool Result,再决定:
- 生成最终回答;
- 继续调用另一个工具;
- 返回错误或停止。
这就是上一章手工重复代码被自动化的地方。
11. Agent Loop 什么时候退出
退出条件不是只有“模型返回普通文字”。
11.1 模型错误或用户取消
Assistant Message 的 stopReason 为 "error" 或 "aborted" 时:
- 发出
turn_end; - 发出
agent_end; - 立即退出,不执行其中 Tool Call。
11.2 当前没有继续工作的理由
当:
- 没有更多 Tool Call;
- 没有 Steering Message;
- Agent 原本结束后也没有 Follow-up Message;
双层循环自然结束并发出 agent_end。
11.3 shouldStopAfterTurn() 请求优雅停止
低层 Loop 调用者可以在完整 Turn 结束后返回 true:
shouldStopAfterTurn: async ({ context }) => {
return shouldPauseBeforeNextTurn(context.messages);
},
它不会中断当前模型或正在执行的工具,而是在 turn_end 之后、轮询 Steering/Follow-up 之前结束。
11.4 整批工具都要求 terminate
AgentToolResult.terminate = true 是跳过自动下一次模型调用的提示。只有这一批所有最终 Tool Result 都为 terminate: true,Loop 才把 hasMoreToolCalls 设为 false。
它仍不阻止之后已经排队的 Steering 或 Follow-up。
11.5 输出达到长度限制
- 没有 Tool Call:本轮结束,若无队列则 Agent 结束;
- 有 Tool Call:不执行截断调用,生成错误 Tool Result,并继续一轮,让模型有机会重新请求。
12. agent_end 与真正空闲有一点距离
Agent.subscribe() 的监听器按注册顺序执行,而且异步监听器会被等待。
agent_end 表示:
Loop 不会再产生新事件。
但本次运行要等 agent_end 的所有监听器也完成后,prompt() 和 waitForIdle() 才完成,随后 finishRun() 把 isStreaming 设回 false。
因此在一个耗时的 agent_end 监听器内部,读取 agent.state.isStreaming 仍可能得到 true。这符合源码生命周期,不是状态更新遗漏。
13. Agent 与低层 Loop API
Pi 同时暴露:
| API | 适合什么场景 |
|---|---|
Agent |
需要持久状态、订阅、队列、取消和易用方法的应用 |
agentLoop() |
应用自己管理 Context 和 EventStream |
runAgentLoop() |
应用已经有事件 Sink,希望直接等待消息结果 |
agentLoopContinue() / runAgentLoopContinue() |
从现有 User/ToolResult 结尾继续,不添加新 Prompt |
桌面 Agent 通常先使用 Agent。低层函数适合自定义 Runtime 或测试。
agentLoop() 返回 EventStream<AgentEvent, AgentMessage[]>;runAgentLoop() 则直接把事件交给 emit 回调,最后返回本次新消息。
14. Continue 不等于“再问一遍”
低层 Continue 从已有 Context 开始,不添加新的 User Message。
它有两个前置条件:
- Context 不能为空; -最后一条消息不能是 Assistant Message。
更准确地说,源码注释要求最后一条 AgentMessage 经 convertToLlm() 后成为 User 或 ToolResult。低层入口只能在转换前检查显式 Assistant role,无法验证自定义消息最终会被转换成什么。
Agent.continue() 也拒绝正在运行时调用。如果当前最后一条是 Assistant Message,它会先尝试消费已经排队的 Steering 或 Follow-up;两者都没有时就抛错。
因此 Continue 的典型用途是:
- Context 已经加入一条 User Message,但尚未生成 Assistant;
- Context 以 Tool Result 结束,需要让模型继续;
- 上层恢复流程已经把 Context 调整到可继续状态。
它不是绕过消息顺序规则的重试按钮。
15. 从事件更新桌面状态
这一章只做最小映射:
agent.subscribe((event) => {
switch (event.type) {
case "agent_start":
setRunStatus("running");
break;
case "turn_start":
incrementTurnCount();
break;
case "message_update":
if (
event.assistantMessageEvent.type === "text_delta"
) {
appendAnswerText(
event.assistantMessageEvent.delta,
);
}
break;
case "agent_end":
setRunStatus("settling");
break;
}
});
await agent.prompt(userText);
setRunStatus("idle");
这里用 "settling" 是为了体现:收到 agent_end 时监听器仍在运行;await agent.prompt() 返回后才真正完成本次运行。
工具执行的详细 UI 状态留到下一章。
16. 源码调用链复盘
flowchart TD
P["Agent.prompt()"] --> RPM["runPromptMessages()"]
RPM --> RS["runWithLifecycle()"]
RS --> RAL["runAgentLoop()"]
RAL --> RL["runLoop()"]
RL --> SAR["streamAssistantResponse()"]
SAR --> SF["streamFn / Models.streamSimple"]
SF --> AM["AssistantMessage"]
AM --> TC{"有 ToolCall?"}
TC -->|否| Q{"有 Steering / Follow-up?"}
TC -->|是| ETC["executeToolCalls()"]
ETC --> TR["ToolResultMessage[]"]
TR --> RL
Q -->|是| RL
Q -->|否| AE["agent_end"]
对应源码:
packages/agent/src/agent.ts:有状态 Agent 包装;packages/agent/src/types.ts:State、Tool、Context、Config 和 Event;packages/agent/src/agent-loop.ts:模型与工具循环;packages/agent/src/stream-fn.ts:默认 Stream Function 接缝;packages/ai/src/models.ts:本课程实际传入的模型 Stream。
17. 本章小结
AgentTool在 Tool 定义上增加本地执行契约。Agent通过streamFn与pi-ai Models连接,不绑定具体 Provider。AgentState同时保存后续配置、对话和运行时状态。- 一个 Turn 是一次 Assistant 响应及其全部 Tool Call/Result。
- 一次 Prompt 可以包含多个 Turn。
- 内层循环处理模型、工具和 Steering,外层循环处理 Agent 原本结束后的 Follow-up。
- 每次模型调用前都可以变换 Agent Context,再转换成标准 LLM Message。
- Tool Call 会经历查找、参数准备、验证、拦截、执行和结果封装。
- Tool Result 进入 Context 后会自动触发下一 Turn。
- 错误、取消、无后续工作、优雅停止和整批 terminate 都可能结束 Loop。
- 截断消息中的 Tool Call 不会被执行。
agent_end后仍要等待异步监听器完成,运行才真正空闲。- Continue 从已有 User/ToolResult 结尾继续,不添加新 Prompt。
下一章会使用完整 Agent Event 联合类型,把模型流、工具执行和 Turn 状态映射成桌面界面。
18. 自测
AgentTool比pi-ai Tool多了哪些运行时能力?- 为什么 Agent 需要注入
streamFn? - 一个 Turn 是否等于一次完整用户任务?
- Tool Result 为什么会自动触发下一次模型调用?
runLoop()为什么有内外两层循环?- 模型响应以
length结束且含 Tool Call 时,Loop 怎么处理? terminate: true在什么条件下才会停止自动后续调用?agent_end到达后,为什么isStreaming仍可能是true?agentLoop()与runAgentLoop()的主要差异是什么?- Continue 对 Context 最后一条消息有什么要求?
本章源码依据
packages/agent/src/agent.tspackages/agent/src/agent-loop.tspackages/agent/src/types.tspackages/agent/src/stream-fn.tspackages/agent/src/index.tspackages/agent/README.mdpackages/ai/src/models.tspackages/ai/src/types.ts