Skip to content

Part II · 源码篇 / Ch 05

源码篇第5章 调度台——pi-coding-agent ​

小A与老Z将发光的回合事件接入打开的会话册,册中连续的历史卡片分出一条新路径。

本章导读: 源码路线第 ④ 站(产品层的核心部件)。v1.0.0 把会话、压缩和 Skills 从 pi-agent-core 移到了 pi-coding-agent/src/core。这一站读 agent-session.ts、session-manager.ts、agent-session-runtime.ts、compaction/compaction.ts 和 skills.ts。要拆掉小A以为的“fork 就是 Git 分支”的错觉,看清会话到底靠什么活过一次进程。

小A照着 源码篇第4章,把 loop 里的状态变化理清楚了。但他翻遍 packages/agent/src,只找到五个文件,会话代码一行都没有。他又在 coding-agent 里看到 fork 这个方法名,忽然冒出个想法:“这不就是 Git 分支吗?我 fork 一个会话,再让两个 Agent 各改各的,互不干扰?”

“停。”老Z说,“会话 fork 不是 Git 分支,也不会创建或隔离 worktree。你这句话里有两个概念被焊在一起了。先弄清会话代码搬到了哪儿,再按装配层把‘会话活过一次进程’讲清楚。”

低层 loop 能把一次流和工具结果推进完,却不知道会话应保存到哪里、何时压缩、哪些 Skills 可用。在 v1.0.0 里,把这些宿主决策接到 loop 上的是 coding-agent 的 AgentSession。

本章依据上游 pi 标签 v1.0.0(提交 a13d35a742c6ef8462812a28fbe1d8c8b7431c32)、@earendil-works/pi-coding-agent 1.0.0。跟读 AgentSession 的装配插口、一次 prompt 的写入、JSONL 会话文件、fork、compaction 和 skills。不覆盖 coding-agent 如何解析命令行和选工作目录(见源码篇第7章),也不证明文件系统在断电下的耐久性。

Harness 去了哪里 ​

小A:“旧资料里说 pi-agent-core 有个 AgentHarness,管会话和压缩。现在呢?”

“上游提交 7fd478a2 的标题是 ‘remove the experimental harness from pi-agent-core’。”老Z说,“v1.0.0 的 packages/agent/src/index.ts 只导出 agent.ts、agent-loop.ts、proxy.ts、setDefaultStreamFn 和 types.ts。原来那层装配的职责,按下表分散到了产品层。”

旧版职责v1.0.0 的位置本章读什么
把宿主决策接到 loopcore/agent-session.ts 的 AgentSession构造函数装的插口
会话文件与会话树core/session-manager.ts 的 SessionManager_persist、loadEntriesFromFile、createBranchedSession
fork 与换会话core/agent-session-runtime.ts 的 AgentSessionRuntimefork
压缩core/compaction/prepareCompaction、compact、getSummarizationFailure
Skillscore/skills.tsloadSkillsFromDir、formatSkillsForPrompt

pi-durable 包里另有一套 harness/ 和 SQLite 存储,那是另一种“先原子落盘、后对外可见”的运行时。本章不展开它;源码篇第13章 先落盘,后可见——pi-durable 专门读那个内核,并与本章对照。

比喻:调度台

loop 是引擎,AgentSession 是调度台:它决定会话写哪、何时压缩、哪些技能可见;引擎本身不是它造的,它只在引擎留出的插口上接线。

调度台接在哪几个插口上 ​

小A猜:“AgentSession 听起来像个更大的循环?”

“不是。它持有一个 Agent,然后在源码篇第4章那些插口上接线。”老Z打开构造函数:

ts
// packages/coding-agent/src/core/agent-session.ts,AgentSession 构造函数(v1.0.0,节选)
// Always subscribe to agent events for internal handling
// (session persistence, extensions, auto-compaction, retry logic)
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
this._installAgentToolHooks();
this._installAgentNextTurnRefresh();
this._installAgentRequestProjection();
this._installAgentBoundaryHooks();
this._installHiddenDeclarationsProjection();

“_installAgentToolHooks 直接把 beforeToolCall/afterToolCall 换成派发扩展的 tool_call 事件,扩展事件的整体结构见源码篇第8章。其余几个 _install* 都先保存 Agent 上原有的回调,再包一层新的,原回调仍会被调用。”老Z说,“注意这不表示每个功能都会启用:扩展没有注册处理器时,对应的边界事件直接跳过。”

插口(源码篇第4章)AgentSession 接上的东西主要边界
subscribe_handleAgentEvent:先发扩展事件,再通知监听者,然后写会话写会话是同步追加,失败会抛出
prepareNextTurn超过阈值时先压缩,再重建 system prompt 与工具清单压缩可能失败或被扩展取消
prepareRequest每次请求前用 buildSessionProjection() 重建要发给模型的 messages投影来自会话树,不来自 loop 的内存数组
finishTurn派发扩展的 turn_end 边界,扩展可要求再请求一次扩展不能把 error/aborted 变回成功

小A注意到第三行:“请求前 messages 被换掉了?”

“对。_installAgentRequestProjection 里的 canonicalContext 用的是 this.sessionManager.buildSessionProjection().messages。”老Z说,“所以模型看到的上下文以会话树为准:压缩摘要、分支摘要、context_edit 都在这一步生效。loop 里的 currentContext 只是这一次 run 的工作副本。”

一次 prompt 的调用和写入序列 ​

小A让他把一次调用从头走到尾:“prompt 从进来到会话写盘,这一路是怎么连的?”

“先看忙碌时的规矩。”老Z说,“AgentSession.prompt 在 Agent 正在流式输出时,要求调用方给 streamingBehavior,值是 steer 或 followUp,否则抛错 ‘Agent is already processing’。这和源码篇第4章 Agent.prompt 的单飞规则一致,只是产品层多给了一个排队选项。”

text
AgentSession.prompt(text)
  → 斜杠命令与模板展开;正在流式输出时按 streamingBehavior 排队
  → 必要时先检查一次压缩
  → before_agent_start 扩展事件
  → agent.prompt(messages)
      → prepareRequest:buildSessionProjection 重建 messages
      → 模型流 → message_end → _handleAgentEvent → sessionManager.appendMessage
      → 工具结果 message_end → 同样追加
      → finishTurn:扩展 turn_end 边界
      → prepareNextTurn:阈值压缩、刷新 system prompt 与工具清单
  → agent_end:之后再按溢出/阈值决定是否压缩、是否续跑

写会话就发生在 _handleAgentEvent 的 message_end 分支:

ts
// packages/coding-agent/src/core/agent-session.ts,_handleAgentEvent(v1.0.0,节选)
await this._emitExtensionEvent(event);
this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
if (event.type === "message_end") {
	if (event.message.role === "custom") {
		entryId = this.sessionManager.appendCustomMessageEntry(/* customType, content, display, details */);
	} else if (
	event.message.role === "system" ||
	event.message.role === "user" ||
	event.message.role === "assistant" ||
	event.message.role === "toolResult"
) {
		entryId = this.sessionManager.appendMessage(event.message);
	}
}

“顺序是:扩展先看到事件,再是公开监听者,最后才写会话。”老Z说,“源码篇第4章说过 Agent 会 await 每个 listener,所以写会话完成之前,loop 不会进入下一步。注意 system message 也会被追加:源码篇第4章那条声明工具变化的 system message,会作为普通 message 条目进入会话文件。”

写入是懒创建加同步追加 ​

小A有点不安:“那写到一半进程崩了呢?会话会不会只剩半条?”

“看 SessionManager._persist。”老Z说:

ts
// packages/coding-agent/src/core/session-manager.ts,_persist(v1.0.0,节选)
_persist(entry: SessionEntry): void {
	if (!this.persist || !this.sessionFile) return;
	if (!this.flushed) {
		if (!this._hasConversation()) return;
		const fd = openSync(this.sessionFile, "wx");
		try {
			for (const e of this.fileEntries) writeFileSync(fd, `${JSON.stringify(e)}\n`);
		} finally { closeSync(fd); }
		this.flushed = true;
	} else {
		appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
	}
}

“有三件事可以确认。第一,文件是懒创建的:只有出现 user 或 assistant 消息后才建文件,注释说明这样打开又关掉 pi 不会留下空文件,而且从用户消息开始写,第一轮没跑完也能留住 prompt。第二,首次创建用 wx 标志,文件已存在就失败,不会覆盖别人的文件。第三,之后每条都是同步 appendFileSync,没有写入队列,也没有 fsync。”

“由此可以推断:同一进程内,写入顺序就是事件顺序;但两个进程同时追加同一个会话文件,从这段代码证明不了安全,断电后最后一行是否完整也证明不了。”

路径触发点行为不覆盖的承诺
正常消息message_end追加一行 JSON已刷到磁盘
还没有对话只有模型、thinking 等设置条目只留在内存退出后能找回这些设置
文件已存在首次 openSync(..., "wx")抛错自动换文件名
内存模式SessionManager.inMemory不写盘进程退出后还在

JSONL 的单位是 session header 加树条目 ​

小A抢着下了个结论:“JSONL 文件嘛,每行都是一条消息?”

“不是。”老Z说,“第一行是 SessionHeader:type: "session"、version(当前常量 CURRENT_SESSION_VERSION = 3)、id、timestamp、cwd,可选 parentSession。后续每一行是一个 SessionEntry,都带 id、parentId 和 timestamp。parentId 把条目连成一棵树,当前位置由 leafId 指着,‘每行一条消息’在这里不成立。”

条目 type例子意义
messagesystem、user、assistant、toolResult 等 AgentMessage对话记录
model_change/thinking_level_change/usage换模型、换思考级别、用量配置与计量,不是对话
compaction/branch_summary压缩摘要、放弃分支的摘要影响后续 context 投影
context_edit替换或隐藏某条内容只改投影,不改原条目
custom/custom_message扩展写的数据或消息扩展状态
label/session_info标注、会话名导航元数据

“加载时要看 loadEntriesFromFile,它比你想的宽松。”老Z说:

ts
// packages/coding-agent/src/core/session-manager.ts,loadEntriesFromFile(v1.0.0,节选)
function parseSessionEntryLine(line: string): FileEntry | null {
	if (!line.trim()) return null;
	try { return JSON.parse(line) as FileEntry; }
	catch { return null; } // Skip malformed lines
}
// ……逐块读完后:
if (header.type !== "session" || typeof header.id !== "string") return [];
if (pending) appendFileSync(resolvedFilePath, "\n");

“坏行直接跳过,不报错;第一条不是合法 header,整个文件当作空会话;文件末尾没有换行的残行,读完后补一个换行,免得下一次追加粘在残行后面。”老Z说,“所以可恢复性来自‘跳过读不懂的行’,代价是坏行里的内容会静默丢掉。这是一个取舍,不是‘append 所以永不丢数据’。”

fork 复制的是会话路径,不是工作区 ​

小A把话题拉回开头:“fork 到底在复制什么?”

“看 AgentSessionRuntime.fork。”老Z说,“它接收一个 entryId 和 position,默认是 before。”

ts
// packages/coding-agent/src/core/agent-session-runtime.ts,fork(v1.0.0,节选)
const beforeResult = await this.emitBeforeFork(entryId, { position });
if (beforeResult.cancelled) return { cancelled: true };
const selectedEntry = this.session.sessionManager.getEntry(entryId);
if (!selectedEntry) throw new Error("Invalid entry ID for forking");
if (position === "at") {
	targetLeafId = selectedEntry.id;
} else {
	if (selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
		throw new Error("Invalid entry ID for forking");
	}
	targetLeafId = selectedEntry.parentId;
}
// 已持久化:SessionManager.open(当前文件) → createBranchedSession(targetLeafId) → 换上新 runtime

“扩展可以在 session_before_fork 取消。before 要求目标是 user message,从它的父条目切开,被选中的用户文本会返回给界面,方便重新编辑;at 则保留到目标条目为止。目标不存在或类型不对,都抛 ‘Invalid entry ID for forking’。”

“真正复制的是 SessionManager.createBranchedSession:它用 getBranch(leafId) 取从根到目标的那一条路径,去掉 label 条目再按解析后的标注重建,然后写一个新 JSONL 文件,新 header 的 parentSession 指向原文件。整个过程只有会话条目和 metadata,没有 Git 命令、目录复制或 worktree API。”

fork 选择读取行为错误边界
position: "at"保留根到目标条目的路径目标不存在;原文件还没写过盘
默认/before目标必须是 user message,保留到它的父条目非 user 目标或不存在;原文件还没写过盘
目标是第一条用户消息父条目为空,直接开新会话,parentSession 指原文件不复制任何条目
新会话新文件、新 id,原文件不动不复制磁盘 worktree

“还有一个容易混的:同一文件里的树导航。”老Z说,“SessionManager.branch(id) 只移动 leafId,下一次追加就成了那个条目的新子节点,原有条目不改不删;navigateTree 的注释明确写着它和 fork() 不同,留在同一个文件里。”

小A顺着往下想:“照这么说,我想让两个实验各改各的文件,就得另找办法?”

“对,用 Git branch/worktree 或其他工作区隔离。”老Z说,“会话 fork 的含义是从历史上下文另开一条路径;从它推出文件隔离,源码里没有这个证据。”

压缩不是无损截断 ​

小A换了个话题:“上下文满了,压缩是谁的活?”

“AgentSession 有两个入口。”老Z说,“手动入口是 compact(),/compact、RPC 和扩展都走它;自动入口是 _checkCompaction() 加 _runAutoCompaction(),原因分 overflow 和 threshold 两类;因此自动入口对应溢出与阈值两条路径。两条路径最后都调用 compaction/ 里的 compact()。”

“手动 compact() 先 abort() 当前操作,注释说明它不会重试或接着跑被打断的回合。然后 prepareCompaction(sessionManager.getBranch(), settings) 选保留边界、估算 token;返回空时,如果最后一条已经是 compaction 就抛 ‘Already compacted’,否则抛 ‘Nothing to compact’。扩展可以在 session_before_compact 取消或直接给出摘要;都没有就生成默认摘要,成功后才 appendCompaction。”

摘要请求的失败判定在 getSummarizationFailure:

ts
// packages/coding-agent/src/core/compaction/compaction.ts,getSummarizationFailure(v1.0.0)
export function getSummarizationFailure(response: AssistantMessage, label: string): string | undefined {
	if (response.stopReason === "error") {
		return `${label} failed: ${response.errorMessage || "Unknown error"}`;
	}
	if (response.stopReason === "length") {
		return `${label} failed: generation hit the token cap and the summary is incomplete`;
	}
	return undefined;
}

“注释说得很清楚:length 停止意味着摘要不完整,不能成为会话检查点。摘要里出现工具调用也算失败。所有摘要请求都经过 completeSummarization:它设 cacheRetention: "none",有调用方的 sessionId 就沿用,没有就生成新的;临时性的流中断会按重试策略重试,确定性错误和 abort 立即返回。”

“这是单独发的一条模型请求,原条目不会被删掉;compaction 条目只改变后续投影。收益是控制上下文长度且保留摘要和最近的尾部;代价是摘要会丢失信息、生成要花 token,error、length、abort 都会让这次写入不发生。”

Skills 只进清单,不授予执行权限 ​

“Skills 呢?加载了是不是就能用了?”小A问。

“加载、展示和授权是三回事。”老Z调出 skills.ts:

loadSkillsFromDir 的发现规则写在注释里:目录里有 SKILL.md 就把它当作 skill 根,不再往下找;否则加载根目录下的直接 .md 文件,并递归子目录找 SKILL.md。它尊重 .gitignore、.ignore、.fdignore。目录不存在返回空结果;读文件失败、frontmatter 解析失败、name 或 description 校验不过,都记成 warning 级 diagnostics。

ts
// packages/coding-agent/src/core/skills.ts,Skill 与 formatSkillsForPrompt(v1.0.0,节选)
export interface Skill {
	name: string;
	description: string;
	filePath: string;
	baseDir: string;
	sourceInfo: SourceInfo;
	disableModelInvocation: boolean;
}
// formatSkillsForPrompt:
// "Use the read tool to load a skill's file when the task matches its description."

“注意 Skill 里没有正文字段。”老Z说,“formatSkillsForPrompt 只把 name 和 description 列进 system prompt,告诉模型任务匹配时用 read 工具去读那个文件;disableModelInvocation 为真的 skill 不进清单。缺少 description 的文件不会被加载;name 校验有问题仍然加载,只是记 warning。”

“所以 skill 本身不执行 shell、不启用 tool、也不强制路径权限。模型读 skill 文件靠的是普通的 read 工具,受普通工具的审批约束。把 SKILL.md 的建议当成安全沙箱,这个前提站不住。”

收益、代价与未覆盖范围 ​

从源码能推出的收益是:pi-agent-core 回到纯循环,任何宿主都能只拿 loop 而不背会话格式;AgentSession 通过插口把会话、压缩、扩展和投影集中在产品层。代价是这个装配层很厚:agent-session.ts 有四千多行,插口层层包裹,排查问题时要分清是哪一层回调做了改动。会话写入是同步追加加宽松读取,简单可靠,但坏行会被静默跳过。

本章未覆盖 coding-agent 如何定位 .pi 和会话目录,没有验证 JSONL 的崩溃恢复,Skills 的内容也不当作可信指令处理。会话 fork 只描述上下文历史;Git worktree 属于版本控制层。

小结 ​

这一章回答的是:一个回合跑完后,会话凭什么“活”过进程重启。在 v1.0.0 里,答案是 coding-agent 的 AgentSession 这个调度台:它在 Agent 的事件和三个回合插口上接线,message_end 时把消息同步追加到 JSONL,请求前用会话树重建模型上下文。createBranchedSession 复制的是根到目标的会话路径,不是 Git 工作区;compaction 是可能失败的独立摘要请求,不是静默截断;skills 只把 name 和 description 列进提示词,不授予执行权限。

  • 边界:session fork 不创建 Git 分支或 worktree;要隔离文件副作用,用 Git 或工作区机制,别指望会话 fork。摘要的 error 与 length 都会让压缩失败,不完整的摘要不会写进会话。

记住:会话 fork 不是 Git 分支。

它复制的是上下文历史,不是文件系统。把“fork 会话”当成“分支工作区”,就会在文件隔离上做出错误的安全假设,这是这一章最容易踩的坑。

源码走查 ​

  1. 列出 packages/agent/src 的文件,读 index.ts;确认 v1.0.0 的 pi-agent-core 不再导出任何会话、压缩或 Skills API。
  2. 在 agent-session.ts 的构造函数找到 subscribe 和五个 _install* 调用;挑 _installAgentBoundaryHooks,说明它怎样保留并调用原来的 finishTurn。
  3. 阅读 _handleAgentEvent 的 message_end 分支;记录哪些 role 走 appendMessage,哪些走 appendCustomMessageEntry,哪些不在这里写。
  4. 阅读 SessionManager._persist 与 _hasConversation;预测一次只换了模型、没发消息就退出的会话会不会留下文件。
  5. 手工构造三行 JSONL:合法 header、合法 entry、一行坏 JSON;对照 loadEntriesFromFile,预测加载结果有几条。不要把它写入真实会话目录。
  6. 从 AgentSessionRuntime.fork 跟到 createBranchedSession;确认新 header 的 parentSession,并确认全程没有任何 Git 或 worktree 调用。
  7. 从 AgentSession.compact 跟到 prepareCompaction 与 getSummarizationFailure;分别写出 error、length 和摘要里出现工具调用时的结果。
  8. 阅读 loadSkillFromFile 和 formatSkillsForPrompt;验证缺 description 的文件不会加载,并指出模型读 skill 正文时用的是哪个工具。