- 本文基于
@earendil-works/pi-agent-core1.1.0(2026-10-07 发布,源码 commit6fb2e78)。源码解读部分引用的是packages/agent/src/agent-loop.ts在该 commit 下的行号。 - 这个包的 hook API 还在演进中。例如 README 提到,旧的
shouldStopAfterTurn已经被移除,改成了finishTurn1。 - 文中标注为“✅ 已实际运行”的代码,用 TypeScript 7.0.2 的 strict 模式做过类型检查,并借助 faux provider 在本地跑通;其余代码只做了类型检查。资料截至 2026-10-09。
- pi-agent-core 是一个有状态的 agent,负责工具执行和事件流,构建在 pi-ai 之上1。整个包大约 2,500 行,是学习“agent 循环如何工程化”的绝佳材料。
- 两层消息模型:应用层的
AgentMessage可以携带自定义类型,经过convertToLlm转换后,才是模型能理解的Message1。 - 循环是一个双层 while:内层处理“工具调用和 steering 消息”,外层处理“follow-up 消息”2。
- 扩展点包括:
beforeToolCall/afterToolCall、prepareRequest/finishTurn、transformContext、steering 与 follow-up 队列、并行或串行执行工具1。
1. 最小示例(✅ 已实际运行)#
import { Agent, type AgentTool } from "@earendil-works/pi-agent-core";import { createModels, fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall, Type,} from "@earendil-works/pi-ai";
// 1) 用 faux provider 代替真实模型,脚本化地返回两轮响应(不需要 API Key)const faux = fauxProvider();const models = createModels();models.setProvider(faux.provider);const model = faux.getModel();
faux.setResponses([ fauxAssistantMessage([fauxText("我先查一下天气。"), fauxToolCall("get_weather", { city: "北京" })], { stopReason: "toolUse", }), fauxAssistantMessage([fauxText("北京今天晴,25°C,很适合跑步。")]),]);
// 2) 定义工具:TypeBox schema 同时负责类型推导和运行时校验const weatherParams = Type.Object({ city: Type.String({ description: "城市名,例如 北京" }) });const getWeather: AgentTool<typeof weatherParams> = { name: "get_weather", label: "查询天气", description: "查询城市今天的天气", parameters: weatherParams, execute: async (_toolCallId, params) => { const data: Record<string, string> = { 北京: "晴,25°C", 上海: "小雨,22°C" }; return { content: [{ type: "text", text: data[params.city] ?? "暂无数据" }], details: { city: params.city } }; },};
// 3) 创建 Agent,并订阅事件const agent = new Agent({ initialState: { systemPrompt: "你是天气助手。", model, tools: [getWeather] }, streamFn: models.streamSimple.bind(models),});
agent.subscribe((event) => { if (event.type === "turn_start") console.log("── turn_start"); if (event.type === "tool_execution_start") console.log(` tool_execution_start: ${event.toolName}`, event.args); if (event.type === "tool_execution_end") console.log(` tool_execution_end: isError=${event.isError}`); if (event.type === "message_end") console.log(` message_end: role=${event.message.role}`); if (event.type === "agent_end") console.log(`── agent_end: 共 ${event.messages.length} 条新消息`);});
await agent.prompt("北京今天适合跑步吗?");const last = agent.state.messages.at(-1);if (last?.role === "assistant") { console.log("最终回答:", last.content.filter((b) => b.type === "text").map((b) => b.text).join(""));}在本地实际运行的输出:
── turn_start message_end: role=user message_end: role=assistant tool_execution_start: get_weather { city: '北京' } tool_execution_end: isError=false message_end: role=toolResult── turn_start message_end: role=assistant── agent_end: 共 4 条新消息最终回答: 北京今天晴,25°C,很适合跑步。手写循环里你要亲自做的事——调用模型、找出工具调用、执行工具、回填结果、继续循环——在这里全部由 agent.prompt() 完成。你只需要定义工具、订阅事件。
2. 两层消息模型#
上图来自 README 的 Message Flow 一节1:
- transformContext:裁剪较早的消息,或者注入外部上下文,是做上下文工程的入口;
- convertToLlm:过滤掉只给 UI 用的消息,把自定义类型转换成 LLM 能理解的格式。
import { Agent } from "@earendil-works/pi-agent-core";import { createModels, type Message } from "@earendil-works/pi-ai";
// 通过声明合并扩展 AgentMessage,加入一个只在 UI 里显示的 notification 类型declare module "@earendil-works/pi-agent-core" { interface CustomAgentMessages { notification: { role: "notification"; text: string; timestamp: number }; }}
// 只有这几种角色是 LLM 能理解的,其余的(自定义类型)都要过滤或转换const LLM_ROLES = new Set(["system", "user", "assistant", "toolResult"]);
const models = createModels();const agent = new Agent({ streamFn: models.streamSimple.bind(models), // 发给 LLM 之前,过滤掉 notification 这类自定义消息 convertToLlm: (messages) => messages.filter((m): m is Message => LLM_ROLES.has(m.role)), // 每次请求前只保留最近 50 条消息,这是最朴素的上下文管理 transformContext: async (messages) => messages.slice(-50),});
agent.state.messages.push({ role: "notification", text: "已连接", timestamp: Date.now() });改编自 README 的 Custom Message Types 一节1。
3. 事件流#
| 事件 | 说明 |
|---|---|
agent_start / agent_end | 一次运行的开始和结束 |
turn_start / turn_end | 一个 turn,即一次 LLM 调用加上它触发的工具执行 |
message_start / message_update / message_end | 任何消息。message_update 只针对 assistant,携带流式增量 |
tool_execution_start / _update / _end | 工具执行的开始、进度、结束 |
以上取自 README1。订阅者会按注册顺序依次被 await;只有等 agent_end 的订阅者处理完,prompt() 才会真正返回1。
4. 源码解读:双层循环#
runLoop 的核心结构,见 agent-loop.ts 第 163 到 331 行2。下面是笔者简化后的伪代码,对应源码中的关键分支:
// 伪代码:根据 agent-loop.ts 中 runLoop 的结构简化而来pending = 取出 steering 消息(用户可能在等待时已经输入了内容)while (true): // 外层循环:处理 follow-up hasMoreToolCalls = true while (hasMoreToolCalls || pending 非空): // 内层循环:处理工具调用和 steering 把 pending 消息追加到上下文(并声明工具集的变化) prepareRequest?() // 每次请求前都可以重建上下文 message = 流式请求 assistant 回复 if message.stopReason in (error, aborted): finishTurn?(); emit turn_end; emit agent_end; return toolCalls = message 中的工具调用 if toolCalls 非空: if message.stopReason == "length": // 输出被截断,参数可能不完整 把这批工具调用全部判为失败 else: 执行工具(并行或串行,带 before/after hook) hasMoreToolCalls = !(这批结果全部要求 terminate) decision = finishTurn?() // 可以返回 end 或 continue emit turn_end if decision == end: emit agent_end; return pending = 取出 steering 消息 followUps = 取出 follow-up 消息 // agent 原本要停了,看看还有没有后续任务 if followUps 非空: pending = followUps; continue if 显式要求 continue: continue breakemit agent_end5. 工具执行:并行、串行与 hooks#
| 配置 | 行为 |
|---|---|
toolExecution: "parallel"(默认) | 先依次做预检,然后并发执行允许的工具。每个工具一完成就发出 tool_execution_end,但 toolResult 消息仍然按 assistant 输出中的原始顺序追加 |
toolExecution: "sequential" | 一个接一个地执行 |
单个工具上的 executionMode: "sequential" | 只要一批里有一个工具要求串行,整批都改为串行 |
以上取自 README1。
import { Agent } from "@earendil-works/pi-agent-core";import { createModels } from "@earendil-works/pi-ai";
const models = createModels();const agent = new Agent({ streamFn: models.streamSimple.bind(models), toolExecution: "parallel", // 预检:参数已经校验通过之后执行,可以拦截这次调用 beforeToolCall: async ({ toolCall }) => { if (toolCall.name === "bash") { return { block: true, reason: "bash 已被禁用" }; } return undefined; }, // 后处理:在发出最终的工具事件之前,可以改写结果 afterToolCall: async ({ toolCall, result, isError }) => { if (!isError && toolCall.name === "notify_done") { return { terminate: true }; // 提示:这批工具执行完后不必再请求 LLM } return undefined; },});
// 在工具执行期间插话(steering),或者在当前任务完成后再加一个任务(follow-up)agent.steer({ role: "user", content: "停一下,改为只检查 src 目录。", timestamp: Date.now() });agent.followUp({ role: "user", content: "完成后顺便总结一下改动。", timestamp: Date.now() });改编自 README 的 Agent Options、Steering and Follow-up 两节1。
工具失败时应该直接抛出异常,而不是把错误信息当作正常内容返回。抛出的异常会被 Agent 捕获,以 isError: true 的形式报告给 LLM1。
6. Agent 的状态与常用方法#
| 成员 | 说明 |
|---|---|
agent.state | model、thinkingLevel、tools、messages、isStreaming、streamingMessage、pendingToolCalls、errorMessage 等1 |
agent.prompt(text | message) | 发起一次运行。可以附带图片 |
agent.continue() | 从现有的上下文继续,比如重试 |
agent.steer() / agent.followUp() | 加入 steering 队列或 follow-up 队列,有 one-at-a-time 和 all 两种出队模式 |
agent.abort() / agent.waitForIdle() | 中止当前运行;等待运行结束 |
agent.prepareRequest / agent.finishTurn | 每次请求前重建上下文;每个 turn 结束时决定是继续还是结束 |
agent.subscribe(fn) | 订阅事件,返回值用来取消订阅 |
- 开头的 system 消息就是提示词,之后的 system 消息可以按 section 修改它。
agent.state.systemPrompt是只读的,它是把对话记录回放一遍得出的结果1。- 这种设计让“修改提示词”也变成可追溯、可回放的事件。
7. 与其他 L3 循环的对比#
| 维度 | pi-agent-core | OpenAI Agents SDK Runner | Anthropic Tool Runner |
|---|---|---|---|
| 语言 | TypeScript | Python(另有 JS 版) | 多种语言 |
| 模型 | 数十家,经由 pi-ai | OpenAI 为主 | Claude |
| 消息模型 | 可以扩展的 AgentMessage | Responses 的 item | Messages 的 content block |
| 中途插话 | steering 和 follow-up 队列 | RunState 中的 add_input | 自己实现 |
| 工具钩子 | beforeToolCall / afterToolCall | 工具护栏、needs_approval | 自己在循环里实现 |
| 内置工具 | 无(在 L4 层的 pi-coding-agent 里才有) | 无(有托管工具和 Sandbox) | 无 |
| 可观测性 | 细粒度的事件流 | 内置 tracing | 自己实现 |
这张表是笔者根据各篇笔记归纳的。
小结#
- pi-agent-core 证明了一件事:一个可用于生产的 agent 循环,核心代码只需要两千多行。复杂度都在各种边界情况上:截断、中止、steering、并行、工具集变化。
- 想深入理解 agent 循环,最好的办法是带着 手写循环 去读
agent-loop.ts,逐一对照它多处理了哪些边界情况。 - 下一步:在它之上加入工具、会话、扩展和 TUI,就是 4.4 pi-coding-agent SDK 与扩展系统。
相关笔记#
- 4.1 pi 全景与设计哲学 · 4.2 pi-ai 统一多模型 API · 4.4 pi-coding-agent SDK 与扩展系统
- 对照:2.3 OpenAI Agents SDK · 5.3 DSH 核心机制与插件开发
参考资料#
注释与出处#
-
earendil-works/pi,
packages/agent/README.md(commit6fb2e78),https://github.com/earendil-works/pi/blob/6fb2e7815167e6b19006fc526d1a5d0f5f998787/packages/agent/README.md ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 -
earendil-works/pi,
packages/agent/src/agent-loop.ts(runLoop位于第 163–331 行,declareToolChanges位于第 333 行),https://github.com/earendil-works/pi/blob/6fb2e7815167e6b19006fc526d1a5d0f5f998787/packages/agent/src/agent-loop.ts#L163-L331 ↩ ↩2 ↩3 ↩4