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。