pi-agent-core 最小 Agent 运行时

pi-agent-core 是一个有状态的 agent,负责工具执行和事件流,构建在 pi-ai 之上。

预计阅读
14分钟
全文字数
1,583字
资料截至
2026-10-09
Agent SDK · pi4.3
版本与时效声明
  • 本文基于 @earendil-works/pi-agent-core 1.1.0(2026-10-07 发布,源码 commit 6fb2e78)。源码解读部分引用的是 packages/agent/src/agent-loop.ts 在该 commit 下的行号。
  • 这个包的 hook API 还在演进中。例如 README 提到,旧的 shouldStopAfterTurn 已经被移除,改成了 finishTurn1。
  • 文中标注为“✅ 已实际运行”的代码,用 TypeScript 7.0.2 的 strict 模式做过类型检查,并借助 faux provider 在本地跑通;其余代码只做了类型检查。资料截至 2026-10-09。
本文要点
  1. pi-agent-core 是一个有状态的 agent,负责工具执行和事件流,构建在 pi-ai 之上1。整个包大约 2,500 行,是学习“agent 循环如何工程化”的绝佳材料。
  2. 两层消息模型:应用层的 AgentMessage 可以携带自定义类型,经过 convertToLlm 转换后,才是模型能理解的 Message1。
  3. 循环是一个双层 while:内层处理“工具调用和 steering 消息”,外层处理“follow-up 消息”2。
  4. 扩展点包括: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. 两层消息模型#

transformContext()

可选:裁剪、注入

convertToLlm()

必需:过滤并转换

AgentMessage[]

(可以包含自定义类型,

比如 notification)

AgentMessage[]

Message[]

user / assistant / toolResult

LLM

transformContext()

可选:裁剪、注入

convertToLlm()

必需:过滤并转换

AgentMessage[]

(可以包含自定义类型,

比如 notification)

AgentMessage[]

Message[]

user / assistant / toolResult

LLM

上图来自 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. 事件流#

工具LLM(经过 pi-ai)Agent你的代码工具LLM(经过 pi-ai)Agent你的代码prompt("读取 config.json")agent_start / turn_startmessage_start/end(用户消息)streamFn(context)流式增量message_start → message_update × n → message_end(assistant,含 toolCall)tool_execution_startexecute(toolCallId, args, signal, onUpdate)结果(中途可以通过 onUpdate 推送进度)tool_execution_end / message_start/end(toolResult)turn_endturn_start(下一轮)带上工具结果再次请求最终文本message_* / turn_end / agent_end
工具LLM(经过 pi-ai)Agent你的代码工具LLM(经过 pi-ai)Agent你的代码prompt("读取 config.json")agent_start / turn_startmessage_start/end(用户消息)streamFn(context)流式增量message_start → message_update × n → message_end(assistant,含 toolCall)tool_execution_startexecute(toolCallId, args, signal, onUpdate)结果(中途可以通过 onUpdate 推送进度)tool_execution_end / message_start/end(toolResult)turn_endturn_start(下一轮)带上工具结果再次请求最终文本message_* / turn_end / agent_end
事件说明
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
break
emit agent_end

是

否

有,并且 stopReason=length

有

没有

是

否

是

否

是

否

开始

追加 pending 消息

prepareRequest → 请求 LLM

stopReason 是 error 或 aborted?

agent_end

有工具调用?

整批判为失败

finishTurn → turn_end

执行工具

beforeToolCall → execute → afterToolCall

decision = end?

还有工具结果要回填,

或者有 steering 消息?

有 follow-up 消息?

是

否

有,并且 stopReason=length

有

没有

是

否

是

否

是

否

开始

追加 pending 消息

prepareRequest → 请求 LLM

stopReason 是 error 或 aborted?

agent_end

有工具调用?

整批判为失败

finishTurn → turn_end

执行工具

beforeToolCall → execute → afterToolCall

decision = end?

还有工具结果要回填,

或者有 steering 消息?

有 follow-up 消息?

三个值得学习的工程细节
  1. 截断保护:如果 stopReason 是 length,说明输出被 token 上限截断了,工具参数可能不完整,于是整批调用直接判为失败,而不是去执行可能残缺的参数2。
  2. steering 和 follow-up 的区别:steering 是在当前这批工具执行完之后插入;follow-up 是在原本要停下的时候才插入1。
  3. 工具集变化要先告诉模型:在发出请求之前,如果运行时实际可执行的工具和对话记录里声明的工具不一致,循环会插入一条 system 消息来声明差异(declareToolChanges)。这样模型看到的工具集永远和真实情况一致21。

5. 工具执行:并行、串行与 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.statemodel、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-coreOpenAI Agents SDK RunnerAnthropic Tool Runner
语言TypeScriptPython(另有 JS 版)多种语言
模型数十家,经由 pi-aiOpenAI 为主Claude
消息模型可以扩展的 AgentMessageResponses 的 itemMessages 的 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 与扩展系统。

相关笔记#

参考资料#

注释与出处#

  1. earendil-works/pi,packages/agent/README.md(commit 6fb2e78),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

  2. 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

输入关键词开始搜索。多个关键词用空格分隔。