一条消息的旅程

按下回车之后:输入经运行协调器进入 CliService,回合系统受理并开跑,pi 的 Agent 循环调模型、跑工具,事件再经会话流回到转录区。

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

这一篇把前三篇的地图串起来:在交互式 TUI 里输入一句话、按下回车,它经过哪些模块,在哪些接口上换了形状,模型的输出又怎样一路流回屏幕。每一站只点出关键的文件与接口,细节留给后面各篇。

先分清一个词。本书说的“回合”(Turn)指从一条用户消息到最终答复的整个过程,对应 pi 的一次 agent.prompt();pi 自己的 turn 只是其中一次模型响应加上随后的一批工具调用,相当于别处说的“步”。

图表加载中…

1. 回车:命令分发

编辑器的提交回调由 InputFlow 接到命令流上(packages/tui/src/tui/controller/interaction/input-flow.ts:161)。如果此时有回合在跑,回车和 Alt+Enter 会先给这次提交打上标记:前者是 steer,后者是 queue(input-flow.ts:366)。

TuiCommandFlow.submit(packages/tui/src/tui/controller/product/command-flow.ts:218)依次分流:以 ! 开头的交给 Shell(command-flow.ts:284);命令表认得的斜杠命令就地执行(command-flow.ts:427);剩下的才是发给 Agent 的消息。有回合在跑时,带 steer 标记的消息调 activeRunFlow.steer 插进当前回合(command-flow.ts:530),否则排进队列(command-flow.ts:569);只有空闲时,消息才交给 ChatController.submit(command-flow.ts:626)。下面先跟着空闲时的这条主路走,引导与排队放到后面。

2. ChatController 与运行协调器

ChatController.submit(packages/tui/src/tui/controller/chat-controller.ts:344)先生成 turn id,把用户消息以 pending 状态写进转录区(chat-controller.ts:372),这样屏幕上立刻有了这一行;接着检查登录(chat-controller.ts:394)、确保会话存在,再调用 executeTuiInteractiveTurn(chat-controller.ts:428)。

真正驱动这次运行的是 TuiRunCoordinator.execute(packages/tui/src/application/run-coordinator.ts:165)。它同一时间只允许一个活动回合,负责超时、取消与最终答案的挑选,并用 toSendMessageRequest 把请求整理成运行时认识的 CliSendMessageReq(run-coordinator.ts:400)。exec 与 ACP 两个入口用的也是这个协调器(packages/tui/src/headless/runner.ts:147、packages/tui/src/acp/agent.ts:304),三个入口从这里开始走同一条路。

3. 过边界:CliService

协调器调 TuiRuntimeAdapter.sendMessage(packages/tui/src/runtime/adapter.ts:171),适配器转给 TuiConversationAccess(packages/tui/src/runtime/adapters/conversation-access.ts:19),后者调用 CliService.sendMessage(packages/local-runtime-v2/src/local/cli-service.ts:282)。除了启动和退出时直接碰一下宿主,TUI 与运行时的往来都经过这里:同一进程里的一次方法调用,没有 HTTP,也没有 RPC 封套。

返回的不是最终结果,而是一个“流的打开结果”:失败时带状态码和错误键,TUI 把它变成 TuiFailure 抛出;成功时是一个帧的异步迭代器,TUI 边读边把每一帧解析成界面事件(conversation-access.ts:35)。

4. 应用层:先订阅,再提交

ConversationApplication.sendMessage(packages/local-runtime-v2/src/application/conversation/conversation-application.ts:154)先过几道闸:执行截止时间、暂停中的队列、保留的 turn id,以及 /review 的准入;然后把附件登记成本地资产,交给直发投递 DirectSendDeliveryService.open(packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts:69)。

open 第一步是向会话流预订一个订阅(SessionStreamService.reserve,packages/local-runtime-v2/src/service/session-system/stream/session-stream-service.ts:107),第二步才以 allowQueue: false 调用回合系统的 submit(direct-send-delivery.ts:74)。回合被受理时,onAccepted 回调把这个订阅的过滤条件绑到新的 turn id 上(direct-send-delivery.ts:88;过滤的实现见 session-stream-service.ts:126)。先订阅、再提交,回合一开始产出的帧也能收到。

5. 回合系统:受理与开跑

回合系统的 submit 在这里分岔(packages/local-runtime-v2/src/service/turn-system/turn-submission.service.ts:53):

  const makeTurnId = options.makeTurnId ?? (() => `turn_${randomUUID()}`);
  return {
    submit: (input) =>
      input.allowQueue
        ? submitQueued(options, input, makeTurnId)
        : submitImmediate(options, input, makeTurnId),
    resumeUserInput: (input) => resumeUserInput(options, input, makeTurnId),
  };

直发走 submitImmediate,经由应用层的 UserMessageTurnDeliveryService 调到 TurnExecutionService.submit(packages/local-runtime-v2/src/service/turn-system/execution/turn-execution.service.ts:53)。submitTurn(turn-execution.service.ts:280)先做提交前的准备,再调 repository.admit 把这个回合持久化受理(turn-execution.service.ts:356);受理成功后,startAcceptedTurn 在进程内登记活动回合,并通过执行协调器开跑(turn-execution.service.ts:497)。

这里有一个刻意的顺序。投递服务的类注释说得很直白:只有在回合被持久化受理之后,才提交可见的用户消息(packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts:93)。执行协调器拿到回合后并不马上开跑,而是先等投递服务放行(packages/local-runtime-v2/src/service/turn-system/execution/execution.coordinator.ts:96),投递服务则要等用户消息写进数据库、写进会话流之后才放行。于是顺序固定为:回合先被受理,用户消息再落库,最后执行协调器才调用 host.run,让 Agent 开跑(execution.coordinator.ts:121)。受理、排队与执行的全部细节见回合系统。

6. Agent 宿主:装配这一回合

LocalAgentHost.run(packages/local-runtime-v2/src/service/turn-system/agent-host/local-agent-host.ts:156)是回合归属与 Agent 循环之间的适配层,按固定顺序做几件事:取得本回合的插件、应用与 MCP 能力快照(local-agent-host.ts:186);预检,备好会话、Agent 配置、模型与历史(local-agent-host.ts:193);装配本回合(local-agent-host.ts:200)——先构建工具表,再由 @mavis/agent-runtime 按已注册的扩展组装,这两步的生产实现见 packages/local-runtime-v2/src/service/turn-system/agent-host/native-production-dependencies.ts:141;跑回合开始时的处理器;最后建一条提交流水线,交给执行器(local-agent-host.ts:326)。

执行器 LocalRuntimeTurnExecutor(packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts:130)把系统提示词、用户消息、模型配置、工具表和取引导消息的回调打包成一次 runTurn 调用(executor.ts:319),交给 LocalAgentTurnRunner。按类注释,后者是 V2 的生产执行器:直接调用 pi,外加本地的输入、输出安全策略(packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts:80),最终调到 PiTurnRunner.runTurn(local-agent-turn-runner.ts:274)。系统提示词与内置 Agent 从哪来,见 Agent 服务。

7. PiTurnRunner:把回合交给 pi

PiTurnRunner.runTurn(packages/agent-core/src/pi-turn-runner/pi-turn-runner.ts:114)的注释把一个回合拆成 8 步:建立本回合的状态(工具表、组合好的模型调用函数、事件桥),发出第一条 session.status,新建 pi 的 Agent,装上工具钩子与模型钩子,订阅 Agent 的事件,接好取消信号,运行 Agent,最后排空事件、补写尾部历史并发出终态。几个要点:

  • Agent 是每回合新建的。newAgent(packages/agent-core/src/pi-turn-runner/agent.ts:33)把系统提示词、模型、工具与历史作为初始状态交给 pi 的 Agent,工具执行方式设为 parallel(agent.ts:80)。runAgent 再用 agent.prompt 把用户消息交进去(agent.ts:103)。
  • 调模型的函数层层包装。composeStreamFn 在 pi-ai 的 streamSimple(宿主也可以换成自己的实现)外面依次套上宿主的输出上限、缓存保留、maxTokens、请求头与宿主注入的 fetch,最外层是超时(packages/agent-core/src/pi-turn-runner/llm.ts:13),默认 20 分钟(packages/agent-core/src/pi-turn-runner/defaults.ts:21)。协议层见模型协议层 pi-ai。
  • 工具调用先过钩子链。setToolHooks 把宿主给的前置钩子装到 pi 的 beforeToolCall 上,任何一个钩子返回 block 就拦下这次调用(packages/agent-core/src/pi-turn-runner/tools.ts:71)。V2 的钩子链依次是重复调用检查、安全防护、插件的 PreToolUse 钩子、工具策略与各扩展注册的钩子,最后才是权限判定(packages/local-runtime-v2/src/service/turn-system/agent-host/execution/plugin-hook-tool-lifecycle.ts:64)。见工具体系与权限模式与规则。

8. pi 的 Agent 循环

agent.prompt 最终进入 runLoop(third_party/pi-mono/packages/agent/src/agent-loop.ts:155)。它是两层循环:内层每轮先把待注入的引导消息插进上下文(agent-loop.ts:182),然后调模型、执行工具(agent-loop.ts:192):

// Stream assistant response
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);
	}
}

除了出错和被中止,继续还是结束只看这次回复里有没有工具调用,不看模型报的结束原因。并行模式下,一批工具调用先按顺序逐个准备(校验参数、过 beforeToolCall),再一起并发执行(agent-loop.ts:485)。一轮结束后,循环再从 Agent 的引导队列里取新消息(agent-loop.ts:253);内层循环在既无工具调用、也无引导消息时退出,外层再看有没有后续消息,没有就发出 agent_end 收工。

运行中按回车发出的引导消息就是从这个口子进来的:它经 CliService.steer(cli-service.ts:466)进入回合系统等待消费;pi 每跑完一步、发出 turn_end,回合执行器就向宿主要一次引导消息(packages/agent-core/src/pi-turn-runner/events.ts:181),用 agent.steer 塞进 pi 的引导队列(events.ts:377),在下一次调模型之前插进上下文。排队的消息则走另一条路:Alt+Enter 经 CliService.enqueueMessage(cli-service.ts:317)以 allowQueue: true 提交(conversation-application.ts:402),写进会话队列;当前回合结束、释放会话后,调度器才把它取出来开一个新回合(packages/local-runtime-v2/src/service/turn-system/initialize.ts:111)。pi 这一层的全部细节见 pi 的 Agent 循环与回合执行器。

9. 事件回流

pi 发出的每个 AgentEvent 都进 PiTurnRunner 的串行队列,由 EventBridge.processEvent(packages/agent-core/src/event-bridge/bridge.ts:282)翻译成运行时事件:消息的开始、增量、结束和工具执行的起止成为正式事件,pi 自己的 agent_start、turn_start 这类生命周期事件只变成调试用的 trace(bridge.ts:294)。

运行时事件交给宿主传入的写入器,进入提交流水线 TurnCommitPipeline.onRuntimeEvent(packages/local-runtime-v2/src/service/turn-system/agent-host/events/turn-commit-pipeline.ts:119):终态事件先暂存,等回合收尾时统一提交;其余事件按顺序交给 RequiredAgentEventDelivery,它按会话串行地依次调用四个投影器(packages/local-runtime-v2/src/service/turn-system/agent-host/events/required-agent-event-delivery.ts:228):

    const authoritative = await this.options.projectors.session.projectRuntimeEvent({
      context,
      event,
    });
    if (!authoritative) {
      throw new AgentEventAcknowledgementError(terminalOutcome(event) ?? 'non-terminal', 'missing');
    }
    validateAcknowledgement(event, authoritative);
    await this.options.projectors.messages.projectRuntimeEvent({ context, event });
    await this.options.projectors.stream.projectRuntimeEvent({ context, event });
    await this.options.projectors.turnFacts.projectRuntimeEvent({ context, event });

session 更新会话状态,messages 把供界面展示的消息写进数据库,stream 把事件写进会话流,turnFacts 更新回合的事实记录。其中写进会话流的,非终态记为 runtime-event 帧,终态记为 turn-terminal 帧(packages/local-runtime-v2/src/service/session-system/agent-projection.ts:111)。发给模型的那份规范历史走另一路,经 onHistoryChanged 单独落库(turn-commit-pipeline.ts:158),细节见会话系统。

写进会话流的帧,经第 4 步预订的订阅回到应用层。mapV2TurnStreamResult 把它们转成 dataJson 字符串(packages/local-runtime-v2/src/application/conversation/session-stream-delivery.ts:15),格式带着 SSE 的影子:第一帧是心跳,最后以 [DONE] 收尾(session-stream-delivery.ts:35、session-stream-delivery.ts:64)。TUI 一侧的 projectTuiSessionStreamFrame(packages/tui/src/runtime/stream-events.ts:139)再把 JSON 解析回 TuiStreamEvent,常见的有文字增量 delta、完整消息 message、会话状态 session-status 与 error(stream-events.ts:102)。

运行协调器每拿到一个事件,就交给 ChatController.applyRuntimeTurnEvent(chat-controller.ts:690),由回合投影 TurnProjection.applyStreamEvent 更新这一回合的各个单元格(packages/tui/src/tui/controller/projection/turn-projection.ts:122),落到转录存储 TranscriptStore.upsert(packages/tui/src/tui/transcript/store.ts:116),再由 TUI 引擎画到屏幕上。转录区与渲染见 TUI 应用与 TUI 引擎。

10. 收尾

pi 发出 agent_end 后,PiTurnRunner 排空事件队列、补写尾部历史,发出终态的 session.status(pi-turn-runner.ts:228);宿主提交终态,会话流里出现 turn-terminal 帧,订阅随之完成,TUI 读到 [DONE]。运行协调器汇总出这一回合的状态、答案与用量,ChatController 把界面状态置回空闲。回合系统这边,执行协调器结算回合归属并释放会话,释放信号触发队列调度——如果刚才有人按过 Alt+Enter,下一个回合就从这里接着开始。

下一篇:内置的 pi-mono:四个包与补丁台账——third_party/pi-mono 里的 agent、ai、coding-agent、tui 各管什么,哪些是上游原样,哪些是 MiniMax 的补丁。

本页目录