Agent 主循环:embedded-runner
职责
embedded-agent-runner 是 OpenClaw 把"模型 + 工具 + 会话历史"拧成一次完整 agent 交互的核心层。它接收一条已经准备好的用户 prompt,在一个 while (true) 循环 (loop) 里反复调用模型、解析工具调用、回填工具结果、按需触发上下文压缩 (compaction),直到模型给出终端回复或者预算 (budget) 耗尽。这一层不直接对接渠道——渠道消息经过网关到达这里时,已经只剩"sessionId + prompt + 配置"。
run.ts 是这个循环的载体。它既要管单轮 (attempt) 的派发,也要在单轮异常时决定是同模型重试 (retry)、切认证 profile、切 fallback 模型,还是触发压缩后继续。换句话说,embedded-runner 不是"调一次模型就走"的薄壳,而是一个带状态机的重试编排器。
设计动机
为什么把 agent 主循环做成一个常驻的 while (true),而不是把每次模型调用交给上层渠道自行重试?核心原因有三个:
第一,重试状态必须跨轮次累积。同一个 prompt 可能因为限流、idle timeout、空回复、推理不完整等多种原因需要重试,而每种重试都有独立的次数上限(如 MAX_SAME_MODEL_RATE_LIMIT_RETRIES、MAX_EMPTY_ERROR_RETRIES、MAX_MISSING_ASSISTANT_RETRIES)。如果重试放到上层,每个渠道都得重写一遍这套状态机,且无法共享 profile 轮换、fallback 链路。
第二,上下文压缩必须与重试协同。当模型上下文溢出时,embedded-runner 不是简单报错,而是先尝试原地压缩 (overflowCompactionAttempts,最多 3 次),压缩完用同一份 prompt 继续。这种"压缩—重试"的耦合不能放到无状态的上层。
第三,成本失控保护要在循环层落地。idleTimeoutBreakerState 是为 #76293 设计的成本熔断器:连续多次 idle timeout 且没有任何模型产出时,强制停止后续 attempt,避免无谓烧钱。这种熔断状态天然属于循环本身。
把循环放在 embedded 层,还有一个副作用好处:CLI runner 和网关 runner 共享同一套重试语义,只是入口不同。
关键文件
run.ts— 主循环文件,4200+ 行,包含while (true)turn 循环、所有重试分支、压缩协同、failover 决策。run.ts iteration caps:1560-1567— 三个核心上限:MAX_TIMEOUT_COMPACTION_ATTEMPTS=2、MAX_OVERFLOW_COMPACTION_ATTEMPTS=3、MAX_RUN_LOOP_ITERATIONS。run.ts while(true) head:1885-1921— 循环头:超限检测 +runLoopIterations自增。run.ts attempt dispatch:2044-2231— 调runEmbeddedAttemptWithBackend派发单轮。run.ts idle breaker step:2278-2307— idle-timeout 熔断判定。run.ts retry branches:3746-3793— reasoning-only / missing-assistant / empty-response 三类重试分支。cli-runner.ts runCliAgent:391-401— CLI 入口薄包装,绑定 lifecycle generation。cli-runner.ts runPreparedCliAgent:508-528— 已准备好的上下文进入执行,挂before_agent_run/llm_input/llm_output/agent_end钩子。session-manager-init.ts prepareSessionManagerForRun:47-117— 进入循环前归一化 session 文件头。
数据流
embedded-runner 的核心循环(run.ts:1885)是一个 while (true),开头先做预算检查:
while (true) {
if (runLoopIterations >= MAX_RUN_LOOP_ITERATIONS) {
const message =
`Exceeded retry limit after ${runLoopIterations} attempts ` +
`(max=${MAX_RUN_LOOP_ITERATIONS}).`;
log.error(
`[run-retry-limit] sessionKey=${params.sessionKey ?? params.sessionId} ` +
`provider=${provider}/${modelId} attempts=${runLoopIterations} ` +
`maxAttempts=${MAX_RUN_LOOP_ITERATIONS}`,
);
const retryLimitDecision = resolveRunFailoverDecision({
stage: "retry_limit",
fallbackConfigured,
failoverReason: lastRetryFailoverReason,
});
return handleRetryLimitExhaustion({ message, decision: retryLimitDecision, ... });
}
runLoopIterations += 1;这里 MAX_RUN_LOOP_ITERATIONS 由 resolveMaxRunRetryIterations(profileCandidates.length, config, agentId) 计算——profile 候选越多、agent 配置越激进,允许的迭代次数就越多。到达上限时,不是简单报错,而是问 resolveRunFailoverDecision 有没有 fallback 模型可以接手;只有 fallback 也用尽,才真正以 livenessState: "blocked" 收尾。
预算通过后,循环组装本次 attempt 的 prompt。prompt 不是原样透传,而是会拼上几条"续写指令":
const basePrompt =
nextAttemptPromptOverride ??
(provider === "anthropic" ? scrubAnthropicRefusalMagic(params.prompt) : params.prompt);
nextAttemptPromptOverride = null;
const promptAdditions = [
reasoningOnlyRetryInstruction,
emptyResponseRetryInstruction,
compactionContinuationRetryInstruction,
].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
const prompt =
promptAdditions.length > 0
? `${basePrompt}\n\n${promptAdditions.join("\n\n")}`
: basePrompt;这三条 addition 各自对应一种重试场景:reasoningOnlyRetryInstruction 在模型只输出思考没给可见答案时续写;emptyResponseRetryInstruction 在零 token 空回复时要求给可见回复;compactionContinuationRetryInstruction 在压缩完成后告诉模型"从压缩后的 transcript 继续,不要从头再来"。这种设计让重试不是简单地重发原 prompt,而是带着上下文意识的"定向续写"。
prompt 准备好后,循环派发到 runEmbeddedAttemptWithBackend(run.ts:2044),这是真正进入单轮 (attempt) 的入口:
const rawAttempt = await runEmbeddedAttemptWithBackend({
sessionId: activeSessionId,
sessionKey: resolvedSessionKey,
promptCacheKey: params.promptCacheKey,
...
sessionFile: activeSessionFile,
workspaceDir: resolvedWorkspace,
cwd: params.cwd,
...
beforeAgentFinalizeRevisionAttempts,
maxBeforeAgentFinalizeRevisions: MAX_BEFORE_AGENT_FINALIZE_REVISIONS,
...
}).catch((err: unknown): never => {
throw postCompactionAbortError ?? err;
}).finally(() => {
clearAttemptTimeoutRelease();
stopLaneProgressHeartbeat();
parentAbortSignal?.removeEventListener?.("abort", relayParentAbort);
if (postCompactionAbortController === attemptAbortController) {
postCompactionAbortController = undefined;
}
});注意 .finally 里清理的三件东西:clearAttemptTimeoutRelease 是 lane 超时释放计时器,stopLaneProgressHeartbeat 停止心跳,parentAbortSignal 解绑——这是为下一次循环迭代留干净状态,避免上一次 attempt 的 watchdog 残留。
attempt 返回后,循环首先做成本熔断(run.ts:2278):
const breakerStep = stepIdleTimeoutBreaker(idleTimeoutBreakerState, {
idleTimedOut,
completedModelProgress: hasCompletedModelProgressForIdleBreaker(attempt),
outputTokens: attemptUsage?.output,
});
if (breakerStep.tripped) {
const breakerMessage =
`Idle-timeout cost-runaway breaker tripped: ` +
`${breakerStep.consecutive} consecutive idle timeouts ` +
`without completed model progress ` +
`(cap=${MAX_CONSECUTIVE_IDLE_TIMEOUTS_BEFORE_OUTPUT}). ` +
`Halting further attempts to bound paid model calls. ` +
`See issue #76293.`;
...
return handleRetryLimitExhaustion({ message: breakerMessage, ... });
}熔断器是纯函数 stepIdleTimeoutBreaker,状态 idleTimeoutBreakerState 在循环外创建、跨 attempt 累积——这样 profile 轮换或同模型重试不会清零计数,真正起到"跨 attempt 的成本闸门"作用。
接着是重试分支(run.ts:3746),每种重试都对应一个独立计数器:
if (
nextReasoningOnlyRetryInstruction &&
reasoningOnlyRetryAttempts < maxReasoningOnlyRetryAttempts
) {
reasoningOnlyRetryAttempts += 1;
reasoningOnlyRetryInstruction = nextReasoningOnlyRetryInstruction;
log.warn(`reasoning-only assistant turn detected: ... retrying ${reasoningOnlyRetryAttempts}/${maxReasoningOnlyRetryAttempts} ...`);
continue;
}
...
if (
!nextReasoningOnlyRetryInstruction &&
nextEmptyResponseRetryInstruction &&
emptyResponseRetryAttempts < maxEmptyResponseRetryAttempts
) {
emptyResponseRetryAttempts += 1;
emptyResponseRetryInstruction = nextEmptyResponseRetryInstruction;
log.warn(`empty response detected: ... retrying ${emptyResponseRetryAttempts}/${maxEmptyResponseRetryAttempts} ...`);
continue;
}这种结构的好处是:每类重试用尽都不影响其他类——reasoning-only 用完后,空回复重试仍可触发;反过来也一样。所有 continue 都把控制权交回循环头,让预算检查和 prompt 重组重新生效,而不是就地跳过检查。
边界与失败
- 循环上限不是硬编码常量:
MAX_RUN_LOOP_ITERATIONS由resolveMaxRunRetryIterations(profileCandidates.length, params.config, sessionAgentId)解析(run.ts:1562)。这意味着同一个 agent 在不同 profile 候选数下重试预算不同——加了备用 profile,循环上限会自动放宽。 - overflow 压缩有独立计数:
MAX_OVERFLOW_COMPACTION_ATTEMPTS=3(run.ts:1561)专管上下文溢出场景,与 timeout 压缩的MAX_TIMEOUT_COMPACTION_ATTEMPTS=2分开计数,避免一种失败把另一种的预算吃光。 - post-compaction 循环守卫:
createPostCompactionLoopGuard(run.ts:1599)针对 #77474——压缩完成后模型如果立刻进入工具循环死循环,守卫会 abort 当前 attempt,而不是等超时。 - lane 超时释放的 grace 计时器:
armAttemptTimeoutRelease(run.ts:2034)在原生 transport 不理会 abort 信号时,给 lane 一个EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS的宽限再释放,避免单次坏 transport 卡死整个 lane 队列。 - before_agent_run 钩子能阻断:
runPreparedCliAgent(cli-runner.ts:508)在真正进入循环前会跑before_agent_run钩子,钩子选择 block 时循环根本不会开始——这是插件层的"拒绝执行"出口。 - CLI 入口与网关入口共用循环:
runCliAgent(cli-runner.ts:392)只做 lifecycle generation 绑定和before_agent_replycron 钩子,真正的工作还是回到runPreparedCliAgent→executePreparedCliRun→ embedded runner。所以 CLI 模式和网关模式的重试语义是一致的,不会有"CLI 重试三次但网关只重试一次"的割裂。
小结
embedded-runner 是一个带状态机的 while (true):它不假设单次模型调用会成功,而是把"重试、压缩、failover、成本熔断"全部内化。所有计数器跨 attempt 累积,所有重试都有独立预算,所有失败都有出口。
要继续往下看,单轮 (attempt) 内部到底怎么调模型、解析 tool_use、回填 tool_result,见 单轮 attempt:模型调用与工具配对;会话 (session) 文件怎么管理、工具怎么注册到 SessionManager 上,见 会话管理:SessionManager;工具本身的定义和策略见 工具系统;各 provider 的流式接入见 Provider 接入。
对照官方资料:Agent runtime 文档 · README。