Prompt 与 Context Engineering
本文是针对上述固定 revision 的源码学习笔记。2026-09-27 已按本地更新后的源码重新复核,文中代码缩写省略无关分支,不保证可独立运行。调用链与分支经过静态源码复核;工程解释和构造示例用于帮助理解,不代表官方设计结论或真实 Provider 运行验证。
本章要解决的问题
Section titled “本章要解决的问题”这一章不把 Prompt 当成一段孤立的字符串来讲,而是沿着一次真实请求的执行顺序阅读源码:
启动 Session → 收集项目规则、Skills 和工具信息 → 构造 System Prompt → 恢复当前 Session 分支 → 接收并展开用户输入 → 启动 Agent Run → 每个 Turn 组装 Context → 转换成统一 LLM Message → 做目标模型兼容 → 序列化为 Provider Payload读完后,你应该能够只根据本章回答:
AGENTS.md、SYSTEM.md、Skills、工具说明如何进入模型输入;- 一条用户输入在变成 API 请求前经过哪些处理;
- Session、AgentState、AgentContext、LLM Context 分别保存什么;
- 为什么 Session 中存在的消息不一定会发送给模型;
- Extension 能在哪些层修改 Context,修改是否持久化;
- 切换模型后,thinking、图片、Tool Call ID 和缺失 ToolResult 如何处理;
- System Prompt 和 tools 为什么是两个独立通道。
一、先建立整条主线
Section titled “一、先建立整条主线”新版把 Prompt 和工具声明放入 SystemMessage,和对话一起组成 transcript。可执行函数仍留在 AgentContext.tools,不能把函数写进 JSONL。
ResourceLoader → BuildSystemPromptOptions → sections patch ─┐Tool Registry → active AgentTool[] → toolsAdded/toolsRemoved ├→ SystemMessageSessionManager → buildSessionProjection() → 历史 messages ─┘AgentSession.prompt → Agent Loop → 消息事件与持久化 → prepareRequest(重建规范投影) → transformContext → convertToLlm → normalizeContext → Provider serializer → HTTP payload下面是类型缩写:
interface AgentContext { messages: AgentMessage[]; // 包括 system tools?: AgentTool[]; // schema + execute,运行时执行集合}// Provider StreamFn 接收 TranscriptContext:Prompt 和工具都在 messages 中。const llmContext = normalizeContext({ messages: await convertToLlm(messages) });pi-ai 的便捷输入 Context 仍接受 systemPrompt 和 tools,normalizeContext() 会把它们折入头部 SystemMessage。因此“API 仍接受旧字段”和“Agent Loop 仍以三条独立字段传给 Provider”是不同命题,后者已经不成立。
Prompt 中的工具文字指导何时使用,toolsAdded 中的 schema 定义调用参数,AgentContext.tools 提供实际执行函数;三者职责仍需分清。
二、阅读前先区分六层数据
Section titled “二、阅读前先区分六层数据”后续源码反复在几种相似类型之间切换。先固定它们的边界。
2.1 SessionEntry:完整、持久化的事实历史
Section titled “2.1 SessionEntry:完整、持久化的事实历史”定义位置:
session-manager.ts的SessionEntry
Session JSONL 不只是聊天消息,还包括:
messagemodel_changethinking_level_changecompactionbranch_summarycustom_messagecustomlabelsession_info它有 id 和 parentId,因此本质是树,不是线性 messages 数组。
2.2 AgentMessage:Coding Agent 运行时消息
Section titled “2.2 AgentMessage:Coding Agent 运行时消息”除标准消息外,它还允许产品自定义角色:
userassistanttoolResultbashExecutioncustombranchSummarycompactionSummary这些角色适合 Session、UI 和 Extension,但 Provider 并不认识后四种。
2.3 AgentState:Agent 当前的可变内存
Section titled “2.3 AgentState:Agent 当前的可变内存”定义和实现位置:
核心字段:
systemPrompt(只读 getter,重放 messages 得到)messages(包括 system)toolsmodelthinkingLevelisStreamingstreamingMessagependingToolCallserrorMessage它是下一次 Run 的起点,但不是完整 Session。
2.4 AgentContext:某次 Run 内部使用的快照
Section titled “2.4 AgentContext:某次 Run 内部使用的快照”Agent.createContextSnapshot() 从 AgentState 复制:
return { messages: this._state.messages.slice(), tools: this._state.tools.slice(),};这里只复制顶层数组。这样 Loop 可以在自己的 currentContext.messages 中追加消息,AgentState 则通过事件管线接收最终消息。
2.5 pi-ai Message 与 Context:Provider 无关的统一协议
Section titled “2.5 pi-ai Message 与 Context:Provider 无关的统一协议”convertToLlm() 会把 AgentMessage[] 收敛成:
SystemMessageUserMessageAssistantMessageToolResultMessage随后用 normalizeContext({ messages }) 得到 TranscriptContext;系统指令和工具声明已在消息内。
2.6 Provider Payload:最终 HTTP 请求体
Section titled “2.6 Provider Payload:最终 HTTP 请求体”它已经是厂商协议,例如:
- Anthropic 的
MessageCreateParams; - OpenAI Responses 的
ResponseCreateParams; - Google 的
contents + systemInstruction。
这一层不持久化,通常只活到本次网络请求结束。
SessionEntry[] 完整持久化树 ↓ buildSessionContext()AgentMessage[] 当前分支的产品消息 ↓ Agent.createContextSnapshot()AgentContext 本次 Run 的工作副本 ↓ context hookAgentMessage[] 本次调用临时改写后的消息 ↓ convertToLlm()pi-ai Message[] Provider 无关消息 ↓ transformMessages() + serializerProvider Payload 最终请求三、启动阶段:资源如何变成 System Prompt
Section titled “三、启动阶段:资源如何变成 System Prompt”这一段从 sdk.ts 的 createAgentSession() 开始读。
3.1 createAgentSession() 先准备三类对象
Section titled “3.1 createAgentSession() 先准备三类对象”函数依次创建或接收:
SettingsManagerSessionManagerResourceLoader若调用者没有传入 ResourceLoader:
resourceLoader = new DefaultResourceLoader(...);await resourceLoader.reload();所以 System Prompt 的文件扫描不发生在 Agent 或 agent-loop 中,而是在 Session 创建之前完成。
3.2 ResourceLoader.reload() 是资源发现总入口
Section titled “3.2 ResourceLoader.reload() 是资源发现总入口”关键位置:
resource-loader.ts的DefaultResourceLoader.reload()
沿着函数向下读,可以看到完整顺序:
清除旧 Extension cache → 可选的 Project Trust bootstrap → reload settings → PackageManager.resolve() → 加载 Extensions → 加载 Skills → 加载 Prompt Templates → 加载 Themes → loadProjectContextFiles() → 解析 SYSTEM.md → 解析 APPEND_SYSTEM.md → 保存本次资源快照这里的“快照”很关键。之后:
getSkills()getPrompts()getAgentsFiles()getSystemPrompt()getAppendSystemPrompt()只是读取内存结果,不会在每个 Turn 重新扫描磁盘。资源变化通常要经过 reload 才会进入新的 Prompt。
3.3 AGENTS.md 的查找算法
Section titled “3.3 AGENTS.md 的查找算法”继续读同一文件顶部的两个函数:
loadContextFileFromDir()loadProjectContextFiles()每个目录取第一份成功读取的规则文件
Section titled “每个目录取第一份成功读取的规则文件”候选顺序是:
AGENTS.override.mdAGENTS.mdAGENTS.MDCLAUDE.mdCLAUDE.MD函数跳过非普通文件以及读取失败的候选,取第一份成功读取的内容。因此同目录同时存在可读取的 AGENTS.override.md 和 AGENTS.md 时,只加载前者;没有 override 时,AGENTS.md 优先于 CLAUDE.md。
查找范围是 agentDir 加 cwd 的祖先链
Section titled “查找范围是 agentDir 加 cwd 的祖先链”算法先读取用户级 agentDir,再从 cwd 一直向文件系统根目录向上查找:
agentDir/AGENTS.md → 文件系统上层祖先/AGENTS.md → 项目祖先/AGENTS.md → cwd/AGENTS.md祖先文件通过 unshift() 加入,因此最终顺序是“通用在前、具体在后”。
它不会向 cwd 的子目录递归。例如 cwd 为 /repo 时,不会自动加载 /repo/src/AGENTS.md;只有 cwd 本身位于 /repo/src,该文件才在祖先链上。
它也不会在 Git 根目录停止,而是一直走到文件系统根目录。重复路径会去重。
嵌套 worktree 的同名规则去重
Section titled “嵌套 worktree 的同名规则去重”findShadowedContextFile() 处理一个额外情况:linked worktree 位于主工作区内部,并且 worktree 根目录有自己的规则文件。
例如主工作区是 /repo,linked worktree 是 /repo/worktrees/feat:
- 两处都有
AGENTS.md,并满足函数中的 Git 目录检查时,跳过主工作区的同名文件,使用 worktree 的规则; - worktree 根没有规则文件时,仍可继承主工作区的规则;
- 主工作区是
CLAUDE.md,worktree 选中的是AGENTS.md时,两个文件名不同,不触发这一同名去重。
所以“祖先由远及近”描述的是一般顺序,不能理解成所有祖先规则都会无条件加载。
AGENTS 与 Project Trust 的边界
Section titled “AGENTS 与 Project Trust 的边界”SYSTEM.md 和 APPEND_SYSTEM.md 的项目级文件要通过:
this.settingsManager.isProjectTrusted()但 reload() 调用 loadProjectContextFiles() 时没有用 trust 过滤 AGENTS/CLAUDE 文件。
因此:
Project Trust 会保护项目 Extensions、Skills、Prompt 等资源的加载,但当前实现并不把项目
AGENTS.md当作完全隔离的可信执行边界。
XML 包装也只是给模型看的结构标记,不是权限系统。
3.4 SYSTEM.md 和 APPEND_SYSTEM.md
Section titled “3.4 SYSTEM.md 和 APPEND_SYSTEM.md”继续读:
DefaultResourceLoader.discoverSystemPromptFile()DefaultResourceLoader.discoverAppendSystemPromptFile()优先级是:
显式 options > 已信任项目 cwd/.pi/SYSTEM.md 或 APPEND_SYSTEM.md > agentDir/SYSTEM.md 或 APPEND_SYSTEM.md二者语义不同:
| 文件 | 作用 |
|---|---|
SYSTEM.md |
替换 Pi 默认 Prompt 主体 |
APPEND_SYSTEM.md |
追加到默认或自定义主体后 |
即使存在 SYSTEM.md,后面的 AGENTS、Skills 和 cwd 仍会追加。它替换的是默认 identity/tools/guidelines/docs 主体,不是整个最终字符串。
3.5 AgentSession 如何消费 ResourceLoader
Section titled “3.5 AgentSession 如何消费 ResourceLoader”_rebuildSystemPrompt() 现在保存完整的构造选项;真正送入历史的是按 section 计算的差量。以下为 agent-session.ts 和 system-prompt.ts 的缩写:
this._baseSystemPromptOptions = normalizeBuildSystemPromptOptions({ cwd: this._cwd, skills: loader.getSkills().skills, contextFiles: loader.getAgentsFiles().agentsFiles, customPrompt: loader.getSystemPrompt(), appendSystemPrompt: loader.getAppendSystemPrompt().join("\n\n"), selectedTools: validToolNames, toolSnippets, toolGuidelines: Object.fromEntries(this._toolPromptGuidelines),});// _preparePromptAndToolLoadout(options, messages):options.selectedTools = this._applyToolLoadout(options.selectedTools).map(tool => tool.name);// _applyToolLoadout 解析 exposure、prepareLoadout,并写 agent.state.tools。const sections = diffSystemPromptSections( getCurrentSystemMessage(messages)?.sections ?? {}, buildSystemPromptSections(options),);return sections ? { role: "system", content: "", sections, timestamp: Date.now() } : undefined;这里省略无效工具过滤。diffSystemPromptSections() 对新增或改变的 section 写入字符串,对删除的 section 写入 null;没有变化就不产生补丁。Loop 再把工具声明差量加到 SystemMessage,发出消息事件,AgentSession 据此保存。
3.6 System Prompt 的有序 sections
Section titled “3.6 System Prompt 的有序 sections”buildSystemPromptSections() 的默认顺序是:
preamble → tools → rules → docs → addendum → project_context → skills → cwd → 自定义 sectionspreamble 是无标签正文,其他 section 包在同名 XML 标签中,例如 <cwd>...</cwd>。空的可选项不生成。工具规则先按当前 selectedTools 汇总,再加通用 promptGuidelines,去重后形成 rules。
if (customPrompt) { promptSections.preamble = customPrompt;} else { // 默认 preamble、tools、rules、docs}if (appendSystemPrompt) promptSections.addendum = appendSystemPrompt;if (contextFiles.length) promptSections.project_context = renderProjectContext(contextFiles);// skills 见 3.8promptSections.cwd = cwd.replace(/\\/g, "/");所以 SYSTEM.md 仍只是替换默认前缀,不会抹掉 AGENTS、Skill 索引和 cwd。buildSystemPrompt() 负责把结构化状态渲染为字符串;整个状态不再只能由一个可写的 agent.state.systemPrompt 字段承载。
AGENTS 如何拼进去
Section titled “AGENTS 如何拼进去”contextFiles 被按原顺序包装:
<project_context>
Project-specific instructions and guidelines:
<project_instructions path="/repo/AGENTS.md">这里是文件正文</project_instructions>
</project_context>来源路径被保留,模型可以知道规则来自哪一层目录。但“后面的规则覆盖前面的规则”没有程序化实现,只能由模型理解。
3.7 工具为何同时影响 Prompt 和 schema
Section titled “3.7 工具为何同时影响 Prompt 和 schema”AgentSession 维护:
_toolRegistry 实际 AgentTool_toolPromptSnippets 一行工具说明_toolPromptGuidelines 工具使用建议setActiveToolsByName() 做两件事:
1. agent.state.tools = 当前 active AgentTool[]2. _rebuildSystemPrompt(active tool names)因此切换工具会同时改变:
- Provider 收到的结构化 tool schemas;
- System Prompt 中的
Available tools和 guidelines。
一个工具只有同时满足以下条件才出现在文字列表:
工具处于 active 状态 +工具提供 promptSnippet没有 snippet 的自定义工具仍然会通过 schema 发送给模型。
3.8 Skill 为什么只放索引
Section titled “3.8 Skill 为什么只放索引”定位:
skills.ts的formatSkillsForPrompt()
System Prompt 常驻的只是:
<skill> <name>...</name> <description>...</description> <location>/absolute/path/SKILL.md</location></skill>模型先根据 description 判断是否相关,相关时再用 read 读取完整 SKILL.md。这叫 progressive disclosure:
常驻低成本索引 → 按任务选择 → 按需加载完整工作流当前会优先选择 read,没有 read 时也可选择 bash 读取 Skill;两者都不可用才省略索引:
const fileReadTool = (["read", "bash"] as const).find(name => selectedTools.includes(name));if (fileReadTool && skills.length) formatSkillsForPrompt(skills, fileReadTool);disable-model-invocation: true 的 Skill 不出现在索引中,但用户仍可通过 /skill:name 显式调用。
至此,启动阶段得到:
_baseSystemPromptOptions = 资源与工具的结构化选项agent.state.tools = active AgentTool[]接下来恢复历史;提交请求时才根据历史中已有的系统状态生成必要补丁。
四、恢复阶段:Session Tree 如何变成 AgentState.messages
Section titled “四、恢复阶段:Session Tree 如何变成 AgentState.messages”回到 createAgentSession(),在创建 Agent 前它会调用:
const existingSession = sessionManager.buildSessionContext();这条链的实现集中在 session-manager.ts:
buildSessionPath() → getSessionContextSettings() → buildContextEntries() → 应用 context_edit,并用 sessionEntryToContextMessages() 转换 → buildSessionProjection() → buildSessionContext()4.1 buildSessionPath():先选当前分支
Section titled “4.1 buildSessionPath():先选当前分支”输入:
全部 SessionEntry[]当前 leafIdid → entry 索引函数从 leaf 沿 parentId 回溯到根,再 reverse:
root → ... → current leaf所以同一 JSONL 中其他分支仍然存在,但不会进入当前上下文。
4.2 getSessionContextSettings():从完整路径恢复运行配置
Section titled “4.2 getSessionContextSettings():从完整路径恢复运行配置”它遍历完整 path,恢复:
thinkingLevelmodel模型来源既可能是 model_change entry,也可能是历史 AssistantMessage 自带的 provider/model。
注意:配置恢复使用完整 path,而消息裁剪在下一步进行。即使旧消息被 compaction 摘要替代,路径上的最新模型和 thinking 设置仍然可以恢复。
4.3 buildContextEntries():应用最新 compaction 边界
Section titled “4.3 buildContextEntries():应用最新 compaction 边界”若当前 path 没有 compaction,直接返回整个 path。
若有多个 compaction,只使用最新一个。返回结构是:
最新 CompactionEntry + compaction 之前从 firstKeptEntryId 开始的保留 entries + compaction 之后的所有 entries也就是说,compaction 没有删除 JSONL 历史。它只改变“当前模型应该看到哪一段历史”。
4.4 sessionEntryToContextMessages():把 entry 投影为消息
Section titled “4.4 sessionEntryToContextMessages():把 entry 投影为消息”转换表:
| SessionEntry | 输出 |
|---|---|
message |
原始 AgentMessage |
custom_message |
CustomMessage |
branch_summary |
BranchSummaryMessage |
compaction |
可选 SystemMessage + CompactionSummaryMessage |
context_edit |
自身不输出,投影阶段排除/替换目标消息 |
usage |
不输出模型消息 |
model_change |
无消息输出 |
thinking_level_change |
无消息输出 |
custom |
无消息输出 |
label |
无消息输出 |
session_info |
无消息输出 |
其中 custom 和 custom_message 必须区分:
custom = Extension 持久化自己的状态 = 不进入模型 Context
custom_message = Extension 注入一条模型可见消息 = 后续会转换为 user message这个函数还会把旧 Session 或手工修改文件中的 null/missing content 归一化:system 用空字符串,其他对话角色用 [],避免下游 serializer 崩溃。
4.5 buildSessionContext():组合结果
Section titled “4.5 buildSessionContext():组合结果”最终返回:
{ messages: AgentMessage[], thinkingLevel: string, model: { provider, modelId } | null,}createAgentSession() 创建 Agent 时传入:
new Agent({ initialState: { messages: existingSession.messages, /* model、thinking 等 */ } });至此可区分三个运行时视图,其中 Prompt 已由消息承载:
AgentState.systemPrompt(只读重放视图)AgentState.messages(含 system)AgentState.tools(执行实现)五、输入阶段:用户文字怎样成为 UserMessage
Section titled “五、输入阶段:用户文字怎样成为 UserMessage”一次交互的产品入口是:
agent-session.ts的AgentSession.prompt()
不要直接从 Agent.prompt() 开始读,否则会漏掉命令、Extension、Skill 和 Template。
5.1 AgentSession.prompt() 的真实执行顺序
Section titled “5.1 AgentSession.prompt() 的真实执行顺序”以下描述默认 expandPromptTemplates = true 的路径:
原始 text/images → 尝试执行 Extension command(命中则返回) → 检查是否正在 compaction(若是则拒绝本次 prompt) → Extension input hook → /skill:name 展开 → /template 展开 → 若正在 streaming,则进入 steer/followUp 队列 → flush 延迟的 Bash 和 custom 消息 → 检查 model 和 auth → 必要时在新输入前 compaction → before_agent_start hook → 根据最终模型归一化图片 → 创建 UserMessage,追加 pending nextTurn 与 hook 返回的 custom messages → 前置必要的 SystemMessage patch → 启动 Agent RunexpandPromptTemplates = false 会同时跳过 Extension command、Skill 和 Template 展开,input hook 仍会执行。这个选项的实际影响比名字中的 Template 更广。
下面逐段解释。
5.2 Extension command 最先处理
Section titled “5.2 Extension command 最先处理”默认开启展开且输入以 / 开头时,_tryExecuteExtensionCommand() 先查 Extension 注册命令。
命中后,命令 handler 自己决定做什么,当前输入不会自动成为 UserMessage,也不会继续做 Skill/Template 展开。
5.3 input hook 看到的是原始输入
Section titled “5.3 input hook 看到的是原始输入”Extension 可以:
handled 完全消费输入transform 改写 text/images 后继续它发生在 Skill 和 Template 之前,因此 Extension 能改写将要展开的命令文本。
5.4 /skill:name 如何展开
Section titled “5.4 /skill:name 如何展开”定位:
AgentSession._expandSkillCommand()
输入:
/skill:review src/auth.ts函数:
从 ResourceLoader 的 Skill 列表找 name → 同步读取完整 SKILL.md → 去掉 frontmatter → 写入 name、location 和相对路径基准 → 追加用户参数近似输出:
<skill name="review" location="/.../SKILL.md">References are relative to /.../review.
Skill 正文</skill>
src/auth.ts这一整段之后作为 UserMessage 文本,而不是临时 System Prompt。
因此 Skill 有两种进入模型的方法:
模型自动选择 System Prompt 看到 Skill 索引 → 模型调用 read → 读取正文成为后续工具上下文
用户显式 /skill:name AgentSession 直接展开正文 → 当前 UserMessage5.5 Prompt Template 如何展开
Section titled “5.5 Prompt Template 如何展开”定位:
prompt-templates.ts的expandPromptTemplate();- 同文件的
parseCommandArgs()和substituteArgs()。
输入:
/review src/auth.ts security处理过程:
解析命令名 review → 找到同名 PromptTemplate → 按引号规则解析参数 → 在模板正文中替换占位符支持:
$1、$2$@、$ARGUMENTS${1:-default}${@:N}${@:N:L}参数值不会递归触发第二轮替换,因此参数中的 $1 不会被再次解释。
Template 是文本宏,不是独立 Agent,也不是 System Prompt。
5.6 streaming 时输入不会立即启动第二个 Run
Section titled “5.6 streaming 时输入不会立即启动第二个 Run”若 Agent 正在运行,调用者必须提供 streamingBehavior,否则 prompt() 抛错;它不会自动选择队列策略。两个可选值决定:
steer 当前 assistant turn 和工具执行结束后 在下一次 LLM 调用前注入
followUp 当前任务本来要结束时 再开始处理Agent 层的 PendingMessageQueue 决定一次 drain 一条还是全部。
这不是简单 UI 队列,它会改变后续 currentContext.messages 的时间位置。
5.7 创建真正的 UserMessage
Section titled “5.7 创建真正的 UserMessage”普通输入最终变成:
{ role: "user", content: [ { type: "text", text: expandedText }, ...images, ], timestamp: Date.now(),}实际执行中先运行下一节的 before_agent_start,再按 hook 选定模型的图片 resize 配置归一化,最后创建这条 UserMessage;上面字段代码省略了图片提示文本。随后 pending nextTurn 和 hook 返回的 custom messages 一起加入 prompts,必要的 system patch 放在最前。
5.8 before_agent_start:修改构造选项或强制覆盖
Section titled “5.8 before_agent_start:修改构造选项或强制覆盖”ExtensionRunner.emitBeforeAgentStart() 给 handlers 一个可修改的 systemPromptOptions。event.systemPrompt 和 ctx.getSystemPrompt() 按最新选项即时渲染,后续 handler 能看到前面的修改。
const currentOptions = normalizeBuildSystemPromptOptions(baseOptions);const result = await handler({ type: "before_agent_start", prompt, images, get systemPrompt() { return buildSystemPrompt(currentOptions); }, systemPromptOptions: currentOptions,}, ctx);if (result?.message) messages.push(result.message);if (result?.systemPrompt !== undefined) currentOptions.forceSystemPrompt = result.systemPrompt;普通 options 修改生成结构化 SystemMessage 补丁并持久化。返回的 custom message 也通过消息事件保存。但返回 systemPrompt 字符串走另一条路:存入本次 _runSystemPromptOptions.forceSystemPrompt,在 transformContext 中、Extension context hooks 之后,临时把所有 system messages 折成一条头部消息,保留当前工具声明并使用强制正文。这个强制正文不写 Session。
const current = getCurrentSystemMessage(transformed);return [{ role: "system", content: forced, ...(current?.toolsAdded ? { toolsAdded: current.toolsAdded } : {}), timestamp: current?.timestamp ?? Date.now(),}, ...transformed.filter(m => m.role !== "system")];本次产品层请求可能经历 retry/compaction 的多个 Core Run;最终 _runAgentPrompt() 的 finally 清除 _runSystemPromptOptions。AgentState.systemPrompt 是 transcript 重放视图,强制覆盖期间要用 session.systemPrompt / ctx.getSystemPrompt() 理解产品层有效值。
六、运行阶段:Agent Run 和 Turn 如何推进
Section titled “六、运行阶段:Agent Run 和 Turn 如何推进”接下来进入两个文件:
6.1 Run 和 Turn 不是同一个概念
Section titled “6.1 Run 和 Turn 不是同一个概念”一次用户请求通常启动一个 Agent Run,但一个 Run 可以包含多个 Turn:
Turn 1:User → Assistant(tool call) → 执行工具并加入 ToolResult
Turn 2:历史 + ToolResult → Assistant(tool call) → 再执行工具
Turn 3:历史 + ToolResult → Assistant(final answer) → Run 结束steering 还可以在 Turn 边界注入新消息;follow-up 可以让本来要结束的 Run 继续。
6.2 Agent.prompt():建立 Run
Section titled “6.2 Agent.prompt():建立 Run”Agent.prompt() 先把字符串或消息归一化,然后进入 runPromptMessages()。
这里传给 runAgentLoop() 的两类数据必须分开理解:
this.createContextSnapshot() // 本轮模型能看到什么this.createLoopConfig() // Loop 应该怎样运行AgentContext 包含:
messages(含 system)+ tools(执行实现)AgentLoopConfig 包含:
modelreasoningconvertToLlmtransformContextgetSteeringMessagesgetFollowUpMessagesfinishTurn / prepareNextTurn / prepareRequestbefore/afterToolCallauth、transport、retry 等前者是数据,后者是运行策略。
6.3 runAgentLoop():把本次 prompts 接到历史后面
Section titled “6.3 runAgentLoop():把本次 prompts 接到历史后面”关键代码:
const initialMessages = declareToolChanges(context, prompts);const currentContext = { ...context, messages: [...context.messages, ...initialMessages],};并依次发出:
agent_startturn_start每条 prompt 的 message_start / message_endAgent.processEvents() 在收到 message_end 时,把完成的消息追加到 AgentState.messages;AgentSession 的事件订阅再负责 Session 持久化和 Extension 生命周期。
因此有两条同步发生的轨迹:
currentContext.messages = 本 Run 内模型继续使用的工作历史
AgentState.messages + Session = 通过事件管线同步的内存状态和持久化历史6.4 runLoop():内外两层循环
Section titled “6.4 runLoop():内外两层循环”定位:
agent-loop.ts的runLoop()
内层循环的正常响应路径是:
若已有完成 Turn:prepareNextTurn → turn_start → 声明工具差量并注入 prepared / pending messages → prepareRequest → 请求一次 assistant response → 收集 Tool Calls、执行工具、追加 ToolResults → finishTurn → turn_end(若决定 end,则结束 Run) → 获取 steering,判断是否继续内层循环首个 Turn 的 turn_start 已由外层入口发出,不会先调用 prepareNextTurn。Provider 返回 error / aborted 时会先调用 finishTurn 但忽略继续决定,然后发出 turn_end、agent_end 并返回。
外层循环负责:
当本轮本来要结束时 → drain follow-up messages → 若存在则重新进入内层 → 若不存在,但 finishTurn 要求 continue,也安排一次请求6.5 ToolResult 为什么会进入下一次模型调用
Section titled “6.5 ToolResult 为什么会进入下一次模型调用”streamAssistantResponse() 返回 AssistantMessage 后,runLoop() 取出其中的 Tool Calls。
工具执行完成后,每个结果被同时加入:
currentContext.messages.push(result);newMessages.push(result);因此下一次内层循环调用模型时,历史末尾是:
AssistantMessage(toolCall)ToolResultMessage模型能根据工具结果继续推理。
6.6 下一 Turn 刷新与每次请求投影
Section titled “6.6 下一 Turn 刷新与每次请求投影”AgentSession 安装两个不同的准备点,省略已有 hook 的组合逻辑后:
// prepareNextTurnWithContext:只在同 Run 确认还要继续时执行const context = await this._compactBeforeNextAssistantResponse({ ...turn.context, messages: this.sessionManager.buildSessionProjection().messages,});const updateMessage = this._preparePromptAndToolLoadout(options, context.messages);return { context: { ...context, tools: this.agent.state.tools.slice() }, messages: updateMessage ? [updateMessage] : [], model: this.agent.state.model, thinkingLevel: this.agent.state.thinkingLevel };
// prepareRequest:每一轮,初始轮也执行;消息已追加后取得最新投影return { context: { ...request.context, messages: this.sessionManager.buildSessionProjection().messages, tools: this.agent.state.tools.slice(),}, model: this.agent.state.model, thinkingLevel: this.agent.state.thinkingLevel };前者可以在大段工具结果之后尝试 threshold compaction,并准备 Prompt 差量;后者保证真正发请求时采用持久化历史投影。两者都可与此前安装的宿主 hook 组合。检查压缩条件并不保证实际生成摘要。
七、每次模型调用前:Context 的最后一次产品层处理
Section titled “七、每次模型调用前:Context 的最后一次产品层处理”真正进入模型的边界在:
agent-loop.ts的streamAssistantResponse()
按顺序只有三步:
1. transformContext(AgentMessage[])2. convertToLlm(AgentMessage[])3. 构造 pi-ai Context 并调用 streamFunction()7.1 Extension context hook
Section titled “7.1 Extension context hook”createAgentSession() 在创建 Agent 时注入:
transformContext: async (messages) => { return runner.emitContext(messages);}ExtensionRunner.emitContext() 先执行:
let currentMessages = structuredClone(messages);然后按 Extension 加载顺序串联:
原始 AgentMessage[] → Extension A 的结果 → Extension B 的结果 → ... → convertToLlm()它可以:
- 删除只想保留在 UI/Session 的消息;
- 注入动态业务信息;
- 脱敏;
- 实现自定义 memory 或裁剪策略;
- 根据当前环境重写历史。
因为入口是 deep clone,默认不会修改:
currentContext.messagesAgentState.messagesSession JSONL它只影响这一次 LLM 调用。若 Extension 想持久化内容,应走 custom_message、普通消息或 Session API,而不是依赖 context hook。
handler 抛错时,Runner 记录 Extension error,并继续使用当前 messages。这里没有事务回滚:入口只 deep clone 一次,各 handler 共用当前副本。如果某个 handler 原地修改副本后抛错,修改仍可能传给后续 handler。克隆保护的是原始消息,不隔离各 handler,也不阻止 Extension 通过其他 API 产生副作用。
context handlers 现在只接收非 system 的对话消息。返回列表有变化时,Runner 用 getCurrentSystemMessage() 将当前 Prompt 和工具状态折成头部消息再接回;列表没变则保留原 system 消息位置。随后 context_with_system handlers 接收完整 transcript,其输出直接使用;丢掉头部 system 时报告错误但不替 handler 恢复。
structuredClone(messages) → context:对话可编辑,Pi 保护系统状态 → context_with_system:完整消息可编辑 → forceSystemPrompt 临时投影(若有) → convertToLlm7.2 convertToLlm():收敛产品自定义角色
Section titled “7.2 convertToLlm():收敛产品自定义角色”定位:
messages.ts的convertToLlm()
转换表:
| AgentMessage role | pi-ai Message |
|---|---|
system |
原样保留,含 sections 与工具声明 |
user |
原样保留 |
assistant |
原样保留 |
toolResult |
原样保留 |
bashExecution |
转为 user text |
custom |
转为 user,保留文本/图片 content |
branchSummary |
加说明和 <summary>,转为 user |
compactionSummary |
加说明和 <summary>,转为 user |
BashExecutionMessage
Section titled “BashExecutionMessage”用户通过 ! 执行的 shell 命令不是模型 Tool Call,因此没有对应 ToolResult。它会格式化为类似:
Ran `git status`再附带:
输出代码块cancelled 状态非零 exit code截断输出的完整文件路径若 excludeFromContext = true,该消息留在产品历史中,但从 LLM Context 删除。
CustomMessage
Section titled “CustomMessage”Extension 的:
customTypedisplaydetails不会发送给模型。模型只收到 content,角色是 user。
Branch/Compaction Summary
Section titled “Branch/Compaction Summary”统一协议没有 summary role,因此二者都转换为 UserMessage,并用前缀解释摘要语义。Provider 无需理解 Pi 的 Session Tree。
7.3 blockImages 是产品 policy
Section titled “7.3 blockImages 是产品 policy”createAgentSession() 没有直接把 convertToLlm 传给 Agent,而是包装为 convertToLlmWithBlockImages。
顺序是:
convertToLlm() → 动态读取 settingsManager.getBlockImages() → 若开启,将 user/toolResult image 替换为 "Image reading is disabled."这是用户或宿主明确禁止图片的策略,不是模型能力检测。
7.4 统一 LLM Context 在这里汇合
Section titled “7.4 统一 LLM Context 在这里汇合”streamAssistantResponse() 构造:
const llmContext = normalizeContext({ messages: llmMessages });然后:
streamFunction(config.model, llmContext, options)此时 coding-agent 层的工作结束。后面进入 pi-ai。
八、Provider 边界:跨模型历史如何变成合法请求
Section titled “八、Provider 边界:跨模型历史如何变成合法请求”每个 Provider serializer 都会先调用:
transform-messages.ts的transformMessages()
这个函数不理解 AGENTS、Skills、Session 或 Extension。输入已经只有标准 Message[]。它处理的问题是:
历史 AssistantMessage 可能来自模型 A,而当前要请求模型 B。怎样保留可迁移的语义,同时删除目标 Provider 无法接受的私有结构?
8.1 第一阶段:逐条归一化
Section titled “8.1 第一阶段:逐条归一化”transformMessages() 先:
null/missing content → []不支持图片时降级处理 thinking block处理 Tool Call thoughtSignature归一化 Tool Call ID同步归一化 ToolResult ID8.2 图片能力降级
Section titled “8.2 图片能力降级”如果:
!model.input.includes("image")则:
User image → "(image omitted: model does not support images)"
ToolResult image → "(tool image omitted: model does not support images)"连续图片只生成一个 placeholder。
它和 blockImages 的差别:
blockImages = 产品不允许发送图片 = coding-agent convertToLlm wrapper
non-vision downgrade = 目标模型不能接收图片 = pi-ai transformMessages8.3 如何判断历史是否来自同一个模型
Section titled “8.3 如何判断历史是否来自同一个模型”必须同时满足:
assistantMessage.provider === target.providerassistantMessage.api === target.apiassistantMessage.model === target.id仅 model id 相同不够,因为不同 Provider/API 的签名格式可能不同。
8.4 thinking block 的迁移规则
Section titled “8.4 thinking block 的迁移规则”规则可整理成:
| thinking 类型 | 同模型 | 跨模型 |
|---|---|---|
| redacted/加密 thinking | 保留 | 删除 |
| 带 signature 字段 | 保留原 block | 有可见文本则转普通 text,否则删除 |
| 无 signature、有文本 | 保留 thinking | 转普通 text |
| 空 thinking | 通常删除;若有同模型可重放 signature 则保留 | 删除 |
这里仅判断 signature 字段是否存在及是否同模型,不验证签名真实性;保留字段也不等于服务端一定接受。
原因是 thinking signature 往往是 Provider 私有、加密或不可验证的数据。跨模型只迁移可见语义,不能迁移 opaque 协议状态。
8.5 Tool Call 的 thoughtSignature
Section titled “8.5 Tool Call 的 thoughtSignature”Tool Call 也可能携带 Provider 私有 thoughtSignature。
跨模型时:
name、arguments、id 可以继续使用thoughtSignature 删除8.6 Tool Call ID 必须成对改写
Section titled “8.6 Tool Call ID 必须成对改写”不同 API 对 ID 的字符和长度要求不同。例如 OpenAI Responses 可能生成含 | 的复合 ID,Anthropic 接口则需要更严格的字符集和长度。
Provider 向 transformMessages() 提供:
normalizeToolCallId(id, targetModel, sourceMessage)只有历史 AssistantMessage 来自不同模型,且 Provider 提供了这个 normalizer 时,通用转换层才调用它;同模型默认保留原 ID。
发生 ID 变化时,函数维护:
original id → normalized id随后把对应 ToolResult 的 toolCallId 同步改写。
只改 Tool Call 而不改结果会产生:
Assistant ToolCall id = normalized_123ToolResult id = original|123Provider 会认为结果不属于该调用。
8.7 第二阶段:修复消息序列不变量
Section titled “8.7 第二阶段:修复消息序列不变量”第一阶段完成 ID 归一化后,函数第二遍扫描消息。
error/aborted AssistantMessage 被跳过
Section titled “error/aborted AssistantMessage 被跳过”stopReason = errorstopReason = aborted这些消息可能包含不完整 reasoning 或 Tool Call,适合留在 Session 中审计,但不适合 replay 给模型。
orphan Tool Call 被补 synthetic ToolResult
Section titled “orphan Tool Call 被补 synthetic ToolResult”如果历史中存在:
AssistantMessage └─ ToolCall(call_1)却没有对应 ToolResult,许多 Provider 会拒绝整次请求。
扫描到以下边界时会补齐:
下一条 AssistantMessageUserMessage 打断工具流messages 结尾生成:
{ role: "toolResult", toolCallId: missingCall.id, toolName: missingCall.name, content: [{ type: "text", text: "No result provided" }], isError: true,}这不是伪造成功结果,而是明确告诉模型“历史中没有结果”。它只存在于本次 Provider Context,不回写 AgentState 或 Session。
为什么必须放在第二遍?因为要先得到目标 Provider 接受的最终 Tool Call ID,才能正确匹配和补齐。
九、最终序列化:统一 Context 怎样进入不同 API
Section titled “九、最终序列化:统一 Context 怎样进入不同 API”各 Provider 根据模型兼容配置处理 system messages、工具集合和跨模型消息,再做字段映射。支持对话中系统更新的模型可以保留更新位置;不支持时会把更新折回头部,不能假设所有 Provider 都直接删除中间 system。
9.1 Anthropic
Section titled “9.1 Anthropic”定位:
anthropic-messages.ts的buildParams();- 同文件的
convertMessages()。
主流程:
transformMessages(context.messages) → convertMessages() 生成 Anthropic messages → 从 transcript 重放/规范化 system 状态 → 头部 SystemMessage 转 params.system → getCurrentTools(context.messages) 转 Anthropic tools → 加 max_tokens、thinking、cache 等参数其中 Tool Call 通常变为 tool_use,ToolResult 变为 tool_result。
9.2 OpenAI Responses
Section titled “9.2 OpenAI Responses”定位:
openai-responses-shared.ts的convertResponsesMessages();openai-responses.ts的buildParams()。
主流程:
transformMessages(context.messages) → System Prompt 转 system/developer item → user text 转 input_text → image 转 input_image → assistant thinking 转 reasoning item → Tool Call 转 function_call → ToolResult 转 function_call_outputreasoning model 且兼容配置允许时,System Prompt 可能使用 developer role。
9.3 最终 payload hook
Section titled “9.3 最终 payload hook”Provider 构造 payload 后会调用 onPayload。createAgentSession() 把它连接到 Extension 的:
before_provider_request所以 Extension 有两个完全不同的改写位置:
| Hook | 看到的数据 | 是否 Provider-specific |
|---|---|---|
context |
AgentMessage[] |
否 |
before_provider_request |
最终 payload | 是 |
前者适合通用历史裁剪,后者适合修改某个 Provider 的具体字段。
十、把一次请求完整推导出来
Section titled “十、把一次请求完整推导出来”以下是为了手工推导而构造的数据,不是真实 Provider 调用记录。目标 Model 配置刻意设为 Anthropic API 且 input 不包含 image,用来演示能力降级分支,不指代某个已验证的线上型号。最终 payload 也只展示相关字段。
假设 Session 当前分支包含:
1. UserMessage("检查构建")2. AssistantMessage( source = OpenAI, thinking + ToolCall(id="call_1|fc_1") )3. ToolResult(toolCallId="call_1|fc_1", "tests failed")4. BashExecutionMessage("git status", output="clean")5. CustomMessage("Issue #42: login timeout")6. AssistantMessage(stopReason="error")用户现在附带一张图片输入:
继续修复目标模型是一个不支持图片的 Anthropic 模型。
第一步:恢复 AgentState
Section titled “第一步:恢复 AgentState”buildSessionContext():
选择当前 leaf path → 应用最新 compaction → 把 message/custom_message/summary 投影为 AgentMessage得到:
UserAssistant(toolCall)ToolResultBashExecutionCustomAssistant(error)第二步:处理新输入
Section titled “第二步:处理新输入”AgentSession.prompt():
Extension command → input hook → Skill/Template → before_agent_start → 根据最终模型归一化图片 → 创建 UserMessage(text + image),前置必要的 SystemMessage patch本例没有命令或展开,因此 prompts 是:
UserMessage([text "继续修复", image])第三步:建立 Run Context
Section titled “第三步:建立 Run Context”Agent.createContextSnapshot() 复制:
旧 AgentState.messages(含 system)active tools(执行实现)runAgentLoop() 先声明需要的系统/工具变化,再把新 UserMessage 接到旧消息末尾。下方为突出对话转换,省略 system 行;它仍随 transcript 进入 serializer。
第四步:context hook
Section titled “第四步:context hook”假设 Extension 删除 customType = issue 的 CustomMessage。
本次临时消息变为:
UserAssistant(toolCall)ToolResultBashExecutionAssistant(error)User(text + image)Session 和 AgentState 中的 CustomMessage 仍然存在。
第五步:convertToLlm()
Section titled “第五步:convertToLlm()”User("检查构建")Assistant(thinking + toolCall)ToolResult("tests failed")User("Ran `git status` ... clean")Assistant(error)User(text + image)第六步:transformMessages()
Section titled “第六步:transformMessages()”目标是跨模型且不支持图片:
thinking 可见文本 → 普通 textProvider 私有 signature → 删除Tool Call ID → Anthropic 兼容 IDToolResult ID → 同步改写Assistant(error) → 删除image → non-vision placeholder检查是否存在 orphan Tool Call得到近似统一消息:
User("检查构建")Assistant(text + ToolCall(id="normalized"))ToolResult(toolCallId="normalized", "tests failed")User("Ran `git status` ... clean")User([ text "继续修复", text "(image omitted: model does not support images)"])第七步:Anthropic serializer
Section titled “第七步:Anthropic serializer”最终 payload 近似:
system: default/custom prompt + APPEND_SYSTEM + AGENTS + Skill metadata + cwd
messages: user ... assistant text + tool_use ... user tool_result ... user bash text ... user text + image placeholder ...
tools: 当前 active tools 的 Anthropic schema这就是“模型本轮究竟看到了什么”的完整答案。
十一、持久化边界:哪些变化会留下来
Section titled “十一、持久化边界:哪些变化会留下来”| 操作 | AgentState | Session | 仅当前 Provider 请求 |
|---|---|---|---|
| 正常 System/User/Assistant/ToolResult | 是 | 是 | 是 |
| BashExecution | 是 | 是 | 转换后发送 |
custom_message |
是 | 是 | 转换后发送 |
Extension context / context_with_system 改写 |
否 | 否 | 是 |
blockImages placeholder |
否 | 否 | 是 |
| non-vision 图片 placeholder | 否 | 否 | 是 |
| 跨模型 signature 清理 | 否 | 否 | 是 |
| Tool Call ID 归一化 | 否 | 否 | 是 |
| synthetic ToolResult | 否 | 否 | 是 |
| Provider 对 error/aborted Assistant 的过滤 | 不回写 | 不回写 | 被删除 |
恢复流程的 context_edit 排除 |
投影不再包含 | 原消息与排除记录都保存 | 按投影发送 |
before_agent_start custom message |
是 | 是 | 是 |
before_agent_start system override |
当前产品层 prompt 编排及自动续跑使用;下次普通 prompt 重新设置 | 不自动作为消息持久化 | 是 |
这张表揭示了整个设计的核心:
Session 追求历史完整;AgentState 追求运行连续;Provider Context 追求本次请求合法、相关、可发送。三者不应该强行相等。
十二、Prompt Engineering 与 Context Engineering 的分工
Section titled “十二、Prompt Engineering 与 Context Engineering 的分工”Prompt Engineering 负责表达
Section titled “Prompt Engineering 负责表达”适合放进 Prompt 的内容:
- 回答风格;
- 项目规范;
- 工具选择建议;
- 某类任务何时使用某个 Skill;
- 需要模型判断的工作原则。
对应源码:
buildSystemPrompt()formatSkillsForPrompt()expandPromptTemplate()AgentSession._expandSkillCommand()Context Engineering 负责选择和修复
Section titled “Context Engineering 负责选择和修复”适合由 Context 管道处理:
- 当前 Session 分支;
- compaction 边界;
- 临时动态知识;
- UI-only 消息过滤;
- 图片 policy;
- 跨模型 thinking/signature;
- Tool Call ID;
- orphan Tool Call。
对应源码:
buildSessionContext()ExtensionRunner.emitContext()convertToLlm()transformMessages()必须由代码保证的不变量
Section titled “必须由代码保证的不变量”以下内容不能只靠 System Prompt:
- Tool 参数 schema 校验;
- 一个 Agent 不能并发两个 Run;
- Tool Call/ToolResult 协议顺序;
- Project Trust;
- Session 持久化;
- retry/abort;
- Provider ID 和签名兼容;
- compaction 边界。
原则:
需要模型理解和判断的规则写 Prompt;可以验证、阻止或修复的不变量写代码。
十三、安全与失败边界
Section titled “十三、安全与失败边界”13.1 Prompt 不是权限系统
Section titled “13.1 Prompt 不是权限系统”以下内容本质上都是模型可读文本:
AGENTS.mdSkill 正文Prompt Template用户输入Extension 注入消息XML 标签只能帮助模型识别来源,不能阻止 Prompt Injection。
在本章核对的实现中,Pi 提供了 active tools 选择、beforeToolCall 拦截扩展点,以及 Project Trust 资源加载检查。它们分别控制可调用工具、调用是否放行、部分项目资源是否加载;这些机制的作用范围需要逐项理解。
文件和网络访问限制、容器或沙箱、凭据隔离、操作审批,则需要宿主按环境额外提供。Pi 的 README 明确说明没有内建 permission popups,可以使用容器或通过 Extensions 自行实现确认流程。
cwd 本身只是工作目录,不能据此推导出文件访问被限定在目录内;允许调用 Bash 也不等于约束了它访问的资源。Project Trust 控制资源加载,不是运行时沙箱。
13.2 Extension hook 失败
Section titled “13.2 Extension hook 失败”context 和 before_agent_start handler 的异常会被 Runner 捕获并记录,再继续后续 handler。这是对 handler 调用的容错,不是回滚机制,也不保证任意 Extension 错误都不会影响请求;例如返回非法数据或修改共享副本,仍可能影响下游。
13.3 Abort 与错误消息
Section titled “13.3 Abort 与错误消息”aborted/error AssistantMessage 可以被持久化,用于 UI 和诊断。下次请求时,transformMessages() 将它们从 Provider Context 删除,避免重放这些未完成的响应。
这不保证任意旧历史都变得合法。该函数会为“有 Tool Call、无结果”补 synthetic ToolResult,但仍会保留遍历到的 ToolResult;对于手工构造的“只有结果、没有调用”等历史,不能把它当作完整协议校验器。
13.4 非法旧历史
Section titled “13.4 非法旧历史”Session 文件可能来自旧版本、fork 或手工修改。sessionEntryToContextMessages() 和 transformMessages() 都对 null/missing content 做防御性归一化。
这是两个 choke point:
持久化历史 → AgentMessage统一 Message → Provider十四、按主线回查源码
Section titled “十四、按主线回查源码”不要按目录逐文件通读。按下面顺序在函数之间跳转,每一步只回答一个问题。
第一遍:启动时 Prompt 从哪里来
Section titled “第一遍:启动时 Prompt 从哪里来”-
sdk.ts:createAgentSession()
看 ResourceLoader、SessionManager 和 Agent 在何时创建。 -
resource-loader.ts:DefaultResourceLoader.reload()
看资源发现的总顺序。 -
resource-loader.ts:loadProjectContextFiles()
看 AGENTS 的候选、祖先方向和最终顺序。 -
resource-loader.ts:discoverSystemPromptFile()
看 SYSTEM 的 trust 和优先级。 -
agent-session.ts:_rebuildSystemPrompt()
看资源和 active tool metadata 在哪里汇合。 -
system-prompt.ts:buildSystemPrompt()
看最终字符串的准确拼接顺序。
完成这一遍后,应能从一个 AGENTS.md 文件追到持久化的 SystemMessage 与请求投影。
第二遍:Session 如何恢复
Section titled “第二遍:Session 如何恢复”session-manager.ts:buildSessionPath()session-manager.ts:getSessionContextSettings()session-manager.ts:buildContextEntries()session-manager.ts:sessionEntryToContextMessages()session-manager.ts:buildSessionContext()sdk.ts:createAgentSession()中agent.state.messages = existingSession.messages
完成后,应能解释“完整 JSONL 还在,但模型只看到当前分支和 compaction-aware 历史”。
第三遍:一条用户输入如何进入 Agent
Section titled “第三遍:一条用户输入如何进入 Agent”agent-session.ts:prompt()agent-session.ts:_tryExecuteExtensionCommand()extensions/runner.ts:emitInput()agent-session.ts:_expandSkillCommand()prompt-templates.ts:expandPromptTemplate()extensions/runner.ts:emitBeforeAgentStart()agent-session.ts:_runAgentPrompt()
完成后,应能说明 Extension command、input hook、Skill 和 Template 的先后关系。
第四遍:一个 Run 如何产生多个 Turn
Section titled “第四遍:一个 Run 如何产生多个 Turn”agent.ts:prompt()agent.ts:createContextSnapshot()agent.ts:createLoopConfig()agent-loop.ts:runAgentLoop()agent-loop.ts:runLoop()agent-session.ts:_installAgentNextTurnRefresh()agent-loop.ts:streamAssistantResponse()
完成后,应能画出 User → Assistant ToolCall → ToolResult → 下一 Turn。
第五遍:模型调用边界
Section titled “第五遍:模型调用边界”extensions/runner.ts:emitContext()messages.ts:convertToLlm()sdk.ts:convertToLlmWithBlockImagestransform-messages.ts:transformMessages()anthropic-messages.ts:buildParams()openai-responses-shared.ts:convertResponsesMessages()
完成后,应能手工把任意 AgentMessage 历史转换成目标 Provider payload。
源码行号会随修改漂移,函数名比固定行号可靠。可从仓库根目录执行:
rg -n "function buildSystemPrompt|_rebuildSystemPrompt" \ packages/coding-agent/src/core
rg -n "buildSessionContext|buildContextEntries|sessionEntryToContextMessages" \ packages/coding-agent/src/core/session-manager.ts
rg -n "async prompt|_expandSkillCommand|emitBeforeAgentStart" \ packages/coding-agent/src/core
rg -n "createContextSnapshot|runAgentLoop|streamAssistantResponse" \ packages/agent/src
rg -n "convertToLlm|transformMessages" \ packages/coding-agent/src packages/ai/src十五、用测试验证理解
Section titled “十五、用测试验证理解”当实现细节难以从主代码确认时,按行为查测试:
| 行为 | 测试位置 |
|---|---|
| System Prompt 的工具列表和 guidelines | packages/coding-agent/test/system-prompt.test.ts |
| ResourceLoader、trust、SYSTEM/APPEND | packages/coding-agent/test/resource-loader.test.ts |
| Session 分支和 compaction 投影 | packages/coding-agent/test/session-manager/build-context.test.ts |
| Skill 索引和显式调用 | packages/coding-agent/test/skills.test.ts、packages/coding-agent/test/sdk-skills.test.ts |
| Template 参数替换 | packages/coding-agent/test/prompt-templates.test.ts |
| 跨模型 thinking、Tool ID、orphan Tool Call | packages/ai/test/transform-messages*.test.ts |
| null/missing content 防御 | packages/ai/test/lax-message-content.test.ts |
| 下一 Turn 前刷新快照 | packages/agent/test/agent-loop.test.ts |
| 工具结果后、同 Run 下一请求前压缩 | packages/coding-agent/test/suite/agent-session-compaction.test.ts |
测试的阅读方式不是逐条背断言,而是:
先根据本章预测行为 → 找到对应测试输入 → 检查期望输出 → 回主函数确认是哪一个分支实现十六、理解检查
Section titled “十六、理解检查”尝试不看前文回答:
- 为什么
AGENTS.md最终不是一条 UserMessage? - 为什么
SYSTEM.md存在时,AGENTS、Skills 和 cwd 仍然会出现? - Tool 的
promptSnippet和结构化 schema 分别解决什么问题? - Session 为什么是树,
AgentState.messages为什么不是完整 Session? - compaction 为什么不等于删除历史?
/skill:name与模型自行读取 Skill 有什么区别?- Extension command、input hook、Skill 和 Template 的执行顺序是什么?
- 一个 Agent Run 为什么可能包含多个 Turn?
- steering 和 follow-up 在消息时间线上有什么区别?
prepareNextTurn何时执行,为什么还可能在同一 Run 内压缩历史?contexthook 为什么默认不会污染 Session?customSessionEntry 与custom_message有什么区别?- BashExecution、CustomMessage、CompactionSummary 最终以什么 role 发送?
blockImages与 non-vision downgrade 的边界在哪里?- 如何判断一条历史 AssistantMessage 来自同一个模型?
- thinking signature 为什么不能跨模型重放?
- Tool Call ID 改写后为什么必须同步改 ToolResult?
- error/aborted AssistantMessage 为什么保留在 Session 却不发给模型?
- synthetic ToolResult 解决什么协议问题,是否会持久化?
contexthook 与before_provider_requesthook 分别适合做什么?
如果这些问题都能沿源码调用链回答,就已经理解了 Pi 的 Prompt 与 Context 主流程。
新增:工具声明投影与 MCP 服务器提示
Section titled “新增:工具声明投影与 MCP 服务器提示”现在应区分持久化工具状态和最终请求声明:codemode 的 prepareLoadout() 可返回 hiddenDeclarations,
工具保持 active/callable,Session 仍记录它们;_installHiddenDeclarationsProjection() 则从请求中每条
SystemMessage 的 toolsAdded/toolsRemoved 过滤隐藏名称。
// agent-session.ts 中 transformContext 的缩写。const transformed = await previousTransformContext(messages, signal);return transformed.map(message => { if (message.role !== "system") return message; return { ...message, toolsAdded: message.toolsAdded?.filter(tool => !hidden.has(tool.name)), toolsRemoved: message.toolsRemoved?.filter(tool => !hidden.has(tool.name)), };}); // 原码省略空字段;没有 previous hook 时直接使用 messages。顺序上,该过滤包在已有 Context 变换外,再由 forced prompt 投影处理。Prompt 工具介绍也排除被隐藏 的名称,防止正文宣称有 read/bash,实际请求却只暴露 codemode。隐藏请求声明不等于删除工具历史。
内置 MCP 在 before_agent_start 更新命名 section mcp_servers,列出 server 及访问方式;默认不会把
所有远端工具 schema 常驻 system prompt。direct 工具等待连接后声明,其他工具通过脚本或搜索按需
发现。.pi/mcp.json 受项目 trust 控制;这不改变前文 AGENTS/CLAUDE 文本加载的独立规则。
defaultTools 也不再只能完整替换:含普通名称的列表指定基础集合,纯 +name/-name 列表可修改
继承选择;例如 ["+codemode", "-bash"] 基于默认集合添加和移除。reload 采用 defaultTools 时激活
新加入的工具,但不会仅因设置里移除名称就停用当前已经激活的工具。
下一阶段连接
Section titled “下一阶段连接”本章只把 Session 当作 Context 的来源,重点解释:
完整 Session Tree → 当前分支 → compaction-aware AgentMessage[] → 本次 Provider Context下一阶段继续深入 Session 本身:
JSONLid / parentIdbranch / fork / clonefirstKeptEntryIdcompaction summarybranch summaryoverflow compact-and-retry届时要回答的是:Pi 如何在不删除完整历史的前提下,让模型工作上下文持续保持在窗口以内。