从源码看 pi 的 AI Agent 编排架构

AI Agent 项目的复杂性往往不在“调用一次大模型”本身,而在如何把用户输入、上下文、工具调用、流式输出、错误恢复、会话持久化和扩展系统组织成一个可控的运行时。pi 这个项目的 Agent 部分正是这种复杂性的集中体现。它不是一个简单的 prompt wrapper,而是把通用 Agent loop、编码场景 runtime、TUI/RPC/print 模式、扩展机制、工具系统和会话树组织成了一套完整的 Agent 编排框架。

packages
├── agent
│   ├── CHANGELOG.md
│   ├── docs
│   ├── package.json
│   ├── README.md
│   ├── src
│   ├── test
│   ├── tsconfig.build.json
│   ├── vitest.config.ts
│   └── vitest.harness.config.ts
├── ai
│   ├── bedrock-provider.d.ts
│   ├── bedrock-provider.js
│   ├── CHANGELOG.md
│   ├── package.json
│   ├── README.md
│   ├── scripts
│   ├── src
│   ├── test
│   ├── tsconfig.build.json
│   └── vitest.config.ts
├── coding-agent
│   ├── CHANGELOG.md
│   ├── docs
│   ├── examples
│   ├── npm-shrinkwrap.json
│   ├── package.json
│   ├── README.md
│   ├── scripts
│   ├── src
│   ├── test
│   ├── tsconfig.build.json
│   ├── tsconfig.examples.json
│   └── vitest.config.ts
└── tui
    ├── CHANGELOG.md
    ├── native
    ├── package.json
    ├── README.md
    ├── src
    ├── test
    ├── tsconfig.build.json
    └── vitest.config.ts

从源码结构看,最核心的分层是 packages/agentpackages/coding-agent。前者提供通用的 Agent core,关注模型交互和工具循环。后者提供面向编码助手的产品运行时,关注工作目录、会话、系统提示词、工具注册、扩展、自动压缩、重试和界面事件。理解这两层的边界,是理解整个 Agent 架构的入口。

一、项目中的 Agent 分层

packages/agent 可以看作 Agent 编排的最小内核。它不关心 TUI,不关心 session 文件怎么存,不关心 slash command,也不关心项目目录里的 AGENTS.md。它关心的是一件事:给定上下文、模型、工具和若干 hook,如何驱动一次或多次 LLM turn,如何执行工具,如何把结果回填给模型,并把整个过程以事件流的形式暴露出去。

这一层的主要文件是:

  • packages/agent/src/agent-loop.ts
  • packages/agent/src/agent.ts
  • packages/agent/src/types.ts

其中 agent-loop.ts 是低层状态机,agent.ts 是有状态封装,types.ts 定义 AgentMessageAgentToolAgentEvent 等核心类型。

packages/coding-agent 则是在 core Agent 之上构建的产品层。它负责把一个通用 Agent 变成“编码 Agent”。这里出现了更多具体概念:当前工作目录、session manager、resource loader、settings manager、model registry、extension runner、built-in tools、prompt templates、skills、compaction、bash execution、interactive mode、RPC mode 等。

这一层的核心文件包括:

  • packages/coding-agent/src/core/sdk.ts
  • packages/coding-agent/src/core/agent-session.ts
  • packages/coding-agent/src/core/agent-session-runtime.ts
  • packages/coding-agent/src/core/messages.ts
  • packages/coding-agent/src/core/tools/index.ts
  • packages/coding-agent/src/core/extensions/*

如果用一句话概括,两层关系是:packages/agent 负责“Agent 怎么跑”,packages/coding-agent 负责“一个编码助手产品应该如何使用这个 Agent”。

二、一次 Prompt 的完整生命周期

用户输入并不会直接进入大模型。它首先进入 AgentSession.prompt()。这是编码 Agent 的真正入口。

这一阶段的目标不是立刻调用模型,而是做一系列前置编排。源码中的顺序很关键,因为它决定了谁有权改写输入、谁有权拦截命令、谁有权修改系统提示词。

第一步是扩展命令处理。如果用户输入以 / 开头,AgentSession 会先检查它是否是扩展注册的 command。如果是,command handler 会立即执行,并且不会进入模型。这意味着扩展命令和普通 prompt 是两条不同路径。扩展命令属于宿主运行时行为,普通 prompt 才属于 Agent 对话行为。

第二步是 input hook。扩展可以监听 input 事件,对用户输入进行 handledtransform。如果返回 handled,当前输入就被扩展消费,不再继续。若返回 transform,输入文本或图片会被改写,然后继续后续流程。这个位置很早,说明扩展可以在 skill/template 展开之前介入原始输入。

第三步是 skill 和 prompt template 展开。/skill:name 会被展开成包含 skill 文件内容的块,prompt template 也会在这里替换。展开后的内容才会成为模型看到的用户消息。

第四步是 streaming 状态判断。如果当前 Agent 正在运行,新的用户输入不会直接启动另一个 prompt。它必须显式选择 steerfollowUpsteer 表示在当前 assistant turn 完成工具调用后尽快注入,followUp 表示等当前 Agent 自然结束后再执行。这是项目中很重要的并发控制点:同一时间只有一个 active run,但允许用户在运行中排队消息。

第五步是模型和认证校验。只有在非 streaming 且将要真实启动模型调用时,才校验当前 model 是否存在,以及 provider 是否有可用认证。这样可以避免无效请求进入 core loop。

第六步是 compaction 前置检查。如果上一条 assistant message 表明上下文过大或之前存在 aborted/error 情况,系统可能会先触发压缩,再继续当前 prompt。

第七步是构造消息数组。普通用户输入会被包装为 role: "user" 的 AgentMessage。如果扩展通过 before_agent_start 注入了 custom message,这些消息也会一起进入本轮 prompt。扩展还可以在这里修改 system prompt。最终,AgentSession 才会调用 core Agent.prompt()

这个流程体现了 coding-agent 的职责:它不直接实现 LLM loop,而是在调用 loop 前完成输入治理、扩展治理、上下文治理和产品策略。

flowchart TD Start["用户输入 text/images"] --> Prompt["AgentSession.prompt()"] Prompt --> CommandCheck{"是否扩展命令 /xxx ?"} CommandCheck -->|是| RunCommand["执行扩展 command handler"] RunCommand --> EndCommand["结束,不进入模型"] CommandCheck -->|否| InputHook{"扩展 input hook"} InputHook -->|handled| EndHandled["扩展已处理,结束"] InputHook -->|transform| Transform["改写文本或图片"] InputHook -->|无处理| Expand Transform --> Expand["展开 skill / prompt template"] Expand --> IsStreaming{"Agent 是否 streaming?"} IsStreaming -->|是| QueueMode{"指定 steer 或 followUp?"} QueueMode -->|steer| Steer["加入 steering queue"] QueueMode -->|followUp| FollowUp["加入 follow-up queue"] QueueMode -->|未指定| Error["抛出并发 prompt 错误"] IsStreaming -->|否| Validate["校验 model 与 auth"] Validate --> PreCompact{"是否需要预压缩?"} PreCompact -->|是| Compact["执行 compaction 后 continue"] PreCompact -->|否| BuildMessages Compact --> BuildMessages["构造 user/custom messages"] BuildMessages --> BeforeStart["扩展 before_agent_start"] BeforeStart --> SystemPrompt["应用扩展 system prompt 或 base prompt"] SystemPrompt --> AgentPrompt["Agent.prompt(messages)"] AgentPrompt --> CoreLoop["进入 core runLoop()"]

三、Core Agent Loop:Turn 驱动的状态机

真正的 Agent loop 在 packages/agent/src/agent-loop.ts。它的核心函数是 runLoop()

runLoop() 的结构可以拆成两层循环。外层循环处理 follow-up 消息,内层循环处理工具调用链和 steering 消息。

一次普通 prompt 的事件顺序大致是:

agent_start
turn_start
message_start user
message_end user
message_start assistant
message_update assistant
message_end assistant
turn_end
agent_end

如果 assistant 产生 tool call,流程会变成:

assistant response
tool_execution_start
tool_execution_update
tool_execution_end
message_start toolResult
message_end toolResult
turn_end
turn_start
assistant response after tool result
...

这里的“turn”不是一条消息,而是“一次 assistant 响应加上它产生的工具执行”。如果 assistant 调用了工具,tool result 会被加入上下文,然后 loop 再开启下一次 assistant turn,让模型基于工具结果继续回答。

runLoop() 里有几个关键判断。

第一,assistant message 如果是 erroraborted,loop 会发出 turn_endagent_end 后退出。错误恢复不在 core loop 内部处理,而是交给上层 AgentSession

第二,如果 assistant message 包含 tool call,就执行工具,并把 tool result message 加入上下文。只要工具 batch 没有全体 terminate: true,loop 就认为还可能有更多 tool calls,于是进入下一轮。

第三,每个 turn 结束后,会调用 prepareNextTurn。这个 hook 可以替换下一轮的 context、model 或 thinking level。这是实现动态模型切换或上下文替换的底层机制。

第四,shouldStopAfterTurn 可以要求当前 turn 结束后优雅停止。它不会中断已经开始的 provider stream,也不会取消正在执行的工具。它只是在 turn 边界阻止下一次 LLM call。

第五,steering 消息在工具调用完成后被检查。也就是说,用户在工具执行期间发出的 steer 不会打断当前工具 batch,而是在当前 assistant turn 完整结束后插入下一轮模型调用。

第六,follow-up 消息只在 loop 本来要停止时检查。它比 steering 更“晚”,语义上是“当前任务完成后再处理”。

这套设计让 core loop 保持简洁:它只关心 turn、message、tool 和 queue,而不把压缩、重试、扩展命令等产品策略塞进底层状态机。

flowchart TD Start["runAgentLoop / runAgentLoopContinue"] --> AgentStart["emit agent_start"] AgentStart --> TurnStart["emit turn_start"] TurnStart --> Pending{"有 pending steering messages?"} Pending -->|有| InjectPending["注入 queued messages 到 context"] Pending -->|无| StreamAssistant InjectPending --> StreamAssistant["streamAssistantResponse()"] StreamAssistant --> AssistantDone["assistant message_end"] AssistantDone --> ErrorCheck{"stopReason 是 error/aborted?"} ErrorCheck -->|是| EndTurnError["emit turn_end"] EndTurnError --> AgentEnd["emit agent_end"] ErrorCheck -->|否| ToolCheck{"assistant 是否包含 toolCall?"} ToolCheck -->|无| TurnEnd["emit turn_end"] ToolCheck -->|有| ExecuteTools["executeToolCalls()"] ExecuteTools --> AppendToolResults["toolResult 写入 context"] AppendToolResults --> TurnEnd TurnEnd --> PrepareNext["prepareNextTurn 可替换 context/model/thinking"] PrepareNext --> StopCheck{"shouldStopAfterTurn?"} StopCheck -->|是| AgentEnd StopCheck -->|否| SteeringPoll{"poll steering queue"} SteeringPoll -->|有| TurnStart SteeringPoll -->|无| FollowUpPoll{"poll follow-up queue"} FollowUpPoll -->|有| TurnStart FollowUpPoll -->|无| AgentEnd

四、消息模型:AgentMessage 到 LLM Message

项目中一个非常重要的抽象是 AgentMessage。它比 LLM provider 接受的 Message 更宽。LLM 只理解 user、assistant、toolResult 等标准消息,但编码 Agent 还需要表达 bash execution、custom extension message、branch summary、compaction summary 等运行时消息。

所以 core Agent 不直接假设所有消息都能发给模型,而是提供 convertToLlm。在 packages/coding-agent/src/core/messages.ts 中,convertToLlm() 负责把产品层消息转换为模型可读消息。

例如,bashExecution 会被转换成一个 user message,内容大致是“Ran command”加上输出。被标记为 excludeFromContext 的 bash execution 会被跳过。custom message 会转换为 user message。branchSummarycompactionSummary 会转换成带 summary 标签的 user message。标准的 user、assistant、toolResult 则原样保留。

这个设计有两个好处。

第一,产品运行时可以保留丰富的事件和 UI 消息类型,不必为了适配模型而丢失结构信息。

第二,模型上下文的构造有一个明确边界。所有“哪些消息应该进入模型,如何进入模型”的逻辑集中在 convertToLlm,而不是散落在 UI、session 或 provider 层。

在 provider 调用前,还有一个 transformContext 阶段。它发生在 convertToLlm 之前,作用于 AgentMessage[]。这意味着扩展或上层逻辑可以先按产品语义修改上下文,再转换成 LLM 消息。比如扩展可以注入 context,压缩逻辑可以替换历史消息,或者过滤某些 custom message。

因此,完整上下文路径是:

AgentMessage[]
→ transformContext()
→ AgentMessage[]
→ convertToLlm()
→ Message[]
→ provider

这条路径是理解整个 Agent 上下文系统的关键。

flowchart LR A["AgentSession / Agent.state.messages<br/>AgentMessage[]"] --> B["transformContext(messages)<br/>扩展 context hook"] B --> C["AgentMessage[]"] C --> D["convertToLlm(messages)"] D --> E["Message[]"] E --> F["streamSimple(model, context)"] F --> G["Provider API"] subgraph AgentMessageTypes["AgentMessage 类型"] U["user"] As["assistant"] TR["toolResult"] Bash["bashExecution"] Custom["custom"] Compact["compactionSummary"] Branch["branchSummary"] end Bash --> D Custom --> D Compact --> D Branch --> D U --> D As --> D TR --> D

五、工具编排:注册、选择、执行与结果回填

工具系统同样分层。

在 core Agent 中,工具类型是 AgentTool。它有 name、label、description、parameters、execute,以及可选的 prepareArgumentsexecutionMode。core loop 只关心这些字段,尤其是如何校验参数、如何执行工具、如何发出 tool execution events、如何生成 toolResult message。

coding-agent 层,工具系统更复杂。它采用 definition-first registry。内置工具、扩展工具、SDK 自定义工具会先合成 ToolDefinition 注册表,再 wrap 成 core Agent 能执行的 AgentTool

这样做的原因是,工具不只是可执行函数。它还影响系统提示词、UI 展示、扩展元数据、active tools 列表、allowlist/denylist,以及 HTML export 渲染。用 definition-first registry 可以让“工具的描述”和“工具的执行”共享同一个源。

AgentSession._refreshToolRegistry() 会收集三类工具:

  1. built-in tools,例如 read、bash、edit、write、grep、find、ls。
  2. extension registered tools。
  3. SDK custom tools。

然后它会应用 allowed/excluded tool 规则,生成 _toolDefinitions_toolRegistry、tool prompt snippets、tool guidelines,并最终调用 setActiveToolsByName() 更新 agent.state.tools 和 system prompt。

工具执行阶段在 core loop 中完成。默认模式是 parallel。也就是说,如果 assistant 一次返回多个 tool call,core loop 会先按顺序做 preflight,然后并发执行允许的工具。工具完成事件 tool_execution_end 按实际完成顺序发出,但 toolResult message 会按 assistant 原始 tool call 顺序写回。这解决了一个常见问题:UI 可以及时显示先完成的工具,但模型上下文保持稳定顺序。

如果任意一个 tool 的 executionMode"sequential",整个 batch 会退回顺序执行。这给 bash、edit 等对顺序敏感的工具留下了控制空间。

工具执行前后还有两个 hook:beforeToolCallafterToolCall。在 coding-agent 中,它们被用于扩展系统的 tool_calltool_result 事件。tool_call 可以阻止执行,tool_result 可以改写结果内容、details 或 error 状态。这样扩展系统能够参与工具安全策略和结果处理,而 core loop 不需要知道扩展存在。

flowchart TD Start["assistant message 包含 toolCall[]"] --> Mode{"toolExecution 模式"} Mode -->|sequential 或任一工具要求 sequential| Sequential["顺序执行 batch"] Mode -->|parallel 默认| Parallel["并发执行 batch"] Sequential --> ToolStart1["emit tool_execution_start"] ToolStart1 --> Prepare1["prepareArguments + validateToolArguments"] Prepare1 --> Before1["beforeToolCall / extension tool_call"] Before1 --> Block1{"是否 block?"} Block1 -->|是| ErrorResult1["生成 error toolResult"] Block1 -->|否| Execute1["tool.execute()"] Execute1 --> After1["afterToolCall / extension tool_result"] After1 --> End1["emit tool_execution_end"] ErrorResult1 --> End1 End1 --> Message1["emit toolResult message"] Parallel --> Preflight["按 assistant 顺序 preflight 每个 toolCall"] Preflight --> RunConcurrent["允许的工具并发执行"] RunConcurrent --> EndConcurrent["tool_execution_end 按完成顺序发出"] EndConcurrent --> OrderedResults["toolResult message 按 assistant 原始顺序写回"] Message1 --> TerminateCheck{"所有 toolResult terminate=true?"} OrderedResults --> TerminateCheck TerminateCheck -->|是| StopToolLoop["停止自动 follow-up LLM call"] TerminateCheck -->|否| ContinueLoop["进入下一 turn,让模型读取 toolResult"]

六、扩展系统如何介入 Agent 编排

扩展系统是 coding-agent 编排中最强的插拔点。它不是单一的插件入口,而是贯穿 Agent 生命周期的多阶段 hook 系统。

扩展可以介入以下阶段:

  • input:原始用户输入进入 prompt 前。
  • before_agent_start:Agent 启动前,可注入 custom message 或修改 system prompt。
  • context:模型调用前,可修改 AgentMessage 上下文。
  • before_provider_request:provider payload 发送前。
  • after_provider_response:provider 响应返回后。
  • tool_call:工具执行前,可 block。
  • tool_result:工具执行后,可改写结果。
  • message_startmessage_updatemessage_end:消息生命周期事件。
  • turn_startturn_end:turn 生命周期事件。
  • agent_startagent_end:Agent run 生命周期事件。
  • session_startsession_shutdownsession_before_switchsession_before_forksession_before_compact 等 session 事件。

这些 hook 分布在不同文件中,但核心调度者是 ExtensionRunnerAgentSession 负责在合适的 Agent 事件位置调用 _emitExtensionEvent(),并在特定场景下调用专用方法,比如 emitInput()emitBeforeAgentStart()emitContext()emitToolCall()emitToolResult()

值得注意的是,扩展 context 是懒读取的。ExtensionRunner.createContext() 返回的对象通过 getter 读取当前 model、cwd、sessionManager、signal 等状态。这样 session reload 或 switch 后,旧 context 可以被 invalidate,避免扩展继续使用陈旧运行时。

扩展系统对 Agent 编排的影响很大。它既可以改用户输入,也可以改系统提示词;既可以增加工具,也可以拦截工具;既可以修改 provider payload,也可以替换 message_end 的 assistant message。因此,分析这个项目的扩展系统,本质上是在分析 Agent 编排的开放边界。

sequenceDiagram participant U as User participant S as AgentSession participant E as ExtensionRunner participant A as Agent Core participant P as Provider participant T as Tool U->>S: prompt(text) S->>E: input E-->>S: handled / transform / continue S->>S: expand skill/template S->>E: before_agent_start E-->>S: custom messages / systemPrompt S->>A: Agent.prompt(messages) A->>E: context E-->>A: transformed AgentMessage[] A->>E: before_provider_request E-->>A: modified payload A->>P: stream request P-->>A: assistant stream A->>E: message_start/update/end A->>E: tool_call E-->>A: allow / block A->>T: execute tool T-->>A: result A->>E: tool_result E-->>A: modified result A->>S: agent_end S->>E: agent_end S->>S: retry / compaction / persistence

七、队列机制:Steering 与 Follow-up

Agent 运行时不允许并发执行多个 prompt。Agent.prompt() 如果在 active run 存在时被调用,会直接报错。但项目仍然支持用户在模型运行中继续输入,这靠的是两个队列:steering queue 和 follow-up queue。

steer 表示“当前 turn 完成后尽快插入”。具体来说,如果 assistant 正在执行工具,steering message 不会打断当前工具,而是在工具执行完成、toolResult 写入之后,下一次 LLM call 之前进入上下文。

followUp 表示“当前 Agent 原本要停止时再执行”。如果当前任务还会因为工具结果继续多轮,follow-up 会等这些自然流程结束后再注入。

两者的差异不是 UI 层概念,而是 core loop 的调度语义。steering 属于内层循环,follow-up 属于外层循环。steering 能够改变当前任务后续方向,follow-up 更像排队下一个任务。

队列还有两种 drain 模式:one-at-a-timeall。在 one-at-a-time 下,每次只取一条 queued message。模型会逐条响应。all 模式下,同一 drain 点会取出所有 queued messages,一次性加入上下文,然后模型统一响应。

AgentSession 还维护 _steeringMessages_followUpMessages 供 UI 显示。当 queued user message 真正进入 message_start 时,AgentSession 会从显示队列中移除对应文本并发出 queue_update。这说明项目区分了“core Agent 队列”和“UI 可见队列”:前者驱动执行,后者驱动界面状态。

八、会话持久化与恢复

AgentSession 订阅 core Agent 的所有事件,并在 message_end 时持久化消息。普通 user、assistant、toolResult 会通过 SessionManager.appendMessage() 写入 session。custom message 会通过 appendCustomMessageEntry() 保存。bash execution、compaction summary、branch summary 等特殊消息则在各自逻辑中保存。

这个持久化发生在扩展 message_end 之后。也就是说,如果扩展在 message_end 中替换了 assistant message,最终写入 session 的是替换后的版本。这一点在测试里也有覆盖。

恢复时,createAgentSession() 会调用 sessionManager.buildSessionContext()。如果 session 有历史消息,它会尝试恢复之前使用的 model 和 thinking level。如果无法恢复原模型,则走 fallback model 逻辑,并产生 modelFallbackMessage。然后将历史 messages 写入 agent.state.messages

session runtime 还支持 new、resume、fork、tree navigation 等操作。AgentSessionRuntime 在切换 session 时会先发 session_before_switch,允许扩展取消;然后发 session_shutdown,dispose 当前 session;最后创建新 runtime 并发 session_start。这保证了扩展在 session 生命周期中有明确边界。

这一层的设计说明:会话不是简单日志,而是一棵可分支、可恢复、可压缩、可标注的执行历史。Agent state 是当前运行态,SessionManager 是持久态,两者通过事件同步。

flowchart TD Event["AgentEvent: message_end"] --> ExtensionFirst["先发 extension message_end"] ExtensionFirst --> Replacement{"扩展是否替换 message?"} Replacement -->|是| Mutate["原地替换 message 对象"] Replacement -->|否| Persist Mutate --> Persist["写入 SessionManager"] Persist --> Role{"message role"} Role -->|user/assistant/toolResult| AppendMessage["appendMessage()"] Role -->|custom| AppendCustom["appendCustomMessageEntry()"] Role -->|bash/compaction/branch| Special["由对应逻辑单独持久化"] subgraph Restore["恢复 session"] Load["SessionManager.buildSessionContext()"] RestoreModel["恢复 model / thinkingLevel"] RestoreMessages["agent.state.messages = session messages"] end AppendMessage --> Load AppendCustom --> Load Special --> Load Load --> RestoreModel --> RestoreMessages

九、上下文管理:压缩、溢出恢复与自动重试

上下文压缩和自动重试没有放进 packages/agent,而是放在 AgentSession。这是合理的,因为它们是产品策略,不是通用 loop 语义。

每次 agent.prompt() 完成后,AgentSession._runAgentPrompt() 会进入 _handlePostAgentRun()。这个函数根据最后一条 assistant message 决定是否继续:

  1. 如果是可重试错误,准备 retry,然后调用 agent.continue()
  2. 如果需要 compaction,执行自动压缩,然后可能调用 agent.continue()
  3. 如果 agent_end 扩展 handler 又排入了消息,也继续。

自动重试针对 rate limit、overloaded、网络错误、5xx、timeout 等临时错误。context overflow 不归重试处理,而归 compaction 处理。重试时会从 agent state 中移除错误 assistant message,但保留 session 中的历史记录。这是一个重要细节:模型重试上下文要干净,但用户历史仍然能看到错误发生过。

自动压缩有两种触发:overflow 和 threshold。overflow 表示 provider 明确返回上下文溢出,或 usage 超出 context window。threshold 表示上下文接近配置阈值。overflow 且 assistant 没有成功回答时,压缩后会 retry;threshold 压缩通常不自动 retry,因为成功回答已经完成,不能从 assistant 尾部直接 continue。

压缩本身也开放给扩展。session_before_compact 可以取消压缩,也可以提供自定义 compaction 结果。否则系统会调用 compact() 生成 summary,并把 compaction entry 写入 session。之后重新构建 session context,更新 agent.state.messages

这套设计的关键是:压缩不是简单裁剪 messages,而是 session tree 上的一个持久事件。它有 summary、firstKeptEntryId、tokensBefore、details,并能被后续恢复逻辑识别。

posted @ 2026-06-21 10:29  Roadinforest  阅读(154)  评论(0)    收藏  举报