pi 的 Agent 循环与回合执行器

pi 的 runLoop 一轮怎么调模型、执行工具、停下来;agent-core 的 PiTurnRunner 与 agent-runtime 又怎样把它包成 MiniMax 运行时里的一个回合。

作者 David更新于 第 6 篇(共 33 篇)

MiniMax Code 没有自己写 Agent 循环。每次“用户发一条消息、模型思考、调几个工具、再回答”,底层跑的都是 pi-agent-core 的 runLoop。MiniMax 在它外面包了两层:packages/agent-core 的 PiTurnRunner 把一次 pi 运行变成运行时里的一个回合,负责钩子、事件翻译与收尾;packages/agent-runtime 在每个回合开始前,把各扩展贡献的工具、提示词和钩子组装好交给它。

先统一术语。pi 把一次 turn_start 到 turn_end 定义为一个 assistant 响应加上它的工具调用与结果(third_party/pi-mono/packages/agent/src/types.ts:420);MiniMax 管这个单位叫 step,而把一次完整的 Agent 运行叫 turn,packages/agent-core/src/pi-turn-runner/hooks.ts:240 的注释专门交代了这层对应。下文“回合”指 MiniMax 的回合,“一步”指 pi 的 turn。

怎么用

循环本身几乎没有直接的开关,用户能碰到的是这些:

场景入口在循环里的落点
任务运行中按 Enter 发消息README.md:138作为 steering,在下一步请求模型之前注入
Alt+Enter 排队后续消息README.md:139交给上层回合系统排队,不走 pi 的 follow-up 队列
Esc 中断README.md:144转成 agent.abort(),尚未执行的工具调用补一条错误结果
单次模型请求超时LLM_REQUEST_TIMEOUT_MS,20 分钟packages/agent-core/src/pi-turn-runner/defaults.ts:21
传输层重试默认最多重试 5 次,退避基准 1 秒、单次上限 30 秒(带抖动),自首次失败起累计不超过 120 秒;只在模型开始可见输出之前重试packages/agent-core/src/pi-turn-runner/llm-retry.ts:43、:458
bash 子进程环境清洗环境变量 MAVIS_BASH_ENV_SANITIZE 取 off、scrub、strictpackages/agent-core/src/bash-subprocess-env.ts:167

第一方代码里 agent.followUp 只在 after-LLM 钩子的 retry 决策里用到一次(packages/agent-core/src/pi-turn-runner/events.ts:128),用户排队的消息走的是回合系统。

pi 的循环:一步怎么走

核心在 third_party/pi-mono/packages/agent/src/agent-loop.ts:155 的 runLoop:外层循环处理 follow-up,内层循环只要“还有工具要继续或有待注入的消息”就一直跑。每一步的主体是这段(agent-loop.ts:193):

const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);
newMessages.push(message);

if (message.stopReason === "error" || message.stopReason === "aborted") {
	await emit({ type: "turn_end", message, toolResults: [] });
	await emit({ type: "agent_end", messages: newMessages });
	return;
}

// Check for tool calls
const toolCalls = message.content.filter((c) => c.type === "toolCall");

const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
	const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);
	toolResults.push(...executedToolBatch.messages);
	hasMoreToolCalls = !executedToolBatch.terminate;

	for (const result of toolResults) {
		currentContext.messages.push(result);
		newMessages.push(result);
	}
}

调模型。streamAssistantResponse(agent-loop.ts:279)先用 transformContext 在 AgentMessage 层面改写上下文,再用 convertToLlm 转成 pi-ai 能懂的消息,拼上系统提示词与工具表,交给 streamFn(缺省为 streamSimple)。API Key 每次调用都经 getApiKey 重新取一遍,方便短期 token 在长时间的工具执行后刷新。流的第一个事件就把未完成的 assistant 消息推进上下文,之后每个增量事件都替换它,并向外发 message_update。

执行工具。executeToolCalls(agent-loop.ts:377)只要本批里有一个工具声明了 executionMode: "sequential",整批就顺序执行;否则先逐个做准备(找工具、校验参数、跑 beforeToolCall),再用 Promise.all 并发执行,结束事件按完成先后发出,而写回上下文的 toolResult 消息仍按模型给出的顺序(agent-loop.ts:574)。工具不存在、参数校验失败、被钩子拦下、执行抛错,都会变成一条 isError 的工具结果,保证每个工具调用都有配对结果。

停下来。循环的出口有这几个:

条件结果位置
模型响应 stopReason 为 error 或 aborted立即结束agent-loop.ts:196
shouldStopAfterTurn 返回真这一步收尾后结束,不再轮询 steeringagent-loop.ts:241
轮询 steering 之后 shouldStopAfterSteering 返回真(MiniMax 补丁)结束,不发下一次请求agent-loop.ts:254
本批结果都带 terminate,或有钩子返回 terminateAgent不再为这批工具结果请求模型,没有待注入消息就结束agent-loop.ts:663
没有工具要继续、没有 steering、也没有 follow-up自然结束agent-loop.ts:261

顺序执行时,一旦某个钩子返回 terminateAgent,同批剩下的调用直接补一条“被钩子停止”的错误结果,不再执行(agent-loop.ts:452)。

third_party/pi-mono/packages/agent/src/agent.ts:174 的 Agent 类是这个无状态循环的有状态外壳:持有 transcript、工具表与模型,提供 prompt()、continue()、steer()、steerBatch()、followUp() 和 abort();两个待注入队列默认每次只取一条(agent/src/agent.ts:226),工具默认并行(agent/src/agent.ts:232)。订阅者按注册顺序被 await,agent_end 的监听者全部返回后运行才算结束(agent/src/agent.ts:575)。运行中抛出的异常由 handleRunFailure(agent/src/agent.ts:498)合成一条失败的 assistant 消息,照常发完 message_end、turn_end、agent_end。

图表加载中…

pi 自己怎么用这个循环

pi 的命令行产品在 Agent 外面又包了一层 AgentSession(third_party/pi-mono/packages/coding-agent/src/core/agent-session.ts:256):订阅事件写 JSONL 会话、派发扩展事件;_runAgentPrompt(agent-session.ts:936)在 agent.prompt() 返回后循环调用 _handlePostAgentRun,出错可重试就重试,需要压缩就压缩,还有排队消息就 agent.continue()。

MiniMax 完全没有用这层外壳:会话存 SQLite、重试放进流函数、压缩改成 before-LLM 钩子、续跑由宿主判定,这些都由下面的 PiTurnRunner 与它的调用方承担。

PiTurnRunner:一个回合的八个步骤

packages/agent-core/src/pi-turn-runner/pi-turn-runner.ts:83 的 PiTurnRunner 只持有进程级默认值,每次 runTurn() 都新建 Agent、EventBridge、事件队列与历史游标。runTurn(pi-turn-runner.ts:114)的注释把流程标成八步:

  1. newTurn 组装本回合状态:合成流函数、工具表、EventBridge、事件编号器。
  2. 发出第一条 session.status(running)。
  3. newAgent 创建 pi 的 Agent,注入系统提示词、模型、工具与历史,并建立历史游标。
  4. 安装工具钩子与 LLM 钩子。
  5. 订阅 Agent 事件,所有事件进同一个串行队列。
  6. 接好取消:调用方的 AbortSignal 触发时转成 agent.abort()。
  7. runAgent:prompt 用户消息,或在已有历史上 continue,然后等 Agent 空闲。
  8. 排空事件队列、补发遗漏的历史、写最终的 session.status。

流函数的包装顺序在 packages/agent-core/src/pi-turn-runner/llm.ts:13:

export function composeStreamFn(resolved: LLMModelConfig): StreamFn {
  // Order matters: each wrapper only fills the option it owns, while
  // explicit per-call options from upstream wrappers still win. The host
  // ceiling is the exception and therefore the innermost link: it has to see
  // the fully accumulated options in order to shrink them.
  const callerHeaders =
    resolved.headers && Object.keys(resolved.headers).length > 0 ? resolved.headers : undefined;
  const hostClamped = withHostMaxOutputTokens(resolved.streamFn, resolved.hostMaxOutputTokens);
  const cacheWrapped = withCacheRetention(hostClamped, resolved.cacheRetention);
  const maxTokensWrapped =
    typeof resolved.maxTokens === 'number' && resolved.maxTokens > 0
      ? withMaxTokens(cacheWrapped, resolved.maxTokens)
      : cacheWrapped;
  const headerWrapped = callerHeaders
    ? withHeaders(maxTokensWrapped, callerHeaders)
    : maxTokensWrapped;
  const fetchWrapped = resolved.fetch ? withFetch(headerWrapped, resolved.fetch) : headerWrapped;
  return wrapStreamFnWithTimeout(fetchWrapped, LLM_REQUEST_TIMEOUT_MS);
}

newTurn(packages/agent-core/src/pi-turn-runner/turn.ts:127)再在外面套指标、传输重试(主请求与压缩等辅助请求各包一份)、开发用的上下文捕获,以及 before-LLM 钩子要求“直接回答”时用的合成响应。交给 Agent 的配置在 packages/agent-core/src/pi-turn-runner/agent.ts:78:

    streamFn: turn.streamFn,
    getApiKey: (provider) => (provider === turn.llm.model.provider ? turn.llm.apiKey : undefined),
    toolExecution: 'parallel',
    sessionId: turn.input.sessionId,
    ...(turn.input.shouldStopAfterSteering
      ? { shouldStopAfterSteering: turn.input.shouldStopAfterSteering }
      : {}),
    ...(turn.input.shouldStopAfterTurn
      ? { shouldStopAfterTurn: turn.input.shouldStopAfterTurn }
      : {}),
    ...(turn.llm.payloadTransform ? { onPayload: turn.llm.payloadTransform } : {}),
    ...(turn.llm.responseObserver ? { onResponse: turn.llm.responseObserver } : {}),

convertToLlm 接的是 projectAgentMessagesForModel(同文件 :42):先滤掉只给宿主看的消息,再调 pi-coding-agent 的 convertToLlm 转换,之后合并同批发送的用户消息、去掉宿主字段、删掉孤立的工具结果,最后过滤过小的图片。onPayload 是模型系统生成的请求补丁生效的地方,见模型系统。

钩子怎样挂进 pi

PiTurnHooks(hooks.ts:252)有八类钩子,各自挂在 pi 的不同接缝上:

钩子挂在哪里能做什么
beforeLlmCallHook改写 agent.transformContext(llm.ts:37)继续、跳过、直接给出回答、只替换本次请求的消息、持久替换历史(压缩)、追加一条隐藏消息、中止
onLlmCallPreparedHook包在流函数外(turn.ts:273)只观察最终请求,失败不影响调用
afterLlmCallHook在 message_end 事件里执行(events.ts:87)替换文本、丢弃响应并重试、判为失败;后两者会先剥掉响应里的工具调用
beforeToolCallHookagent.beforeToolCall(packages/agent-core/src/pi-turn-runner/tools.ts:78)拦下工具,标注是否由权限拦截
onToolExecutionStartHookagent.onToolExecutionStart(tools.ts:97)准入通过、真正执行前的时间点
afterToolCallHookagent.afterToolCall(tools.ts:104)逐个合并对结果的改写
onHistoryChangedHookmessage_end 与 tool_execution_end 后刷新历史游标(events.ts:320)把新增消息交给宿主持久化
onStepEndHookturn_end 事件(events.ts:323)每步结束后的处理,可再排入消息

before-LLM 钩子抛异常时,运行时选择“关门”:直接中止这次模型调用(llm.ts:212),不带着没处理好的上下文去请求。

steering 与收尾

每步的 turn_end 之后,事件订阅者会向宿主轮询外部消息(events.ts:195):

      for (;;) {
        // The poll reports whether more steps already follow. At an exit
        // boundary the host may withhold messages so the close below hands
        // them back to the session instead of extending a finished answer.
        const boundary = hasToolCalls || agent.hasQueuedMessages() ? 'mid-turn' : 'exit';
        const steering = [...((await turn.input.getSteeringMessages?.({ boundary })) ?? [])];
        enqueueSteering(agent, steering);
        if (steering.length > 0 || hasToolCalls || agent.hasQueuedMessages()) return;

boundary 区分“后面还有步”和“这一步已是结尾”。在结尾处,宿主不会把用户的 steering 塞进来延长一个已经答完的回合,而是留着让关闭流程把它交还会话、当成新的请求(packages/local-runtime-v2/src/service/turn-system/agent-host/execution/user-input-control.ts:49)。轮询为空时调用 tryBeginClose 原子地关闭“继续受理”;关闭被拒说明有新工作抢先到达,就再轮询一次。同批立即发送的用户消息用 steerBatch 一次注入(events.ts:373),对应台账里 2026-09-08 那条补丁。

Agent 空闲后还有一道 convergeAtIdle(events.ts:222):如果这时又冒出 steering,就 agent.continue() 再跑,直到关闭成功。最终结局只有三种,completed、aborted、failed(packages/agent-core/src/event-bridge/types.ts:90),失败信息会先经错误分类器映射成稳定的状态码。

EventBridge:从 AgentEvent 到 RuntimeEvent

pi 的事件不直接给界面用。packages/agent-core/src/event-bridge/bridge.ts:282 的 processEvent 把 message_* 与 tool_execution_* 翻译成 stream.resp 分块(文本、思考、工具调用),把 agent_*、turn_* 和 tool_execution_update 降为 debug.trace。两个细节:assistant 消息的 ID 由宿主分配,桥在 message_start 时先请求 ID,拿到之前的增量不会发出;带工具调用的完整消息要等所有 tool_execution_end 到齐才发(bridge.ts:174 的注释解释了原因:否则持久化下来的工具状态永远停在“已开始”)。

packages/agent-core/src/protocol/ 负责词汇:packages/agent-core/src/protocol/runtime-event.ts:77 把 @mavis/protocol 里由 thrift 生成的 IRuntimeEvent 以旧名字 RuntimeEvent 重新导出;packages/agent-core/src/protocol/agent-event.ts:5 的注释说明,这里镜像一份 pi 的 AgentEvent 类型,是为了让宿主不必直接依赖 pi 也能消费同一套事件形状。

工具契约:RuntimeTool 变成 AgentTool

宿主提供的工具是 RuntimeTool:LLM 看得见的定义 def 与执行实现 impl 分开(packages/agent-core/src/tools/types.ts:142),可用 defineRuntimeTool 或装饰器 bindTool 声明。newTools(tools.ts:26)把它们包成 pi 的 AgentTool:重名直接报错;工具没声明 executionMode 时按 sequential 处理(tools.ts:140);执行时把本回合上下文、toolCallId 与 assistant 消息 ID 注入 impl.execute。

若宿主没绑定 read、write、edit、bash,newTools 会退回 pi 的 createReadTool 等内置工具(tools.ts:44)。v2 默认关掉了这条退路(packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts:285),产品里的这几个工具来自 agent-tools 对 pi 工具工厂的再包装,详见文件与 Shell 工具。

由于 pi 遇到一个顺序工具就让整批顺序执行,只有同一批里全是声明了 parallel 的工具(如 read、grep、glob,packages/agent-core/src/tools/builtin-defs.ts:24)才会真正并发。

两个小模块

  • prompt-read.ts:宿主无关的提示词模板读取契约。PromptSnapshotSource 负责捕获快照、读取某个模板键,PromptReadScope 让扩展在一次模型调用里读同一份快照;BoundedInternalTurnPromptReadRegistry 在内部回合受理之前暂存已渲染的提示词,容量默认 128(packages/agent-core/src/prompt-read.ts:59)。
  • bash-subprocess-env.ts:bash 子进程环境的两层清洗(bash-subprocess-env.ts:6)。A 层总是剥掉运行时边界相关的变量,防止子进程冒充父会话;B 层按模式处理凭据:交互默认 off,检测到 CI 或 GITHUB_ACTIONS 时自动 scrub,按黑名单删 ANTHROPIC_API_KEY、OPENAI_API_KEY、MCODE_PROVIDER_API_KEY 等;strict 则按名字模式删。createBashEnvSpawnHook 把它做成 pi createBashTool 的 spawnHook。

agent-runtime:每个回合组装扩展

packages/agent-runtime 只有约 1400 行,做一件事:让各功能模块以“扩展”的形式挂到回合上。入口 createAgentRuntime(packages/agent-runtime/src/api.ts:16)先用 resolveExtensions(packages/agent-runtime/src/resolve.ts:21)按 profile 的 overlay 对 base 做禁用、启用、替换,任何对不上的 ID 都在构造时直接抛错;再由 Registry 依次调用每个扩展的 init(pi)。

init 拿到的 ExtensionAPI 只在该扩展自己的初始化窗口内有效,窗口外调用 registerTool、on 之类都会抛错(packages/agent-runtime/src/registry.ts:307)。扩展能贡献五类东西:工具(静态或每回合工厂)、系统提示词前缀、用户提示词前缀、提醒(<system-reminder> 块,按优先级排序)和钩子。assembleTurn(registry.ts:345)先同步快照各扩展的启用状态,再逐个收集,钩子按表映射到 agent-core 的字段(registry.ts:126):

const HOOK_MAPPINGS = [
  { pi: 'onLlmCallPreparedHook', event: 'on_llm_call_prepared', adapter: 'context' },
  { pi: 'beforeLlmCallHook', event: 'before_llm_call', adapter: 'context' },
  { pi: 'afterLlmCallHook', event: 'after_llm_call', adapter: 'context' },
  { pi: 'beforeToolCallHook', event: 'before_tool_call', adapter: 'tool-context' },
  { pi: 'afterToolCallHook', event: 'after_tool_call', adapter: 'tool-context' },
  { pi: 'onHistoryChangedHook', event: 'on_history_changed', adapter: 'context' },
  { pi: 'onStepEndHook', event: 'on_step_end', adapter: 'context' },
] as const satisfies readonly {

表里没有 onToolExecutionStartHook,这个时间点只能由宿主直接接。turn_start、turn_end 两类处理器不进 PiTurnHooks,以原始数组交给宿主在回合前后调用。packages/agent-runtime/src/factories.ts:71 起提供 createToolExtension、createHookExtension、createPromptExtension、createReminderExtension 和组合型的 createExtension,省掉手写 init 的样板。

v2 在 packages/local-runtime-v2/src/service/turn-system/agent-host/native-production-dependencies.ts:131 用 createAgentRuntime({ base: ... }) 建出普通回合用的运行时,挂上会话报告、失控保护、工具输出预算等扩展(packages/local-runtime-v2/src/service/turn-system/production-composition.ts:515);assembleTurn 的结果再由宿主拼成 RunTurnInput,交给包着 PiTurnRunner 的 LocalAgentTurnRunner(local-agent-turn-runner.ts:86)。回合怎样被受理、排队、调度,见回合系统。

下一篇:模型协议层 pi-ai——循环里的 streamSimple 背后,九种 API 形态怎样统一成同一套流式事件。

本页目录