跳到主要内容

长程上下文治理与模型转向

30 秒导读: 第 1 章讲的是「一个回合」怎么转。本章讲回合之外的四块支撑,让 agent 能跑几百个回合的长任务而既不迷路、又能被纠偏:上下文满了怎么压缩、模型跑偏了怎么当场穿越重来、大活怎么外包给子代理、以及怎么让第二个模型在旁边盯着随时插话。


1. 这是什么(零基础也能懂)

先说清楚一件事:一个编码 agent 跑长任务,会同时遇到两类麻烦。

  • 上下文会爆。 每读一个文件、每跑一次测试,输出都堆进对话历史。模型的上下文窗口是固定的(比如 20 万 token),堆满就没法继续。
  • 模型会跑偏。 它可能在第 40 个回合写出你项目里明令禁止的代码,或者陷进一个死胡同,或者根本忘了几十回合前定下的目标。

主循环(第 1 章)只管「收到消息 → 想 → 调工具 → 再想」这个单回合。它不管上面两件事。本章的四套机制就是补这两个洞的「电池」:

机制中文名解决哪类麻烦一句话
compaction上下文压缩上下文会爆把旧历史总结成一段话,腾出空间
TTSR时间旅行流规则模型会跑偏边生成边查,一犯规就中止、提醒、从原点重来
subagents子代理上下文会爆 + 分工把子任务外包给隔离的子进程,只收回结论
advisor顾问模型会跑偏第二个模型全程旁观,该拦就拦、该提醒就提醒

一句话直觉:主循环是发动机,这四套是仪表盘 + 刹车 + 拖车 + 副驾。 没有它们车也能开,但开不远、开不稳。

本章按这四条主线分层讲,每条都从「要解决的小问题」讲到「真实实现」。不重复第 1 章的单回合内容。


2. 顶层全景(四者怎么合起来)

先给一张图,看这四套东西各自站在主循环的哪个位置。怎么读: 中间竖线是主循环的一个回合;左右两侧是本章的四套机制,箭头表示它们何时介入。

┌─────────────────────────────────────┐
│ 主循环(第 1 章) │
│ │
┌── compaction ──┤ 回合开始:上下文快满了? │
│ (省地方) │ └─是→ 先压缩历史再进模型 │
│ │ │
│ │ ── 模型流式吐字 ──────────────► │◄── advisor
│ TTSR ────────┤ 每个 delta 都过一遍 TTSR 规则 │ (第二模型旁观:
│ (纠偏,穿越) │ └─命中→ 中止流→注入提醒→原点重试│ 每回合结束喂它
│ │ │ delta,它可
│ │ ── 调工具 ────────────────────► │ advise 插话)
│ subagents ────┤ task 工具→ 隔离子进程跑子任务 │
│ (外包) │ └─只把 typed 结论并回主上下文 │
└────────────────┤ │
│ 回合结束 │
└─────────────────────────────────────┘

四者共同的暗线,是都在和「上下文」这份共享状态打交道,而且都必须小心一件事:别把 provider 的 prompt cache(提示词缓存,同一前缀重复发送时 provider 只按缓存价收费)搞热变冷。这条约束贯穿全章——你会反复看到「只动尾巴,别动已经发出去的前缀」这句话。

部件职责一览:

部件干什么主要在哪
compaction历史转摘要,腾空间packages/agent/src/compaction/compaction.ts
pruning删掉过期/无用的工具输出packages/agent/src/compaction/pruning.ts
shake机械抠掉大块内容换占位符packages/agent/src/compaction/shake.ts
TtsrManager边流边匹配规则、报告命中packages/coding-agent/src/export/ttsr.ts
TTSR 编排中止流、注入提醒、原点重试packages/coding-agent/src/session/agent-session.ts
task/executor起子进程、收 typed yieldpackages/coding-agent/src/task/executor.ts
AdvisorRuntime喂 delta、驱动第二模型packages/coding-agent/src/advisor/runtime.ts

3. 主线一:上下文压缩(compaction)与 append-only 的配合

这条线要解决的小问题:对话历史只增不减,迟早撑爆上下文窗口。 oh-my-pi 用一套「三级递进」的减负手段,从最轻(删一条过期工具输出)到最重(把半程历史总结成一段话)。

3.1 什么时候压?——触发判定

判定入口是 shouldCompact(compaction.ts:245):上下文 token 超过阈值就压。阈值由 resolveThresholdTokens(compaction.ts:270)算——固定 token 优先,否则按百分比,再否则「窗口减去保留区」。保留区至少是窗口的 15%(effectiveReserveTokens,compaction.ts:238)。

这里有个不显然但关键的设计:喂给判定的 token 数不能只信 provider 报的用量。看 compactionContextTokens(compaction.ts:266):

// compaction.ts:266,取「provider 报的」和「本地估的」较大值
export function compactionContextTokens(providerContextTokens, storedConversationEstimate) {
return Math.max(Math.max(0, providerContextTokens), Math.max(0, storedConversationEstimate));
}

为什么?因为有些扩展会在发请求前压缩线路上的载荷(比如混淆器、inline snapcompact)。provider 于是报了个「缩水的」prompt token,如果压缩判定只信它,真实存下来的历史就会无限长到溢出,到那时连压缩本身都跑不动了。用本地估算做地板,判定才诚实。计费显示仍用 provider 精确值——只有压缩这个决定取地板。

3.2 从哪里切?——切点检测

要压缩,得先决定「保留最近多少、总结掉前面多少」。findCutPoint(compaction.ts:502)从最新往回走,累加每条消息的估算大小,累到 keepRecentTokens 就切。

铁律:绝不在 tool result 上切(findValidCutPoints,compaction.ts:418)。因为 tool result 必须紧跟它的 tool call;在中间切会留下一个「悬空」的工具调用。所以合法切点只有 user / assistant / bash 等消息。切在带 tool call 的 assistant 上时,它后面的 tool result 一起保留。

如果切点落在一个回合的中间(不是 user 消息处),就叫分裂回合(split turn)。这时前半段会被单独做一份「回合前缀摘要」(generateTurnPrefixSummary,compaction.ts:1462),这样保留下来的后半段不至于失去上文。

3.3 怎么压?——三级减负

oh-my-pi 不是只有「总结」一招。它有一条从轻到重的阶梯,能用轻的就不用重的:

轻 ───────────────────────────────────────────────► 重
①pruning ②shake ③compaction
删过期/无用工具输出 大块内容换占位符 半程历史→一段摘要
改几条 tool result 抠 fenced/XML 块 整段丢弃+LLM 总结
不调 LLM 不调 LLM 调 LLM(或远程)

① pruning(修剪工具输出,pruning.ts) —— 最便宜,不调模型。两种受害者:

  • superseded(被取代):同一个文件被 read 了两次,旧的那次就是死重。readToolSupersedeKey(pruning.ts:417)按「文件路径去掉行号选择器」算 key,新读取取代旧读取,旧的被盖成 [Superseded by a newer read of this file](SUPERSEDED_NOTICE,pruning.ts:66)。
  • useless(无用):工具自己标了 useless: true(比如搜索零命中)。盖成 [Uneventful result elided](USELESS_NOTICE,pruning.ts:69)。

pruning 全程贯彻 prompt-cache 意识:一条工具结果如果它后面的所有消息 token 总量超过 cacheWarmSuffixTokens,说明它躺在已经发出去的热缓存前缀里,改它会逼 provider 重写整个后缀(cacheWrite 溢价)。所以它只在便宜的尾巴里修剪,或者等 context 闲置够久(缓存已冷)再一次性冲刷(pruneSupersededToolResults,pruning.ts:248)。

② shake(抖落,shake.ts) —— 中等,不调模型。机械地把整条 tool result、以及大段 fenced 代码块 / 顶层 XML 块换成短占位符。检测用 collectShakeRegions(shake.ts:286),就地替换用 applyShakeRegions(shake.ts:422)。它有两档配置:自动档保护最近 16k token(DEFAULT_SHAKE_CONFIG,shake.ts:45),手动 /shake 则激进到清空一切可清(AGGRESSIVE_SHAKE_CONFIG,shake.ts:53)。

③ compaction(压缩,compaction.ts) —— 最重,调模型。把切点之前的历史整段丢弃,用 LLM 总结成一段文字(generateSummary,compaction.ts:719),再拼上一份 PR 风格的短摘要(generateShortSummary,compaction.ts:929)。总结时把对话序列化成纯文本再喂,免得模型把历史当成对话去续写。

3.4 三级都绕不开的「保护名单」

三级减负有个共享的安全阀:tool-protection.ts。有些工具结果永远不能动——最典型的是 skill 内容。isProtectedToolResult(tool-protection.ts:42)接受字符串(按工具名保护)或谓词(检查配对的 tool call)。isSkillReadToolResult(tool-protection.ts:38)就是个谓词:配对的 read 调用路径以 skill:// 开头就保护。默认名单里两者都在(DEFAULT_PRUNE_CONFIG.protectedTools,pruning.ts:56)。

3.5 和 append-only context 的配合

这是本节的收口。主循环用一个 append-only(只追加) 的消息日志(AppendOnlyLog,append-only-context.ts:104),这样每回合发给 provider 的前缀字节稳定,缓存能一直命中。唯一合法的非追加操作是 replaceTail(),专门留给压缩(append-only-context.ts:120)。

那压缩把中间一大段换成摘要、数组直接变短了,append-only 日志怎么办?看 syncMessages(append-only-context.ts:207)分三种情况:

情况现象处理
Append前缀没变、尾巴变长直接 push 新条目
Compaction数组变短清空日志、整段重放
In-place rewrite中间某条被就地改写找最长字节稳定前缀,截到那儿再追加尾巴

第三种是精妙处:早期版本一遇改写就清空整个日志,在 llama.cpp / 本地后端上每回合都被逼重新 prefill ~40k token(#3406)。现在保留稳定前缀,provider 的 KV 缓存只从「真正变了的那条消息」往后重算。这就是压缩和 append-only 的配合本质:压缩负责腾地方,append-only 负责让腾地方之后缓存尽量少变冷。

3.6 远程压缩与分支摘要(点到为止)

  • 远程压缩 V2(compaction-v2-streaming.ts):对接 OpenAI Codex 的做法,往正常 Responses 流里追加一个 compaction_trigger 项,让 provider 端就地压,把「保留的真实用户消息 + 压缩项」作为替换历史存回来(V2_RETAINED_MESSAGE_TOKEN_BUDGET = 64_000,compaction-v2-streaming.ts:30)。成功走远程就跳过本地 LLM 总结,省一次调用。
  • 分支摘要(branch-summarization.ts):在会话树里跳到另一个节点时,把「要离开的那条分支」总结掉,免得上下文丢失。入口 collectEntriesForBranchSummary(branch-summarization.ts:120)先找公共祖先,再收集要总结的条目。

4. 主线二:TTSR 时间旅行流规则(边跑边纠偏)

这条线要解决的小问题:等模型把一整段错的东西生成完、你再事后检查,太晚了。 TTSR(Time Traveling Stream Rules,时间旅行流规则)的思路是——在模型还在流式吐字时就逐块匹配规则,一旦命中,中止这次生成,回到这个回合的起点,把规则作为系统提醒注入进去,重来一遍。就像时间旅行:把跑偏的那次生成当没发生过。

4.1 一条规则长什么样

规则来自项目里的 .md / .mdc 文件(Cursor / Windsurf / Cline 格式),被翻译成统一的 Rule 结构(rule.ts:41)。frontmatter 的关键字段(RuleFrontmatter,rule.ts:23):

字段作用
condition触发用的正则(可字符串或数组)
astCondition触发用的 ast-grep 结构模式(只对 edit/write 流)
scope限定在哪些流上匹配(text / thinking / tool:edit(*.ts))
interruptMode本规则的中断模式覆盖(never/prose-only/tool-only/always)

parseRuleConditionAndScope(rule.ts:197)做了一个贴心的推断:如果 condition 写的像个文件 glob(比如 *.rs),就自动把它翻成 scope 简写 tool:edit(*.rs) + tool:write(*.rs),再补一个 catch-all 条件 .*。这样用户写规则不必懂全套语法。

内置默认规则挂在 provider id builtin-defaults(BUILTIN_DEFAULTS_PROVIDER_ID,rule.ts:18),优先级最低,同名的用户/项目规则能覆盖它。

4.2 规则怎么分桶

不是所有规则都走 TTSR。bucketRules(rule-buckets.ts:33)是唯一漏斗,按优先级分三桶:

每条规则 ──► 被 disable? ─是─► 丢弃
│否

有 condition/astCondition 且 addRule 接受? ─是─► TTSR 桶(注册进 manager)
│否

alwaysApply === true? ─是─► always 桶(每回合都塞进上下文)
│否

有 description? ─是─► rulebook 桶(按需检索)

注意:ttsrManager.addRule(rule) 返回布尔——只有它接受了(条件能编译、scope 能触达某个流),这条才真算 TTSR 规则;否则继续往下落桶。

4.3 TtsrManager 怎么边流边匹配

管理器 TtsrManager(ttsr.ts:72)对每个流保持一个独立缓冲区,按来源/工具键隔离,免得 assistant 正文的匹配和某个工具参数流的匹配互相串味。三个检查入口:

方法用于语义
checkDelta普通文本/思考流追加到缓冲区再匹配(ttsr.ts:350)
checkSnapshotmatcherDigest 的工具替换缓冲区(数字快照每 delta 重算)(ttsr.ts:365)
checkAstSnapshotedit/write 的 ast 条件异步跑原生 ast-grep(ttsr.ts:391)

ast 匹配特别之处:它需要一门语言,从工具 path 参数的扩展名推断(#deriveLang,ttsr.ts:372),调原生 astMatch 引擎,而且节流——连续相同的快照(常见,因为多数 delta 只改非源码参数)直接跳过,不重复跑匹配器。

重复触发由 #canTrigger(ttsr.ts:86)+ markInjectedByNames(ttsr.ts:515)控制:repeatMode: "once" 的规则一个会话只触发一次,repeatGap 模式则要隔够多少回合才能再触发。

4.4 命中之后:中止 → 注入 → 原点重试(且穿越 compaction)

这是整条线的高潮,编排在 agent-session.ts#handleTtsrMatches(agent-session.ts:4130)。先判断这次命中该「打断整个流」还是只「挂到某个工具结果上」。若是打断型:

模型正在流式生成第 N 回合

│ checkDelta/checkSnapshot 命中规则 R

① 立即 abort 流(agent.abort,ttsr.ts 里的 reason 带规则名)


② 调度一个 post-prompt 重试任务(带 retryToken + generation 防串)


③ contextMode === "discard"?
└─是→ replaceMessages(切掉那条半截的 assistant 回合) ← agent-session.ts:4180


④ 把命中的规则拼成 system reminder,appendMessage 一条
customType: "ttsr-injection"(display:false),并落盘 ← agent-session.ts:4186


⑤ agent.continue() —— 从被清理过的原点重跑这一回合

关键点:

  • 原点重试,不是接着写。 contextMode: "discard"(默认,ttsr.ts:56DEFAULT_SETTINGS)会先 replaceMessages 把那条跑偏的半截 assistant 消息从状态里删掉(agent-session.ts:4178),模型看不到自己刚才的错,只看到新注入的提醒,于是干净重来。
  • 注入是一条持久化的 custom 消息。 它同时进内存状态和会话存储(appendCustomMessageEntry),会话条目类型是 ttsr_injection(entries.ts:89)。
  • 穿越 compaction 依然成立。 因为注入落成了普通会话条目,后续压缩把它当普通消息对待——分支摘要里 ttsr_injection 被显式列为「不贡献对话内容」的条目类型(branch-summarization.ts:197),即压缩会安全地跳过它而非报错。TTSR 的效果(那条提醒)通过存储层活过压缩边界。
  • 防竞态。 重试任务捕获 retryTokenpromptGeneration,任何一个对不上就放弃这次重试(agent-session.ts:4162),避免过期的重试污染新的对话。

4.5 /omfg:让模型自己写 TTSR 规则

有意思的配套:当你对 agent 刚才的行为不满,可以 /omfg <抱怨>,让模型根据这次对话自动生成一条 TTSR 规则堵住这个坑。编排在 OmfgController(omfg-controller.ts:39)。

它最巧的地方是生成后要拿真实历史验证(最多重试 3 次,MAX_ATTEMPTS,omfg-controller.ts:34):validateRuleAgainstAssistantHistory(omfg-rule.ts:346)把刚生成的规则拿去匹配这次会话里所有 assistant 输出面(文本/思考/工具参数)。如果匹配不上,说明规则写错了,把失败反馈喂回模型重生成;如果匹配上但 scope 太宽,buildScopeFeedback(omfg-rule.ts:477)会建议一个更窄的 scope(比如 tool:write(*.rb))。规则必须能真的抓住刚才那个坏例子,才准存盘——这把「模型幻觉出一条永不触发的规则」挡在门外。


5. 主线三:子代理(把大活隔离外包)

这条线要解决的小问题:有些子任务(跑一整套探索、改一个独立模块)会产生海量中间输出,全塞进主上下文既污染又爆窗口。 解法:起一个隔离的子进程去干,主 agent 只收回一份结构化的结论

5.1 隔离怎么做

子代理可以跑在一个 copy-on-write 的 git worktree 里,改动被捕获,再(可选)合并回主仓库。这套生命周期抽在 isolation-runner.ts,TaskTool 和 eval 的 agent() 桥共用:

① prepareIsolationContext(cwd) 每次顶层调用一次:解析 git root + 拍基线快照
│ (isolation-runner.ts:56)

② runIsolatedSubprocess(opts) 每次 spawn:起 worktree → 跑子进程 → 抓 branch/patch
│ → 拆掉 worktree(finally 保证)(isolation-runner.ts:133)

③ mergeIsolatedChanges(opts) 每次 spawn:把捕获的改动应用回主仓
(patch 模式 apply,branch 模式 cherry-pick)
(isolation-runner.ts:221)

合并结果是三态的(IsolationMergeOutcome.changesApplied,isolation-runner.ts:207):true=合了(或没东西可合)、false=试了但失败(产物保留待人工)、null=调用方压根跳过合并(比如 eval agent(apply=False))。失败路径尽量保住子代理的输出,只有隔离建立本身出错才走 buildFailureResult

5.2 并发控制

多个子代理并行跑靠 parallel.tsmapWithConcurrencyLimit(parallel.ts:26)是 worker-pool:结果按输入顺序返回;abort 时返回部分结果(aborted: true),出错则快速失败(不等其他 worker)。

配套一个计数信号量 Semaphore(parallel.ts:93),两个细节值得看:

  • max <= 0 视为无限并发,对应设置 UI 里 task.maxConcurrency = 0 的「Unlimited」(#3305)。
  • acquire(signal) 支持传 abort 信号——等待者若中途放弃(父任务取消),必须把自己从队列摘掉,否则后来的 release() 会唤醒一个已废弃的等待者,永久缩小有效并发(#3464)。resize() 原地调整上限而非替换实例,保证在飞的槽位仍被计数,运行时改并发也绝不会越过上限。

5.3 输出 id 不撞车

每个子代理产出要有唯一 id(供 agent:// URL 解析、artifact 不互相覆盖)。AgentOutputManager(output-manager.ts:23)的策略:首次用的名字原样保留,重名才加数字后缀(AnnaAnna-2Anna-3)。resume 时先扫盘上已有输出文件,保证不覆盖前一次的产物。还预留了 advisor 的 transcript 名(__advisor),免得某个子代理正好叫这个把顾问日志冲掉。

5.4 typed、schema-validated 的 yield

这是子代理「只收回结论」的落地。子代理不是把整段自然语言吐回来,而是通过 yield 调用产出带类型标签、经输出 schema 校验的结构化结果。纯组装逻辑抽在 yield-assembly.ts,assembleYieldResult(yield-assembly.ts:127)负责把一串 yield 调用折叠成最终 payload:

  • 增量段:数组型 type(如 type: ["findings"])贡献一个可累加的小节,自己从不决定终止
  • 终结项:字符串型 type(如 type: "result")或无类型的最后一个 yield 才是终止标记。
  • 数组型 schema 对齐:arrayValuedLabels(yield-assembly.ts:101)从输出 schema 里挑出「声明为数组」的顶层字段,这些标签即使只 yield 了一次也累成列表——否则单个 type: ["findings"] 会组装成裸对象、过不了数组型 schema 校验。

组装完的结果交给 finalizeSubprocessOutput(executor.ts:528)做最终 schema 校验,再作为一条干净的结论并回主上下文。runSubprocess(executor.ts:1787)是子进程运行的总入口。


6. 主线四:advisor 第二模型旁观(纠偏的另一半)

这条线要解决的小问题:TTSR 靠死规则,抓不住「语义上」的跑偏——比如方向选错了、忽略了项目约定、该停不停。 advisor(顾问)是一个独立的第二模型,全程旁观主 agent 的对话,该提醒就 advise 插一句。它是只读的审查者,不改代码。

6.1 顾问怎么拿到主对话

AdvisorRuntime(runtime.ts:67)在主 agent 每个回合结束时被喂一次增量:onTurnEnd(runtime.ts:99)算出自上次以来的新消息(#renderDelta,runtime.ts:192),排进队列,由 #drain(runtime.ts:276)驱动顾问模型消化。

三个精妙处:

  • 顾问有自己的 append-only 上下文,且能被重新播种。 主 agent 一压缩/切分支/换会话,历史就被重写了,顾问看到的旧上下文全失效。reset(runtime.ts:172)把游标倒回 0,下一回合重放压缩后的完整 transcript,让顾问拿到新鲜上下文而不是对着已经不存在的历史瞎评。
  • 重复注入的规则会被折叠。 主 agent 每回合都逐字重塞 plan/goal 模式规则(~1k token)。#dedupContextMessage(runtime.ts:226)发现内容和上次字节相同,就折成一行 (unchanged — still in effect),免得顾问每回合重读全套规则。折叠时返回克隆——输入共享主 transcript,绝不能改。
  • 失败三次就丢背包。 顾问回合失败会回滚那次的脏消息(#rollbackFailedTurn,runtime.ts:262),连续失败 3 次就丢掉积压、通知宿主,防止顾问把主流程拖死。

顾问的额外知识来自 WATCHDOG.md(discoverWatchdogFiles,watchdog.ts:127)和项目上下文文件——沿 cwd 往上一路收集(collectConfigCandidates,watchdog.ts:53),拼进顾问的系统提示,好让这个只读审查者能拿用户的标准去要求主 agent。

6.2 advise 怎么插话:aside / steer / preserve

顾问调 advise({ note, severity })(AdviseTool,advise-tool.ts:159)。severity 决定投递通道(resolveAdvisorDeliveryChannel,advise-tool.ts:108):

severity是否打断通道
nit(或省略)aside:排队,下个步骤边界才落地
concern / blockersteer:插进当前流(可能中止在飞工具),立即处理
任意——preserve:用户刚手动打断后,不自动续跑,把建议存成可见卡片

给主 agent 看到的内容是 <advisory guidance="weigh, don't blindly obey">…</advisory>(formatAdvisorBatchContent,advise-tool.ts:58)——框定为「建议,不是命令」,让主 agent 权衡而非盲从。

6.3 emission-guard:让「一次一条」变成代码硬约束

顾问的系统提示写着「每次更新最多一条 advise、绝不重复」。但真实模型会违反——issue #3520 记录过一个会话里 309 次 advise、其中 114 次是 Stop.。光靠提示词不行,得让规则在代码里承重AdvisorEmissionGuard(emission-guard.ts:116)在 enqueueAdvice 边界按顺序过三道闸:

  1. 噪声过滤:Stop. / LGTM / no issues 这类无信息填充词直接丢(SUPPRESSED_NORMALIZED_PHRASES,emission-guard.ts:51;归一化 normalizeAdvisorNote,emission-guard.ts:32)。
  2. 会话级去重:同一条建议(归一化后)只放行一次,FIFO 淘汰。
  3. 每次更新限一条:一个顾问模型 prompt 周期只放行一条真建议——但被抑制的调用不消耗这个名额,免得一句噪声把真正的 concern 挤掉。

AdviseTool 对被抑制的调用照样返回 Recorded.(advise-tool.ts:198),故意对顾问模型不可见——一旦把「已抑制」反馈回去,模型会换个说法绕过去(Stop.Halt.Stop now.)。


7. 记忆与检查点(各点一句)

本章四线是「跑长任务」的即时支撑;还有两块是跨会话/可回溯的支撑,这里各点一句:

  • hindsight(packages/coding-agent/src/hindsight/):持久化的长期记忆后端,按 global / per-project / per-project-tagged 三种范围隔离「记忆银行」(bank.ts:1),并把命名的 mental models 拼进 developer 指令,绕过每回合的检索 HTTP 开销(mental-models.ts)。
  • mnemopi(packages/coding-agent/src/mnemopi/):带 embedding 的记忆后端(embed-worker.ts / embed-protocol.ts),做语义召回。
  • checkpoint / rewind(packages/coding-agent/src/tools/checkpoint.ts):checkpoint 记下当前消息数与会话条目 id(CheckpointState,checkpoint.ts:11),rewind 让 agent 带着一份「调查结论」回到检查点——即会话树内的一次受控回溯,和压缩/shake 一样触发顾问重新播种。

8. 巧妙之处(可带走的技术)

  • 压缩判定取「provider 用量」和「本地估算」的地板(compaction.ts:266),防止线路压缩扩展骗过触发器让真实历史无限膨胀。
  • 减负全程 prompt-cache 感知:只动缓存尾巴、深处的缓存前缀留给「反正要重建缓存」的压缩/shake 去回收(pruning.ts:347shake.ts:39)。
  • append-only 的最长稳定前缀保留(append-only-context.ts:207),让一次就地改写不至于让本地后端整段重新 prefill(#3406)。
  • TTSR 原点重试穿越压缩:注入落成持久会话条目,压缩把它当普通消息安全跳过(branch-summarization.ts:197),纠偏效果活过压缩边界。
  • /omfg 生成的规则必须先抓住真实坏例子才准存(omfg-rule.ts:346),把「幻觉出一条永不触发的规则」挡在门外。
  • 顾问的「一次一条」从提示词升级成代码闸门(emission-guard.ts:116),且对模型不可见,避免它换措辞绕过去。
  • 信号量等待者带 abort 自摘(parallel.ts:110),防止放弃的等待者被后续 release 唤醒而永久缩小并发(#3464)。

9. 边界与局限

  • 压缩是有损的。 半程历史变成一段摘要,细节必然丢。切点检测尽量保住最近回合和分裂回合的前缀,但更早的东西只剩摘要里那几句。
  • TTSR 只匹配「流经它的表面」。 condition 是正则、astCondition 靠文件扩展名推断语言——非 edit/write 的流没有 digest,就产生不了 ast 匹配(agent-session.ts:4119)。
  • 顾问是尽力而为,不是门禁。 它的建议明确框成「weigh, don't blindly obey」,主 agent 可以不理;失败三次直接丢背包,不阻塞主流程。
  • 子代理隔离依赖 git。 prepareIsolationContext 在非 git 仓库的 cwd 会抛错(isolation-runner.ts:56),由调用方转成 task-tool 失败。

10. 横向对比

同 shelf 的其它编码 agent 也要处理长上下文,但取舍不同。oh-my-pi 的特色是把「纠偏」做成了一等公民:大多数项目只有压缩(省地方),oh-my-pi 额外有 TTSR(死规则纠偏)+ advisor(语义纠偏)两套主动机制,而且都精心设计成能穿越压缩边界、不破坏 prompt cache。压缩本身也比常见的「一招总结」更分层(pruning → shake → compaction 三级),能用轻的就不动重的。

参见本组其它章:主循环见 01-agent-loop.md;工具宇宙与 :// 内部 URL 见 03-tool-surface.md;原生核心(ast-grep 等)见 05-native-core.md


11. 代码地图(导航索引)

主题文件关键符号
压缩触发判定packages/agent/src/compaction/compaction.tsshouldCompactresolveThresholdTokenscompactionContextTokens
切点检测packages/agent/src/compaction/compaction.tsfindCutPointfindValidCutPointsprepareCompaction
压缩主流程packages/agent/src/compaction/compaction.tscompactgenerateSummarygenerateTurnPrefixSummary
工具输出修剪packages/agent/src/compaction/pruning.tspruneToolOutputspruneSupersededToolResultsreadToolSupersedeKey
shake 抖落packages/agent/src/compaction/shake.tscollectShakeRegionsapplyShakeRegionsAGGRESSIVE_SHAKE_CONFIG
工具保护名单packages/agent/src/compaction/tool-protection.tsisProtectedToolResultisSkillReadToolResult
append-only 配合packages/agent/src/append-only-context.tsAppendOnlyLog.replaceTailAppendOnlyContextManager.syncMessages
远程压缩 V2packages/agent/src/compaction/compaction-v2-streaming.tsV2_RETAINED_MESSAGE_TOKEN_BUDGETrequestCompactionV2Streaming
分支摘要packages/agent/src/compaction/branch-summarization.tscollectEntriesForBranchSummarygenerateBranchSummary
TTSR 规则解析packages/coding-agent/src/capability/rule.tsRuleFrontmatterparseRuleConditionAndScopeBUILTIN_DEFAULTS_PROVIDER_ID
TTSR 分桶packages/coding-agent/src/capability/rule-buckets.tsbucketRules
TTSR 匹配引擎packages/coding-agent/src/export/ttsr.tsTtsrManagercheckDeltacheckSnapshotcheckAstSnapshotmarkInjectedByNames
TTSR 中止/注入/重试packages/coding-agent/src/session/agent-session.ts#handleTtsrMatches#checkTtsrStream#checkTtsrAstStream
/omfg 编排packages/coding-agent/src/modes/controllers/omfg-controller.tsOmfgController#generateCandidate
/omfg 规则校验packages/coding-agent/src/modes/controllers/omfg-rule.tsparseGeneratedRulevalidateRuleAgainstAssistantHistorybuildScopeFeedback
子代理隔离packages/coding-agent/src/task/isolation-runner.tsprepareIsolationContextrunIsolatedSubprocessmergeIsolatedChanges
并发控制packages/coding-agent/src/task/parallel.tsmapWithConcurrencyLimitSemaphore
输出 id 分配packages/coding-agent/src/task/output-manager.tsAgentOutputManager
typed yield 组装packages/coding-agent/src/task/yield-assembly.tsassembleYieldResultarrayValuedLabels
子进程运行packages/coding-agent/src/task/executor.tsrunSubprocessfinalizeSubprocessOutput
顾问运行时packages/coding-agent/src/advisor/runtime.tsAdvisorRuntimeonTurnEnd#drainreset
顾问配置发现packages/coding-agent/src/advisor/watchdog.tsdiscoverWatchdogFilescollectConfigCandidates
advise 工具与投递packages/coding-agent/src/advisor/advise-tool.tsAdviseToolresolveAdvisorDeliveryChannelformatAdvisorBatchContent
顾问发言闸门packages/coding-agent/src/advisor/emission-guard.tsAdvisorEmissionGuardnormalizeAdvisorNote