AgentSession — Compaction(上下文压缩)

文件位置:.\packages\coding-agent\src\core\agent-session.ts

依赖模块:.\packages\coding-agent\src\core\compaction/compaction.tsutils.tsbranch-summarization.ts

本文拆解 AgentSession 中 Compaction(上下文压缩)机制的完整实现,包括 AgentSession 中的编排方法以及 compaction/ 模块中函数的实现逻辑。在 Agent 的会话机制中,上下文窗口有限和长时间会话的需求之间存在矛盾,而 Compaction 机制正是为了解决这个问题而设计的。


一、Compaction 概述

Compaction(上下文压缩)是 AgentSession 的核心机制之一,用于在长时间会话中压缩历史消息,以维持 LLM 上下文窗口在可管理范围内。Pi 的 Compaction 设计有三个关键特征:

  1. LLM 驱动的摘要:不用简单的截断策略,而是调用 LLM 对历史会话生成结构化摘要,保留任务目标、决策、进度等关键信息。Compact 前后的消息列表在语义上保持一致、逻辑上相容,具体的策略由 compaction/branch-summarization.ts 中的 summarizeBranch() 实现。在 agent-session.ts 中,
  2. 双模式触发:支持手动触发/compact 命令)和自动触发(阈值/溢出两种场景)
  3. 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 获取压缩设置(reserveTokenskeepRecentTokens 等)控制压缩范围
  • 调用 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 的 ID
    • tokensBefore — 压缩前的 token 数
    • details — 其他细节
1
2
3
4
5
if (extensionCompaction) {
    // 使用扩展提供的压缩结果
} else {
    const result = await compact(preparation, this.model, apiKey, headers, ...);
}
  • 如果扩展未接管,调用 compact() 函数(见三-2 节)执行 LLM 摘要生成
  • 传递 thinkingLevelstreamFnenv 等参数支持不同模型的推理能力和流式方式

步骤 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
底层存储SettingsManagerglobalSettings.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() 提交前
返回值booleantrue 表示调用方应继续 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 的最终落脚点
返回值booleantrue 表示调用方需要 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() 的三个区别:

  1. customInstructions: undefined — 自动压缩不会传递自定义指令,因为触发方不是用户
  2. cancel 的处理 — 扩展取消自动压缩时,发出 compaction_end(aborted: true) 并返回 false,不会像 compact() 那样抛出 “Compaction cancelled”
  3. reasonwillRetry 透传 — 扩展可以据此决定不同的压缩策略(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 对象传递给扩展。扩展可以读取 compactionEntrysummarytokensBeforedetails 等信息,用于自身的状态同步或 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 在精简上下文上重试

具体来说:

  1. willRetry 只有在 overflow 场景下才为 true_checkCompaction Case 1 且 stopReason !== "stop"
  2. 压缩完成后,agent.state.messages 已被替换为精简版本
  3. 但 messages 末尾仍有一条 LLM 返回的 overflow error 消息(它是 assistant role,被 buildSessionContext() 保留了下来? 不——第 4 步留意到消息已被 slice(0, -1) 处理了——实际上是在这里才最后确保移除它)
  4. 返回 true 后,上层 _handlePostAgentRun 返回 true,触发 _runAgentPrompt 中的 while (await this._handlePostAgentRun()) { await this.agent.continue(); } 循环
  5. 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
成功返回值CompactionResultboolean(是否 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 — 包含 firstKeptEntryIndexturnStartIndexisSplitTurn

实现算法

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)的中间。这时需要:

  1. 从切割点往前找到该 turn 的起始 user 消息(turnStartIndex
  2. 在 summary 中包含 turn 前缀的信息(通过 generateTurnPrefixSummary
  3. 保留的 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()bashExecutioncustom 等自定义角色转换为标准 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 中提取文件路径
  • 识别 readwriteedit 三种工具调用
1
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] }
  • modifiedFiles = edited ∪ written
  • readFiles = 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持久化到文件的数据结构
  • estimatedTokensAftercompact() 中计算,在 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)
};
配置项默认值说明
enabledtrue自动压缩开关
reserveTokens16384触发阈值 = contextWindow - reserveTokens。越大越不容易触发
keepRecentTokens20000压缩后保留的最近消息 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)
Agentagent.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() 做两件事:

  1. 调用 this._unsubscribeAgent() —— 从 Agent 的事件系统里移除 _handleAgentEvent 监听器
  2. _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" 的 entry
  • calculateContextTokens — 导入自 ./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/ 模块完整分析