学习目标
read / write / edit / bash / ls / find / grep,Windows 另有 powershell)各自的参数 schema、返回结构与系统提示词贡献。truncate.ts 的 2000 行 / 50KB 截断、OutputAccumulator 临时文件与 bash 的 1MiB structuredContent。file-mutation-queue.ts 为什么用按真实路径串行的全局队列保护写操作,以及 path-utils 的路径规范化策略。id / parentId / leafId 与各 entry 类型的作用。/tree、/fork、/clone 三种分支操作在文件层面的差别,理解 resume = 活跃分支回放。convertToLlm 对自定义消息类型的过滤规则。packages/coding-agent/src/core/tools/:read.ts/write.ts/edit.ts/bash.ts(含powershell.ts)精读,truncate.ts/output-accumulator.ts/file-mutation-queue.ts/path-utils.ts通读packages/coding-agent/src/core/session-manager.ts(entry 类型、SessionManager类)与messages.ts(自定义消息与convertToLlm)packages/coding-agent/docs/session-format.md(精读)、sessions.md(后半:分支与存储)、message-types.md
§10.1七大内置工具总览:参数、返回与提示词贡献
coding-agent 的工具都住在 packages/coding-agent/src/core/tools/ 下,由 index.ts 统一导出。全部工具名集合是 "read" | "bash" | "powershell" | "edit" | "write" | "grep" | "find" | "ls";其中 powershell 是 Windows 专属的 bash 变体(复用同一套 shell 机制),所以通常说「七大内置工具」。每个工具同时提供三样东西:给模型的 参数 schema(TypeBox 定义)、给运行时的返回结构(content 文本块 + 可选 details / structuredContent)、给系统提示词的 promptSnippet / promptGuidelines(第 9 讲组装提示词时已见过它们如何拼进 <tools> 段落)。
| 工具 | 参数 schema | 返回结构 | prompt 描述(节选) |
|---|---|---|---|
read |
path;offset?(1 起始行号);limit?(行数) |
文本块(截断时附续读提示);图片返回 image 块;codemode 走 structuredContent(字符串或 {type:"image",data,mimeType,note}) |
snippet:“Read file contents”;guideline:“Use read to examine files instead of cat or sed.”;描述声明截断到 2000 行或 50KB、支持 jpg/png/gif/webp/bmp 附件 |
write |
path;content |
文本 Successfully wrote to ${path};自动递归建父目录 |
snippet:“Create or overwrite files”;guideline:“Use write only for new files or complete rewrites.” |
edit |
path;edits[](每个 oldText/newText,须唯一、不重叠) |
文本 Successfully replaced N block(s) in ${path};details 含 diff、patch、firstChangedLine |
snippet:“Make precise file edits with exact text replacement, including multiple disjoint edits in one call”;guideline 要求一次调用批量改同一文件 |
bash |
command;timeout?(秒,可选、无默认超时) |
stdout+stderr 合流,保留最后 2000 行 / 50KB;截断时附 full_output_path;structuredContent = {output, truncated, full_output_path?, exit_code, wall_time_seconds},非零退出码置 isError: true |
snippet:“Execute bash commands (ls, grep, find, etc.)”;guideline:可检查 PI_* 环境变量获取会话/模型信息 |
ls |
path?(默认当前目录);limit?(默认 500 条) |
字母序条目列表,目录带 / 后缀,含 dotfiles;截断附条目上限提示 |
snippet:“List directory contents”;描述:截断到 500 条或 50KB |
find |
pattern(glob);path?;limit?(默认 1000 条) |
相对搜索目录的文件路径列表;底层调用 fd(自动下载),遵守 .gitignore;截断附提示 |
snippet:“Find files by glob pattern (respects .gitignore)”;描述:截断到 1000 条或 50KB |
grep |
pattern;path?;glob?;ignoreCase?;literal?;context?(上下文行数);limit?(默认 100 个匹配) |
文件:行号: 内容 格式匹配行;底层调用 rg(JSON 输出);details 含 matchLimitReached、linesTruncated |
snippet:“Search file contents for patterns (respects .gitignore)”;描述:100 匹配 / 50KB,长行截到 500 字符 |
powershellWindows 专属 |
与 bash 相同(command / timeout?) |
复用 createShellToolDefinition;临时文件前缀 pi-powershell |
snippet:“Execute PowerShell commands”;命令前强制 UTF-8 输出编码,并修补 PowerShell 5.1 原生命令 stderr 导致的假退出码 |
表中描述均摘自各工具源码里的 description 与 xxxToolSystemPromptContribution 常量;「截断到 N」的数值就是 truncate.ts 的 DEFAULT_MAX_LINES = 2000 与 DEFAULT_MAX_BYTES = 50 * 1024。
一个值得注意的工程模式:每个工具都定义了 Pluggable Operations 接口(ReadOperations / WriteOperations / EditOperations / BashOperations…),默认实现走本地文件系统,扩展可以整体替换成 SSH 等远端实现。工具本体因此只关心「参数校验、路径解析、输出治理、队列」这些与存储位置无关的策略。
§10.2精读四个核心工具:read / write / edit / bash
10.2.1read:流式选行,截断提示「可继续」
read 不只是 fs.readFile 的包装。对文本文件,它优先走 createReadStream 的流式选行路径:只把 truncateHead 可能展示的行存进内存,其余行只计数不保存,所以读一个几 GB 的日志也不会把进程撑爆。图片走另一条分支:按 MIME 检测后用 processImage 自动缩放,再作为 image 块返回;如果当前模型不支持视觉输入,会附加一条说明文本。
// 截断发生时,给模型一条可行动的续读提示(节选自 execute 内部)
if (truncation.truncated) {
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
const nextOffset = endLineDisplay + 1;
outputText = truncation.content;
if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
} else {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
}
details = { truncation };
}
limit 优先于截断(截断前先尊重 limit);单行本身超过 50KB 时不会返回半行,而是提示改用 bash: sed -n 'Np' file | head -c 51200 兜底。10.2.2write:一次全量写,队列内检查中止
write 的语义是「整文件覆盖」:递归创建父目录后一次性写入。它的执行体被 withFileMutationQueue 包住(§10.4 详述),并且在每个 await 之后检查 signal.aborted,而不是在 abort 事件回调里直接 reject——注释里写明了原因:在监听器里 reject 会提前释放队列锁,而此时磁盘上的操作可能还没结束。
const absolutePath = resolveToCwd(path, ctx?.cwd || cwd);
const dir = dirname(absolutePath);
return withFileMutationQueue(absolutePath, async () => {
// Do not reject from an abort event listener here: that would release the
// mutation queue while an in-flight filesystem operation may still finish.
const throwIfAborted = (): void => {
if (signal?.aborted) throw new Error("Operation aborted");
};
throwIfAborted();
await ops.mkdir(dir); // Create parent directories if needed.
throwIfAborted();
await ops.writeFile(absolutePath, content);
throwIfAborted();
return { content: [{ type: "text", text: `Successfully wrote to ${path}` }], details: undefined };
});
releaseNext() 再抛错,队列锁不会泄漏。10.2.3edit:先校验、后 diff、再写入,容忍模型格式漂移
edit 是对「精确文本替换」的严谨实现。执行顺序刻意安排为:读取 → 剥离 BOM → 归一为 LF → 应用替换 → 先算 diff → 再写盘(diff 计算失败时文件不会被破坏)。写回时用「替换块拼接回原始文本」的方式,让未改动行保留原本的 CRLF 行尾。另一个有趣细节在 prepareEditArguments:某些模型(源码注释点名 Opus 4.6、GLM-5.1)会把 edits 塞成 JSON 字符串或单个对象,工具在这里做兼容归一,同时兼容旧的顶层 oldText/newText 参数。
// Strip BOM before matching. The model will not include an invisible BOM in oldText.
const { bom, text: content } = splitBom(rawContent);
const normalizedContent = normalizeToLF(content);
const { baseContent, newContent, replacements } =
applyEditsToNormalizedContent(normalizedContent, edits, path);
// Diff before writing, so diff computation cannot fail after the write.
const editDiff = generateEditDiff(path, baseContent, newContent, replacements);
throwIfAborted();
// Splice into the original text, so untouched lines keep their line endings.
const finalContent =
bom + (content === normalizedContent
? newContent
: applyReplacementsToOriginal(content, replacements));
await ops.writeFile(absolutePath, finalContent);
edits[].oldText 都按原始文件匹配(不是增量应用),因此重叠/嵌套的编辑会直接报错——描述里明确要求把相邻改动合并成一个 edit。10.2.4bash:进程树、超时、退出码与流式输出
bash 与 powershell 共用 createShellToolDefinition,本地执行后端是 createLocalShellOperations。它值得列出至少六条输出/超时/退出码规则(动手实践第 3 步会验证):
- 无默认超时;
timeout参数为秒,必须是有限正数,换算成毫秒后不得超过2_147_483_647(MAX_TIMEOUT_MS)。 - 工作目录不存在直接报错:
Working directory does not exist: ...,不会 fallback 到别处。 - stdout 与 stderr 合流进同一个
OutputAccumulator;UI 更新经onUpdate节流,间隔BASH_UPDATE_THROTTLE_MS = 100毫秒。 - abort 与超时都调用
killProcessTree杀整个进程组(Unix 上 shell 以 detached 进程组启动);被信号杀死时退出码按 shell 惯例折算为128 + 信号编号。 - 超时错误保持
timeout:<seconds>消息形态;若 shell 本身早已退出、只是后台进程还在写管道(issue #11613),ShellTimeoutError.shellExit会把两个事实都写进错误信息。 - 非零退出码不是异常而是错误结果:输出照常返回,追加
Command exited with code N,并置isError: true;退出码为null才算「进程未正常终止」的失败。
/** Output limit of `structuredContent.output`, which programmatic callers such as codemode scripts receive. */
const STRUCTURED_OUTPUT_MAX_BYTES = 1024 * 1024; // 1MiB
const bashOutputSchema = Type.Object({
output: Type.String({ description: "Combined stdout and stderr, possibly truncated" }),
truncated: Type.Boolean(),
full_output_path: Type.Optional(Type.String({ description: "Full output, when truncated" })),
exit_code: Type.Number(),
wall_time_seconds: Type.Number(),
});
content 里最多 2000 行 / 50KB 的尾部快照;codemode 这类程序化调用方拿到 structuredContent,最多 1MiB,超长部分首尾各半、中间用 [... N bytes omitted ...] 标记,并附 full_output_path 指向临时文件全文。§10.3输出治理:大输出为什么不炸上下文
LLM 的上下文窗口是稀缺资源,一次 cat 一个 200MB 文件就能把会话毁掉。Pi 用三层防线把任何工具输出压回安全规模——这三层的职责完全不同:第一层减少「产生的行数」,第二层限制「进入上下文的字节数」,第三层保证「全文仍可找回」。
10.3.1truncate.ts:两个独立上限,先到者赢
truncate.ts 是所有工具共享的截断工具箱。规则要点:行上限 2000、字节上限 50KB(Buffer.byteLength 按 UTF-8 计,不是字符数);两个上限独立判断,谁先触顶谁生效;truncatedBy 告诉调用方是 "lines" 还是 "bytes"。首行单独超过 50KB 时,truncateHead 返回空内容并置 firstLineExceedsLimit: true,由 read 工具转成「改用 bash」的建议。
export const DEFAULT_MAX_LINES = 2000;
export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line
export interface TruncationResult {
content: string; // 截断后的内容
truncated: boolean;
truncatedBy: "lines" | "bytes" | null;
totalLines: number; // 原始总行数
totalBytes: number; // 原始总字节
outputLines: number;
outputBytes: number;
lastLinePartial: boolean; // 仅 bash 尾截断可能出现半行
firstLineExceedsLimit: boolean;
maxLines: number;
maxBytes: number;
}
truncateHead 供 read/ls/find/grep(保留开头);truncateTail 供 bash(保留结尾);另有 truncateLine 专治 grep 长匹配行(500 字符 + ... [truncated] 后缀)。10.3.2OutputAccumulator:有界内存 + 溢写临时文件
bash 的输出是流式的,可能持续几分钟、几个 GB。OutputAccumulator 的策略是:用流式 UTF-8 解码器逐块拼接,内存里只保留一个「尾部滚动窗口」(约 2×50KB,足够 truncateTail 使用);一旦总输出越过任一上限,就把原始字节流落到临时文件,滚动窗口与计数继续工作。结束时 snapshot({ persistIfTruncated: true }) 产出模型可见的尾部快照与 fullOutputPath。
append(data: Buffer): void {
this.totalRawBytes += data.length;
this.appendDecodedText(this.decoder.decode(data, { stream: true }));
if (this.tempFileStream || this.shouldUseTempFile()) {
this.ensureTempFile();
this.tempFileStream?.write(data);
} else if (data.length > 0) {
this.rawChunks.push(data);
}
}
private shouldUseTempFile(): boolean {
return (
this.totalRawBytes > this.maxBytes ||
this.totalDecodedBytes > this.maxBytes ||
this.totalLines > this.maxLines
);
}
readFullOutput(maxBytes) 在 finish() 与 closeTempFile() 之后调用:文件不超 maxBytes 时原样返回;超过则取首尾各半、中间插入 [... N bytes omitted ...],并且都从合法 UTF-8 字符边界切分(跳过续字节)。它等价于你在 Java 里写过的 tail 采集器:内存放一个 ArrayDeque<byte[]> 滚动窗口保最近 N 字节,越过阈值就切到 FileChannel 追加写。区别在于它同时维护三套账本——原始字节(rawBytes)、解码字节(decodedBytes)、行数(lines),任何一套超限都触发落盘;而 Promise 化的 closeTempFile() 保证快照与文件句柄生命周期一致,避免读到半截文件。
§10.4写操作串行化:file-mutation-queue 与 path-utils
Agent 的一次回复可能同时发出多个工具调用,运行时会并发执行它们;两个 edit 同时读写同一个文件就是教科书级的竞态:A 读旧内容、B 读旧内容、A 写、B 写——A 的修改静默丢失。file-mutation-queue.ts 用一个模块级 Map<string, Promise<void>> 解决:key 是文件的真实路径,value 是该文件当前排队的 Promise 链。
const fileMutationQueues = new Map<string, Promise<void>>();
let registrationQueue = Promise.resolve();
async function getMutationQueueKey(filePath: string): Promise<string> {
const resolvedPath = resolve(filePath);
try {
return await realpath(resolvedPath); // 符号链接归一,同一目标同一把锁
} catch (error) {
if (isMissingPathError(error)) return resolvedPath; // 新文件尚不存在
throw error;
}
}
export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
const registration = registrationQueue.then(async () => {
const key = await getMutationQueueKey(filePath);
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
let releaseNext!: () => void;
const nextQueue = new Promise<void>((resolveQueue) => { releaseNext = resolveQueue; });
const chainedQueue = currentQueue.then(() => nextQueue);
fileMutationQueues.set(key, chainedQueue);
return { key, currentQueue, chainedQueue, releaseNext };
});
registrationQueue = registration.then(() => undefined, () => undefined);
const { key, currentQueue, chainedQueue, releaseNext } = await registration;
await currentQueue; // 等前面同文件的写完成
try {
return await fn();
} finally {
releaseNext(); // 唤醒下一个排队者
if (fileMutationQueues.get(key) === chainedQueue) {
fileMutationQueues.delete(key); // 队列清空后回收 Map 项
}
}
}
realpath,符号链接与不同写法指向同一物理文件时共享同一队列(文件还不存在时退回 resolve 后的路径);② registrationQueue 让"查 Map、接链"这个注册段本身串行,消除 check-then-act 竞态;③ 不同文件的队列彼此独立,仍然并行。Map<path, Promise> 相当于 Guava 的 Striped<Lock>:同 key 互斥、异 key 并行。currentQueue.then(() => nextQueue) 是一条 CompletableFuture 链——新任务排在队尾,自己的完成句柄 releaseNext 就是「解锁」。最值得学的是 registrationQueue:因为「读取-判断-写入 Map」本身是异步的(要 await realpath),如果不先把注册段串行化,两个并发注册可能都读到旧队尾、各接各的链,锁就失效了。这是 Java 里 ConcurrentHashMap.computeIfAbsent 一步到位、异步世界却必须显式排队的经典差异。
10.4.1path-utils:路径解析与那些"奇怪"的兼容分支
所有工具入口都先做路径归一:resolveToCwd(path, cwd) 基于 utils/paths.ts 的 resolvePath,支持 ~ 展开、去掉模型爱加的 @ 前缀、归一 Unicode 空格。读类工具走 resolveReadPathAsync,若解析结果不存在,还会依次尝试三类 macOS 截图命名的变体:AM/PM 前的窄不换行空格(U+202F)、NFD 分解形式的文件名、法文弯引号 U+2019。这些分支看似琐碎,却真实对应「用户把截图文件名贴给模型」的高频场景。
§10.5会话持久化:JSONL 每行一个 entry,串成一棵树
第 8 讲我们看到 AgentSession 在事件回调里调用 sessionManager.appendMessage(...);现在打开落盘格式。会话是一个 JSONL 文件:第一行是 SessionHeader(不参与树),之后每行一个 entry,全部继承 SessionEntryBase——四个字段就够把线性文件变成树:type、id(通常 8 位十六进制短 ID)、parentId(根为 null)、ISO 时间戳。
export const CURRENT_SESSION_VERSION = 3;
export interface SessionHeader {
type: "session";
version?: number; // v1 sessions don't have this
id: string; // 会话 ID(UUIDv7)
timestamp: string;
cwd: string; // 会话绑定的工作目录,创建后不变
parentSession?: string; // /fork、/clone 创建时指向源文件
}
export interface SessionEntryBase {
type: string;
id: string; // Usually an 8-char hex ID
parentId: string | null; // Parent entry ID (null for a root entry)
timestamp: string; // ISO timestamp
}
id/parentId)→ v2 树结构 → v3 把 hookMessage 角色改名 custom。旧文件读入时 migrateToCurrentVersion 原地升级并重写。10.5.1entry 类型清单
| type | 作用 | 进入 LLM 上下文? |
|---|---|---|
message | 承载一条 AgentMessage(system / user / assistant / toolResult / bashExecution / custom);system 消息按段落增量记录提示词与工具增减 | 是(经投影) |
model_change / thinking_level_change | 记录中途切换模型 / 思考档位;路径上最新者生效 | 仅影响设置,不产生消息 |
usage | 非 assistant 消息的用量记账(如 kind: "cache_warm"),计入会话统计 | 否 |
compaction | 压缩边界:摘要 + firstKeptEntryId + 完整 system 快照 | 是(摘要替代更早历史) |
branch_summary | 切分支时对被离开路径的摘要,fromId 指向旧 leaf | 是(转成 branchSummary 消息) |
custom | 扩展私有状态持久化(如虚拟模型路由状态 pi.virtual-model-state) | 否(buildSessionContext 忽略) |
custom_message | 扩展注入上下文的可见消息,display 控制 TUI 呈现 | 是(转成 user 消息) |
context_edit | 对更早某条可见 entry 的追加式编辑:replacement: null 表示从模型上下文剔除 | 改变被目标的贡献,自身不产生消息 |
label | 给 entry 打书签(/tree 里显示、可跳转) | 否 |
session_info | 会话显示名(/name、--name) | 否 |
10.5.2current 指针与「活跃分支回放」
SessionManager 内存里维护一个 leafId——当前树位置。每次 _appendEntry 都把新 entry 挂到当前 leaf 下并把 leaf 前移;_buildIndex(加载文件时)把 leaf 重置为文件中最后一条 entry。构建模型上下文时的调用链是:buildSessionContext() → buildSessionProjection() → buildSessionPath()(从 leaf 沿 parentId 走到根)→ 应用压缩边界与 context_edit 投影。也就是说,resume 的本质就是活跃分支回放:文件里所有分支都在,但只有 leaf 到根这条路径会发给模型。
parentId 重建的逻辑树。/tree 选中 u2 改写重发后,新 user 消息成为 a2 的新子节点,与 u3 并列成分支;开启「Summarize」时先追加 branch_summary(其 parentId=新起点、fromId=旧 leaf),摘要进入新分支上下文。每行 entry 是一个不可变领域事件,等价于 Event Store 里的 append-only 记录;leafId 就是分支 HEAD。分支不复制数据,只移动指针(branch()),这正是 Git 的做法;压缩(compaction)相当于 Kafka 的 log compaction / 事件溯源里的快照——旧事件不删除,只在回放时被摘要替代。第 9 讲的 context_edit 则是「投影修正」:原始事件不动,读取端按最新 edit 重算视图,与 CQRS 读模型异曲同工。
§10.6分支操作语义:/tree、/fork、/clone 与 resume
三种「回到过去」的操作在用户看来越来越重,在存储层面的差别其实非常清晰:
| 操作 | 存储层动作 | 产生新文件? | 适用场景(官方文档原话意译) |
|---|---|---|---|
/tree |
branch(branchFromId) 仅移动 leafId;选中 user 消息时把 leaf 移到其 parentId 并把原文放回编辑器,改写重发即形成新分支;到根用 resetLeaf();可选先追加 branch_summary(branchWithSummary) |
否(同一文件内树内移动) | 相关备选方案应留在同一会话里对照 |
/fork |
选中更早的 user 消息 → createBranchedSession(targetLeafId):取 root→目标 leaf 路径,剥离/重建 label 链,写入新文件;新 header 的 parentSession 指向源文件;运行时以 session_start: reason "fork" 重建 |
是(新 ID + 新文件) | 备选方案要独立成一项长期工作 |
/clone |
等价于在当前 leaf 上 fork(runtimeHost.fork(leafId, { position: "at" })):复制当前活跃分支为新会话 |
是 | 想为当前状态留一份独立副本 |
跨目录 fork 由 SessionManager.forkFrom(sourcePath, targetCwd) 支持:读取源文件全部 entry,换新 cwd 写入目标目录的新会话文件。
10.6.1resume:加载文件 → 重建索引 → 活跃分支回放
--continue 走 SessionManager.continueRecent(cwd):在会话目录里找最近文件并 open;--resume 打开选择器。打开文件时 loadEntriesFromFile 逐行解析(带 1MiB 读缓冲),migrateToCurrentVersion 升级旧版本,_buildIndex 建立 byId 索引并把 leafId 设为最后一条 entry——于是「上次说到哪」自动恢复。下一次模型请求前,buildSessionContext() 沿 leaf→root 回放,再叠加系统提示词、上下文文件、技能描述(第 9 讲)。
10.6.2steering、follow-up、abort 的持久化体现
运行中的插话与打断最终都收敛为「普通 entry 落盘」,没有专用的 steering 文件:
- steering(中途插话):消息先进
_steeringMessages内存队列并agent.steer(...)打断当前循环;当它作为message_start(user)真正进入历史时,从队列摘除。持久化发生在message_end:sessionManager.appendMessage(event.message),与普通消息无异。 - follow-up(排队跟进):同理走
_followUpMessages队列,等 agent 空闲后按序投递,投递后同样以 user entry 落盘。 - abort(取消):被中止的 assistant 回调仍会以
message_end结束(stopReason: "aborted")并持久化——恢复会话时模型能看到「上次被打断在哪」;流式中的"pending"消息则不会持久化。 !直连 shell:以role: "bashExecution"的 message entry 落盘(recordBashResult);若 agent 正在流式中则先挂_pendingBashMessages,等agent_end再追加,避免插进某个 toolUse/toolResult 对中间。
// Handle session persistence
if (event.type === "message_end") {
let entryId: string | undefined;
if (event.message.role === "custom") {
// Persist as CustomMessageEntry
entryId = this.sessionManager.appendCustomMessageEntry(
event.message.customType, event.message.content,
event.message.display, event.message.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);
}
if (entryId) this._entryIdsByMessage.set(event.message, entryId);
// Other message types (bashExecution, compactionSummary, branchSummary)
// are persisted elsewhere
}
message_end,谁就进树。这也是扩展注入消息(sendMessage)能被持久化的原因——它们以 custom 角色走同一条路径。§10.7存储布局与自定义消息类型
10.7.1按工作目录分组的会话目录
每个会话记录创建时的 cwd 且永不改变;工具、@path 引用、项目配置都从这个目录解析。默认存储按工作目录分组:
// ~/.pi/agent/sessions/--<path>--/<timestamp>_<session-id>.jsonl
function getDefaultSessionDirPath(cwd: string, agentDir = getDefaultAgentDir()): string {
const resolvedCwd = resolvePath(cwd);
const resolvedAgentDir = resolvePath(agentDir);
const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
return join(resolvedAgentDir, "sessions", safePath);
}
C:\work\pi 会编码成 --C-work-pi--;文件名形如 2026-11-10T09-30-00-000Z_<uuidv7>.jsonl。可用 --session-dir、PI_CODING_AGENT_SESSION_DIR 或 sessionDir 设置改位置(CLI 优先级最高)。两个关于「何时才写文件」的细节:其一,会话文件直到出现第一条 user 或 assistant 消息才创建(_hasConversation() 判定)——只改了模型、思考档位这类 setup entry 的会话留在内存里,开完就关不会留下垃圾文件;首个落盘 entry 用 "wx" 独占创建整文件,此后逐条 append。其二,--no-session 走 SessionManager.inMemory()(persist = false),完全 ephemeral,退出即失。
10.7.2扩展消息类型与 convertToLlm 过滤
coding-agent 通过 TypeScript 的 declaration merging 给基础 AgentMessage 联合加了四个角色:bashExecution、custom、branchSummary、compactionSummary。它们能进会话树,但 LLM API 只认 system/user/assistant/toolResult,所以发给模型前必须经过 convertToLlm 转换或过滤:
export function convertToLlm(messages: AgentMessage[]): Message[] {
return messages
.map((m): Message | undefined => {
switch (m.role) {
case "bashExecution":
if (m.excludeFromContext) return undefined; // “!!” 前缀:不出现在上下文
return { role: "user",
content: [{ type: "text", text: bashExecutionToText(m) }],
timestamp: m.timestamp };
case "custom":
return { role: "user", content: ..., timestamp: m.timestamp };
case "branchSummary":
return { role: "user", content: BRANCH_SUMMARY_PREFIX + m.summary + ..., ... };
case "compactionSummary":
return { role: "user", content: COMPACTION_SUMMARY_PREFIX + m.summary + ..., ... };
case "system": case "user":
case "assistant": case "toolResult":
return m; // 原样通过
default:
return undefined; // 未知角色直接剔除
}
})
.filter((m) => m !== undefined);
}
Ran \`cmd\` + 代码块);excludeFromContext 的 bashExecution 与未知角色返回 undefined 剔除;四类标准角色原样通过。它同时被 prompt 调用、压缩摘要生成和扩展复用。再对照 §10.5.1 的两个 entry:custom entry 是纯扩展状态,buildSessionContext 根本不会把它变成消息(convertToLlm 都到不了);custom_message entry 会变成 CustomMessage,再由 convertToLlm 转成 user 消息进入上下文。「进树」与「进上下文」是两个独立决策——这正是扩展持久化状态而不污染对话的机制。
动手实践
~/.pi/agent/sessions/ 下任选一个较大的 .jsonl 文件,用 30 行以内的脚本逐行 JSON.parse,按 type 分类打印,再按 parentId 重建树(孤儿节点当额外根处理,与 getTree() 一致)。验证三件事:header 不在树里;_buildIndex 语义下的「最后一条 entry」即默认 leaf;若文件里有 compaction,对照 firstKeptEntryId 找出被摘要替代的范围。
参考 docs/session-format.md 末尾的 Parsing Example;也可以直接调用 parseSessionEntries() 对照结果。
/tree 选中第一条 user 消息改写重发得到分支 B(开一次 Summarize,观察 branch_summary 的 fromId);回到分支 A 后执行 /fork 选同一条 user 消息。用第 1 步的脚本分别画出原文件与 fork 文件:原文件应是一棵带分叉的树,fork 文件只有一条 root→目标 的链,且 header 里 parentSession 指向原文件。最后 /clone 一次,确认它与「在当前 leaf fork」等价。
MAX_TIMEOUT_MS 校验;cwd 不存在报错;输出合流与 100ms 节流;killProcessTree 与 128+信号 折算;ShellTimeoutError.shellExit 的双事实消息;非零退出码走 isError: true 而非异常;exitCode === null 才算终止失败。再各自构造一个触发场景(如 sleep 2 配 timeout: 1、exit 3、后台进程写管道的脚本)验证提示文案。
常见误区
误区一:/tree 回退会「删掉」走错的那段对话。
真相:会话文件是 append-only 的,branch() 只改内存里的 leafId,不修改、不删除任何一行。被离开的分支永远在文件里,随时可以再 /tree 回去;只有 /fork//clone 才产生只含一条路径的新文件(源文件同样原封不动)。
误区二:Agent 一次只执行一个工具,写队列是过度设计。
真相:模型一次回复可携带多个 toolCall,运行时会并发执行;两个 edit 同时读改写同一文件就会丢失更新。队列按 realpath 串行同文件、并行异文件,且注册段本身也被 registrationQueue 串行化,防止「两个注册都接到旧队尾」。
误区三:截断上限 50KB 是「5 万个字符」。
真相:DEFAULT_MAX_BYTES 用 Buffer.byteLength(content, "utf-8") 度量,中文等多字节字符按实际字节计。同时行数上限(2000)独立生效,先触顶者赢;这也是为什么 truncateTail 特意处理 UTF-8 字符边界,避免切出半个字符。
误区四:启动 pi 就一定会创建会话文件。
真相:_persist() 在首次落盘前检查 _hasConversation()——只有出现 user/assistant 消息才以 "wx" 创建文件;--no-session 则用 inMemory() 完全不落盘。临时开个 pi 查个问题不会污染 sessions 目录。
自测问答
为什么 write / edit 需要一个全局的文件变更串行队列?点击展开答案
因为 Agent 一次回复中的多个工具调用可能并发执行,两个写操作对同一文件的「读-改-写」交错会静默丢失先写入的修改。withFileMutationQueue 以 realpath 为 key 把同文件操作串成 Promise 链(同文件互斥),不同文件仍并行;注册段本身再由 registrationQueue 串行,避免异步的「查 Map 接链」竞态。写工具还在每个 await 后检查 signal.aborted,而不是在 abort 监听器里 reject,确保队列锁等到当前磁盘操作 settle 后才释放。
/fork 与 /tree 分支的本质区别是什么?/clone 又算哪一种?
/tree 是同文件内的指针移动:branch(id) 只把 leafId 移到历史 entry,之后追加的消息成为该 entry 的新子节点,与旧分支并存于一棵树里。/fork 是跨文件拷贝:createBranchedSession(leafId) 把 root→目标 leaf 的路径重写进一个新 .jsonl(新会话 ID,header 的 parentSession 指回源文件),原文件不动。/clone 本质是「在当前 leaf 位置 fork」,即把当前活跃分支复制成新文件。
大输出的三层防线分别是什么?read 与 bash 的截断方向为何不同?
第一层:工具自身条目上限——ls ≤500 条、find ≤1000 条、grep ≤100 个匹配且单行 500 字符、read 支持 offset/limit。第二层:truncate.ts 全局 2000 行或 50KB(UTF-8 字节)上限,先到者赢,绝不返回半行(bash 尾截断的单行超限是唯一例外)。第三层:OutputAccumulator 把超限输出全文写入临时文件(full_output_path),codemode 的 structuredContent.output 最多 1MiB 且首尾各半加省略标记。方向差异:read 用 truncateHead 保留开头(读文件通常从头看),bash 用 truncateTail 保留结尾(错误信息与最终结果通常在末尾)。
会话 JSONL 里 entry 如何构成树?current(leaf)指针决定了什么?
除首行 SessionHeader 外每行 entry 都有 id 与 parentId(根为 null),按引用连成树;加载时 _buildIndex 把 leaf 设为文件最后一条 entry。leafId 决定「活跃分支」:buildSessionPath 从 leaf 沿 parentId 走到根,这条路径经过 buildSessionProjection(应用压缩与 context_edit)后就是发给模型的消息——所以 resume 等于活跃分支回放,其他分支虽在文件里但不进上下文。
custom entry 与 custom_message entry 有何区别?convertToLlm 会怎么处理它们?
custom entry 是扩展私有状态(如虚拟模型路由状态),会进树但不参与 LLM 上下文,buildSessionContext 直接忽略;custom_message entry 是扩展注入的可见消息,回放时变成 CustomMessage(display 只控制 TUI 呈现,details 不发给模型)。convertToLlm 把 CustomMessage 内容转成 user 消息;同时它会把 excludeFromContext 的 bashExecution(!! 前缀)与未知角色过滤为 undefined,branchSummary/compactionSummary 则包上 <summary> 前后缀转成 user 文本。
steering 插话、follow-up 与 abort 分别如何体现在持久化文件里?
三者最终都落成普通 message entry:steering/follow-up 消息在真正进入历史时以 user entry 由 message_end 处理器 appendMessage 落盘(内存队列只负责投递时机);被打断的 assistant 回复以 stopReason: "aborted" 的 assistant entry 落盘(流式 "pending" 状态不持久化);! 直连 shell 以 role: "bashExecution" entry 落盘,流式期间先挂起、agent_end 再追加以维持 toolUse/toolResult 配对完整。