Skip to content

Agent 主循環:embedded-runner

源码版本v2026.6.11

職責

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_RETRIESMAX_EMPTY_ERROR_RETRIESMAX_MISSING_ASSISTANT_RETRIES)。如果重試放到上層,每個通道都得重寫一遍這套狀態機,且無法共享 profile 輪換、fallback 鏈路。

第二,上下文壓縮必須與重試協同。當模型上下文溢出時,embedded-runner 不是簡單報錯,而是先嘗試原地壓縮 (overflowCompactionAttempts,最多 3 次),壓縮完用同一份 prompt 繼續。這種「壓縮—重試」的耦合不能放到無狀態的上層。

第三,成本失控保護要在循環層落地。idleTimeoutBreakerState 是為 #76293 設計的成本熔斷器:連續多次 idle timeout 且沒有任何模型產出時,強制停止後續 attempt,避免無謂燒錢。這種熔斷狀態天然屬於循環本身。

把循環放在 embedded 層,還有一個副作用好處:CLI runner 和閘道 runner 共享同一套重試語意,只是入口不同。

關鍵檔案

資料流

embedded-runner 的核心循環(run.ts:1885)是一個 while (true),開頭先做預算檢查:

typescript
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_ITERATIONSresolveMaxRunRetryIterations(profileCandidates.length, config, agentId) 計算——profile 候選越多、agent 設定越激進,允許的迭代次數就越多。到達上限時,不是簡單報錯,而是問 resolveRunFailoverDecision 有沒有 fallback 模型可以接手;只有 fallback 也用盡,才真正以 livenessState: "blocked" 收尾。

預算通過後,循環組裝本次 attempt 的 prompt。prompt 不是原樣透傳,而是會拼上幾條「續寫指令」:

typescript
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) 的入口:

typescript
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):

typescript
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),每種重試都對應一個獨立計數器:

typescript
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_ITERATIONSresolveMaxRunRetryIterations(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_reply cron 鉤子,真正的工作還是回到 runPreparedCliAgentexecutePreparedCliRun → embedded runner。所以 CLI 模式和閘道模式的重試語意是一致的,不會有「CLI 重試三次但閘道只重試一次」的割裂。

小結

embedded-runner 是一個帶狀態機的 while (true):它不假設單次模型呼叫會成功,而是把「重試、壓縮、failover、成本熔斷」全部內化。所有計數器跨 attempt 累積,所有重試都有獨立預算,所有失敗都有出口。

要繼續往下看,單輪 (attempt) 內部到底怎麼呼叫模型、解析 tool_use、回填 tool_result,見 單輪 attempt:模型呼叫與工具配對;工作階段 (session) 檔案怎麼管理、工具怎麼註冊到 SessionManager 上,見 工作階段管理:SessionManager;工具本身的定義和策略見 工具系統;各 provider 的串流接入見 Provider 接入

對照官方資料:Agent runtime 文件 · README