创建日期:2026-09-07 | 最近更新:2026-09-07 事实核对基于
@earendil-works/pi-agent-core0.85.1(2026-09-05 发布)的 README、源码与本机实测;示例无需 API Key。
pi-agent-core:把「多轮工具循环」工程化
一句话:pi-agent-core 提供
Agent——一个有状态、会调工具、每步都发事件的多轮循环。 上一篇(pi-ai)给你「跟模型说话的原语」;这一层把你「每轮自己写while循环、自己拼事件」的活全接管,并让 UI、日志、会话持久化都有标准抓手。它就是 InkOS 文档里那个 pi-agent harness:模型只负责「理解、提议调用能力」,循环怎么推进、事件怎么发、上下文怎么管,全在这一层。
1. 为什么还需要这一层
pi-ai 已经能「发消息→拿结构化回复」了,但 agent 不是一问一答:它要可能连续多轮地调工具,中间还要
- 保存并更新「会话状态」(系统提示、模型、工具集、全部消息);
- 判断「要不要继续」(模型
stopReason==='toolUse'就该执行工具再喂回去); - 让外部(UI / CLI / 别的进程)能看到每步在干嘛,甚至中途打断;
- 出错重试、上下文太长时压缩、跨进程持久化。
Agent 把这些从你的业务代码里抽出去,你只需要声明:系统提示是什么、有哪些工具、每轮把模型换成哪个、要不要自定义消息类型。
Agent 自身不实现任何厂商协议——它通过一个注入的 streamFn 调用你配好的 pi-ai Models。两层职责清清楚楚:
const agent = new Agent({
initialState: { systemPrompt, model, tools, messages, thinkingLevel },
streamFn: models.streamSimple.bind(models), // 唯一必须注入的「怎么连模型」
// 可选:transformContext / convertToLlm / sessionId / getApiKey / 各种钩子…
});
2. 一个关键心智模型:AgentMessage ≠ LLM Message
Agent 内部用的是 AgentMessage——一种更自由的类型:除了标准的 user / assistant / toolResult,你还可以塞自定义角色(比如 UI 通知、内部审计记录),LLM 完全看不懂也没关系。
每次发请求前会走一条管线:
AgentMessage[] --(可选)--> transformContext --(必需)--> convertToLlm --> 标准 Message[] --> LLM
裁旧消息/注入外部上下文 滤掉 UI 专用消息 / 自定义类型转标准
transformContext:发请求前的「裁剪与注入」钩子,适合做上下文压缩、注入动态信息;convertToLlm:把AgentMessage[]转成 LLM 能懂的user/assistant/toolResult。只有当你引入自定义消息类型时才必须自己写它(见 §7)。
用自定义消息声明合并扩展类型:
declare module '@earendil-works/pi-agent-core' {
interface CustomAgentMessages {
notification: { role: 'notification'; text: string; timestamp: number };
}
}
3. Agent 状态:agent.state
| 字段 | 含义 |
|---|---|
systemPrompt | 系统提示 |
model | 当前模型(运行时可直接换) |
thinkingLevel | off/minimal/low/medium/high/xhigh/max |
tools | 工具集 |
messages | 全部消息 |
isStreaming / streamingMessage | 是否在跑 / 当前流式中的部分消息 |
pendingToolCalls | 正在执行的工具 id |
errorMessage | 最近的错误 |
常用:agent.prompt('...')、agent.continue()(出错后从现状重试,最后一条必须是 user/toolResult)、agent.abort()、await agent.waitForIdle()、agent.reset()、agent.state.systemPrompt = '...' 直接改状态。给 state.tools / state.messages 赋值时会拷贝顶层数组再存,所以你 push 的数组不会污染内部状态。
4. 一次 prompt() 的完整事件序列(实测)
prompt("上海天气怎么样?") 在有工具时的事件轨迹,本机用 fauxProvider 实测输出:
== agent_start ==
== turn_start == ← 一个 turn = 一次 LLM 调用 + 若干次工具执行
[message_start] user
[message_end] user content=[text]
[message_start] assistant
[message_end] assistant content=[toolCall] ← 模型提议调工具
[tool_execution_start] get_weather
[tool] get_weather(Shanghai) 执行中… ← 你的 execute() 在跑
[tool_execution_end] get_weather ok=true
[message_start] toolResult ← 结果作为消息回填
[message_end] toolResult content=[text]
== turn_end == toolResults=1
== turn_start == ← 自动开下一个 turn(LLM 读 toolResult)
[message_start] assistant
[message_end] assistant content=[text] ← 最终回答
== turn_end == toolResults=0
== agent_end == messages=4
对照 README 的抽象时序,逐段理解:
prompt("Hello")
├─ agent_start
├─ turn_start
├─ message_start/end { user }
├─ message_start { assistant(含 toolCall)}
├─ message_update… { 流式增量,assistantMessageEvent 里带 delta }
├─ message_end { assistant }
├─ tool_execution_start/update/end { 宿主执行工具 }
├─ message_start/end { toolResult }
├─ turn_end { message, toolResults[] }
├─ turn_start … ← 有工具结果就再来一轮
├─ … 直到模型不再调工具
└─ agent_end { messages[] }
| 事件 | 含义 |
|---|---|
agent_start / agent_end | 整轮开始 / 结束(agent_end 的订阅是「最后一道屏障」,等它也算进 waitForIdle) |
turn_start / turn_end | 一个 turn(一次 LLM 调用 + 工具执行)开始/结束 |
message_start / message_end | 任意消息(user / assistant / toolResult)开始/完成 |
message_update | 仅 assistant:流式增量,内含 assistantMessageEvent(可转发给 UI) |
tool_execution_start / update / end | 工具生命周期 |
用 agent.subscribe(async (event, signal) => {...}) 订阅;订阅按注册顺序 await,所以 agent_end 里做「落库、发通知」这类收尾是可靠的。返回的 unsubscribe() 可退订。
5. 工具:AgentTool 的完整契约
const readFileTool = {
name: 'read_file',
label: '读取文件', // 给 UI 显示用
description: '读取文件内容',
parameters: Type.Object({ path: Type.String({ description: '文件路径' }) }),
execute: async (toolCallId, params, signal, onUpdate) => {
// onUpdate?.({ content: [...], details: {} }) ← 可流式上报进度
return { content: [{ type: 'text', text: content }], details: { path: params.path } };
},
};
- 成功就 return,失败就 throw。throw 会被 Agent 捕获并以
isError: true的toolResult报给模型——别把错误当正常 content 返回,否则模型分不清。 - 返回里可带
details(结构化元数据,进会话/审计)、可带图片 content(视觉模型)。 - 返回值/钩子可带
terminate: true:提示「这批工具干完就停,别自动追问」。只有当同一批所有工具结果都带terminate时循环才提前停。
工具执行模式:默认 parallel(同一批工具并发执行,事件按完成顺序发,但落库的 toolResult 仍按模型原始顺序);sequential 逐个执行。可全局 toolExecution 或单工具 executionMode: 'sequential'(只要批里有一个工具要求串行,整批变串行)。
三个钩子(拦截点)
| 钩子 | 时机 | 常见用法 |
|---|---|---|
beforeToolCall | 参数校验后、真正执行前 | 白名单拦截:{ block: true, reason: 'bash is disabled', terminate: true } |
afterToolCall | 工具跑完后、发 tool_execution_end 前 | 给结果打审计标记 { details: { audited: true } } |
shouldStopAfterTurn | 一个 turn 正常结束后 | 判断上下文是否该压缩、该停了 |
Agent 类里 assistant message_end 是工具 preflight 前的一道屏障——所以 beforeToolCall 看到的 agent.state 已经包含了刚才那条要调工具的 assistant 消息。
6. 打断与追加:steer / followUp
跑长任务时你一定想要两种能力:
agent.steer({ role: 'user', content: '停!先别写文件,改成总结。', timestamp: Date.now() }); // 工具还在跑时
agent.followUp({ role: 'user', content: '顺便把结果摘要一下。', timestamp: Date.now() }); // 本轮跑完后
- steer(打断):
message_end检查到 steering 消息 → 等当前这批工具全部跑完 → 注入 → 下个 turn 响应; - followUp(追加):只有「没有更多工具调用且没有 steering」时才检查,有就把下个任务排队再跑一轮。
队列策略 steeringMode / followUpMode:'one-at-a-time'(默认,一次消费一条)或 'all';clearSteeringQueue() / clearFollowUpQueue() / clearAllQueues() 清队。
7. 会话与记忆:往哪存、能不能重启不丢
0.85 里「会话持久化」被抽成了独立包 @earendil-works/pi-session-backend-sqlite-node(Node 的 node:sqlite 实现),agent-core 本体不背原生 SQLite 依赖——你想让 agent 重启后从上次的 messages 接着聊,就接它;不要会话持久化时,默认全在内存里。
从
index.d.ts的根入口 re-export 能看出,agent-core 内部其实已经长出一整套 harness 子层:harness/session、harness/context、skills、上下文compact(token 估算/找切点)、search、telemetry等。README 目前主讲的Agent类只是它面向用户的正面;这块子层(很像在把 coding-agent 里验证过的能力往通用 harness 收敛)值得后续单独拆一篇。
8. 低层 API:agentLoop
不想用 Agent 类、想自己完全掌控循环?agent-loop 提供了低层可观测流:
import { agentLoop, agentLoopContinue } from '@earendil-works/pi-agent-core';
for await (const event of agentLoop([userMessage], context, config, undefined, models.streamSimple.bind(models))) {
console.log(event.type);
}
// agentLoopContinue(context, config, ...) 从现有上下文继续
根入口也暴露了 runAgentLoop / runAgentLoopContinue(非迭代器、回调式的变体)。注意低层流的定位是观测:它不保证你的 async 事件处理在后续阶段前完成——需要「处理完再继续」的屏障语义就用 Agent 类。
9. 网页/代理场景
浏览器里不想直连厂商(Key 不能暴露在浏览器)时,streamProxy 让 Agent 走后端代理:
const agent = new Agent({
streamFn: (model, context, options) =>
streamProxy(model, context, {
...options,
authToken: '...',
proxyUrl: 'https://your-server.com', // 后端再转发给 pi-ai / 厂商
}),
});
10. 在这层之上能长出什么(给拆 InkOS 埋伏笔)
Agent 给了你「多轮循环 + 事件 + 工具生命周期」,但它故意不替你决定:哪些动作要人确认、执行结果要不要先校验再落盘、状态怎么写才不会滚雪球。InkOS 正是在这里叠了自己的生产约束:
- 模型提议的重动作先弹确认闸门(
beforeToolCall之类的拦截点就是闸门的落点); - 执行权收在确定性工具里,以落盘为准,模型说「写完了」不算;
- 状态走「Reflector 输出 JSON delta → 代码校验 → immutable 落盘」而非模型直接写 markdown;
- 审计不通过就
terminate或进修订,而不是无限自动追问。
所以:pi-ai 决定你「能不能调对模型」,pi-agent-core 决定你「循环跑不跑得稳」,而确认闸门、状态机、原子落盘这些「agent 靠不靠谱」的事,是你要在它俩之上自己写的。 下一篇系列,我们正好用 InkOS 当样本,看这套约束具体怎么长出来。
关联
- 上一篇:pi-ai:统一几十家 LLM 的模型连接层
- pi 入门
- 上游应用:本仓库 InkOS 入门(其 harness 循环、确认闸门、原子落盘,本篇 §10 是读它的前置)
参考
- README:npmjs.com/package/@earendil-works/pi-agent-core(本文 API 以它为准)
- 会话后端:npmjs.com/package/@earendil-works/pi-session-backend-sqlite-node
- 事件序列与示例均为本机
fauxProvider实测输出 - 许可:MIT