上一章解释了多轮对话为什么依赖结构化 Transcript。现在用户关闭桌面 Agent,再次启动:
昨天我让 Agent 读取了
src/config.ts,今天打开应用后,它应该继续理解“把那个端口改成 8080”。
内存里的 agent.state.messages 已经消失。我们需要把可重建会话的事实写入持久层,并在启动时恢复。
Pi 源码中有两套相关实现:
pi-agent-coreHarness 的通用 Session、Storage、Repository 抽象;pi-coding-agent自己的 JSONLSessionManager。
两者共享“Header、Entry、树、当前 Leaf、从 Entry 重建 Context”等理念,但不是同一个可互换的类。本章先用通用 Harness 构建桌面 Agent,再解释 coding-agent 的文件格式。
1. 不要只保存 messages.json
最小的错误实现是:
await writeFile(
"messages.json",
JSON.stringify(agent.state.messages),
);
它至少遗漏:
- Session ID 和创建时间;
- 工作目录;
- 当前模型;
- Thinking Level;
- 当前启用的工具;
- 分支之间的父子关系;
- Context Compaction;
- 扩展的持久状态;
- 当前位于哪一个分支 Leaf。
Pi 把会话存成一系列 Entry:
flowchart LR
H["Session Metadata / Header"] --> E1["Message Entry"]
E1 --> E2["Message Entry"]
E2 --> M["Model Change Entry"]
M --> E3["Message Entry"]
E3 --> C["Compaction Entry"]
消息只是 Entry 的一种。
2. Session Entry 的共同骨架
packages/agent/src/harness/types.ts 定义:
interface SessionTreeEntryBase {
type: string;
id: string;
parentId: string | null;
timestamp: string;
}
每个 Entry 都有 ID、父 Entry、ISO 时间和具体类型。
消息 Entry:
interface MessageEntry
extends SessionTreeEntryBase {
type: "message";
message: AgentMessage;
}
模型变化不是修改旧 Header,而是追加新 Entry:
interface ModelChangeEntry
extends SessionTreeEntryBase {
type: "model_change";
provider: string;
modelId: string;
}
这种 append-only 设计保留了发生顺序,也为下一章的会话树和分支打下基础。
3. Entry 有哪些类型
通用 Harness 的 SessionTreeEntry 包括:
| Entry | 保存什么 | 是否直接变成 Context Message |
|---|---|---|
message |
User、Assistant、Tool Result 等 Agent Message | 是 |
thinking_level_change |
Thinking Level 变化 | 否,重建运行设置 |
model_change |
Provider 和 Model ID | 否,重建运行设置 |
active_tools_change |
当前启用的工具名 | 否,重建运行设置 |
compaction |
压缩摘要和保留尾部 | 转成 Compaction Summary |
branch_summary |
离开分支时的摘要 | 转成 Branch Summary |
custom |
应用或扩展状态 | 默认不进 Context |
custom_message |
应用注入的上下文消息 | 是 |
label |
对目标 Entry 的标签 | 否 |
session_info |
会话名称等信息 | 否 |
leaf |
显式记录当前活动 Leaf | 否 |
因此,Session 是“可重放的会话事件”,而不仅是聊天气泡数组。
4. 三层抽象:Repo、Storage、Session
flowchart TB
APP["桌面应用"] --> R["SessionRepo"]
R -->|create / open / list / delete / fork| S["Session"]
S -->|appendMessage / buildContext / moveTo| ST["SessionStorage"]
ST --> J["JSONL"]
ST --> M["Memory"]
ST --> Q["SQLite"]
SessionRepo
管理一组会话:
interface SessionRepo {
create(options): Promise<Session>;
open(metadata): Promise<Session>;
list(options?): Promise<SessionMetadata[]>;
delete(metadata): Promise<void>;
fork(source, options): Promise<Session>;
}
桌面应用的“新建、最近会话、删除、复制会话”对应 Repo。
SessionStorage
负责一个会话的底层 Entry:
interface SessionStorage {
getMetadata(): Promise<SessionMetadata>;
getLeafId(): Promise<string | null>;
setLeafId(id: string | null): Promise<void>;
createEntryId(): Promise<string>;
appendEntry(entry): Promise<void>;
getEntry(id):
Promise<SessionTreeEntry | undefined>;
getEntries(options?):
Promise<SessionTreeEntry[]>;
getPathToRootOrCompaction(id):
Promise<SessionTreeEntry[]>;
}
Session
在 Storage 上提供领域 API:
await session.appendMessage(message);
await session.appendModelChange(
provider,
modelId,
);
const context =
await session.buildContext();
业务代码主要使用 Repo 和 Session,不需要知道 Entry 实际存成一行 JSON 还是一张数据库表。
5. 先用 Memory Repository 跑通
开发阶段可以用:
import {
InMemorySessionRepo,
} from "@earendil-works/pi-agent-core";
const repo = new InMemorySessionRepo();
const session = await repo.create();
它实现和持久后端相同的 Repo 接口:
await session.appendMessage({
role: "user",
content: "记住默认端口是 3000",
timestamp: Date.now(),
});
const restored =
await session.buildContext();
但它只存在于当前 JavaScript 进程。应用退出后,Map 和 Entry 数组一起消失。
Memory Repository 适合单元测试、界面预览、临时 Session 和不希望污染磁盘的场景。它不满足“重启恢复”。
6. JSONL Session 是什么
JSONL 是“一行一个 JSON 对象”:
{"type":"session","version":3,"id":"session-1","timestamp":"2026-07-27T08:00:00.000Z","cwd":"/work/app"}
{"type":"message","id":"e1","parentId":null,"timestamp":"2026-07-27T08:00:01.000Z","message":{"role":"user","content":"读取 config.ts","timestamp":1785139201000}}
{"type":"model_change","id":"e2","parentId":"e1","timestamp":"2026-07-27T08:00:02.000Z","provider":"openai","modelId":"gpt-example"}
第一行是 Header,后面每行是 Entry。
优点:
- 新 Entry 可以追加到文件末尾;
- 人可以直接查看;
- 单个 Entry 容易导出和调试;
- 文件损坏可定位到具体行;
- 不需要数据库服务。
代价:
- 打开时需要解析文件;
- 大量会话的搜索和分页不如数据库自然;
- 多进程并发写需要额外设计;
- 更新通过追加新事实表达,而不是原地改行。
7. Session Header 保存什么
通用 Harness 的 JSONL Header 当前是:
interface SessionHeader {
type: "session";
version: 3;
id: string;
timestamp: string;
cwd: string;
parentSession?: string;
metadata?: Record<string, unknown>;
}
Header 不是树中的 Entry,没有 parentId。它描述格式版本、Session ID、创建时间、工作目录、父 Session 文件和应用 Metadata。
JsonlSessionStorage.open() 会检查:
- 第一行必须是有效 JSON;
type必须是"session";- 当前通用 Harness JSONL Storage 接受
version: 3; - ID、时间和 cwd 必须存在;
- Metadata 必须是对象。
这比直接 JSON.parse() 后盲目信任可靠,但 Entry 读取只做基础形状检查。桌面应用仍应把 Session 文件视为需要谨慎处理的输入。
8. 创建一个 JSONL Repository
Node 宿主可以提供 NodeExecutionEnv:
import {
JsonlSessionRepo,
} from "@earendil-works/pi-agent-core";
import {
NodeExecutionEnv,
} from "@earendil-works/pi-agent-core/node";
const cwd = "/work/my-desktop-agent";
const env = new NodeExecutionEnv({ cwd });
const repo = new JsonlSessionRepo({
fs: env,
sessionsRoot:
"/work/app-data/sessions",
});
const session = await repo.create({
cwd,
metadata: {
appVersion: "1.0.0",
},
});
JsonlSessionRepo 会规范化 Session 根目录,把 cwd 编码为子目录,创建带时间和 Session ID 的 .jsonl 文件,写入 Header,再返回 Session。
不要把 API Key 放进 metadata。JSONL 是普通本地文件,不会自动加密。
9. 把 Session 交给 AgentHarness
创建新 Session 时,先显式记录初始运行设置:
await session.appendModelChange(
model.provider,
model.id,
);
await session.appendThinkingLevelChange("off");
await session.appendActiveToolsChange(
tools.map((tool) => tool.name),
);
AgentHarness 构造器只把 model、thinkingLevel 和 activeToolNames 放进当前进程的内存字段,不会因为收到这些构造参数就自动追加 Session Entry。只有显式调用 Session 的 append 方法,或后续调用 Harness 的 setModel()、setThinkingLevel()、setTools()、setActiveTools() 等变更 API,相关设置才会写入 Session。
通用 Harness 接收 Session:
const harness = new AgentHarness({
session,
models,
model,
tools,
systemPrompt:
"你是桌面开发助手。",
});
await harness.prompt(
"读取 src/config.ts,告诉我默认端口。",
);
Harness 在每个 message_end 执行:
await session.appendMessage(event.message);
所以会写入 User Message、最终 Assistant Message、Tool Result Message,以及进入 Agent 生命周期的 Custom Agent Message。
在 turn_end,Harness 还会刷新本轮通过变更 API 排队的 Model、Thinking、Active Tools 和 Custom Entry 等写入,然后发出 save_point。这不包含从未显式写入的初始构造参数。
sequenceDiagram
participant A as Agent Loop
participant H as AgentHarness
participant S as Session
participant D as Storage
A-->>H: message_end(User)
H->>S: appendMessage
S->>D: appendEntry
A-->>H: message_end(Assistant)
H->>S: appendMessage
S->>D: appendEntry
A-->>H: turn_end
H->>S: flush pending writes
H-->>H: emit save_point
Partial message_update 不会逐块写入 Session;最终 message_end 才保存消息。这避免每个 Token 都形成一条历史 Entry。
10. 直接使用 Agent 时怎样保存
如果没有使用 AgentHarness,可以订阅基础 Agent 事件:
agent.subscribe(async (event) => {
if (event.type !== "message_end") {
return;
}
await session.appendMessage(
event.message,
);
});
还要自己保存 Model、Thinking、Active Tools、自定义状态和写入失败。只订阅 agent_end 再保存全部 State 也能工作,但需要自行处理重复、崩溃窗口和增量写入。
11. 应用重启后怎样找到会话
Repository 的 list() 返回 Metadata,而不是把所有完整 Session 一次载入:
const recent = await repo.list({ cwd });
const selected = recent[0];
if (!selected) {
throw new Error("没有可恢复会话");
}
const session =
await repo.open(selected);
JSONL Repo 按创建时间从新到旧排序。它读取每个文件第一行的 Header 生成 Metadata;无效 Session Header 会在列表发现中被跳过。
用户真正选择后再 open(),会话列表则可以先显示 ID、创建时间、cwd、Metadata 和文件路径。
12. 从 Entry 重建 Context
打开 Session 后:
const restored =
await session.buildContext();
返回:
interface SessionContext {
messages: AgentMessage[];
thinkingLevel: string;
model: {
provider: string;
modelId: string;
} | null;
activeToolNames: string[] | null;
}
buildContext() 不是返回所有物理 Entry。它会:
- 从当前 Leaf 沿
parentId找到活动路径; - 处理最新 Compaction;
- 把 Message Entry 转回 Agent Message;
- 把 Custom Message、Branch Summary 和 Compaction Summary 投影成 Agent Message;
- 默认忽略普通 Custom Entry;
- 从路径恢复最近的 Model、Thinking Level 和 Active Tools。
flowchart LR
ALL["Storage 中所有 Entry"] --> PATH["当前 Leaf 的活动路径"]
PATH --> COMPACT["应用 Compaction 规则"]
COMPACT --> PROJECT["Entry → AgentMessage"]
PROJECT --> CTX["SessionContext"]
所以 getEntries() 用于历史、审计和树,buildContext() 用于继续 Agent。
13. 恢复 AgentHarness
Harness 每次 Turn 开始时都会:
const context =
await session.buildContext();
把打开后的 Session 传给新 Harness,就能恢复消息:
const restored =
await session.buildContext();
const restoredModel =
resolveModelFromAppRegistry(
restored.model,
fallbackModel,
);
const restoredTools =
resolveKnownToolNames(
restored.activeToolNames,
tools,
);
const harness = new AgentHarness({
session,
models,
model: restoredModel,
thinkingLevel:
normalizeThinkingLevel(
restored.thinkingLevel,
),
tools,
activeToolNames: restoredTools,
});
三个 resolve... 是桌面应用 Helper,不是 Pi API。
AgentHarness 构造函数要求宿主传入当前 Model、Thinking Level 和 Active Tools。它会从 Session 重建消息,但不会在构造时自动用 SessionContext 覆盖这些参数。宿主应从 Model Registry 解析保存的 ID、为不存在的模型提供明确回退、过滤失效工具并验证 Thinking Level。
恢复后:
await harness.prompt(
"把那个端口改成 8080。",
);
本轮开始时会再次从 Session 构建消息历史。
14. 为什么用 Entry 重建,而不是保存 Agent 实例
JavaScript 对象不能可靠跨版本、跨进程恢复:
- 函数和闭包不能 JSON 化;
- Provider Client 不能安全持久化;
- AbortController 已失效;
- Event Listener 属于当前窗口;
- 工具实现可能已经升级;
- 模型目录可能变化;
- Partial Message 可能处于不一致状态。
Entry 保存的是稳定事实:用户说了什么、模型回答了什么、工具返回了什么、何时切换设置,以及当前分支在哪里。新进程用新代码和新依赖重建运行对象。
15. JSONL 怎样追加和恢复 Leaf
JsonlSessionStorage.appendEntry():
- 把 Entry 序列化成一行;
- 追加换行;
- 更新内存 Entry 数组;
- 更新 ID 索引和 Label Cache;
- 把当前 Leaf 设为新 Entry ID。
切换到旧分支时,setLeafId() 不修改旧行,而是追加:
interface LeafEntry {
type: "leaf";
targetId: string | null;
// 以及共同 Entry 字段
}
重新打开文件时,普通 Entry 让 Leaf 指向自己的 ID,leaf Entry 让 Leaf 指向 targetId。因此“当前在哪里”也能从 append-only 日志恢复。
16. SQLite Repository 什么时候有价值
桌面应用会话数量增多后,SQLite 为事务、索引、物化状态以及后续扩展复杂查询提供了基础。当前 Pi 的 SqliteSessionRepo.list() 公开能力只有列出全部 Session,或按 cwd 过滤;它没有通用多条件或分页参数。
Pi 的 Node SQLite 后端在独立包:
@earendil-works/pi-storage-sqlite-node
创建:
import {
NodeExecutionEnv,
} from "@earendil-works/pi-agent-core/node";
import {
createNodeSqliteFactory,
SqliteSessionRepo,
} from "@earendil-works/pi-storage-sqlite-node";
const env = new NodeExecutionEnv({ cwd });
const repo = new SqliteSessionRepo({
env,
sqlite: createNodeSqliteFactory(),
databasePath:
"/work/app-data/sessions.sqlite",
});
const session = await repo.create({
cwd,
metadata: {
appVersion: "1.0.0",
},
});
源码打开数据库时配置:
PRAGMA journal_mode=WAL;
PRAGMA synchronous=FULL;
PRAGMA busy_timeout=5000;
并执行版本迁移。SQLite Repo 仍返回相同的 Session 领域对象,所以 Harness 上层不需要因为后端变化而改写 Agent 流程。
17. JSONL、Memory、SQLite 怎样选择
| 后端 | 重启恢复 | 适合 |
|---|---|---|
| InMemory | 否 | 测试、临时会话、预览 |
| JSONL | 是 | 本地优先、易调试、会话文件导入导出 |
| SQLite | 是 | 桌面产品、大量会话、事务与查询 |
第一版桌面 Agent 可以从 JSONL 开始。若产品已有 SQLite 数据层,SQLite Repo 更自然。业务层应依赖 Repo,不要为每个后端复制一套 Agent 逻辑。
18. coding-agent 的 SessionManager
packages/coding-agent/src/core/session-manager.ts 是 coding-agent 使用的专用实现:
- 当前格式版本也是 3;
- Header + append-only Entry;
- Session 内部也是树;
- 支持
create、open、continueRecent、inMemory; - 默认文件位于
~/.pi/agent/sessions/下按 cwd 编码的目录; buildSessionContext()恢复 Message、Model 和 Thinking Level;- 旧格式可在加载时迁移。
SessionManager 负责解析和构建 Session Context,真正把恢复结果应用到运行 Agent 的是 packages/coding-agent/src/core/sdk.ts 中的 createAgentSession():
flowchart TD
O["createAgentSession(options)"] --> C["SessionManager.buildSessionContext"]
C --> H{"存在历史消息?"}
H -->|是| M["尝试恢复 Model"]
M --> A{"模型存在且 Provider 已配置认证?"}
A -->|是| RM["使用恢复模型"]
A -->|否| F["选择可用回退模型并返回提示"]
H -->|是| T["恢复 Thinking 或使用默认值"]
T --> CL["按当前模型能力 clamp"]
C --> MSG["恢复 Agent Messages"]
O --> TOOLS["按本次 tools / noTools / excludeTools 计算 Active Tools"]
更精确地说:
- 调用方没有显式传
model时,才尝试使用 Session 保存的 Model; - 保存的模型必须仍存在,并且 Provider 已配置认证;
- 无法恢复时会选择初始可用模型,并通过
modelFallbackMessage提示; - 调用方没有显式传
thinkingLevel时,才从 Session 恢复; - 恢复值会按当前模型能力
clampThinkingLevel(); - 历史消息会写入新 Agent 的
state.messages; - 新 Session 会主动追加初始 Model 和 Thinking Entry;
- 已有旧 Session 若没有 Thinking Entry,会补写当前有效 Thinking;
- Active Tools 不在 coding-agent 的 Session Context 中,而是根据本次启动的
tools、noTools、excludeTools和默认工具集合重新计算。
它与 Harness Session 的关键区别:
| 通用 Harness Session | coding-agent SessionManager |
|---|---|
SessionRepo / SessionStorage 抽象 |
一个专用 Manager 管理 JSONL |
| Memory、JSONL、SQLite 后端 | coding-agent 自己的 JSONL/内存模式 |
| Context 还包含 Active Tools | 专用 Context 返回 Message、Thinking、Model |
| 异步 Storage 接口 | 主要使用 coding-agent 本地文件实现 |
不要把 SessionManager 直接当成 AgentHarness 构造函数需要的 Session。直接嵌入完整 coding-agent SDK 时可使用它;围绕通用 Agent Harness 构建时,优先使用 Repo/Session 抽象。
19. 从 Agent Event 到持久 Entry
sequenceDiagram
participant UI as 桌面 UI
participant H as AgentHarness
participant L as Agent Loop
participant S as Session
participant R as Repository Storage
UI->>H: prompt
H->>S: buildContext
S->>R: 读取活动路径
R-->>S: Session Entries
S-->>H: SessionContext
H->>L: runAgentLoop
L-->>H: message_end
H->>S: appendMessage
S->>R: append Entry
L-->>H: turn_end
H->>S: flush pending writes
H-->>UI: save_point
恢复不是“把文件直接传给模型”,而是:
Storage
→ Entry
→ 活动路径
→ SessionContext
→ AgentContext
→ convertToLlm
→ pi-ai Context
→ Provider
20. 崩溃与写入失败怎样处理
应用需要:
- 在窗口退出前等待当前运行 settle;
- 展示持久化错误,不能假装已保存;
- 不在
message_update中高频写完整历史; - 对数据库或 JSONL 文件做备份;
- 写入自定义 Entry 时保持数据可迁移;
- 不把秘密无条件写入 Message、Details 或 Metadata;
- 打开损坏 Session 失败时保留原文件并提供诊断;
- 不把“UI 已显示”当成“磁盘已持久化”。
Harness 在 message_end 保存消息;turn_end 刷新排队写入后发出 save_point。桌面 UI 可以把 save_point 作为“这一 Turn 的待写变更已经刷新”的信号。
JSONL 的 append-only 结构降低了重写整个文件的风险,但不代表系统掉电时绝对不会出现尾行问题。SQLite 的事务也不替代应用级错误处理。
21. 最小恢复清单
- 创建同一类型的 Repository;
list()获取 Session Metadata;- 用户选择后
open(); buildContext()验证活动路径可重建;- 从应用 Model Registry 解析保存的 Model;
- 验证 Thinking Level;
- 过滤已经不存在的 Active Tool;
- 用新 Provider、工具和 Listener 创建新 Harness;
- UI 从 Entry 或 Agent Message 重建聊天卡片;
- 下一次 Prompt 前再次从 Session 构建 Context;
- 写入失败时明确告诉用户。
22. 常见误区
“只保存最后一次 Assistant 文本就能恢复”
错误。模型还需要 User Message、Tool Call、Tool Result 和相关协议字段。
“Session 中所有 Entry 都应该发给模型”
错误。Custom State、Label、Session Name、Leaf 等 Entry 不直接进入 Context。
“open Session 后,Harness 会自动选择已经保存的模型”
错误。Harness 构造时的运行配置由宿主传入;宿主要解析 SessionContext。
“Memory Repository 也能在应用重启后继续”
错误。它只适合当前进程。
“JSONL 和 SQLite 需要两套 Agent 业务代码”
错误。它们都通过 Repo、Storage 和 Session 抽象提供相同领域操作。
“coding-agent SessionManager 就是 Harness Session”
错误。它们理念相近,但类、接口和支持的后端不同。
23. 本章小结
- Session 由 Header/Metadata 和 append-only Entry 组成;
- Message、Model、Thinking、Active Tools、Compaction 和扩展状态分别记录;
parentId与当前 Leaf 让历史天然成为树;- Repo 管理会话集合,Storage 管理单会话 Entry,Session 提供领域 API;
- InMemory 适合测试,JSONL 适合本地文件,SQLite 适合桌面产品;
- AgentHarness 在
message_end追加消息,在 Turn 边界刷新待写 Entry; buildContext()从活动路径投影出继续运行所需的消息和设置;- 宿主仍要解析已保存的 Model、Thinking Level 和 Active Tools;
- coding-agent 的 SessionManager 是另一套专用 JSONL 实现,不能和 Harness Session 混用。
下一章会利用 id、parentId 和 Leaf,解释为什么一次会话不是数组,而是一棵树。
24. 自测
- 为什么 Session Entry 不只有 Message Entry?
- SessionRepo、SessionStorage 和 Session 各负责什么?
- JSONL 文件第一行和后续行分别是什么?
- AgentHarness 在什么事件上保存最终消息?
getEntries()与buildContext()的区别是什么?- 为什么恢复 Harness 时仍要由宿主解析 Model 和 Active Tools?
- Memory、JSONL 和 SQLite 分别适合什么场景?
- coding-agent SessionManager 能否直接传给 AgentHarness?