跳转到内容

Prompt 与 Context Engineering

内容状态
已校验
校验日期
2026/10/1

本文是针对上述固定 revision 的源码学习笔记。2026-09-27 已按本地更新后的源码重新复核,文中代码缩写省略无关分支,不保证可独立运行。调用链与分支经过静态源码复核;工程解释和构造示例用于帮助理解,不代表官方设计结论或真实 Provider 运行验证。

这一章不把 Prompt 当成一段孤立的字符串来讲,而是沿着一次真实请求的执行顺序阅读源码:

启动 Session
→ 收集项目规则、Skills 和工具信息
→ 构造 System Prompt
→ 恢复当前 Session 分支
→ 接收并展开用户输入
→ 启动 Agent Run
→ 每个 Turn 组装 Context
→ 转换成统一 LLM Message
→ 做目标模型兼容
→ 序列化为 Provider Payload

读完后,你应该能够只根据本章回答:

  1. AGENTS.md、SYSTEM.md、Skills、工具说明如何进入模型输入;
  2. 一条用户输入在变成 API 请求前经过哪些处理;
  3. Session、AgentState、AgentContext、LLM Context 分别保存什么;
  4. 为什么 Session 中存在的消息不一定会发送给模型;
  5. Extension 能在哪些层修改 Context,修改是否持久化;
  6. 切换模型后,thinking、图片、Tool Call ID 和缺失 ToolResult 如何处理;
  7. System Prompt 和 tools 为什么是两个独立通道。

新版把 Prompt 和工具声明放入 SystemMessage,和对话一起组成 transcript。可执行函数仍留在 AgentContext.tools,不能把函数写进 JSONL。

ResourceLoader → BuildSystemPromptOptions → sections patch ─┐
Tool Registry → active AgentTool[] → toolsAdded/toolsRemoved ├→ SystemMessage
SessionManager → 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 提供实际执行函数;三者职责仍需分清。

后续源码反复在几种相似类型之间切换。先固定它们的边界。

2.1 SessionEntry:完整、持久化的事实历史

Section titled “2.1 SessionEntry:完整、持久化的事实历史”

定义位置:

Session JSONL 不只是聊天消息,还包括:

message
model_change
thinking_level_change
compaction
branch_summary
custom_message
custom
label
session_info

它有 id 和 parentId,因此本质是树,不是线性 messages 数组。

2.2 AgentMessage:Coding Agent 运行时消息

Section titled “2.2 AgentMessage:Coding Agent 运行时消息”

除标准消息外,它还允许产品自定义角色:

user
assistant
toolResult
bashExecution
custom
branchSummary
compactionSummary

这些角色适合 Session、UI 和 Extension,但 Provider 并不认识后四种。

2.3 AgentState:Agent 当前的可变内存

Section titled “2.3 AgentState:Agent 当前的可变内存”

定义和实现位置:

核心字段:

systemPrompt(只读 getter,重放 messages 得到)
messages(包括 system)
tools
model
thinkingLevel
isStreaming
streamingMessage
pendingToolCalls
errorMessage

它是下一次 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[] 收敛成:

SystemMessage
UserMessage
AssistantMessage
ToolResultMessage

随后用 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 hook
AgentMessage[] 本次调用临时改写后的消息
↓ convertToLlm()
pi-ai Message[] Provider 无关消息
↓ transformMessages() + serializer
Provider Payload 最终请求

三、启动阶段:资源如何变成 System Prompt

Section titled “三、启动阶段:资源如何变成 System Prompt”

这一段从 sdk.ts 的 createAgentSession() 开始读。

3.1 createAgentSession() 先准备三类对象

Section titled “3.1 createAgentSession() 先准备三类对象”

函数依次创建或接收:

SettingsManager
SessionManager
ResourceLoader

若调用者没有传入 ResourceLoader:

resourceLoader = new DefaultResourceLoader(...);
await resourceLoader.reload();

所以 System Prompt 的文件扫描不发生在 Agent 或 agent-loop 中,而是在 Session 创建之前完成。

3.2 ResourceLoader.reload() 是资源发现总入口

Section titled “3.2 ResourceLoader.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。

继续读同一文件顶部的两个函数:

loadContextFileFromDir()
loadProjectContextFiles()

每个目录取第一份成功读取的规则文件

Section titled “每个目录取第一份成功读取的规则文件”

候选顺序是:

AGENTS.override.md
AGENTS.md
AGENTS.MD
CLAUDE.md
CLAUDE.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 根目录停止,而是一直走到文件系统根目录。重复路径会去重。

findShadowedContextFile() 处理一个额外情况:linked worktree 位于主工作区内部,并且 worktree 根目录有自己的规则文件。

例如主工作区是 /repo,linked worktree 是 /repo/worktrees/feat:

  • 两处都有 AGENTS.md,并满足函数中的 Git 目录检查时,跳过主工作区的同名文件,使用 worktree 的规则;
  • worktree 根没有规则文件时,仍可继承主工作区的规则;
  • 主工作区是 CLAUDE.md,worktree 选中的是 AGENTS.md 时,两个文件名不同,不触发这一同名去重。

所以“祖先由远及近”描述的是一般顺序,不能理解成所有祖先规则都会无条件加载。

SYSTEM.md 和 APPEND_SYSTEM.md 的项目级文件要通过:

this.settingsManager.isProjectTrusted()

但 reload() 调用 loadProjectContextFiles() 时没有用 trust 过滤 AGENTS/CLAUDE 文件。

因此:

Project Trust 会保护项目 Extensions、Skills、Prompt 等资源的加载,但当前实现并不把项目 AGENTS.md 当作完全隔离的可信执行边界。

XML 包装也只是给模型看的结构标记,不是权限系统。

继续读:

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 据此保存。

buildSystemPromptSections() 的默认顺序是:

preamble → tools → rules → docs → addendum
→ project_context → skills → cwd → 自定义 sections

preamble 是无标签正文,其他 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.8
promptSections.cwd = cwd.replace(/\\/g, "/");

所以 SYSTEM.md 仍只是替换默认前缀,不会抹掉 AGENTS、Skill 索引和 cwd。buildSystemPrompt() 负责把结构化状态渲染为字符串;整个状态不再只能由一个可写的 agent.state.systemPrompt 字段承载。

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 发送给模型。

定位:

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[]
当前 leafId
id → entry 索引

函数从 leaf 沿 parentId 回溯到根,再 reverse:

root → ... → current leaf

所以同一 JSONL 中其他分支仍然存在,但不会进入当前上下文。

4.2 getSessionContextSettings():从完整路径恢复运行配置

Section titled “4.2 getSessionContextSettings():从完整路径恢复运行配置”

它遍历完整 path,恢复:

thinkingLevel
model

模型来源既可能是 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 崩溃。

最终返回:

{
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.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 Run

expandPromptTemplates = false 会同时跳过 Extension command、Skill 和 Template 展开,input hook 仍会执行。这个选项的实际影响比名字中的 Template 更广。

下面逐段解释。

默认开启展开且输入以 / 开头时,_tryExecuteExtensionCommand() 先查 Extension 注册命令。

命中后,命令 handler 自己决定做什么,当前输入不会自动成为 UserMessage,也不会继续做 Skill/Template 展开。

Extension 可以:

handled 完全消费输入
transform 改写 text/images 后继续

它发生在 Skill 和 Template 之前,因此 Extension 能改写将要展开的命令文本。

定位:

  • 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 直接展开正文
→ 当前 UserMessage

定位:

  • 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 的时间位置。

普通输入最终变成:

{
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 如何推进”

接下来进入两个文件:

一次用户请求通常启动一个 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 继续。

Agent.prompt() 先把字符串或消息归一化,然后进入 runPromptMessages()。

这里传给 runAgentLoop() 的两类数据必须分开理解:

this.createContextSnapshot() // 本轮模型能看到什么
this.createLoopConfig() // Loop 应该怎样运行

AgentContext 包含:

messages(含 system)+ tools(执行实现)

AgentLoopConfig 包含:

model
reasoning
convertToLlm
transformContext
getSteeringMessages
getFollowUpMessages
finishTurn / prepareNextTurn / prepareRequest
before/afterToolCall
auth、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_start
turn_start
每条 prompt 的 message_start / message_end

Agent.processEvents() 在收到 message_end 时,把完成的消息追加到 AgentState.messages;AgentSession 的事件订阅再负责 Session 持久化和 Extension 生命周期。

因此有两条同步发生的轨迹:

currentContext.messages
= 本 Run 内模型继续使用的工作历史
AgentState.messages + Session
= 通过事件管线同步的内存状态和持久化历史

定位:

  • 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()

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.messages
AgentState.messages
Session 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 临时投影(若有)
→ convertToLlm

7.2 convertToLlm():收敛产品自定义角色

Section titled “7.2 convertToLlm():收敛产品自定义角色”

定位:

转换表:

AgentMessage role pi-ai Message
system 原样保留,含 sections 与工具声明
user 原样保留
assistant 原样保留
toolResult 原样保留
bashExecution 转为 user text
custom 转为 user,保留文本/图片 content
branchSummary 加说明和 <summary>,转为 user
compactionSummary 加说明和 <summary>,转为 user

用户通过 ! 执行的 shell 命令不是模型 Tool Call,因此没有对应 ToolResult。它会格式化为类似:

Ran `git status`

再附带:

输出代码块
cancelled 状态
非零 exit code
截断输出的完整文件路径

若 excludeFromContext = true,该消息留在产品历史中,但从 LLM Context 删除。

Extension 的:

customType
display
details

不会发送给模型。模型只收到 content,角色是 user。

统一协议没有 summary role,因此二者都转换为 UserMessage,并用前缀解释摘要语义。Provider 无需理解 Pi 的 Session Tree。

createAgentSession() 没有直接把 convertToLlm 传给 Agent,而是包装为 convertToLlmWithBlockImages。

顺序是:

convertToLlm()
→ 动态读取 settingsManager.getBlockImages()
→ 若开启,将 user/toolResult image 替换为
"Image reading is disabled."

这是用户或宿主明确禁止图片的策略,不是模型能力检测。

streamAssistantResponse() 构造:

const llmContext = normalizeContext({ messages: llmMessages });

然后:

streamFunction(config.model, llmContext, options)

此时 coding-agent 层的工作结束。后面进入 pi-ai。


八、Provider 边界:跨模型历史如何变成合法请求

Section titled “八、Provider 边界:跨模型历史如何变成合法请求”

每个 Provider serializer 都会先调用:

这个函数不理解 AGENTS、Skills、Session 或 Extension。输入已经只有标准 Message[]。它处理的问题是:

历史 AssistantMessage 可能来自模型 A,而当前要请求模型 B。怎样保留可迁移的语义,同时删除目标 Provider 无法接受的私有结构?

transformMessages() 先:

null/missing content → []
不支持图片时降级
处理 thinking block
处理 Tool Call thoughtSignature
归一化 Tool Call ID
同步归一化 ToolResult ID

如果:

!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 transformMessages

8.3 如何判断历史是否来自同一个模型

Section titled “8.3 如何判断历史是否来自同一个模型”

必须同时满足:

assistantMessage.provider === target.provider
assistantMessage.api === target.api
assistantMessage.model === target.id

仅 model id 相同不够,因为不同 Provider/API 的签名格式可能不同。

规则可整理成:

thinking 类型 同模型 跨模型
redacted/加密 thinking 保留 删除
带 signature 字段 保留原 block 有可见文本则转普通 text,否则删除
无 signature、有文本 保留 thinking 转普通 text
空 thinking 通常删除;若有同模型可重放 signature 则保留 删除

这里仅判断 signature 字段是否存在及是否同模型,不验证签名真实性;保留字段也不等于服务端一定接受。

原因是 thinking signature 往往是 Provider 私有、加密或不可验证的数据。跨模型只迁移可见语义,不能迁移 opaque 协议状态。

Tool Call 也可能携带 Provider 私有 thoughtSignature。

跨模型时:

name、arguments、id 可以继续使用
thoughtSignature 删除

不同 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_123
ToolResult id = original|123

Provider 会认为结果不属于该调用。

8.7 第二阶段:修复消息序列不变量

Section titled “8.7 第二阶段:修复消息序列不变量”

第一阶段完成 ID 归一化后,函数第二遍扫描消息。

stopReason = error
stopReason = aborted

这些消息可能包含不完整 reasoning 或 Tool Call,适合留在 Session 中审计,但不适合 replay 给模型。

orphan Tool Call 被补 synthetic ToolResult

Section titled “orphan Tool Call 被补 synthetic ToolResult”

如果历史中存在:

AssistantMessage
└─ ToolCall(call_1)

却没有对应 ToolResult,许多 Provider 会拒绝整次请求。

扫描到以下边界时会补齐:

下一条 AssistantMessage
UserMessage 打断工具流
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。

定位:

主流程:

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。

定位:

主流程:

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_output

reasoning model 且兼容配置允许时,System Prompt 可能使用 developer role。

Provider 构造 payload 后会调用 onPayload。createAgentSession() 把它连接到 Extension 的:

before_provider_request

所以 Extension 有两个完全不同的改写位置:

Hook 看到的数据 是否 Provider-specific
context AgentMessage[] 否
before_provider_request 最终 payload 是

前者适合通用历史裁剪,后者适合修改某个 Provider 的具体字段。


以下是为了手工推导而构造的数据,不是真实 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 模型。

buildSessionContext():

选择当前 leaf path
→ 应用最新 compaction
→ 把 message/custom_message/summary 投影为 AgentMessage

得到:

User
Assistant(toolCall)
ToolResult
BashExecution
Custom
Assistant(error)

AgentSession.prompt():

Extension command
→ input hook
→ Skill/Template
→ before_agent_start
→ 根据最终模型归一化图片
→ 创建 UserMessage(text + image),前置必要的 SystemMessage patch

本例没有命令或展开,因此 prompts 是:

UserMessage([text "继续修复", image])

Agent.createContextSnapshot() 复制:

旧 AgentState.messages(含 system)
active tools(执行实现)

runAgentLoop() 先声明需要的系统/工具变化,再把新 UserMessage 接到旧消息末尾。下方为突出对话转换,省略 system 行;它仍随 transcript 进入 serializer。

假设 Extension 删除 customType = issue 的 CustomMessage。

本次临时消息变为:

User
Assistant(toolCall)
ToolResult
BashExecution
Assistant(error)
User(text + image)

Session 和 AgentState 中的 CustomMessage 仍然存在。

User("检查构建")
Assistant(thinking + toolCall)
ToolResult("tests failed")
User("Ran `git status` ... clean")
Assistant(error)
User(text + image)

目标是跨模型且不支持图片:

thinking 可见文本 → 普通 text
Provider 私有 signature → 删除
Tool Call ID → Anthropic 兼容 ID
ToolResult 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)"
])

最终 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 的内容:

  • 回答风格;
  • 项目规范;
  • 工具选择建议;
  • 某类任务何时使用某个 Skill;
  • 需要模型判断的工作原则。

对应源码:

buildSystemPrompt()
formatSkillsForPrompt()
expandPromptTemplate()
AgentSession._expandSkillCommand()

适合由 Context 管道处理:

  • 当前 Session 分支;
  • compaction 边界;
  • 临时动态知识;
  • UI-only 消息过滤;
  • 图片 policy;
  • 跨模型 thinking/signature;
  • Tool Call ID;
  • orphan Tool Call。

对应源码:

buildSessionContext()
ExtensionRunner.emitContext()
convertToLlm()
transformMessages()

以下内容不能只靠 System Prompt:

  • Tool 参数 schema 校验;
  • 一个 Agent 不能并发两个 Run;
  • Tool Call/ToolResult 协议顺序;
  • Project Trust;
  • Session 持久化;
  • retry/abort;
  • Provider ID 和签名兼容;
  • compaction 边界。

原则:

需要模型理解和判断的规则写 Prompt;可以验证、阻止或修复的不变量写代码。


以下内容本质上都是模型可读文本:

AGENTS.md
Skill 正文
Prompt Template
用户输入
Extension 注入消息

XML 标签只能帮助模型识别来源,不能阻止 Prompt Injection。

在本章核对的实现中,Pi 提供了 active tools 选择、beforeToolCall 拦截扩展点,以及 Project Trust 资源加载检查。它们分别控制可调用工具、调用是否放行、部分项目资源是否加载;这些机制的作用范围需要逐项理解。

文件和网络访问限制、容器或沙箱、凭据隔离、操作审批,则需要宿主按环境额外提供。Pi 的 README 明确说明没有内建 permission popups,可以使用容器或通过 Extensions 自行实现确认流程。

cwd 本身只是工作目录,不能据此推导出文件访问被限定在目录内;允许调用 Bash 也不等于约束了它访问的资源。Project Trust 控制资源加载,不是运行时沙箱。

context 和 before_agent_start handler 的异常会被 Runner 捕获并记录,再继续后续 handler。这是对 handler 调用的容错,不是回滚机制,也不保证任意 Extension 错误都不会影响请求;例如返回非法数据或修改共享副本,仍可能影响下游。

aborted/error AssistantMessage 可以被持久化,用于 UI 和诊断。下次请求时,transformMessages() 将它们从 Provider Context 删除,避免重放这些未完成的响应。

这不保证任意旧历史都变得合法。该函数会为“有 Tool Call、无结果”补 synthetic ToolResult,但仍会保留遍历到的 ToolResult;对于手工构造的“只有结果、没有调用”等历史,不能把它当作完整协议校验器。

Session 文件可能来自旧版本、fork 或手工修改。sessionEntryToContextMessages() 和 transformMessages() 都对 null/missing content 做防御性归一化。

这是两个 choke point:

持久化历史 → AgentMessage
统一 Message → Provider

不要按目录逐文件通读。按下面顺序在函数之间跳转,每一步只回答一个问题。

  1. sdk.ts:createAgentSession()
    看 ResourceLoader、SessionManager 和 Agent 在何时创建。

  2. resource-loader.ts:DefaultResourceLoader.reload()
    看资源发现的总顺序。

  3. resource-loader.ts:loadProjectContextFiles()
    看 AGENTS 的候选、祖先方向和最终顺序。

  4. resource-loader.ts:discoverSystemPromptFile()
    看 SYSTEM 的 trust 和优先级。

  5. agent-session.ts:_rebuildSystemPrompt()
    看资源和 active tool metadata 在哪里汇合。

  6. system-prompt.ts:buildSystemPrompt()
    看最终字符串的准确拼接顺序。

完成这一遍后,应能从一个 AGENTS.md 文件追到持久化的 SystemMessage 与请求投影。

  1. session-manager.ts:buildSessionPath()
  2. session-manager.ts:getSessionContextSettings()
  3. session-manager.ts:buildContextEntries()
  4. session-manager.ts:sessionEntryToContextMessages()
  5. session-manager.ts:buildSessionContext()
  6. sdk.ts:createAgentSession() 中 agent.state.messages = existingSession.messages

完成后,应能解释“完整 JSONL 还在,但模型只看到当前分支和 compaction-aware 历史”。

第三遍:一条用户输入如何进入 Agent

Section titled “第三遍:一条用户输入如何进入 Agent”
  1. agent-session.ts:prompt()
  2. agent-session.ts:_tryExecuteExtensionCommand()
  3. extensions/runner.ts:emitInput()
  4. agent-session.ts:_expandSkillCommand()
  5. prompt-templates.ts:expandPromptTemplate()
  6. extensions/runner.ts:emitBeforeAgentStart()
  7. agent-session.ts:_runAgentPrompt()

完成后,应能说明 Extension command、input hook、Skill 和 Template 的先后关系。

第四遍:一个 Run 如何产生多个 Turn

Section titled “第四遍:一个 Run 如何产生多个 Turn”
  1. agent.ts:prompt()
  2. agent.ts:createContextSnapshot()
  3. agent.ts:createLoopConfig()
  4. agent-loop.ts:runAgentLoop()
  5. agent-loop.ts:runLoop()
  6. agent-session.ts:_installAgentNextTurnRefresh()
  7. agent-loop.ts:streamAssistantResponse()

完成后,应能画出 User → Assistant ToolCall → ToolResult → 下一 Turn。

  1. extensions/runner.ts:emitContext()
  2. messages.ts:convertToLlm()
  3. sdk.ts:convertToLlmWithBlockImages
  4. transform-messages.ts:transformMessages()
  5. anthropic-messages.ts:buildParams()
  6. 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

当实现细节难以从主代码确认时,按行为查测试:

行为 测试位置
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

测试的阅读方式不是逐条背断言,而是:

先根据本章预测行为
→ 找到对应测试输入
→ 检查期望输出
→ 回主函数确认是哪一个分支实现

尝试不看前文回答:

  1. 为什么 AGENTS.md 最终不是一条 UserMessage?
  2. 为什么 SYSTEM.md 存在时,AGENTS、Skills 和 cwd 仍然会出现?
  3. Tool 的 promptSnippet 和结构化 schema 分别解决什么问题?
  4. Session 为什么是树,AgentState.messages 为什么不是完整 Session?
  5. compaction 为什么不等于删除历史?
  6. /skill:name 与模型自行读取 Skill 有什么区别?
  7. Extension command、input hook、Skill 和 Template 的执行顺序是什么?
  8. 一个 Agent Run 为什么可能包含多个 Turn?
  9. steering 和 follow-up 在消息时间线上有什么区别?
  10. prepareNextTurn 何时执行,为什么还可能在同一 Run 内压缩历史?
  11. context hook 为什么默认不会污染 Session?
  12. custom SessionEntry 与 custom_message 有什么区别?
  13. BashExecution、CustomMessage、CompactionSummary 最终以什么 role 发送?
  14. blockImages 与 non-vision downgrade 的边界在哪里?
  15. 如何判断一条历史 AssistantMessage 来自同一个模型?
  16. thinking signature 为什么不能跨模型重放?
  17. Tool Call ID 改写后为什么必须同步改 ToolResult?
  18. error/aborted AssistantMessage 为什么保留在 Session 却不发给模型?
  19. synthetic ToolResult 解决什么协议问题,是否会持久化?
  20. context hook 与 before_provider_request hook 分别适合做什么?

如果这些问题都能沿源码调用链回答,就已经理解了 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 时激活 新加入的工具,但不会仅因设置里移除名称就停用当前已经激活的工具。

本章只把 Session 当作 Context 的来源,重点解释:

完整 Session Tree
→ 当前分支
→ compaction-aware AgentMessage[]
→ 本次 Provider Context

下一阶段继续深入 Session 本身:

JSONL
id / parentId
branch / fork / clone
firstKeptEntryId
compaction summary
branch summary
overflow compact-and-retry

届时要回答的是:Pi 如何在不删除完整历史的前提下,让模型工作上下文持续保持在窗口以内。