AgentSession — Compaction(上下文压缩)#
文件位置:.\packages\coding-agent\src\core\agent-session.ts
依赖模块:.\packages\coding-agent\src\core\compaction/ — compaction.ts、utils.ts、branch-summarization.ts
本文拆解 AgentSession 中 Compaction(上下文压缩)机制的完整实现,包括 AgentSession 中的编排方法以及 compaction/ 模块中函数的实现逻辑。在 Agent 的会话机制中,上下文窗口有限和长时间会话的需求之间存在矛盾,而 Compaction 机制正是为了解决这个问题而设计的。
一、Compaction 概述#
Compaction(上下文压缩)是 AgentSession 的核心机制之一,用于在长时间会话中压缩历史消息,以维持 LLM 上下文窗口在可管理范围内。Pi 的 Compaction 设计有三个关键特征:
- LLM 驱动的摘要:不用简单的截断策略,而是调用 LLM 对历史会话生成结构化摘要,保留任务目标、决策、进度等关键信息。Compact 前后的消息列表在语义上保持一致、逻辑上相容,具体的策略由
compaction/branch-summarization.ts 中的 summarizeBranch() 实现。在 agent-session.ts 中, - 双模式触发:支持手动触发(
/compact 命令)和自动触发(阈值/溢出两种场景) - Extension Hook:通过
session_before_compact 扩展事件,允许扩展自定义压缩逻辑或完全接管压缩
Compaction 完整流程#
Agent 产生消息
│
▼
_checkCompaction() ──┬── 检查是否 Overflow ──→ _runAutoCompaction("overflow", willRetry)
│ │
│ └── 检查是否超 Threshold ──→ _runAutoCompaction("threshold", false)
│
▼ (用户手动触发)
compact() ──→ prepareCompaction() ──→ compact() ──→ sessionManager.appendCompaction()
│ │
▼ ▼
定位切割点 LLM 生成摘要
计算 tokens 提取文件操作
提取前次摘要 合并为 CompactionResult
二、AgentSession 中的 Compaction 方法#
2.1 compact(customInstructions?) — 手动压缩#
1
| async compact(customInstructions?: string): Promise<CompactionResult>
|
| 项目 | 内容 |
|---|
| 所在文件 | agent-session.ts |
| 返回值 | Promise<CompactionResult> — 包含摘要文本、首条保留 entry ID、压缩前 token 数、压缩后估计 token 数、扩展数据 |
| 功能 | 用户或 RPC 模式手动触发压缩的入口。对应 /compact 命令。 |
执行流程#
步骤 1 — 断开 agent 事件并中止当前操作
1
2
3
4
| this._disconnectFromAgent();
await this.abort();
this._compactionAbortController = new AbortController();
this._emit({ type: "compaction_start", reason: "manual" });
|
- 断开 agent 事件订阅(
_disconnectFromAgent()),防止压缩期间收到 agent 事件干扰 await this.abort() 中止正在进行的 LLM 流式响应- 注意 agent-session 是 event-driven 的,所以需要做上述两步操作来确保压缩期间不会有新的消息被处理
- 创建专用的
AbortController,使压缩过程可被取消 - 发出
compaction_start 事件通知 UI,同时标注触发原因为 "manual"(手动触发)
步骤 2 — 验证模型并获取认证信息
1
2
| if (!this.model) throw new Error(formatNoModelSelectedMessage());
const { apiKey, headers, env } = await this._getCompactionRequestAuth(this.model);
|
- 没有模型则抛出错误
- 通过
_getCompactionRequestAuth() 获取 API Key,比普通请求更宽容:如果是自定义 streamFn(非 streamSimple),认证失败也返回空 key 而非抛出错误 - 这里的模型和认证信息用于后续调用 LLM 生成摘要,有别于普通的 agent 请求,因为压缩可能需要更高的 token 预算和不同的流式处理方式。所以在 pi-agent 的框架下,压缩的 model 是独立设置的。
步骤 3 — 准备压缩数据
1
2
3
4
5
6
7
8
9
10
| const pathEntries = this.sessionManager.getBranch();
const settings = this.settingsManager.getCompactionSettings();
const preparation = prepareCompaction(pathEntries, settings);
if (!preparation) {
const lastEntry = pathEntries[pathEntries.length - 1];
if (lastEntry?.type === "compaction") {
throw new Error("Already compacted");
}
throw new Error("Nothing to compact (session too small)");
}
|
- 从
SessionManager 获取当前分支的所有 entry:从当前子叶回溯到 session 的根节点,这是包含当前分支的完整消息列表,将以此作为分割点的位置候选 - 从
SettingsManager 获取压缩设置(reserveTokens、keepRecentTokens 等)控制压缩范围 - 调用
prepareCompaction()(见 compaction.ts 的实现)计算切割点并提取待压缩的消息 - 在压缩开始前,还需要检查是否满足压缩条件(由
prepareCompaction() 返回的结果决定),下面只是返回原因:- 已经压缩:如果最后一条 entry 是
compaction,抛出 “Already compacted” - 无需压缩:抛出 “Nothing to compact (session too small)",提示会话太短无需压缩
步骤 4 — 检查扩展是否有自定义压缩逻辑
1
2
| let extensionCompaction: CompactionResult | undefined;
let fromExtension = false;
|
- 声明两个变量:
extensionCompaction 用于存储 extension 提供的压缩结果fromExtension 标记压缩结果是否来自 extension,以便在后续持久化时区分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| if (this._extensionRunner.hasHandlers("session_before_compact")) {
const result = (await this._extensionRunner.emit({
type: "session_before_compact",
preparation,
branchEntries: pathEntries,
customInstructions,
reason: "manual",
willRetry: false,
signal: this._compactionAbortController.signal,
})) as SessionBeforeCompactResult | undefined;
if (result?.cancel) {
throw new Error("Compaction cancelled");
}
if (result?.compaction) {
extensionCompaction = result.compaction;
fromExtension = true;
}
}
|
- 在注册表中搜索,如果有 extension 注册了
session_before_compact handler,优先调用,通过 emit() 广播其类型 - Extension 可以:
cancel — 取消压缩- 提供
compaction 结果 — 完全接管压缩逻辑(如 ArtifactIndex 等结构化压缩)
步骤 5 — 执行压缩
1
2
3
4
| let summary: string;
let firstKeptEntryId: string;
let tokensBefore: number;
let details: unknown;
|
- 声明压缩结果的变量,后续根据是否由 extension 提供结果来赋值。它们分别是:
summary — LLM 生成的结构化摘要文本firstKeptEntryId — 压缩后保留的第一条 entry 的 IDtokensBefore — 压缩前的 token 数details — 其他细节
1
2
3
4
5
| if (extensionCompaction) {
// 使用扩展提供的压缩结果
} else {
const result = await compact(preparation, this.model, apiKey, headers, ...);
}
|
- 如果扩展未接管,调用
compact() 函数(见三-2 节)执行 LLM 摘要生成 - 传递
thinkingLevel、streamFn、env 等参数支持不同模型的推理能力和流式方式
步骤 6 — 持久化并重建上下文
1
2
3
4
| this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.state.messages = sessionContext.messages;
|
- 通过
sessionManager.appendCompaction() 将压缩结果写入会话文件 - 通过
sessionManager.buildSessionContext() 重建精简后的消息列表 - 将新消息列表设置到 agent 状态中
步骤 7 — 发出事件并返回结果
1
2
| this._emit({ type: "compaction_end", reason: "manual", result: compactionResult, ... });
return compactionResult;
|
- 异常处理:捕获所有错误,区分"用户取消"和其他失败
- finally 块:重置
_compactionAbortController 并重新连接 agent 事件
使用样例#
1
2
3
4
5
| // 手动压缩
await session.compact();
// 带自定义指令的压缩
await session.compact("Focus on summarizing the API design decisions");
|
2.2 abortCompaction() — 中止压缩#
1
| abortCompaction(): void
|
- 同时中止手动和自动压缩
- 通过调用两个
AbortController 的 .abort() 实现 - 被中止的压缩会在各自的 catch 块中发出
compaction_end(aborted: true)事件
2.3 setAutoCompactionEnabled() / autoCompactionEnabled — 自动压缩开关#
1
2
| setAutoCompactionEnabled(enabled: boolean): void
get autoCompactionEnabled(): boolean
|
| 项目 | 内容 |
|---|
| 所在文件 | agent-session.ts 第 2083-2090 行 |
| 可见性 | public |
| 底层存储 | SettingsManager → globalSettings.compaction.enabled |
1
2
3
4
5
6
7
8
| // 完整实现仅两行
setAutoCompactionEnabled(enabled: boolean): void {
this.settingsManager.setCompactionEnabled(enabled);
}
get autoCompactionEnabled(): boolean {
return this.settingsManager.getCompactionEnabled();
}
|
两行代码,纯粹的委托模式(Delegation Pattern)。所有逻辑在 SettingsManager 中。
SettingsManager 实现#
1
2
3
4
5
6
7
8
9
10
11
12
13
| // settings-manager.ts 第 754-760 行
getCompactionEnabled(): boolean {
return this.settings.compaction?.enabled ?? true; // 默认 true
}
setCompactionEnabled(enabled: boolean): void {
if (!this.globalSettings.compaction) {
this.globalSettings.compaction = {};
}
this.globalSettings.compaction.enabled = enabled;
this.markModified("compaction", "enabled");
this.save();
}
|
- 读取:从当前生效的
settings 中读取(可能被 session-level 覆盖),默认值 true - 写入:操作
globalSettings(用户级全局设置),标记修改后写入磁盘 - 保存触发:
this.save() 将修改持久化到用户配置文件(非 session 文件),确保重启后保持 markModified 参数:"compaction", "enabled" 用于部分序列化,只保存变更的配置块
TUI 集成链路#
用户通过 TUI 设置面板 > Auto-compact 切换时:
settings-selector.ts "autocompact" toggle
│ 用户点选 "true" / "false"
▼
interactive-mode.ts onAutoCompactChange 回调
│ this.session.setAutoCompactionEnabled(enabled)
│ this.footer.setAutoCompactEnabled(enabled)
▼
agent-session.ts setAutoCompactionEnabled()
│ this.settingsManager.setCompactionEnabled(enabled)
▼
settings-manager.ts setCompactionEnabled()
│ this.globalSettings.compaction.enabled = enabled
│ this.save() → 写入磁盘
同时,Footer 状态栏同步更新:
1
2
3
| // footer.ts 第 150 行
const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
// 显示为 "Compact" 或 "Compact (auto)"
|
读取链路(运行时)#
_checkCompaction() / _runAutoCompaction()
│ this.settingsManager.getCompactionSettings()
▼
settings-manager.ts getCompactionSettings()
│ {
│ enabled: this.getCompactionEnabled(), // ← 使用这里
│ reserveTokens: this.getCompactionReserveTokens(),
│ keepRecentTokens: this.getCompactionKeepRecentTokens(),
│ }
▼
agent-session.ts
│ if (!settings.enabled) return false; // 开关关闭则跳过
设计要点#
| 特性 | 说明 |
|---|
| 委托模式 | getter/setter 仅为 SettingsManager 的薄封装,不保存本地状态 |
| 全局持久化 | 写入 globalSettings 而非 sessionSettings,跨会话保持 |
| 默认开启 | 默认值为 true(?? true),新用户自动受益 |
| 运行时读取 | _checkCompaction() 每次调用都从 SettingsManager 读取,无需同步机制 |
| TUI 双更新 | 设置变更时同时更新 session 状态和 footer 显示,两者无状态同步依赖 |
| 无事件通知 | 没有发 compaction_enabled_changed 事件——_checkCompaction() 下次调用时自动生效 |
调用链总结#
用户 (TUI settings) → onAutoCompactChange → setAutoCompactionEnabled()
↓
settingsManager.setCompactionEnabled()
↓
保存到 globalSettings
代理运行时 → _checkCompaction() → settingsManager.getCompactionSettings()
↓
读取 enabled → 决定是否跳过
三、AgentSession 内部 Compaction 判断逻辑#
3.1 _checkCompaction() — 自动压缩检查#
1
| private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<boolean>
|
| 项目 | 内容 |
|---|
| 可见性 | private |
| 调用时机 | 每次 agent 产生一条 assistant 消息后(agent_end 事件处理中)以及每次 prompt() 提交前 |
| 返回值 | boolean — true 表示调用方应继续 agent(继续处理后续消息) |
前置过滤条件#
1
2
3
| if (!settings.enabled) return false; // 自动压缩未开启
if (aborted && skipAbortedCheck) return false; // 跳过用户取消的消息
if (assistant来自压缩前) return false; // 防止刚压缩完又触发
|
- 跨模型跳过:如果当前模型与 assistant 消息的 source 模型不同,跳过 overflow 检查。这是为了防止用户在遇到 overflow 后手动切换到大窗口模型时,旧模型的 overflow 错误被误判为新模型的 overflow
- 时间戳边界检查:如果 assistant 消息的时间戳早于最新的压缩 entry,跳过检查。防止刚压缩完一次后,第一条 prompt 因为引用了 compression 之前缓存的 usage 数据而误触发再次压缩
Case 1 — Overflow(上下文溢出)#
1
2
3
4
5
6
7
8
| if (sameModel && isContextOverflow(assistantMessage, contextWindow)) {
const willRetry = assistantMessage.stopReason !== "stop";
if (!willRetry) return await this._runAutoCompaction("overflow", false);
if (this._overflowRecoveryAttempted) { /* 已尝试过一次恢复,放弃 */ return false; }
this._overflowRecoveryAttempted = true;
this.agent.state.messages = messages.slice(0, -1); // 移除错误消息
return await this._runAutoCompaction("overflow", willRetry);
}
|
- 当 LLM 返回 context overflow 错误时触发
- 如果消息是
stop 完结(即 assistant 成功完成但超过窗口),压缩后不重试 - 如果消息未完结(error),尝试一次"压缩 + 重试"恢复链
- 使用
_overflowRecoveryAttempted 标记防止无限循环 isContextOverflow() 来自 @earendil-works/pi-ai/compat,根据 LLM 返回的 usage 数据和 contextWindow 判断- 动机:上下文溢出是最糟糕的情况 — LLM 无法继续。自动压缩 + 重试可以让会话无缝恢复,用户无感知
Case 2 — Threshold(阈值触发)#
1
2
3
4
5
6
7
8
9
10
11
12
| let contextTokens: number;
// 对于 error 消息或 usage 全部为零的消息,从最后有效的 assistant 估计
if (assistantMessage.stopReason === "error" || directContextTokens === 0) {
const estimate = estimateContextTokens(messages);
if (estimate.lastUsageIndex === null) return false;
contextTokens = estimate.tokens;
} else {
contextTokens = directContextTokens;
}
if (shouldCompact(contextTokens, contextWindow, settings)) {
return await this._runAutoCompaction("threshold", false);
}
|
- 当上下文使用量超过
contextWindow - reserveTokens 时触发 - 对于 error 消息或零 usage 消息(如 API 529 错误、格式异常的响应),回退到基于
estimateContextTokens() 的估算,防止无限累积上下文 - 估算时检查 usage 来源是否为压缩前的消息(防止刚压缩又触发)
- 阈值压缩后不重试 — 用户继续手动操作
estimateContextTokens() 策略详解#
1
2
3
4
5
6
7
8
9
10
11
12
| // compaction/compaction.ts
export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate {
const usageInfo = getLastAssistantUsageInfo(messages);
if (!usageInfo) return { tokens: 全量估算, lastUsageIndex: null };
// 有真实 usage 数据: usageTokens + 后续消息的估算
const usageTokens = calculateContextTokens(usageInfo.usage);
let trailingTokens = 0;
for (let i = usageInfo.index + 1; i < messages.length; i++) {
trailingTokens += estimateTokens(messages[i]);
}
return { tokens: usageTokens + trailingTokens, ... };
}
|
- 优先使用真实 usage:取最后一条有效 assistant 消息的
usage 数据 - 回退估算:如果没有有效 usage,对所有消息用
estimateTokens() 估算 - 混合模式:有 usage 数据时,usage 之前用真实数据,之后用估算
3.2 _runAutoCompaction() — 自动压缩执行#
1
| private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<boolean>
|
| 项目 | 内容 |
|---|
| 可见性 | private |
| 调用时机 | _checkCompaction() 中两种 case 的最终落脚点 |
| 返回值 | boolean — true 表示调用方需要 agent.continue() 或仍有排队消息 |
整体流程#
_runAutoCompaction() 是自动压缩的执行入口,与手动 compact() 共享核心逻辑(prepareCompaction → extension hook → compact → appendCompaction),但在错误处理策略和前置验证上有显著差异:
_checkCompaction() 判定触发条件
│
▼
_runAutoCompaction(reason, willRetry)
│
├── 1. 获取设置 (getCompactionSettings)
├── 2. 验证模型 (this.model)
├── 3. 获取认证 (getApiKeyAndHeaders / _getCompactionRequestAuth)
├── 4. 准备压缩 (prepareCompaction) — 无内容则静默返回 false
├── 5. 发出 compaction_start 事件
├── 6. Extension 决策 (session_before_compact)
├── 7. 执行压缩 (extension 提供结果 或 compact() 函数)
├── 8. 检查 abort 信号
├── 9. 持久化 (sessionManager.appendCompaction)
├── 10. 重建上下文 (buildSessionContext)
├── 11. 通知 extension (session_compact)
├── 12. 发出 compaction_end 事件
└── 13. 返回 boolean (continue / queued / false)
步骤详解#
步骤 1-3 — 前置验证(静默失败)
1
2
3
4
5
6
7
8
9
10
11
12
13
| const settings = this.settingsManager.getCompactionSettings();
if (!this.model) {
return false; // 无模型 → 静默跳过,不报错
}
// 获取 API Key
if (this.agent.streamFn === streamSimple) {
const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model);
if (!authResult.ok || !authResult.apiKey) {
return false; // 认证失败 → 静默跳过
}
}
|
与 compact() 的关键区别:所有前置验证失败都静默返回 false,绝不抛出错误。
- 无模型 =>
false(自动场景下用户可能还没选模型) - 认证失败 =>
false(API Key 未配置时自动压缩不应崩溃) streamFn === streamSimple 时走标准 registry 认证路径,其他 streamFn(如自定义 provider)走 _getCompactionRequestAuth 的宽松认证
步骤 4 — 准备压缩
1
2
3
4
5
| const pathEntries = this.sessionManager.getBranch();
const preparation = prepareCompaction(pathEntries, settings);
if (!preparation) {
return false; // 无内容可压缩 → 静默跳过
}
|
- 与
compact() 完全相同的调用逻辑 prepareCompaction() 返回 undefined 意味着会话太短、内容不足或刚压缩完- 自动场景下不抛出错误,只返回
false,上层 _checkCompaction 也返回 false,调用方继续正常运行
步骤 5 — 发出开始事件并创建 AbortController
1
2
3
| this._emit({ type: "compaction_start", reason });
this._autoCompactionAbortController = new AbortController();
started = true;
|
- 发出
compaction_start 事件,TUI 据此更新状态栏和设置 Escape handler - 使用独立的
_autoCompactionAbortController(不与手动 compact() 共用 _compactionAbortController) started 标记后续用于异常处理:只有 started=true 后才需要发出 compaction_end 事件
步骤 6 — Extension 决策
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| if (this._extensionRunner.hasHandlers("session_before_compact")) {
const extensionResult = (await this._extensionRunner.emit({
type: "session_before_compact",
preparation,
branchEntries: pathEntries,
customInstructions: undefined, // 自动压缩无自定义指令
reason,
willRetry,
signal: this._autoCompactionAbortController.signal,
})) as SessionBeforeCompactResult | undefined;
if (extensionResult?.cancel) {
this._emit({ type: "compaction_end", reason, result: undefined, aborted: true, willRetry: false });
return false;
}
if (extensionResult?.compaction) {
extensionCompaction = extensionResult.compaction;
fromExtension = true;
}
}
|
与 compact() 的三个区别:
customInstructions: undefined — 自动压缩不会传递自定义指令,因为触发方不是用户cancel 的处理 — 扩展取消自动压缩时,发出 compaction_end(aborted: true) 并返回 false,不会像 compact() 那样抛出 “Compaction cancelled”reason 和 willRetry 透传 — 扩展可以据此决定不同的压缩策略(overflow 场景可能需要更激进的压缩)
步骤 7 — 执行压缩
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| if (extensionCompaction) {
summary = extensionCompaction.summary;
firstKeptEntryId = extensionCompaction.firstKeptEntryId;
tokensBefore = extensionCompaction.tokensBefore;
details = extensionCompaction.details;
} else {
const compactResult = await compact(
preparation,
this.model,
apiKey,
headers,
undefined, // customInstructions = undefined
this._autoCompactionAbortController.signal,
this.thinkingLevel,
this.agent.streamFn,
env,
);
// ... 解构结果
}
|
与 compact() 的结构完全相同,只有两点差异:
| 参数 | compact() | _runAutoCompaction() |
|---|
customInstructions | 用户传入 | undefined(自动压缩) |
signal | _compactionAbortController.signal | _autoCompactionAbortController.signal |
步骤 8 — 检查 abort 信号(压缩后)
1
2
3
4
| if (this._autoCompactionAbortController.signal.aborted) {
this._emit({ type: "compaction_end", reason, result: undefined, aborted: true, willRetry: false });
return false;
}
|
- 在 LLM 生成摘要后、持久化之前再次检查中止信号
- 这么设计是因为 LLM 的
compact() 函数在生成摘要时可能花费数秒,用户在此期间按 Escape 中止,AbortController 被触发,compact() 内部会抛出 AbortError 被 catch 块捕获 - 但还有一种情况:
compact() 在 abort 之前已经成功返回(LLM 响应已完成,但调用者还没检查 signal)。这里补一个二次检查确保用户取消优先
步骤 9-10 — 持久化并重建上下文
1
2
3
4
5
| this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
const newEntries = this.sessionManager.getEntries();
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.state.messages = sessionContext.messages;
const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
|
appendCompaction() 写入会话文件buildSessionContext() 重建消息列表(已压缩版本)- 将
agent.state.messages 替换为精简版本——所有后续 LLM 调用将基于这个新上下文 estimateMessagesTokens() 计算压缩后的 token 数,用于 UI 显示
步骤 11 — 通知 Extension
1
2
3
4
5
6
7
8
9
10
11
12
13
| const savedCompactionEntry = newEntries.find(
(e) => e.type === "compaction" && e.summary === summary
) as CompactionEntry | undefined;
if (this._extensionRunner && savedCompactionEntry) {
await this._extensionRunner.emit({
type: "session_compact",
compactionEntry: savedCompactionEntry,
fromExtension,
reason,
willRetry,
});
}
|
与 compact() 相同:从 newEntries 中查找刚写入的 CompactionEntry(按 summary 匹配),将 entry 对象传递给扩展。扩展可以读取 compactionEntry 的 summary、tokensBefore、details 等信息,用于自身的状态同步或 UI 更新。
步骤 12 — 发出结束事件
1
2
| const result: CompactionResult = { summary, firstKeptEntryId, tokensBefore, estimatedTokensAfter, details };
this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });
|
- TUI 收到
compaction_end 后清除 Loader、恢复 Escape handler、更新状态栏显示 willRetry 被透传,TUI 据此决定是否显示 “retrying…” 提示
步骤 13 — 返回结果
1
2
3
4
5
6
7
8
9
10
| if (willRetry) {
const messages = this.agent.state.messages;
const lastMsg = messages[messages.length - 1];
if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {
this.agent.state.messages = messages.slice(0, -1); // 移除 overflow 错误消息
}
return true; // 告诉 _handlePostAgentRun: 需要 agent.continue()
}
return this.agent.hasQueuedMessages(); // 检查是否有排队消息需要处理
|
overflow 恢复路径(willRetry = true):
LLM overflow 报错
→ _checkCompaction() Case 1
→ _runAutoCompaction("overflow", true)
→ 压缩完成
→ 移除最后一条 assistant error 消息
→ return true
→ _handlePostAgentRun() 返回 true
→ _runAgentPrompt() 调用 agent.continue()
→ LLM 在精简上下文上重试
具体来说:
willRetry 只有在 overflow 场景下才为 true(_checkCompaction Case 1 且 stopReason !== "stop")- 压缩完成后,
agent.state.messages 已被替换为精简版本 - 但 messages 末尾仍有一条 LLM 返回的 overflow error 消息(它是 assistant role,被
buildSessionContext() 保留了下来? 不——第 4 步留意到消息已被 slice(0, -1) 处理了——实际上是在这里才最后确保移除它) - 返回
true 后,上层 _handlePostAgentRun 返回 true,触发 _runAgentPrompt 中的 while (await this._handlePostAgentRun()) { await this.agent.continue(); } 循环 agent.continue() 在精简后的上下文上重试请求,LLM 不再 overflow
threshold 路径(willRetry = false):
每次 agent_end 检查 → 上下文超过阈值
→ _checkCompaction() Case 2
→ _runAutoCompaction("threshold", false)
→ 压缩完成
→ return this.agent.hasQueuedMessages()
→ true: 还有排队消息,继续处理
→ false: 压缩结束,等待用户下一条输入
- threshold 压缩后不重试,只是压缩上下文
- 如果 extension 或 steer/followUp 有排队消息,返回
true 确保它们被处理 - 否则返回
false,控制流回到 _handlePostAgentRun → 回到 _runAgentPrompt 的 while 循环终止
异常处理#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| catch (error) {
const errorMessage = error instanceof Error ? error.message : "compaction failed";
if (started) {
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
errorMessage:
reason === "overflow"
? `Context overflow recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`,
});
}
return false;
} finally {
this._autoCompactionAbortController = undefined;
}
|
异常处理与 compact() 有根本性的不同:
| 特性 | compact() | _runAutoCompaction() |
|---|
| 异常传播 | throw error(向上传播) | return false(静默恢复) |
| 错误事件 | 发出 compaction_end + rethrow | 发出 compaction_end + 返回 false |
| 错误消息格式 | "Compaction failed: " | "Context overflow recovery failed: " 或 "Auto-compaction failed: " |
started 检查 | 无(始终抛出) | 只有发出了 compaction_start 才发事件 |
| finally | 重置 controller + reconnect agent | 只重置 controller(无需 reconnect,因为未断开) |
设计动机:自动压缩不应让整个 agent session 崩溃。如果自动压缩失败:
- overflow 场景:overflow 恢复失败 → 用户收到
"Context overflow recovery failed" 但 session 仍在运行,可以手动 /compact 或切换模型 - threshold 场景:压缩失败 → 静默失败,下次
agent_end 会再次检查并重试 - 两种场景都不会丢失用户数据或中断对话
与 compact() 的对比总结#
| 维度 | compact()(手动) | _runAutoCompaction()(自动) |
|---|
| 触发源 | 用户 /compact 命令或 RPC 调用 | agent 消息循环内部自动触发 |
| AbortController | _compactionAbortController | _autoCompactionAbortController |
| agent 事件 | 断开(_disconnectFromAgent()) | 不断开(在事件处理内部运行) |
| 前置验证失败 | 抛出 Error | 返回 false |
| 认证失败 | 抛出 Error | 返回 false |
| 扩展取消 | 抛出 "Compaction cancelled" | 发出 compaction_end(aborted) + 返回 false |
| 压缩失败 | throw error(传播给调用方) | 发出 compaction_end(error) + 返回 false |
| customInstructions | 透传给 LLM 摘要生成 | undefined |
| 成功返回值 | CompactionResult | boolean(是否 continue/hasQueued) |
| willRetry | 恒为 false(手动触发无重试) | overflow 时为 true,threshold 时为 false |
| finally | 重置 controller + 重新连接 agent | 仅重置 controller |
| 断开期间消息堆积 | 手动压缩时消息排队等待 | 不存在(事件处理器持续运行) |
使用示例(内部调用链)#
1
2
3
4
5
6
7
8
9
10
11
| // Overflow recovery chain
// agent-session.ts line 1075-1080
_handlePostAgentRun() -> _checkCompaction(msg)
-> case "overflow" (stopReason !== "stop")
-> _runAutoCompaction("overflow", willRetry: true)
-> compact() 在内部生成摘要
-> appendCompaction() 持久化
-> buildSessionContext() 重建上下文
-> return true
-> _handlePostAgentRun() 返回 true
-> throw new Error("..."); // 如果 willRetry=false 且 overflow 未恢复
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // Threshold compression chain
// agent-session.ts line 1092
_handlePostAgentRun() -> _checkCompaction(msg)
-> case "threshold" (contextTokens > contextWindow - reserveTokens)
-> _runAutoCompaction("threshold", willRetry: false)
-> compact() 在内部生成摘要
-> appendCompaction() 持久化
-> buildSessionContext() 重建上下文
-> return this.agent.hasQueuedMessages()
-> _handlePostAgentRun() 返回 false
-> _runAgentPrompt() 的 while 循环终止
---
## 四、Compaction 模块(`compaction/compaction.ts`)
### 4.1 `prepareCompaction()` — 压缩准备
```typescript
export function prepareCompaction(
pathEntries: SessionEntry[],
settings: CompactionSettings,
): CompactionPreparation | undefined
|
| 项目 | 内容 |
|---|
| 位置 | compaction/compaction.ts |
| 返回值 | CompactionPreparation | undefined — 如果无可压缩内容则返回 undefined |
纯函数,不做任何 I/O。接收 session entries 和设置,计算出压缩所需的所有数据,是 compact() 函数的前置步骤。
返回的数据结构#
1
2
3
4
5
6
7
8
9
10
| export interface CompactionPreparation {
firstKeptEntryId: string; // 第一条保留的 entry 的 UUID
messagesToSummarize: AgentMessage[]; // 需要摘要后丢弃的消息
turnPrefixMessages: AgentMessage[]; // 切分 turn 时前缀中的消息
isSplitTurn: boolean; // 是否切分了某个 turn
tokensBefore: number; // 压缩前的 token 数
previousSummary?: string; // 上次压缩的摘要(用于增量更新)
fileOps: FileOperations; // 从消息中提取的文件操作
settings: CompactionSettings; // 压缩设置
}
|
实现细节#
步骤 1 — 查找上次压缩边界
1
2
3
4
5
6
| let prevCompactionIndex = -1;
for (let i = pathEntries.length - 1; i >= 0; i--) {
if (pathEntries[i].type === "compaction") {
prevCompactionIndex = i; break;
}
}
|
- 从后往前找最近一次
compaction entry - 如果找到,获取其
firstKeptEntryId 对应在 entries 中的索引作为 boundaryStart - 如果没找到,
boundaryStart = 0(从会话开始)
步骤 2 — 计算切割点
1
| const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
|
- 调用
findCutPoint() 确定在哪个位置切割(见四-2 节)
步骤 3 — 分离待压缩消息
1
2
3
| const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
// messagesToSummarize: boundaryStart ~ historyEnd 的消息
// turnPrefixMessages: 如果切分 turn,turn 前缀中的消息
|
步骤 4 — 提取文件操作
1
| const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
|
- 从切除的消息中提取
read/write/edit 文件路径 - 同时合并上次压缩的
details 中的文件列表(实现跨压缩的文件追踪链)
步骤 5 — 返回或 undefined
1
2
3
| if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
return undefined; // 没有需要压缩的内容
}
|
使用场景#
- 会话一条新消息都没有就尝试压缩 → 返回
undefined - 刚压缩完又尝试压缩(没有新 entry) → 返回
undefined - 正常会话积累了 20 轮对话 → 计算出切割点,返回完整的 Preparation
4.2 findCutPoint() — 切割点定位#
1
2
3
4
5
6
| export function findCutPoint(
entries: SessionEntry[],
startIndex: number,
endIndex: number,
keepRecentTokens: number,
): CutPointResult
|
| 项目 | 内容 |
|---|
| 位置 | compaction/compaction.ts 约第 360-420 行 |
| 返回值 | CutPointResult — 包含 firstKeptEntryIndex、turnStartIndex、isSplitTurn |
实现算法#
1. 找出所有合法切割点(user / assistant / bashExecution / custom / branchSummary)
2. 从最新消息向后累积估算 token,直到超过 keepRecentTokens
3. 在最近一个不超过预算的合法切割点切割
4. 检查是否切分了一个 turn(切割点不是 user 消息时可能切分 turn)
合法切割点过滤:
1
2
3
4
5
6
| function findValidCutPoints(entries, startIndex, endIndex): number[] {
// 只保留: user, assistant, bashExecution, custom,
// branchSummary, compactionSummary
// 排除: toolResult(必须跟在对应的 toolCall 后面)
// 排除: thinking_level_change, model_change 等元数据 entry
}
|
不能切在 toolResult 上:tool result 必须和对应的 tool call 一起保留。如果在 assistant 消息后切割,其后续的 tool results 会自动保留。
行话解释 — isSplitTurn:
当切割点落在 assistant 消息上(而非 user 消息),意味着我们切在了一个交互回合(turn)的中间。这时需要:
- 从切割点往前找到该 turn 的起始
user 消息(turnStartIndex) - 在 summary 中包含 turn 前缀的信息(通过
generateTurnPrefixSummary) - 保留的 sufffix(切割点之后)会正常呈现在上下文中
使用样例#
假设 entries 为: [H, U1, A1, T1, U2, A2, T2, U3, A3]
H=header, U=user, A=assistant, T=toolResult
keepRecentTokens = 20000
从后往前累加: A3(5000) + T2(100) + U3(1000) + A2(8000) + T1(50) + U2(500) = 14650 < 20000
再加: A1(10000) = 24650 >= 20000
→ 切割点选在 A1(最近的不超预算的合法切割点)
→ isSplitTurn = true(不是 user 消息)
→ turnStartIndex = U2
4.3 compact() — 核心压缩函数#
1
2
3
4
5
6
7
8
9
10
11
| export async function compact(
preparation: CompactionPreparation,
model: Model<any>,
apiKey: string | undefined,
headers?: Record<string, string>,
customInstructions?: string,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
env?: Record<string, string>,
): Promise<CompactionResult>
|
| 项目 | 内容 |
|---|
| 位置 | compaction/compaction.ts 约第 750-820 行 |
| 返回值 | CompactionResult — { summary, firstKeptEntryId, tokensBefore, estimatedTokensAfter?, details? } |
纯逻辑函数(无 I/O、无副作用),接收 prepareCompaction() 的结果,调用 LLM 生成摘要。
实现细节#
Case A — 非切分 turn(正常情况)
1
| summary = await generateSummary(messagesToSummarize, model, ...);
|
- 调用
generateSummary() 生成结构化摘要
Case B — 切分 turn
1
2
3
4
5
6
| // 并行生成两个摘要
const [historyResult, turnPrefixResult] = await Promise.all([
generateSummary(messagesToSummarize, model, ...),
generateTurnPrefixSummary(turnPrefixMessages, model, ...),
]);
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
|
- 并行调用两次 LLM,提高性能
- 历史摘要 + turn 前缀摘要合并为一个 summary
摘要后追加文件列表:
1
2
3
4
5
6
7
8
9
10
| const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
// 生成格式如:
// <read-files>
// src/index.ts
// src/utils.ts
// </read-files>
// <modified-files>
// src/app.ts
// </modified-files>
|
- 使用 XML 标签格式,便于 LLM 在后来的上下文中解析
- 文件列表跨压缩累计(通过
extractFileOperations 从上次压缩的 details 继承)
4.4 generateSummary() — LLM 摘要生成#
1
2
3
4
5
6
7
8
9
10
11
12
13
| export async function generateSummary(
currentMessages: AgentMessage[],
model: Model<any>,
reserveTokens: number,
apiKey?: string,
headers?: Record<string, string>,
signal?: AbortSignal,
customInstructions?: string,
previousSummary?: string,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
env?: Record<string, string>,
): Promise<string>
|
| 项目 | 内容 |
|---|
| 位置 | compaction/compaction.ts 约第 540-625 行 |
| 返回值 | string — 结构化摘要文本 |
实现细节#
步骤 1 — 计算 maxTokens
1
2
3
4
| const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Infinity,
);
|
- 使用
reserveTokens 的 80% 作为摘要的 token 预算 - 同时受模型
maxTokens 限制
步骤 2 — 选择提示模板
1
| let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
|
- 初次的摘要(无
previousSummary):使用 SUMMARIZATION_PROMPT,生成完整的结构化摘要 - 增量摘要(有
previousSummary):使用 UPDATE_SUMMARIZATION_PROMPT,在已有摘要的基础上更新
步骤 3 — 序列化会话
1
2
| const llmMessages = convertToLlm(currentMessages); // 转换自定义消息类型
const conversationText = serializeConversation(llmMessages); // 序列化为纯文本
|
convertToLlm() 将 bashExecution、custom 等自定义角色转换为标准 LLM 消息格式serializeConversation() 将所有消息拼接为纯文本,防止 LLM 误认为是要继续的对话
步骤 4 — 发送到 LLM
1
| const response = await completeSummarization(model, context, options, streamFn);
|
- 支持自定义
streamFn(不仅仅是 completeSimple) - 使用
SUMMARIZATION_SYSTEM_PROMPT 作为系统消息,明确指示 LLM 不要继续对话,只输出结构化摘要
结构化摘要格式#
## Goal
[用户想要完成什么?]
## Constraints & Preferences
- [约束和偏好]
## Progress
### Done
- [x] [已完成的任务]
### In Progress
- [ ] [正在进行的工作]
### Blocked
- [阻碍因素]
## Key Decisions
- **[决定]**: [理由]
## Next Steps
1. [下一步行动]
## Critical Context
- [需要保留的关键上下文]
使用样例#
1
2
3
4
5
6
7
8
| // 首次压缩
const summary = await generateSummary(historyMessages, model, 16384, apiKey);
// 增量压缩(已有前次摘要)
const updatedSummary = await generateSummary(
newMessages, model, 16384, apiKey,
undefined, undefined, undefined, previousSummary
);
|
五、Compaction 模块(compaction/utils.ts)#
5.1 文件操作追踪#
1
2
3
4
5
6
7
| export interface FileOperations {
read: Set<string>;
written: Set<string>;
edited: Set<string>;
}
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void
|
- 从 assistant 消息的
toolCall 中提取文件路径 - 识别
read、write、edit 三种工具调用
1
| export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] }
|
modifiedFiles = edited ∪ writtenreadFiles = read 中不在 modifiedFiles 里的部分- 排序后返回
5.2 消息序列化#
1
| export function serializeConversation(messages: Message[]): string
|
- 将所有消息转换为纯文本格式
- Tool result 截断到 2000 字符(
TOOL_RESULT_MAX_CHARS),防止摘要请求过大 - 输出格式示例:
[User]: 请帮我重构这个函数
[Assistant thinking]: 需要先理解现有逻辑...
[Assistant]: 好的,我来重构
[Assistant tool calls]: read(path="src/utils.ts")
[Tool result]: [... 2000 字符截断 ...]
5.3 Token 估算#
1
| export function estimateTokens(message: AgentMessage): number
|
- 使用
chars / 4 的启发式方法估算 token 数 - 为不同消息类型(user、assistant、bashExecution、custom 等)分别计算
- 图片每条估算 4800 字符(
ESTIMATED_IMAGE_CHARS),约 1200 tokens - 保守估算(高估而非低估),防止意外超限
六、Compaction 相关的 Session Types#
6.1 CompactionEntry#
1
2
3
4
5
6
7
8
9
| // session-manager.ts
export interface CompactionEntry<T = unknown> extends SessionEntryBase {
type: "compaction";
summary: string; // LLM 生成的结构化摘要
firstKeptEntryId: string; // 第一条保留 entry 的 UUID
tokensBefore: number; // 压缩前总 token 数
details?: T; // 扩展数据(如文件操作列表)
fromHook?: boolean; // 是否由扩展生成
}
|
details 类型参数化(<T = unknown>),扩展可存任意结构数据fromHook 向后兼容:undefined = pi 生成,true = 扩展生成
6.2 BranchSummaryEntry#
1
2
3
4
5
6
7
| export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
type: "branch_summary";
fromId: string; // 来源 entry ID
summary: string; // 分支摘要
details?: T; // 扩展数据
fromHook?: boolean; // 是否由扩展生成
}
|
- 与
CompactionEntry 结构类似,但用于分支导航(navigateTree)时的摘要 - 不参与 LLM 上下文中的 token 计算(被视为系统元数据)
6.3 CompactionResult#
1
2
3
4
5
6
7
8
| // compaction/compaction.ts
export interface CompactionResult<T = unknown> {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
estimatedTokensAfter?: number; // 压缩后估算 token 数
details?: T; // 扩展数据
}
|
- 与
CompactionEntry 的区别:CompactionResult 是内存中的返回类型,CompactionEntry 是持久化到文件的数据结构 estimatedTokensAfter 在 compact() 中计算,在 CompactionEntry 中不存储(每次从消息列表实时计算)
七、shouldCompact() — 压缩决策函数#
1
2
3
4
5
6
7
8
| export function shouldCompact(
contextTokens: number,
contextWindow: number,
settings: CompactionSettings,
): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
}
|
| 项目 | 内容 |
|---|
| 位置 | compaction/compaction.ts 约第 170 行 |
| 调用方 | _checkCompaction() 的 threshold case |
逻辑:当 contextTokens > contextWindow - reserveTokens 时触发压缩。
示例:
contextWindow = 200000 (默认 Claude 4 上下文)
reserveTokens = 16384 (默认配置)
当前上下文 token 数 = 185000
185000 > 200000 - 16384 = 183616 → 触发压缩
八、Compaction 相关事件#
8.1 事件类型#
1
2
3
4
5
6
7
8
9
10
| // AgentSession 发出的事件类型
| { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }
| {
type: "compaction_end";
reason: "manual" | "threshold" | "overflow";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
errorMessage?: string;
}
|
compaction_start — 压缩开始,携带触发原因compaction_end — 压缩结束,携带结果或错误信息aborted=true 表示用户取消(不影响会话状态)errorMessage 仅在非取消的失败时设置
8.2 Extension Events#
1
2
3
4
5
6
7
8
9
| // Extension 系统发出的事件
type: "session_before_compact";
// 扩展可返回 SessionBeforeCompactResult:
// - cancel: 取消压缩
// - compaction: 扩展自定义压缩结果
type: "session_compact";
// 压缩完成后通知扩展
// 包含压缩结果、是否来自扩展、触发原因
|
九、Compaction 配置项#
1
2
3
4
5
6
| // compaction/compaction.ts
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
enabled: true, // 自动压缩默认开启
reserveTokens: 16384, // 保留的 token 余量(16K)
keepRecentTokens: 20000, // 保留最近的 token 数(20K)
};
|
| 配置项 | 默认值 | 说明 |
|---|
enabled | true | 自动压缩开关 |
reserveTokens | 16384 | 触发阈值 = contextWindow - reserveTokens。越大越不容易触发 |
keepRecentTokens | 20000 | 压缩后保留的最近消息 token 数。越大保留的上下文越多 |
十、与其他核心模块的关系#
+-------------------------+ +---------------------------+
| Compaction 模块 | | AgentSession |
| (compaction/) | | |
| - prepareCompaction() | <------| compact() |
| - compact() | | _checkCompaction() |
| - generateSummary() | | _runAutoCompaction() |
| - findCutPoint() | | abortCompaction() |
| - shouldCompact() | +---------------------------+
| - estimateTokens() | |
+-------------------------+ |
| |
v v
+------------------+ +-------------------+
| SessionManager | | SettingsManager |
| - appendCompaction| | - getCompactionSettings|
| - buildSessionContext| | - setCompactionEnabled|
| - getBranch() | +-------------------+
| - getEntries() |
+------------------+
v
+-------------------+
| ExtensionRunner |
| - session_before_compact |
| - session_compact |
+-------------------+
| 模块 | 协作方式 |
|---|
| SessionManager | 提供会话 entry、持久化压缩结果、重建上下文消息列表 |
| SettingsManager | 提供压缩配置(reserveTokens、keepRecentTokens、enabled) |
| ExtensionRunner | 提供 session_before_compact hook 供扩展自定义压缩 |
| ModelRegistry | 提供模型认证信息(API Key) |
| Agent | agent.state.messages 在压缩后被替换为精简消息列表 |
十一、总结:Compaction 设计动机#
| 设计点 | 动机 |
|---|
| LLM 生成摘要而非简单截断 | 保留关键决策、任务目标、进度等结构信息,下次 LLM 调用时可持续理解上下文 |
| 增量摘要(有 previousSummary 时) | 避免每次都重新生成全部摘要,减少 token 消耗,保证信息连续性 |
| 双模式触发 | overflow 场景需要自动恢复链防止会话中断,threshold 场景在用户无感时预压缩 |
| turn 切分处理 | 当切割点在 assistant 消息上时,用 turn prefix summary 保留该轮对话的前因,防止信息断裂 |
| 文件操作追踪 | 跨压缩累积文件修改记录,让 LLM 始终了解哪些文件被修改过 |
| Extension hook | 允许扩展实现更复杂的压缩策略(如结构化 Artifact 索引),框架不限制生态 |
| 保守的 token 估算(chars/4 + 图片 4800) | 高估而非低估,防止实际上下文超过模型窗口导致的 overflow |
附录:_disconnectFromAgent() — 事件断连机制#
定义位置#
agent-session.ts 中部,事件订阅区域,AgentSession 类的私有方法。
1
2
3
4
5
6
7
8
9
10
11
| /**
* Temporarily disconnect from agent events.
* User listeners are preserved and will receive events again after resubscribe().
* Used internally during operations that need to pause event processing.
*/
private _disconnectFromAgent(): void {
if (this._unsubscribeAgent) {
this._unsubscribeAgent();
this._unsubscribeAgent = undefined;
}
}
|
配对的方法#
1
2
3
4
| private _reconnectToAgent(): void {
if (this._unsubscribeAgent) return; // 已连接,跳过
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
}
|
实现原理#
_unsubscribeAgent 是构造函数中 agent.subscribe(this._handleAgentEvent) 的返回值——一个 unsubscribe 函数。
1
2
| // 构造函数中
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
|
_disconnectFromAgent() 做两件事:
- 调用
this._unsubscribeAgent() —— 从 Agent 的事件系统里移除 _handleAgentEvent 监听器 - 将
_unsubscribeAgent 设为 undefined —— 标记为已断开
对应的 _reconnectToAgent() 检查标记,如果已断开则重新 subscribe。
关键设计要点#
- 只影响 AgentSession 内部监听:
_eventListeners(用户通过 subscribe() 注册的外部监听器)不受影响,它们被保留在数组中。重新连接后,_handleAgentEvent 会再次开始向这些监听器转发事件 - 仅在需要独占控制时使用:调用者包括
compact() 和某些需要暂控消息流的内部操作。断开期间,_handleAgentEvent 中的自动持久化/压缩检查逻辑不会触发 - 容错恢复:
_reconnectToAgent() 首行检查 if (this._unsubscribeAgent) return,防止重复订阅
调用链路示例(compact 场景)#
compact()
→ _disconnectFromAgent() // 暂停事件转发
→ await this.abort() // 中止当前 LLM 调用
→ ...执行压缩逻辑...
→ _reconnectToAgent() // 恢复事件转发(在 finally 块中)
在事件系统中的位置#
Agent (pi-agent-core)
│ emits events
▼
_handleAgentEvent (私有事件处理器)
│
├── 1. 消息队列去重(steering/followUp)
├── 2. 转发给扩展系统(_emitExtensionEvent)
├── 3. 转发给外部监听器(_eventListeners)
├── 4. 消息持久化(sessionManager.appendMessage)
└── 5. 自动重试 / 自动压缩检查
_disconnectFromAgent() 切断了 Agent → _handleAgentEvent 的管道。
订阅了 _handleAgentEvent 的外部监听器不受影响,
但 Agent 不再向 AgentSession 发送事件。
十二、Compaction 相关的其他核心方法#
12.1 _bindExtensionCore() — 向扩展暴露 Compaction 接口#
位置:agent-session.ts 约第 2270 行,ExtensionRunner.bindCore() 调用中。
相关代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // 在 _bindExtensionCore 的第二个参数(runtimeContext)中
{
getContextUsage: () => this.getContextUsage(),
compact: (options) => {
void (async () => {
try {
const result = await this.compact(options?.customInstructions);
options?.onComplete?.(result);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
options?.onError?.(err);
}
})();
},
// ...
}
|
功能:通过 ExtensionRunner.bindCore() 将 Compaction 能力注入到扩展的运行时上下文(ctx)中。扩展可以通过以下方式调用:
1
2
3
4
5
6
7
8
9
10
| // 扩展代码中
ctx.compact({
customInstructions: "Focus on summarizing API changes",
onComplete: (result) => {
console.log(`Compacted: ${result.tokensBefore} -> ${result.estimatedTokensAfter} tokens`);
},
onError: (err) => {
console.error(`Compaction failed: ${err.message}`);
},
});
|
设计要点:
- 异步非阻塞:使用
void (async () => { ... })() 模式,不阻塞扩展的事件处理流程 - 双回调:
onComplete / onError 让扩展可以选择同步等待或 fire-and-forget - 直接复用
this.compact():与用户手动 /compact 走完全相同的代码路径,保证行为一致 - customInstructions 传递:允许扩展指导摘要的关注方向
12.2 getContextUsage() — 上下文使用量计算(Compaction 边界感知)#
位置:agent-session.ts 约第 2980 行。
源码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| getContextUsage(): ContextUsage | undefined {
const model = this.model;
if (!model) return undefined;
const contextWindow = model.contextWindow ?? 0;
if (contextWindow <= 0) return undefined;
const branchEntries = this.sessionManager.getBranch();
const latestCompaction = getLatestCompactionEntry(branchEntries);
if (latestCompaction) {
// Check if there's a valid assistant usage after the compaction boundary
const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
let hasPostCompactionUsage = false;
for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
const entry = branchEntries[i];
if (entry.type === "message" && entry.message.role === "assistant") {
const assistant = entry.message;
if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
const contextTokens = calculateContextTokens(assistant.usage);
if (contextTokens > 0) {
hasPostCompactionUsage = true;
break;
}
}
}
}
if (!hasPostCompactionUsage) {
return { tokens: null, contextWindow, percent: null };
}
}
const estimate = estimateContextTokens(this.messages);
const percent = (estimate.tokens / contextWindow) * 100;
return { tokens: estimate.tokens, contextWindow, percent };
}
|
功能:返回当前上下文使用量(token 数、上下文窗口大小、百分比)。TUI 状态栏依赖该数据显示 tokens/contextWindow (percent%)。
Compaction 边界感知(关键设计):
压缩后,agent state 中的 messages 被替换为精简版本,但最后一次 assistant 消息的 usage 记录的仍是压缩前的大上下文大小。如果直接使用这个 usage 数据,会得到错误的偏大上下文占用。
getContextUsage() 的解决策略:
如果存在最新的 compaction entry:
└── 从最新 entry 往后搜索,检查是否有压缩后的有效 assistant 消息
├── 有 → 使用 estimateContextTokens() 估算(基于精简后的消息列表)
└── 没有 → 返回 { tokens: null, percent: null }
(上下文使用量未知,等待下一次 LLM 响应)
如果没有 compaction:
└── 使用 estimateContextTokens() 估算
引用类型出处:
getLatestCompactionEntry — 导入自 ./session-manager.ts,定义于 session-manager.ts 第 311 行,从 entries 数组中反向查找最后一个 type === "compaction" 的 entrycalculateContextTokens — 导入自 ./compaction/index.ts,定义于 compaction/compaction.ts 第 108 行,从 usage 中提取 totalTokens(回退到各分量之和)estimateContextTokens — 导入自 ./compaction/index.ts,定义于 compaction/compaction.ts 第 160 行,混合真实 usage 数据与 estimateTokens() 估算
文档生成日期:2026-07-01
文件版本:基于 agent-session.ts(3160 行)及 compaction/ 模块完整分析