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