跳到主要内容

创建日期:2026-09-07 | 最近更新:2026-09-07 事实核对基于 @earendil-works/pi-agent-core 0.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当前模型(运行时可直接换)
thinkingLeveloff/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: truetoolResult 报给模型——别把错误当正常 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/sessionharness/contextskills、上下文 compact(token 估算/找切点)、searchtelemetry 等。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 当样本,看这套约束具体怎么长出来。

关联

参考