pi-agent-core:Agent Runtime
TL;DR:pi-agent-core 是建在 pi-ai 之上的通用 Agent Runtime,一个进程内的库,不懂 coding、不懂会话、不碰文件。它分两层:无状态的
agentLoop负责"调模型 → 执行工具 → 决定要不要再调一次"的双层循环,有状态的Agent类负责 transcript、steering/follow-up 两个队列、事件订阅和 abort。内层循环跑工具调用和 steering(当前这批工具跑完再插话),外层循环只在 agent 本来要停时检查 follow-up。所有产品策略(持久化、压缩、权限、重试)都通过prepareRequest、transformContext、beforeToolCall、finishTurn等 hook 从上层注入,状态变化则通过事件流向外广播。
定位:一个不懂 coding 的 Agent Runtime
pi-ai 只能完成一次模型请求:给它模型、消息和工具声明,它返回一条流式的 AssistantMessage。真正的 agent 需要把多次请求和工具执行串起来:模型返回 tool call,执行工具,把结果写回上下文,再请求一次,直到模型不再调用工具。pi-agent-core 就是做这件事的那一层,它的本质是一个带状态管理、工具执行和消息队列的 Agent Runtime。
它的边界划得很窄,它不知道:
- 会话怎么存(JSONL、分支、恢复),那是 pi-coding-agent 的
SessionManager; - 系统提示词怎么拼(AGENTS.md、skills、工具说明),那是 coding-agent 的资源加载;
- 上下文快满了怎么办(compaction),那是 coding-agent 通过 hook 注入的;
- 哪些工具危险、要不要弹窗确认,那是扩展通过
beforeToolCall注入的; - 失败要不要重试,那是 coding-agent 在 run 结束后调
agent.continue()实现的。
用一句话概括它的态度:Agent 只管运行时状态,不管配置和策略。 它知道自己正在用哪个模型,但不知道为什么选这个模型;它知道有哪些工具可用,但不知道这些工具是怎么被发现和注册的。
这条边界在 1.0 被进一步收紧。0.8x 时代这个包里还有一个实验性的 AgentHarness(会话存储、durable runtime、harness 版 read/edit/bash 工具、compaction、skills、prompt templates 等),coding-agent 其实从没用过它,而是用 Agent + 自己的 AgentSession。1.0 把整个 harness 从包里删掉了,现在包里只剩四样东西:Agent 类、agent loop、proxy 流函数,以及它们的类型。需要持久会话的场景改用独立的 @earendil-works/pi-durable。
运行形态:它是一个普通的 TypeScript 库,跑在调用方进程内,没有服务、没有常驻进程、没有自己的 I/O。调用关系是单向的:
pi-coding-agent 的 AgentSession(或任何应用)
│ new Agent({ streamFn, convertToLlm, hooks... }),agent.prompt() / steer() / followUp()
▼
Agent(agent.ts,有状态外壳)
│ 每次 run:快照 + 配置 → runAgentLoop(..., emit)
▼
agentLoop(agent-loop.ts,无状态引擎)
│ 每次请求:streamFn(model, context, options)
▼
调用方注入的 StreamFn(通常是 pi-ai 的 models.streamSimple,或 streamProxy)
注意 streamFn 是必须由调用方注入的。agent-core 不依赖任何 provider 目录,这样只用一个 provider 的应用打包时不会把所有 provider 都带进来;如果嫌每次传麻烦,可以用 setDefaultStreamFn() 注册一个进程级默认值。
一句话:pi-ai 是"一次请求",pi-agent-core 是"多轮循环 + 工具执行 + 生命周期事件",产品能力全在它之上。
两层结构:无状态引擎 + 有状态外壳
无状态引擎 agentLoop:循环应该知道尽可能少的东西
一个直觉上的答案是循环引擎应该知道得越多越好:怎么管理会话、怎么重试、怎么压缩、怎么持久化,毕竟这些都是"循环过程中"会遇到的问题。Pi 给出了一个反直觉的答案:循环引擎应该知道尽可能少的东西。 它只管把消息送进 LLM、把响应拿回来、有工具调用就执行、然后决定要不要继续。它不知道消息从哪来、要存到哪去、哪些工具该被允许、上下文是不是快溢出了。给它 context + config,它产出一串事件,最终返回本次 run 新增的消息 newMessages。
- 代价:会话持久化、错误重试、上下文压缩、UI 渲染全都得由上层实现。
- 收益:同一个循环可以被终端 CLI、Slack bot、Web UI、测试用例随意组合,只要给不同配置。把循环做薄,是为了让上层做厚时有足够的自由度。
低层 API 有两个入口:agentLoop(prompts, context, config, signal, streamFn) 带新消息启动,agentLoopContinue(context, config, signal, streamFn) 从已有上下文继续(最后一条必须是 user 或 toolResult,不能是 assistant,否则 provider 会拒绝)。它们立即返回一个 EventStream,可以 for await 消费。要注意这个流是观察性的:事件顺序有保证,但循环不会等你处理完事件再往下走。
有状态外壳 Agent
一个真正可用的 agent 还需要:记住对话历史、通知多个订阅者、接收运行中用户发来的消息、能被中断、防止同时跑两次。Agent 类就是在循环之上加的这层外壳:
- 状态:
agent.state里有model、thinkingLevel、tools、messages,以及只读的isStreaming、streamingMessage(正在流式生成的那条 assistant 消息)、pendingToolCalls、errorMessage。 - 队列:
steer()和followUp()各自往一个队列里塞消息,立即返回。 - 订阅:
subscribe(listener)注册监听器,返回取消函数。 - 控制:
prompt()/continue()启动一次 run;abort()中止;waitForIdle()等 run 完全结束;reset()清空对话和队列。 - 互斥:同一时刻只能有一个 run。run 进行中再调
prompt()会直接抛错,提示你用steer()或followUp()。
每次 prompt() 内部都是三步:
Agent.prompt(text)
│
├─ ① 打包:createContextSnapshot() → { messages, tools } 的浅拷贝,用完即弃
│ createLoopConfig() → 把 Agent 上的字段和 hook 映射成 AgentLoopConfig
│ getSteeringMessages = () => steeringQueue.drain()
│ getFollowUpMessages = () => followUpQueue.drain()
├─ ② 执行:runAgentLoop(messages, snapshot, config, emit = processEvents, signal)
│
└─ ③ 归约:processEvents(event)
先更新 _state(message_end 时把消息 push 进 state.messages,
tool_execution_start/end 时增删 pendingToolCalls……)
再按注册顺序逐个 await 每个 listener
这里有两个值得一提的设计。第一,Agent 的 transcript 是由事件归约出来的:所有状态修改都走 processEvents() 这一个入口,循环本身从不碰 Agent 的状态。第二,listener 是被 await 的:Agent 把 assistant 的 message_end 当作屏障,listener 处理完之后才开始工具预检,所以 beforeToolCall 看到的状态里一定已经有了发起这次调用的 assistant 消息;agent_end 的 listener 也算 run 的一部分,prompt() 的 promise 和 waitForIdle() 要等它们都结束才 resolve。这就是想要"事件处理当屏障"时应该用 Agent 而不是裸 agentLoop 的原因。
数据最终在哪:纯用 agent-core 时,transcript 只在内存里的 agent.state.messages,进程退出就没了。在 coding-agent 里,AgentSession 订阅 message_end 把每条消息追加到 JSONL 会话文件,又在 prepareRequest 里每次请求前从会话文件重建上下文。所以对 coding-agent 来说,会话文件才是权威数据,agent.state.messages 只是运行时的工作副本。
一句话:
agentLoop是纯计算,Agent是状态管理;状态只通过事件归约来变,策略只通过 hook 注入。
Turn、Run 和 Session
理解双层循环之前,先分清三个粒度:
- Turn:一次 LLM 请求,加上这次响应里所有工具调用的执行。事件上就是一对
turn_start/turn_end,turn_end里带着这条 assistant 消息和它的全部 toolResults。 - Run:一次
agent.prompt()(或continue())启动的完整运行,从agent_start到agent_end。一个 run 包含若干 turn,中途注入的 steering 和 follow-up 都还在同一个 run 里。 - Session:跨越多次用户输入的长期会话,包含多个 run。agent-core 没有 Session 这个概念,持久化、恢复、分支都是 pi-coding-agent 的事。
Session(coding-agent 管)
├── Run 1:agent.prompt("修好失败的测试")
│ ├── Turn 1:LLM → read, read
│ ├── Turn 2:LLM → edit
│ ├── Turn 3:LLM → bash
│ └── Turn 4:LLM → 最终回答
└── Run 2:agent.prompt(...)
因此 Agent.prompt() 不等于一次 pi-ai.stream():前者是"加入输入消息 + 零到多次 LLM 请求 + 零到多次工具执行 + 处理 steering/follow-up + 更新状态"。
双层循环:内层跑工具和 steering,外层接 follow-up
为什么要分两层
用户和 agent 交互时有两种插话意图,语义和消费时机都不同:
- Steering(转向):agent 正在干活时插一条指令,比如"别改公共 API,只改内部实现"。它要在当前这批工具执行完之后注入,影响下一次 LLM 调用。
- Follow-up(追加):给 agent 追加一个后续任务,比如"做完以后顺便更新 CHANGELOG"。它只在 agent 本来要停下时才被消费,不打断手头的活。
如果只有一层循环,就区分不了"agent 还在干活时插入的指令"和"agent 干完活后追加的任务"。所以 Pi 把循环拆成两层:内层负责"持续工作",外层负责"被唤醒"。
伪代码
下面是 1.5 的 runLoop 去掉细节后的骨架:
async function runLoop(context, config) {
let pending = await config.getSteeringMessages() // 启动时先看一眼:用户可能在等待时就打了字
while (true) { // 外层:只负责 follow-up
let hasMoreToolCalls = true
while (hasMoreToolCalls || pending.length > 0) { // 内层:工具调用链 + steering
if (!isFirstTurn) {
await config.prepareNextTurn?.(lastTurn) // 可换 context/model,coding-agent 在这里做压缩
emit("turn_start")
}
appendAndEmit(pending); pending = [] // steering / follow-up 都从这里进上下文
await config.prepareRequest?.(context) // 每次请求前:换上权威上下文
const msg = await streamAssistantResponse(context) // transformContext → convertToLlm → streamFn
if (msg.stopReason === "error" || msg.stopReason === "aborted") {
await config.finishTurn?.(...); emit("turn_end"); emit("agent_end")
return // 硬退出,不看任何队列
}
const results = await executeToolCalls(msg) // beforeToolCall / afterToolCall 在这里
hasMoreToolCalls = msg.hasToolCalls && !allTerminate(results)
const decision = await config.finishTurn?.({ msg, results })
emit("turn_end")
if (decision?.action === "end") { emit("agent_end"); return }
pending = await config.getSteeringMessages() // 当前批工具全部跑完,才看 steering
}
pending = await config.getFollowUpMessages() // 本来要停了,才看 follow-up
if (pending.length === 0) break // (finishTurn 返回 continue 时还会再补一轮)
}
emit("agent_end")
}
几个容易讲错的点:
- steering 和 follow-up 共用同一个注入通道
pending。外层循环唯一的动作就是把 follow-up 赋给pending然后重新进入内层。两种消息走同一条路进上下文,区别只在什么时候被拉取。 - steering 不会打断正在进行的工作。源码的契约是"当前 assistant 消息里的工具调用不会被跳过"。模型一次发了三个工具调用,用户中途 steer,三个工具照样跑完,steering 在下一次请求前进入上下文。所以别把 steering 说成"立刻打断",它是在定义好的安全边界插入的。真要立刻停,用
abort()。 - follow-up 不是递归调用
prompt()。语义上它像"下一次 prompt",实现上它仍属于当前这个 run:循环继续,而不是prompt()再调用自己。好处是 follow-up 和之前的工作共享同一个 run 的生命周期、同一个 abort signal,agent_end只发一次。 - 错误靠
stopReason传递,不靠异常。pi-ai 的StreamFn契约要求失败编码进流里,最终消息的stopReason是"error"或"aborted"。循环只看stopReason,不需要 try-catch。
队列模式
Agent 的两个队列各有一个模式,steeringMode 和 followUpMode,默认都是 "one-at-a-time":
"one-at-a-time":每个拉取点只取最老的一条,其余留到下一个拉取点。用户连发三条 steering,模型会分三次请求逐条看到。"all":一次把队列里所有消息都取出来注入。
默认逐条的理由是让模型每次只面对一条新指令,避免几条互相矛盾的插话被一起塞进去。coding-agent 把这两个模式做成了用户设置。另外 peekQueuedMessages() 可以按当前模式预览下一批会被注入的消息而不消费,clearSteeringQueue() / clearFollowUpQueue() / clearAllQueues() 用来丢弃排队消息(coding-agent 在用户 abort 时把排队消息清出来还给编辑器)。
continue() 与队列
continue() 用于"不加新消息、从现有上下文接着跑",典型用途是重试:coding-agent 在 run 因可重试错误结束后,把失败的那次回复留在原始历史里、但从发给模型的上下文投影中排除,等退避时间过后再调 continue()。它的规则是:
- 末尾是 user 或 toolResult:直接从当前上下文再请求一次。
- 末尾是 assistant(provider 不接受从 assistant 继续):退而取一批排队的 steering,没有就取一批 follow-up,当作新输入开一个 run;两个队列都空则抛错。
一句话:内层循环条件是"还有工具调用或有待注入消息",steering 在每个 turn 结束时拉取;外层只在内层自然结束后拉取 follow-up,有就重进内层。
一次真实任务:「把失败的测试修好」
下面沿一次真实任务走一遍,看每一步谁在做什么。场景:在 coding-agent 里,用户输入"把失败的测试修好";模型先读文件、再改代码、再跑测试;改代码时用户按 Enter 插了一条 steering,跑测试时又按 Alt+Enter 追加了一条 follow-up。
启动 run。 AgentSession 调 agent.prompt("把失败的测试修好")。Agent 检查没有活跃 run,创建 AbortController,把 isStreaming 置为 true,打包快照和配置,进入 runAgentLoop。循环发出 agent_start、turn_start,再为用户消息发出 message_start / message_end(Agent 在 message_end 时把它 push 进 state.messages,AgentSession 在同一个事件上把它追加到会话文件)。然后先拉一次 steering 队列,此时是空的。
Turn 1:读文件(并行)。 请求前依次经过三道处理:prepareRequest(coding-agent 在这里从会话文件重建上下文,如果用的是虚拟模型还会做路由)、transformContext(转给扩展的 context 事件,扩展可以临时改写这次请求能看到的消息)、convertToLlm(把 AgentMessage[] 转成 LLM 认识的 Message[],过滤掉 UI 专用的自定义消息)。然后调 streamFn,pi-ai 的流式事件被翻译成 message_start → 一串 message_update → message_end,UI 靠 message_update 里的 text_delta 实时渲染。模型返回两个工具调用:read("tests/foo.test.ts") 和 read("src/foo.ts")。
默认是并行模式:两个调用依次预检(找工具、prepareArguments 兼容旧参数、按 JSON Schema 校验、beforeToolCall),然后并发执行。tool_execution_end 按完成先后发出,但 toolResult 消息按模型给出的原始顺序写入上下文。工具结果都写回后,finishTurn 运行(coding-agent 在这里分发扩展的 turn_end 边界),然后发出 turn_end。因为有工具调用,内层继续。
Turn 2:改代码,用户中途 steer。 下一轮开始前先调 prepareNextTurn:coding-agent 在这里检查上下文是否超过压缩阈值,超了就先压缩,同时刷新系统提示词和工具清单。然后 turn_start、请求、模型返回 edit("src/foo.ts", ...)。edit 执行期间,用户在 TUI 里按 Enter 输入"别改 foo 的公共签名,只改内部实现"。因为 agent 正在跑,这条消息走 agent.steer() 进 steering 队列,立刻返回,正在执行的 edit 不受影响。edit 跑完,finishTurn、turn_end,然后循环拉取 steering 队列,拿到了这条消息。
Turn 3:带着 steering 跑测试。 prepareNextTurn 后发出 turn_start,steering 消息作为 user 消息发出 message_start / message_end 进入上下文。模型看到新指令,确认刚才的改动只动了内部实现,于是调 bash("npm test")。beforeToolCall 这时可能拦下它:如果装了权限扩展,扩展会弹窗问用户,用户拒绝就返回 { block: true, reason },工具不执行,模型收到一条错误 toolResult 和原因。这里用户同意了,bash 执行时通过 onUpdate 持续产生 tool_execution_update,UI 实时滚动测试输出。测试跑着的时候,用户又按 Alt+Enter 输入"顺便更新一下 CHANGELOG",这条消息进 follow-up 队列。bash 结束,afterToolCall 可以改写结果(比如截断超长输出或脱敏),然后 tool_execution_end、toolResult 消息、finishTurn、turn_end。steering 队列为空,但有工具调用,内层继续。
Turn 4:模型宣布完成。 模型回复"测试已全部通过",没有工具调用。turn_end 之后 steering 队列为空,hasMoreToolCalls 为 false,内层循环退出。外层拉取 follow-up 队列,拿到"顺便更新一下 CHANGELOG",赋给 pending 后重新进入内层。
Turn 5–6:处理 follow-up。 follow-up 消息进入上下文,模型调 edit("CHANGELOG.md"),再下一轮给出总结、没有工具调用。内层退出,外层再拉 follow-up,空了,break,发出 agent_end。
收尾。 Agent 逐个 await agent_end 的 listener,然后 finishRun() 清掉 isStreaming、pendingToolCalls、activeRun,prompt() 的 promise 才 resolve。之后才轮到 coding-agent 自己的 run 后处理:检查要不要重试、要不要因上下文溢出而压缩,需要的话再调 agent.continue() 开一个新 run。本场景一切顺利,整个过程是一个 run、六个 turn,steering 和 follow-up 都没有开新的 run。
工具执行
工具执行是循环里规矩最多的部分。每个工具调用都走同一条三段式管道:
tool_execution_start
→ 预检:找工具(找不到 → 错误结果)
→ prepareArguments(可选,兼容旧参数格式)
→ 按 parameters 的 JSON Schema 校验参数(失败 → 错误结果)
→ beforeToolCall(可 block)
→ 执行:tool.execute(id, args, signal, onUpdate),onUpdate → tool_execution_update
→ 收尾:afterToolCall(可改写结果)
tool_execution_end → toolResult 消息的 message_start / message_end
并行和串行。 默认 toolExecution: "parallel":一批调用先串行预检,再并发执行允许的那些。串行预检保证权限检查、用户确认这种有副作用的判断按确定顺序发生(不会同时弹两个确认框),并发执行减少多工具调用的等待。tool_execution_end 按完成顺序发出,方便 UI 尽早更新;toolResult 消息按 assistant 消息里的原始顺序写入,保证上下文稳定。也可以设成 "sequential" 一个一个跑。单个工具还能声明 executionMode: "sequential",只要一批里有一个这样的工具,整批都串行。并行模式的代价是同一批调用之间不能有依赖(比如"先读再改"),Pi 的做法是让模型自己把有依赖的操作放在不同 turn 里。
错误都变成可恢复的对话。 工具找不到、参数校验失败、beforeToolCall 拦截、execute() 抛异常、afterToolCall 抛异常,全都被转成 isError: true 的 toolResult 返回给模型,模型下一轮可以修正参数重试。循环不会因为工具出错而崩溃。工具作者的约定是失败时抛异常(或返回 isError: true),而不是把错误写进正常 content 里假装成功。
输出被截断时不执行。 如果 assistant 消息因为输出 token 上限被截断(stopReason === "length"),其中的工具调用参数可能被尽力修补成"能解析但不完整"的 JSON。循环不会执行它们,而是全部返回错误,提示模型用完整参数重新发起。
beforeToolCall 和 afterToolCall。 beforeToolCall 在参数校验之后、执行之前运行,拿到的是校验过的参数和当前上下文,返回 { block: true, reason } 就阻止执行,适合权限检查、安全策略、用户确认、工作区边界检查。afterToolCall 在执行之后、tool_execution_end 之前运行,可以按字段替换 content、details、isError 等(逐字段覆盖,不做深合并),适合脱敏、审计、结果增强。两个 hook 都会拿到 abort signal,需要自己响应中止。coding-agent 把它们接到扩展系统的 tool_call / tool_result 事件上,权限弹窗就是这样实现的:agent-core 本身没有任何权限概念。
content 给模型,details 给 UI。 工具结果分两部分:content 是发给模型的文本/图片,details 是任意结构化数据,只给日志和 UI 渲染用(比如 edit 工具的 diff)。这样 LLM 只看到它需要理解的内容,UI 也不用从模型输出里二次解析数据。
terminate:让工具决定"不用再问模型了"。 工具结果(或被 block 的结果、afterToolCall 的覆盖)可以带 terminate: true,提示这批工具跑完就别再自动请求模型了。只有当一批里所有结果都是 terminate 时才生效,混合批次照常继续。这个字段只在 agent 层闭环,不会出现在发给 provider 的 toolResult 消息里。
runToolCall():嵌套调用也要过 hook。 有些工具会调用别的工具,比如 codemode 让模型写一段 JS 去调 agent 的工具。如果这些内部调用绕过管道,权限检查就形同虚设。所以 agent-loop 导出了 runToolCall():它把一次调用走完同样的参数准备、校验、beforeToolCall、执行、afterToolCall,只是不发事件、不写消息。
一句话:串行预检保证安全决策有确定顺序,并发执行保证速度;所有失败都变成模型能看到的错误结果,而不是让循环崩溃。
控制 Hook:上层注入策略的入口(1.5)
agent-core 自己不做决策,它在循环的固定位置留了一组 hook(都在 AgentLoopConfig 上,只有 convertToLlm 必填),由上层填入策略;另有从 pi-ai 透传的 onPayload 等回调用来观察或改写原始 provider 请求/响应。用 Agent 时,两个队列 hook 由 Agent 自己用内部队列实现,不对外暴露。
先看它们在一个 provider turn 里的触发顺序:
[非首轮] prepareNextTurn → (若还没取到消息则补拉一次 steering)→ turn_start
注入待处理消息(steering / follow-up / prepareNextTurn 追加的消息)
prepareRequest
→ transformContext → convertToLlm → getApiKey → streamFn (streamAssistantResponse 内部)
工具:预检(含 beforeToolCall)→ 执行 → afterToolCall
finishTurn
turn_end
→ finishTurn 返回 end:agent_end
→ 否则拉 steering;内层结束后拉 follow-up;都没有则 agent_end
逐个说明它们解决什么问题:
convertToLlm:消息边界的翻译。 agent 内部的消息类型是 AgentMessage,它是 LLM 消息(system / user / assistant / toolResult)加上应用通过声明合并扩展的自定义消息。LLM 只认识前者,所以每次请求前必须转换:自定义消息要么转成 user 消息,要么过滤掉。Agent 的默认实现只保留四种标准角色。coding-agent 有自己的版本,把 bash 执行记录、压缩摘要、分支摘要等自定义消息转成模型能读的 user 消息。
transformContext:改写这一次请求的视图。 它在 convertToLlm 之前运行,输入输出都是 AgentMessage[],适合裁剪旧消息、注入检索结果、临时隐藏某些内容。它处理的是"本次 LLM 请求看到的视图",不改 agent.state.messages,所以可以出现"会话原始历史 ≠ 本次发给模型的消息"。coding-agent 把它接到扩展的 context 事件上。
prepareRequest:每次请求前装上权威上下文(新增)。 它在每一次 provider 请求前运行,包括第一次,此时待注入的消息已经发出事件。它可以替换 context、模型、thinking level,替换后对本次及后续请求生效。它解决的问题是:循环内存里那份 context 可能和持久层不一致(比如中途发生了压缩,或扩展追加了条目),所以 coding-agent 在这里每次都从会话文件重建 messages,虚拟模型的路由也在这里做。它不拉队列,运行期间新来的 steering 等下一个正常拉取点。
prepareNextTurn:下一轮开始前刷新运行快照。 只在循环确定要继续时,在下一个 turn_start 之前运行,可以替换 context / model / thinking level,或追加消息。coding-agent 在这里做阈值压缩、重建系统提示词和工具清单(工具变化会被写成 system 消息追加进去)。因为压缩可能耗时较长,它跑完后循环会补拉一次 steering,接住期间用户打的字。和 prepareRequest 的区别是:prepareNextTurn 是"turn 之间"的钩子,首轮不运行,可以追加消息;prepareRequest 是"每次请求前"的钩子,首轮也运行,不能追加消息。
finishTurn:一轮结束时决定是否继续(取代 shouldStopAfterTurn)。 它在 assistant 消息和所有工具结果都定稿之后、turn_end 之前运行,返回三种结果之一:undefined 保持正常调度;{ action: "end" } 在 turn_end 之后立即结束 run,不拉队列、不再准备请求;{ action: "continue" } 保证至少再有一次请求,如果工具结果、steering 或 follow-up 本来就会触发下一次请求,就不额外加;否则循环会用当前上下文多请求一次。这是对旧 shouldStopAfterTurn 的泛化:旧 hook 只能"优雅停止",新 hook 还能要求"再来一轮"(coding-agent 的扩展可以在 turn_end 边界上请求继续,就是靠它)。还有两个差异要注意:finishTurn 对 error / aborted 的响应也会运行,但这时它的返回值被忽略,因为那两种情况是硬退出;另外,无条件返回 continue 会造成死循环。
getSteeringMessages / getFollowUpMessages:两个队列的拉取点。 契约是不能抛异常,没有消息就返回空数组。steering 在 run 开始时、每个 turn 结束时(除非 finishTurn 选了 end)、以及 prepareNextTurn 之后(如果之前没拉到)被拉取;follow-up 只在内层循环自然结束时被拉取。
getApiKey:每次请求时动态取 key。 对 GitHub Copilot 这类短期 OAuth token,一次长工具执行期间 token 可能就过期了,所以 key 在每次请求时解析,而不是在构造时固定。
beforeToolCall / afterToolCall:见上一节。
这些 hook 的共同契约是"不要抛异常,返回安全的兜底值"。convertToLlm、transformContext 一旦抛出,低层循环会直接中断,不会产生正常的事件序列;这时只能靠 Agent 的兜底(见后文"中止与错误")。
一句话:
prepareRequest管"这次请求用什么上下文",transformContext/convertToLlm管"这次请求怎么呈现给模型",prepareNextTurn管"下一轮开始前刷新什么",finishTurn管"这一轮之后停还是继续",工具 hook 管"这次工具能不能跑、结果怎么改"。
事件流:为什么事件和 Hook 分开
循环向外发出一串 AgentEvent,分三层生命周期:
agent_start run 开始
turn_start 一次 LLM 请求 + 它的工具执行
message_start / message_end 每条消息(system / user / assistant / toolResult)
message_update 仅 assistant 流式生成时,带 pi-ai 的原始增量事件
tool_execution_start / _update / _end 每个工具调用
turn_end { message, toolResults }
agent_end { messages } run 结束(携带本次 run 新增的所有消息)
message_start / message_end 对所有角色都会发,所以 UI 和持久化只需要监听 message_end 就能拿到每一条定稿的消息,不用关心它是用户输入、模型回复还是工具结果。message_update 只给 assistant,tool_execution_end 带 durationMs(execute() 本身耗时,不含 hook)。
事件和 hook 是两个方向的东西。 事件是"循环 → 外界",告诉外界发生了什么,用来刷新 UI、写日志、落盘;hook 是"外界 → 循环",在固定位置向循环要一个决定,返回值会改变循环的行为。订阅 message_end 不会让你阻止一个工具调用,beforeToolCall 返回 block 才可以;listener 的返回值被忽略,hook 的返回值就是决策本身。
为什么不把两者合成一个机制?因为它们的要求不同:
- 决策必须有唯一答案、在确定的时刻给出。 一个工具要不要执行,循环得在执行前拿到明确结果。如果靠"广播事件、看谁回应",多个监听者回答冲突时听谁的?没人回答时等多久?hook 是循环配置上的单个函数,答案唯一。
- 观察可以有任意多个,彼此互不干扰。 UI、日志、会话持久化、扩展都可以同时订阅事件。
- 分开以后边界清楚。 要改变行为就去找 hook,要知道发生了什么就订阅事件,不存在"某个 listener 偷偷改了循环行为"的情况。
要注意一个细节:在 Agent 层,listener 虽然不能改变决策,但它们是被 await 的,一个慢 listener 会拖慢整个循环(这也是 message_end 能当屏障的原因)。裸 agentLoop 的事件流则完全是观察性的,不会等消费者。
中止与错误
abort 是协作式的。 agent.abort() 只做一件事:触发当前 run 的 AbortController。这个 signal 被传给 streamFn、每个工具的 execute()、beforeToolCall / afterToolCall、prepareRequest 和 finishTurn,各方自己负责响应:
- 正在流式请求时:pi-ai 的 provider 停止请求,返回一条
stopReason: "aborted"的 assistant 消息,循环走硬退出路径(finishTurn→turn_end→agent_end)。 - 正在执行工具时:工具应该响应 signal 尽快结束;串行模式下跑完当前工具就不再跑后续的;并行模式下还没开始执行的调用直接得到 "Operation aborted" 错误结果。这批结果写回后,下一次请求会立即以 aborted 结束。
- 队列不会被清空:
Agent.abort()不碰 steering / follow-up 队列。coding-agent 在 abort 时自己把排队消息清出来还给编辑器,让用户决定要不要重发。
错误分三层兜底:
- 模型请求失败:
StreamFn的契约是不抛异常,把失败编码进流里,最终消息的stopReason是"error"并带errorMessage。循环把它当作一个正常结束的 turn 处理,然后硬退出。Agent在turn_end时把errorMessage记到state.errorMessage。agent-core 本身不重试,重试是 coding-agent 在 run 结束后判断错误是否可重试(过载、限流、服务端错误)、指数退避、再调continue()实现的。 - 工具失败:全部变成错误 toolResult 交给模型,循环继续(见"工具执行")。
- 意外异常:如果某个违反契约的 hook 抛出异常,
Agent会 catch 住,补发一条最小的失败事件序列:一条空的 assistant 消息(stopReason为"error",如果已 abort 则为"aborted"),加上message_start/message_end/turn_end/agent_end,保证订阅者总能看到 run 正常收尾,isStreaming也一定会复位。已经通过message_end写进state.messages的内容不会回滚。
与 pi-ai 的边界
agent-core 和 pi-ai 之间只有一个连接点:StreamFn。它的形状和 pi-ai 的 models.streamSimple 一致,(model, context, options) → AssistantMessageEventStream。agent-core 在调用前把上下文经 transformContext、convertToLlm 转成 pi-ai 认识的形状,把 API key、请求身份、abort signal 合进 options,然后消费返回的流,把 pi-ai 的流式事件翻译成 message_* 事件。除此之外,它还用 pi-ai 的 validateToolArguments() 校验工具参数,复用 pi-ai 的消息类型,AgentTool 也只是在 pi-ai 的 Tool(name、description、parameters)上加了 execute 等运行时字段。
系统提示词和工具声明住在 transcript 里(1.x 的变化)。 旧版本的 context 是 { systemPrompt, messages, tools } 三件套;1.5 里系统提示词和"模型可以调用哪些工具"都由 transcript 中的 system 消息承载:开头的 system 消息就是提示词,后面的 system 消息可以追加内容或按名字替换段落。agent.state.systemPrompt 变成只读,是从 transcript 回放出来的。agent.state.tools 是"能执行的工具",每次请求前循环会把它和 transcript 里声明的工具做 diff,有差异就写一条带 toolsAdded / toolsRemoved 的 system 消息告诉模型。这样做的好处是提示词和工具清单的变化本身也是历史的一部分,回放 transcript 就能还原模型在任意时刻看到的东西。
proxy:浏览器里不放 key。 streamProxy() 是一个现成的 StreamFn 实现:它把请求 POST 到你自己的服务端 /api/stream,用 Bearer token 认证,服务端持有真正的 provider key 并代发请求,回传去掉了 partial 字段的精简事件以节省带宽,客户端在本地重新拼出完整的 AssistantMessage。对 agent 来说它和直连没有区别,换一个 streamFn 就行,这也再次说明了把 StreamFn 做成注入点的价值。
常见追问
为什么默认是并行执行工具,而不是更安全的串行? 模型一次发出多个调用时,绝大多数是互不依赖的读操作,并行能明显缩短等待;真正有顺序要求的工具可以声明 executionMode: "sequential" 把整批降级为串行,而预检本身始终是串行的,安全决策不受影响。
steering 和 abort 都能"打断",怎么选? steering 不丢弃任何已经开始的工作,只改变下一次请求的输入,适合"方向不对,调整一下";abort 会让进行中的请求和工具尽快停止,适合"完全不要做了"。前者 run 继续,后者 run 结束。
既然有 prepareRequest,为什么还要 transformContext? 职责不同:prepareRequest 改的是循环持有的 context 本身(后续请求也用它),并且能换模型;transformContext 只产出本次请求的一个临时视图,不影响后续。coding-agent 用前者对齐持久层,用后者给扩展一个改写视图的机会。
为什么 agent-core 不自带重试和压缩? 重试要判断哪些错误可重试、等多久、要不要换模型,压缩要知道消息怎么持久化、摘要存在哪,这些都是产品策略。放进循环会让循环依赖会话存储和设置系统;留在上层,循环才能在 CLI、Web、测试里原样复用。
为什么 Agent 只允许单个 run? transcript 只有一份,两个 run 同时往里追加消息会让顺序和工具结果的对应关系失去意义。并发输入的需求由两个队列来满足:运行中的输入一律排队,由循环在安全点取用。
源码入口(v1.5):packages/agent/src/agent-loop.ts、packages/agent/src/agent.ts、packages/agent/src/types.ts、packages/agent/src/proxy.ts、packages/agent/README.md、packages/coding-agent/src/core/agent-session.ts