跳到主要内容

数据截至 (上游 commit 8d6cbee1b527)

智能体运行时:循环、失败转移与上下文压缩

30 秒导读: 这一章讲 OpenClaw 里真正调用模型的那一层。它被切成两半:一半是 packages/agent-core 里 那个干净的「双循环」——只管发请求、跑工具、发事件;另一半是 src/agents/embedded-agent-runner/ 下 近百个模块组成的「编排壳」——管选模型、排队、超时、换凭据、换模型、压上下文。读完你会知道:为什么这两半 必须分开,以及一次「模型超时」是怎么一路变成「先压缩再重试,还不行就换 auth profile,再不行就换模型」的。


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

  • 一句话定义: 智能体运行时 = 「把一句用户输入,变成一串模型调用 + 工具执行,并且在中途出错时还能自己救回来」的那段代码。

  • 它要解决的问题: 你在群里 @ 了一句「帮我查一下这个仓库的构建脚本」。表面上是一次问答,实际上要发生的是:

    1. 模型回一段话,里面夹着「我要调用 read_file」;
    2. 运行时真的去读文件,把结果塞回去;
    3. 模型再回一段话,可能再调工具……直到它说「我说完了」;
    4. 中途你又补了一句「顺便看下 CI 配置」——这句得能插进正在跑的对话,而不是排到下一轮;
    5. 中途模型超时了 / 额度用光了 / 上下文塞满了——都不该让用户看到一句冷冰冰的 500。
  • 谁在用它: 上游是 回复流水线(它决定「该跑一次 agent 了」),下游是 工具与沙箱(真正动手的部分)。本章只讲中间这层。

  • 一句话直觉: 把内核想成一台只会「问-做-再问」的状态机,把编排壳想成围着它转的运维团队—— 换电源(auth profile)、换机器(model failover)、清桌面(compaction)全是运维干的,状态机自己毫不知情。

  • 两半的分界线,一句话记住: 内核只认识 AgentLoopConfig 里那几个回调;所有「换什么、等多久、压多少」 的策略,都由编排壳填进这些回调里。


2. 顶层全景(它大概怎么转)

2.1 两半的分工

怎么读这张图:从上往下是一次 run 的生命周期;左边是编排壳做的事,右边是内核做的事,中间的箭头是它们唯一的接口——回调。

编排壳 (src/agents/embedded-agent-runner/) 内核 (packages/agent-core)
───────────────────────────────────────────── ────────────────────────────
① 运行前:选模型/选 auth/定思考等级/回填会话键


② 排队:session lane → global lane(带超时)


③ 一次 attempt ──────── 调用 ───────────────► runAgentLoop

◄─── 事件流(agent/turn/message)──┤

④ 出错?分类 → 压缩 / 换 profile / 换模型 │ 内层循环:tool call + steering
│ (retry,回到 ③) │ 外层循环:排队消息把它重新拉起
▼ │
⑤ 运行后:终态归一 + 记 auth 成功/失败 ◄───────────┘

2.2 部件一句话职责

部件干什么在哪个文件
内核双循环发请求、跑工具、注入 steering、发事件packages/agent-core/src/agent-loop.ts:298 (runLoop)
Agent 包装类持有 transcript / 队列 / abort,把事件喂给订阅者packages/agent-core/src/agent.ts:214 (Agent)
事件流容器一个可异步迭代、又能单独 await 终值的队列packages/llm-core/src/utils/event-stream.ts:9 (EventStream)
编排壳入口补 config、捕获代次、排队、起 attemptsrc/agents/embedded-agent-runner/run-orchestrator.ts:79 (runEmbeddedAgent)
失败转移策略把「这次为什么失败」映射成一个动作src/agents/embedded-agent-runner/run/failover-policy.ts:157 (resolveRunFailoverDecision)
auth profile 账本记凭据的失败次数、冷却窗口、禁用窗口src/agents/auth-profiles/usage.ts:991 (markAuthProfileFailure)
上下文引擎可被插件替换的「压缩/装配」实现src/context-engine/registry.ts:712 (resolveContextEngine)

2.3 主线走一遍(不进代码)

一次正常的 run,粗粒度是这样五步:

  1. 解析。 从 config + 显式参数里定出 provider/model、思考等级、会话键。
  2. 排队。 先进这个会话自己的 lane,再进全局 lane,防止同一会话并发写 transcript。
  3. 跑 attempt。 编排壳构造好 config,调内核循环,一边跑一边收事件。
  4. 判定。 attempt 结束后看结果:成功就收工;失败就按分类走压缩 / 换凭据 / 换模型,回到第 3 步。
  5. 归一。 把「超时 / 取消 / 阻塞 / 失败 / 完成」这几种终态压成一个不会被后续噪声覆盖的结论。

3. 内核:干净的双循环

这节讲内核为什么是两层循环,而不是一层。

3.1 它要解决的小问题

一层 while 只能表达「模型还要调工具吗?」。但真实聊天里还有第二个问题: 「模型已经想收工了,可用户在它跑的时候又说了一句话,怎么办?」

这两个问题的时机不同:前者在每一轮结束时判断,后者在整个循环本来要退出时判断。于是有了内外两层。

外层 while(true)
├── 内层 while(还有工具调用 || 有待注入消息)
│ ├─ 注入 pendingMessages(steering)
│ ├─ streamAssistantResponse ← 一次模型调用
│ ├─ executeToolCalls ← 跑工具
│ ├─ prepareNextTurn / shouldStopAfterTurn
│ └─ 重新取 steering 消息

└── 内层退出后:取 followUp 消息?
有 → 当作 pending,continue 外层(循环被"复活")
无 → break,发 agent_end
  • 内层条件在 packages/agent-core/src/agent-loop.ts:355:while (hasMoreToolCalls || pendingMessages.length > 0)
  • 外层复活在 agent-loop.ts:512:getFollowUpMessages() 拿到东西就塞进 pendingMessages 并继续。

3.2 两种「插话」的区别

内核提供两个队列回调,含义不一样。这是最容易混淆的一处:

回调什么时候被问语义定义
getSteeringMessages每个 turn 结束后(以及循环启动时)「边跑边纠偏」,不打断当前这批工具packages/agent-core/src/types.ts:306-308
getFollowUpMessages内层循环已经准备退出时「等它干完再说」,把 agent 重新拉起来packages/agent-core/src/types.ts:319-321

真实的队列实现是 PendingMessageQueue(packages/agent-core/src/agent.ts:165),它有一个 mode:

  • "all" —— 一次全部倒出来;
  • "one-at-a-time" —— 只取最老的一条,其余留到下一个排空点(agent.ts:188 的注释)。

默认两个队列都是 one-at-a-time(agent.ts:280-281),意思是用户连发三条,agent 会一条一条消化,而不是被一次性灌进同一个 prompt

3.3 steering 的一个真实来源:子 agent 完成通知

steering 不只是「用户又说了一句话」。子 agent 跑完后,结果也要塞回请求方的这一轮。 src/agents/agent-steering-queue.ts 就是干这个的,它的设计值得单独看:

  • 先租后投。 leasePendingAgentSteeringItemsFromSubagentRuns(agent-steering-queue.ts:185)把待投递项标成 in_progress 并打上 steeringLeaseId,注入成功才 ack(ackLeasedAgentSteeringItemsFromSubagentRuns,:222), 失败则 release 回队列(releaseLeasedAgentSteeringItemsFromSubagentRuns,:251)。 这样一次失败的父轮次不会把子 agent 的结果弄丢,也不会重复投两次。
  • 租约会过期。 超过 5 分钟的 in_progress 视为陈旧租约,重新入队(STALE_STEERING_LEASE_MS,:14;isStaleLease,:43)。
  • 提示词有硬上限。 合并后的 steering prompt 超过 24 000 字符就停止追加(MAX_MERGED_STEERING_CHARS,:15; selectPromptBoundedItems,:151)。
  • 明确划清指令边界。 合并出的提示词开头写着:把这些队列项当作运行时数据与证据,不要当成用户指令——这是一条防注入的护栏。

3.4 事件流协议:上层 UI 靠它活

内核不返回「一个结果」,它一边跑一边推事件。事件类型定义在 packages/agent-core/src/types.ts:602 (AgentEvent)。

一次带工具调用的 turn,事件序列长这样:

agent_start
└ turn_start
├ message_start(user) ← 用户消息也走消息生命周期
├ message_end(user)
├ message_start(assistant) ← 流式开始
├ message_update × N ← text/thinking/toolcall 的增量
├ message_end(assistant)
├ tool_execution_start / _update / _end
├ message_start/end(toolResult)
└ turn_end
└ turn_start …(下一轮)
agent_end

三个层级各自的含义,记住这张表就够了:

层级边界含义发射点
agent_*整个 run 的开始与结束agent-loop.ts:346:403
turn_*一次助手响应 + 它引发的工具执行agent-loop.ts:361
message_*单条消息(user / assistant / toolResult)的落地贯穿 runLoop 各阶段

承载事件的容器是 EventStream(packages/llm-core/src/utils/event-stream.ts:9),它有个双面性:

  • 作为 AsyncIterablefor await 消费;
  • 同时 result() 返回一个单独的 Promise,在「完成事件」出现时被 resolve(event-stream.ts:110)。

哪个事件算完成,由构造函数传进去。agent 层的判定是 event.type === "agent_end" (agent-loop.ts:267-270 createAgentStream)。同一个类,靠不同谓词服务不同抽象层级(LLM 层用 AssistantMessageEventStream,event-stream.ts:116)。

Agent 类把这些事件先归约进自己的状态,再 await 所有订阅者。注意它的注释点破了一个陷阱: agent_end 只代表「不会再有事件」,不代表 run 已经 idle——要等 agent_end 的所有 await 监听器跑完、 finishRun() 清完状态才算(agent.ts:296-297:600-601 的注释)。

3.5 中断:必须落成一条 aborted 消息

这是内核里最不显然、也最值得抄走的一处设计。

问题: 用户按了取消。如果 transcript 就停在一条 stopReason: "toolUse" 的助手消息上,后面的会话 后处理(压缩、续跑)会以为「这轮还没做完,继续吧」,于是从一条半截的工具调用上接着往下跑。

做法: 中止时主动写入一条空的助手消息,stopReason 标成 "aborted"(stopIfAborted,agent-loop.ts:320):

// Persist an aborted assistant outcome so session post-processing does not
// compact or continue from the preceding toolUse message.
const abortedMessage = withAssistantTurnTaint(createFailureMessage(config.model,, true));
newMessages.push(abortedMessage);
// 如果 turn 已关闭,先补一个 turn_start,保证事件配对

createFailureMessage(packages/agent-core/src/turn-interruption.ts:5)构造的这条消息:内容为空文本、 usage 全零、stopReason"aborted""error"errorMessage 带上原因。

同一个构造还被 pushLoopFailure(agent-loop.ts:274)复用:当整个循环抛异常时,它按 message_start → message_end → turn_end → agent_end 的顺序补齐事件,保证 UI 永远看到一套完整的生命周期, 而不是流突然断掉。

stopIfAborted 在一轮里被检查了五次(:356:382:439:487 等)——注入消息前、发请求前、 turn 结束后、取 steering 之后。每一个可能耗时的边界后面都跟一次中断检查。

3.6 一个小而关键的清洗:非可执行工具调用

模型有时会在 stopReason 不是 toolUse 的情况下(比如被 length 截断)吐出半个工具调用块。 removeNonExecutableToolCalls(agent-loop.ts:113)在响应定稿时把这些块直接删掉:

if (message.stopReason === "toolUse") return message; // 正常批次,保留
const content = message.content.filter((item) => item.type !== "toolCall");

配合派发条件 message.stopReason === "toolUse" && toolCalls.length > 0(agent-loop.ts:413), 残缺的工具调用既不会被执行,也不会污染 transcript


4. 编排壳:脏活全在这里

run.ts 那个巨文件已经被拆成近百个模块:入口在 run-orchestrator.ts,主循环在 run-loop.ts, attempt 的各阶段在 run/attempt-*.ts 一族。按时间切成三段最清楚:运行前、运行中、运行后。

4.1 运行前:把"模糊输入"解析成"确定事实"

入口 runEmbeddedAgent(src/agents/embedded-agent-runner/run-orchestrator.ts:79)只做两件小事——补 config 快照、 捕获生命周期代次——然后交给 runEmbeddedAgentInternal(同文件 :102)。真正的解析有四项:

① 会话键回填。 调用方经常只给 sessionId 不给 sessionKey,但下游(hooks、压缩、上下文引擎)都需要它。 backfillSessionKey(run/session-bootstrap.ts:195)做一次只读查表把它补出来,并且在最早的位置调用 (调用点 run-orchestrator.ts:116),好让所有下游拿到的都是非空值。

② 模型解析。 resolveInitialEmbeddedRunModel(run/runtime-resolution.ts:86)的优先级是一张小判定表:

输入情况结果
provider 和 model 都显式给了原样使用
只给了 model用 alias 索引解析这个字符串,provider 取解析结果或默认
都没给用该 agent 的配置默认值,再兜底到内置默认

③ 思考等级初值。 resolveInitialThinkLevel(run/runtime-resolution.ts:54):显式请求优先;否则用 resolveThinkingDefault,并把当前模型的 reasoning 能力当成一份临时单条 catalog 传进去—— 也就是说,同一个「默认等级」在不支持推理的模型上会得到不同结果。

④ auth profile 收窄。 createScopedAuthProfileStore(run/auth-store.ts:26)把全量凭据库裁成只含本次候选的 子集。这既是最小权限,也让后续的失败记账不会误伤无关 profile。

4.2 运行中之一:两级 lane 排队

一次 run 要串行化两件事:同一会话不能并发写 transcript,全局并发要有上限。于是用了两级队列, 外层是会话 lane,内层是全局 lane(run-orchestrator.ts):

enqueueSession(会话 lane)(:185)
│ 先等本会话的延迟维护做完
└── enqueueGlobal(全局 lane)(:197)
└── 真正的 attempt 循环
  • lane 标识:resolveSessionLane / resolveGlobalLane(src/agents/embedded-agent-runner/lanes.ts:6:11)。
  • 先会话后全局的顺序是有意为之:注释说明这样「别的会话在本会话等自己的维护 lane 时仍能开工」(run-orchestrator.ts:188-189)。
  • 入队前先 waitForDeferredTurnMaintenanceForSession(调用点 run-orchestrator.ts:192),保证同会话的后续读能看到之前的延迟 transcript 重写。

优先级由触发来源决定(resolveEmbeddedRunSessionQueuePriority,run/lane-runtime.ts:60):

trigger优先级
user / manualforeground
cron / heartbeat / memory / overflowbackground
其它normal

超时不是直接用 timeoutMs,而是加一段宽限:resolveEmbeddedRunLaneTimeoutMs(run/lane-runtime.ts:37) 返回 timeoutMs + 30_000(EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS,:11)。理由很直觉——lane 超时是最后一道 兜底,它必须晚于 run 自己的超时,否则每次正常超时都会先被 lane 杀掉、丢掉正常的收尾逻辑。

lane 超时还是「有进展就续命」型的:任务通过 taskTimeoutProgressAtMs 回调汇报心跳 (run/lane-controller.ts:82),noteLaneTaskProgress 在每次阶段通知时更新(:60)。

4.3 运行中之二:attempt 重试循环

编排壳自己的主循环在 run-loop.ts,每转一圈是一次 attempt。上限值分散在各恢复模块,挨在一处定义的是:

常量管什么位置
MAX_TIMEOUT_COMPACTION_ATTEMPTS2超时触发的压缩最多做几次run/timeout-context-recovery.ts:10
MAX_OVERFLOW_COMPACTION_ATTEMPTS3溢出触发的压缩最多做几次src/agents/agent-compaction-constants.ts:14
总轮数上限按候选 profile 数与 config 计算attempt 总轮数run/retry-limit.ts

超过总轮数不是简单抛错,而是再走一次失败转移决策(stage: "retry_limit" 分支,run-loop.ts:325resolveRunFailoverDecision),决定是「换个模型重来」还是「把错误返回给用户」。

4.4 运行后:终态归一

一次 run 的终态可能被多个来源同时报告:wait 结果、liveness 探测、超时归因。agent-run-terminal-outcome.ts 把它们压成一个 AgentRunTerminalOutcome(buildAgentRunTerminalOutcome,:492)。核心是两条规则:

规则一:硬超时的判定要谨慎。 只有当超时阶段属于 preflight/provider/post_turn (HARD_TIMEOUT_PHASES,:462),或者「状态是 timeout 且 provider 确实已经被打通过」(providerStarted,:498-506), 才算硬超时。队列和网关排空阶段的超时属于等待层的不确定性,不该算到模型头上。

规则二:终态有粘性。 mergeAgentRunTerminalOutcome(src/agents/agent-run-terminal-outcome-merge.ts:16)里, cancelled 一旦确立就不再变;hard_timeout 也守住不放,除非后来的证据能证明完成时刻早于或等于那次超时时刻。 这样「超时之后姗姗来迟的清理错误」不会把结论降级成一个普通失败。


5. 韧性:换凭据,再换模型

这节讲 OpenClaw 最工程化的一块:失败之后到底做什么。

5.1 两级失败转移

先建立心智模型。失败转移是两层套娃:

外层:换模型 (runWithModelFallback) ← src/agents/model-fallback-runner.ts:156
└── 内层:换 auth profile (advanceAuthProfile) ← run/auth-controller.ts:633
└── 一次 attempt
  • 内层便宜:同一个模型,换一份凭据重试。
  • 外层贵:换到 fallback 模型清单里的下一个,整个 run 重来(外层调用点 run-entry.ts:389)。
  • 内层用尽或遇到「换凭据也没用」的错误,才升级到外层。

5.2 决策函数:一张表就是全部策略

resolveRunFailoverDecision(src/agents/embedded-agent-runner/run/failover-policy.ts:157)是唯一的决策点。 它按「在哪个阶段失败」分三种入参,返回四种动作之一:

动作含义
continue_normal这不算失败,照常继续
rotate_profile换 auth profile 重试
fallback_model换模型重试
surface_error / return_error_payload认输,把错误交出去

几条藏在小函数里的规则,比动作本身更有信息量:

  • format 失败是终点站。 isTerminalFormatFailure(failover-policy.ts:83):请求体被 provider 拒了 (payload 形状问题),除非显式允许格式重试,否则既不换号也不换模型——换了也是同样的请求体。
  • 超时不换号,除非另有实锤。 shouldRotatePrompt(:93)明确排除 timeout;assistant 阶段则由 shouldRotateAssistant(:119)判断。
  • 重试上限的升级有门槛。 shouldEscalateRetryLimit(:77)只在原因不是 timeout/format/session_expired 时才允许换模型——这三种再换一个模型大概率还是同样的结果。
  • 原因会被"择强而取"。 mergeRetryFailoverReason(:149)保留旧原因,除非本次给出了更强的信号。

5.3 auth profile:资格、排序与冷却

资格判定。 resolveAuthProfileEligibility(src/agents/auth-profiles/order.ts:197)返回一个带 reasonCode 的 结构而不是布尔值,原因码包括 profile_missingprovider_mismatchmode_mismatch 等—— 排障时能直接说清「为什么这份凭据没被用」。

排序。 resolveAuthProfileOrder(order.ts:446)的关键设计(:399 起的注释):

  • 显式配置的顺序仍然是硬约束(用户说了算);
  • 但在这个顺序内部,处于冷却中的 profile 被整体挪到可用 profile 后面,并按冷却到期时间升序排;
  • 用户显式指定的 preferredProfile 如果还在名单里,依然排第一。

进函数第一件事是 clearExpiredCooldowns(order.ts:295-298),注释说明这是为了让刚过期的 profile 拿到全新的错误计数,而不是带着旧账被立刻重罚。

轮换。 advanceAuthProfile(run/auth-controller.ts:633)向后扫描候选:跳过冷却中的、跳过被 lockedProfileId 锁定的(:74);成功切换后把思考等级重置回初值、清空已尝试等级集合(:648-649)—— 新凭据是一次干净的重来。

记账。 markAuthProfileFailure(src/agents/auth-profiles/usage.ts:991)在 store 锁内更新统计。 它把失败分成两条赛道:

赛道触发原因退避依据
普通冷却其它所有原因30s → 1min → 5min 封顶calculateAuthProfileCooldownMs,usage.ts:709-718
禁用billing / auth_permanent指数退避,基准与上限可配DISABLED_FAILURE_BACKOFF_POLICIES,usage.ts:735

两个易忽略的细节:

  • 活跃窗口不可延长。 keepActiveWindowOrRecompute(usage.ts:865)保证窗口内的重复失败不会把恢复时间无限往后推。
  • 冷却范围会自动放宽。 冷却可以是「只针对某个 model」的。但如果在活跃窗口里换了个 model 又失败, 或者出现了 profile 级别的失败(auth/billing/format……),cooldownModel 会被清成 undefined, 于是没有任何 model 能绕过这次冷却(usage.ts:259:274:835:955-957)。

哪些失败该记到共享账本上? 这是 resolveAuthProfileFailureReason (run/auth-profile-failure-policy.ts:14)回答的问题:format 失败不记账,因为那是「这一条 transcript 形状不对」,不是「这份凭据不健康」;把它记上去会连累所有共享同一 profile 的会话,严重时把整个 provider 拖黑一整个退避窗口。同理豁免的还有 server_errorempty_response,以及尚未打通 provider 的 timeout

成功也要记。 跑完后 fire-and-forget 地调 markAuthProfileSuccess(调用点 run/auth-profile-success.ts:47),但它给自己设了一个 1 秒的慢日志阈值 (POST_RUN_AUTH_PROFILE_SUCCESS_SLOW_MS,:25)——后台记账变慢时能被看见,而不是静默拖慢每一次 run。

5.4 失败原因的分类

原因码是一个封闭联合,共 16 个(FAILOVER_REASONS,packages/gateway-protocol/src/failover-reasons.ts:1; 在 agents 侧再出口于 src/agents/failover/signal.ts:8-9): authauth_permanentformatrate_limitoverloadedbillingserver_errortimeouttls_certificatecontext_overflowmodel_not_foundsession_expiredempty_responseno_error_detailsunclassifiedunknown

分类入口是 classifyFailoverReason(src/agents/failover/classify.ts:428), 它同时吃错误文本和 HTTP 状态码(经 extractLeadingHttpStatus / classifyFailoverClassificationFromHttpStatus, classify.ts:7:20), 并给 provider 插件留了钩子——同一个 402,不同 provider 可能意味着不同的事。

一类特殊的分类是**「跑完了但等于没跑」**。classifyEmbeddedAgentRunResultForModelFallback (src/agents/embedded-agent-runner/result-fallback-classifier.ts:197)把这些结果也判成可换模型重试:

  • 完全没有可见回复(empty_result);
  • 只有推理没有答案(reasoning_only_result);
  • 只有计划没有最终答案(planning_only_result)。

同样重要的是它拒绝重试的那些情况:已经中止、已经有对外投递证据、 被 hook 主动拦下(hook_block——重试等于绕过一次策略决定)、以及刻意的静默回复。

外层换模型跑完仍然全败时,mergeEmbeddedAgentRunResultForModelFallbackExhaustion(:36) 会保留最新一次的记账,但把最可信的那份 payload 呈现给用户,并把执行轨迹里的 winner 字段清空。

还有一条给无人值守场景的专线:resolveEmbeddedRunFailureSignal (src/agents/embedded-agent-runner/failure-signal.ts:28)只在 trigger === "cron" 时生效, 把 exec 类工具的 SYSTEM_RUN_DENIED / INVALID_REQUEST 升级成 fatalForCron: true 的信号—— 定时任务不能把"shell 被沙箱拒了"当成一次正常的静默完成。


6. 上下文:压缩、截断、落盘

这节讲运行时怎么跟「上下文窗口」这个硬约束搏斗。

6.1 四条触发路径

压缩不是只有 /compact 一条路。四条路径的触发时机和参数都不一样:

路径触发时机关键判据入口
手动 /compact用户下指令force: truecompactEmbeddedAgentSession,compact.queued.ts:246
预算压缩预检时预算不够目标 budget同上
超时压缩模型超时且 prompt 占比 > 65%tokenUsedRatio > 0.65run/timeout-context-recovery.ts:39-49
溢出压缩provider 报了上下文溢出错误文本命中溢出模式run/overflow-context-recovery.ts:122-138

超时压缩这条路的推理链值得复述:模型超时 + 提示词已占满上下文的六成半 ⇒ 大概率是「上下文太长导致 每次都慢到超时」的死循环 ⇒ 先压缩再重试,能打破它(timeout-context-recovery.ts:56 的日志文案就是这么写的)。

溢出压缩这条路则先做一次去重判断:如果本次 attempt 里已经发生过压缩(比如 SDK 自动压缩), 就不再立刻叠加一次显式压缩,只是重试(overflow-context-recovery.ts:134-138:溢出在 attempt 内已被处理过时, context overflow persisted after in-attempt compaction …; retrying prompt without additional compaction)。

6.2 压缩的排队与降级

compactEmbeddedAgentSession(compact.queued.ts:246)是带 lane 排队的版本;已经在 lane 里的调用方必须用 compactEmbeddedAgentSessionDirect(compact.ts:227)——否则自己等自己,死锁。这条约束写在函数注释里 (compact.queued.ts:244)。

compactEmbeddedAgentSessionDirect 本身也有 fallback:如果没配置显式压缩模型、但配了 fallback 清单, 它会用 runWithModelFallback 包住压缩(compact.ts:24 引入)。

还有一条「不要在请求路径上做重活」的降级:当上下文引擎自己拥有压缩、并且声明支持后台轮次维护时, 预算压缩会被推迟到后台,当场返回 compacted: false 加一个专门的 reason (shouldDeferOwningContextEngineBudgetCompaction,compact.queued.ts:116; deferOwningContextEngineBudgetCompaction,:160)。理由:请求期的预算压缩能吃掉整个回复预检预算。

所有插件侧的 compact() 都被安全超时包住(compactContextEngineWithSafetyTimeout, src/agents/embedded-agent-runner/compaction-safety-timeout.ts:125),超时或抛错一律转成干净的 { ok: false }, 而不是把裸 rejection 抛给只检查 result.ok 的调用方。

6.3 压缩之后:重放清洗与会话换代

压缩完的上下文不是直接接着用,而是被两条机制夹住:

  • 重放侧清洗。 喂回模型前,sanitizeSessionHistory(replay-history.ts:761)按 transcript policy 清洗; 其中早于最近一次压缩摘要的 thinking 签名会被剥掉(replay-history.ts:838-852)——因为签名绑定旧上下文前缀, 留着会触发 Anthropic 的「Invalid signature in thinking block」;压缩之后产生的新条目是在新上下文里生成的,签名有效,不剥。
  • 会话目标换代(上下文引擎侧)。 引擎可以在压缩结果里报告一个新的会话目标(新 sessionId/sessionFile), resolveContextEngineCompactionSuccessor(compaction-successor.ts:18)负责校验并接管这个目标: 身份不一致直接抛错(:28-30),绑定必须仍是同一会话(assertSameSessionBinding,:147)。

注:旧的 agents.defaults.compaction.truncateAfterCompaction 配置已退役(迁移逻辑在 src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts:59-70),换代不再由宿主配置开关驱动。

6.4 重复用户消息的消除

同一句话被连发两次(重试、客户端重发)会让压缩上下文白白膨胀。 dedupeDuplicateUserMessagesForCompaction(compaction-duplicate-user-messages.ts:63)的判据很克制:

条件为什么
时间窗口60 秒(:6)只打击「短时间内的重发」,不误伤真的重复提问
最短长度24 字符(:7)「好的」「继续」这类短语允许重复
归一化空白折叠 + NFC + 小写(:56)抗排版差异
含图片的消息直接放行无法只凭文本判定等价

第一条永远保留,只丢后来的重复(:79 的注释)——第一条要充当被摘要分支的锚点。

6.5 tool result 截断:两道闸

第一道:长度截断。 truncateToolResultText(tool-result-truncation.ts:360)不是简单砍头, 它先用 hasImportantTail(:350)判断尾部是否含错误/摘要/JSON 收尾;如果是,就改成 头 + 中间省略标记 + 尾的策略(使用点 :385)。错误信息通常在最后,砍尾等于把最有用的部分扔了。

上限本身是三段式的(resolveAutoLiveToolResultMaxChars,src/agents/tool-result-limits.ts:11):

模型上下文单条 tool result 上限
< 100k tokens16 000 字符
≥ 100k tokens32 000 字符
≥ 200k tokens64 000 字符

并且再叠一层比例封顶:单条 tool result 不得超过上下文窗口的 30% (MAX_TOOL_RESULT_CONTEXT_SHARE,tool-result-limits.ts:3;calculateMaxToolResultCharsWithCap,:25)。

第二道:请求前的守卫。 installToolResultContextGuard(tool-result-context-guard.ts:456) 把自己包在 agent 的 transformContext——这就是编排壳挂进内核的方式。守卫在每次请求前做两件事:

  1. 先按上面的上限做就地截断(enforceToolResultLimit,:482-484);
  2. 如果开了 mid-turn 预检、且围栏之后出现了新的 tool result,就估算下一轮的 token 压力, 需要时抛 MidTurnPrecheckSignal 把控制权交回编排壳去做恢复(:487-521;信号类在 run/midturn-precheck.ts:28)。

预检的判定本身是路由式的:shouldPreemptivelyCompactBeforePrompt(run/preemptive-compaction.ts:331) 返回 fits / compact_only / truncate_tool_results_only / compact_then_truncate 四选一(:396-409)—— 该压就压、该截就截,而不是只有"溢出"一个档位

6.6 transcript 落盘与重放

落盘与重写。 压缩/清洗后要改 transcript 时,走 rewriteTranscriptEntriesInSessionManager(src/agents/embedded-agent-runner/transcript-rewrite.ts:126)—— 以 SessionManager 为唯一写入点,而不是直接改文件。

重放。 从磁盘读回的 transcript 不能直接喂给 provider,要过两道:

  • sanitizeSessionHistory(replay-history.ts:761)——清洗管线。它先解析出一份 transcript policy (按 model api / provider / model 决定),再跑通用清洗,再让 provider 插件加自己的钩子。 函数顶部一行注释要求改逻辑时同步更新 docs/reference/transcript-hygiene.md(:776)。
  • validateReplayTurns(replay-history.ts:952)——校验。provider 插件的校验优先;没有插件才落到 通用轮次校验。

thinking 块是重放里最麻烦的一类内容,为此有一整套梯度处理:

函数做什么位置
stripInvalidThinkingSignatures剥掉签名缺失/为空的 thinking 块;默认豁免最后一条助手消息src/agents/embedded-agent-runner/thinking.ts:112
stripStaleThinkingSignaturesForCompactionReplay只剥「早于最近一次压缩摘要」的签名src/agents/thinking-signatures.ts:71
assessLastAssistantMessage判定末条助手消息是 valid / incomplete-thinking / incomplete-textthinking.ts:351
dropReasoningFromHistory整体丢弃历史推理内容thinking.ts:315

为什么豁免最后一条?provider 会拒绝被改过的最新 thinking 块,所以坏掉的末条 必须走恢复路径(wrapAnthropicStreamWithRecovery,thinking.ts:576),而不是在发请求前偷偷改写。

还有一类脏输入来自模型自己:有些模型把工具调用当成纯文本吐出来。packages/tool-call-repair 专门修这个,导出三组能力——解析纯文本工具调用块(payload.ts)、在流事件层归一化 (stream-normalizer.ts)、把它提升成真正的工具调用块(promote.ts),入口见 packages/tool-call-repair/src/index.ts:1-25

6.7 上下文引擎:可插拔的委托

上下文的装配与压缩不是写死的,而是一个插件槽 plugins.slots.contextEngine,默认 legacy (resolveContextEngine,src/context-engine/registry.ts:712)。

给插件的下坡路。 第三方引擎如果不想自己实现压缩算法,可以在自己的 compact() 里直接调 delegateCompactionToRuntime(src/context-engine/delegate.ts:150),复用 OpenClaw 内置的压缩路径 (内置 legacy 引擎自己就是这么干的,src/context-engine/legacy.ts:42)。

给宿主的护栏:隔离(quarantine)。 自定义引擎不能把整个运行时拖下水。 wrapResolvedContextEngine(registry.ts:99)用 Proxy 包住每个受保护方法:

调用 engine.<method>
├─ 参数里带的 signal 已中止 → 直接抛(「中止是调用方意图,不是引擎不稳定」,registry.ts:180 的注释)
├─ 该引擎已被隔离 → 走默认引擎的同名方法
└─ 正常调用
└─ 抛异常 → 记录隔离(recordContextEngineQuarantine,:245)→ 转默认引擎兜底

被隔离之后,连 info 属性都会改报默认引擎的信息(registry.ts:130-135), 这样按引擎作用域分派的下游逻辑不会继续指向那个坏引擎。运行时的隔离清单可查 (listContextEngineQuarantines,:283),也可显式清除。


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

  • 中断落成一条真实消息,而不是一个标记位。 stopReason: "aborted" 的空助手消息让「后处理不要从半截 toolUse 继续」这条规则由数据本身表达,任何读 transcript 的代码都不需要额外约定 (packages/agent-core/src/agent-loop.ts:320-341)。

  • 失败时补齐整套生命周期事件。 pushLoopFailure(agent-loop.ts:274)按 message_start → message_end → turn_end → agent_end 顺序补发,UI 端因此只需要写一条渲染路径

  • 一个 EventStream 类,靠谓词服务两层抽象。 完成条件与终值提取都是构造参数 (packages/llm-core/src/utils/event-stream.ts:9:110),于是 agent 层用 agent_end、LLM 层另配。

  • lane 超时永远晚于业务超时。 timeoutMs + 30s 的宽限(run/lane-runtime.ts:37-45)保证兜底机制不会抢走 正常超时路径的收尾权。这是一条通用的「多层超时」经验。

  • format 失败不写进共享账本。 区分「这条 transcript 有问题」和「这份凭据有问题」 (run/auth-profile-failure-policy.ts:14),避免一个坏会话冷却掉全部并发会话。

  • 活跃冷却窗口不可被延长、但可被放宽范围。 前者防止重试把恢复时间推到无穷远,后者防止换个 model 绕过冷却 (src/agents/auth-profiles/usage.ts:865:955-957)。

  • 截断优先保尾。 错误和摘要都在输出末尾,hasImportantTail + 头尾双段策略比朴素截断多救回最关键的信息 (tool-result-truncation.ts:350:385)。

  • 预检是路由,不是开关。 请求前的 token 压力判定返回四种路线(压/截/都干/都别干), 把「一刀切溢出」换成可精确接管的本地信号(run/preemptive-compaction.ts:396-409)。

  • 子 agent 结果用「租约」投递。 lease → ack / release + 陈旧租约回收 (agent-steering-queue.ts:185/:222/:251),既不丢也不重。

  • 压缩失败一律转成结果对象。 插件的 compact() 被安全超时包住,超时/抛错都变成 { ok: false } (compaction-safety-timeout.ts:125),调用方只需检查一处。


8. 边界与局限

  • 内核不认识 provider。 它只吃一个 StreamFn(再出口于 packages/agent-core/src/types.ts:25), 契约要求「不许抛,把失败编码进流」。违反这条契约的 stream 实现会绕过正常事件序列。

  • 回调不许抛。 convertToLlmtransformContextshouldStopAfterTurn、两个队列回调的文档都写着 「must not throw or reject」(types.ts:217:245:266:279:306:319)。抛了就会中断底层循环, 留下不完整的事件序列。唯一被设计成「可以抛」的是 resolveDeferredTool,它的异常会变成一条错误工具结果(types.ts:361)。

  • 续跑有前置条件。 agentLoopContinue 要求 context 非空、且最后一条消息不是 assistant (agent-loop.ts:174);更微妙的是,最后一条必须能被 convertToLlm 转成 user 或 toolResult, 而这一点代码里无法静态校验,只能运行时兜。

  • 压缩的重入必须由调用方自己保证。 在 lane 内调队列版本会死锁,靠的是两个函数的注释约定, 不是运行时检查(compact.queued.ts:244)。

  • 本章不覆盖工具本体与沙箱。 工具怎么定义、权限怎么判、沙箱怎么隔离,见 工具、技能与沙箱。本章只讲循环怎么调度它们。

  • 也不覆盖:通道归一化与会话路由见 入站; 运行队列之外的回复分块投递见 回复流水线; 插件槽机制本身见 插件化内核


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

主题文件路径符号名
内核主循环(内外双层)packages/agent-core/src/agent-loop.tsrunLoop
带 prompt 启动循环packages/agent-core/src/agent-loop.tsrunAgentLoop / agentLoop
无 prompt 续跑packages/agent-core/src/agent-loop.tsagentLoopContinue
中断落成 aborted 消息packages/agent-core/src/agent-loop.tsturn-interruption.tsstopIfAborted / createFailureMessage
循环异常时补齐事件packages/agent-core/src/agent-loop.tspushLoopFailure
残缺工具调用清洗packages/agent-core/src/agent-loop.tsremoveNonExecutableToolCalls
事件与配置契约packages/agent-core/src/types.tsAgentEvent / AgentLoopConfig / StreamFn
有状态包装与队列packages/agent-core/src/agent.tsAgent / PendingMessageQueue
事件流容器packages/llm-core/src/utils/event-stream.tsEventStream / AssistantMessageEventStream
纯文本工具调用修复packages/tool-call-repair/src/index.tsparseStandalonePlainTextToolCallBlocks
编排壳入口src/agents/embedded-agent-runner/run-orchestrator.tsrunEmbeddedAgent / runEmbeddedAgentInternal
编排壳主循环src/agents/embedded-agent-runner/run-loop.tsattempt 循环、retry_limit 分支
入口级换模型src/agents/embedded-agent-runner/run-entry.tsrunEmbeddedAgentEntry
会话键回填src/agents/embedded-agent-runner/run/session-bootstrap.tsbackfillSessionKey
初始模型/思考等级src/agents/embedded-agent-runner/run/runtime-resolution.tsresolveInitialEmbeddedRunModel / resolveInitialThinkLevel
auth 库收窄src/agents/embedded-agent-runner/run/auth-store.tscreateScopedAuthProfileStore
lane 解析src/agents/embedded-agent-runner/lanes.tsresolveSessionLane / resolveGlobalLane
lane 超时与优先级src/agents/embedded-agent-runner/run/lane-runtime.tsrun/lane-controller.tsresolveEmbeddedRunLaneTimeoutMs / resolveEmbeddedRunSessionQueuePriority
失败转移决策src/agents/embedded-agent-runner/run/failover-policy.tsresolveRunFailoverDecision
auth profile 轮换src/agents/embedded-agent-runner/run/auth-controller.tsadvanceAuthProfile
哪些失败记入共享账本src/agents/embedded-agent-runner/run/auth-profile-failure-policy.tsresolveAuthProfileFailureReason
跑后成功记账src/agents/embedded-agent-runner/run/auth-profile-success.tsPOST_RUN_AUTH_PROFILE_SUCCESS_SLOW_MS
冷却与禁用记账src/agents/auth-profiles/usage.tsmarkAuthProfileFailure / calculateAuthProfileCooldownMs
资格与排序src/agents/auth-profiles/order.tsresolveAuthProfileEligibility / resolveAuthProfileOrder
失败原因分类src/agents/failover/classify.tssignal.tsclassifyFailoverReason / FailoverReason
原因码全集packages/gateway-protocol/src/failover-reasons.tsFAILOVER_REASONS
结果级模型 fallback 分类src/agents/embedded-agent-runner/result-fallback-classifier.tsclassifyEmbeddedAgentRunResultForModelFallback
cron 致命失败信号src/agents/embedded-agent-runner/failure-signal.tsresolveEmbeddedRunFailureSignal
终态归一与粘性src/agents/agent-run-terminal-outcome.tsagent-run-terminal-outcome-merge.tsbuildAgentRunTerminalOutcome / mergeAgentRunTerminalOutcome
外层换模型src/agents/model-fallback-runner.tsmodel-fallback-candidates.tsrunWithModelFallback / resolveModelCandidateChain
压缩(带 lane 排队)src/agents/embedded-agent-runner/compact.queued.tscompactEmbeddedAgentSession
压缩(lane 内直调)src/agents/embedded-agent-runner/compact.tscompactEmbeddedAgentSessionDirect
压缩安全超时src/agents/embedded-agent-runner/compaction-safety-timeout.tscompactContextEngineWithSafetyTimeout
超时压缩src/agents/embedded-agent-runner/run/timeout-context-recovery.tsMAX_TIMEOUT_COMPACTION_ATTEMPTS
溢出压缩src/agents/embedded-agent-runner/run/overflow-context-recovery.ts溢出重试判定
压缩后继任会话目标src/agents/embedded-agent-runner/compaction-successor.tsresolveContextEngineCompactionSuccessor
重复用户消息消除src/agents/embedded-agent-runner/compaction-duplicate-user-messages.tsdedupeDuplicateUserMessagesForCompaction
tool result 截断src/agents/embedded-agent-runner/tool-result-truncation.tssrc/agents/tool-result-limits.tstruncateToolResultText / resolveAutoLiveToolResultMaxChars
请求前上下文守卫src/agents/embedded-agent-runner/tool-result-context-guard.tsinstallToolResultContextGuard
mid-turn 预检src/agents/embedded-agent-runner/run/preemptive-compaction.tsrun/midturn-precheck.tsshouldPreemptivelyCompactBeforePrompt / MidTurnPrecheckSignal
transcript 重写src/agents/embedded-agent-runner/transcript-rewrite.tsrewriteTranscriptEntriesInSessionManager
重放清洗与校验src/agents/embedded-agent-runner/replay-history.tssanitizeSessionHistory / validateReplayTurns
thinking 签名处理src/agents/embedded-agent-runner/thinking.tssrc/agents/thinking-signatures.tsstripInvalidThinkingSignatures / assessLastAssistantMessage / wrapAnthropicStreamWithRecovery
子 agent 结果 steeringsrc/agents/agent-steering-queue.tsleasePendingAgentSteeringItemsFromSubagentRuns
上下文引擎解析与隔离src/context-engine/registry.tsresolveContextEngine / wrapResolvedContextEngine / recordContextEngineQuarantine
压缩委托给内置实现src/context-engine/delegate.tsdelegateCompactionToRuntime