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 は「1 回モデルを呼んで終わり」の薄い殻ではなく,状態機械を持つ再試行オーケストレータです。

設計動機

なぜ agent メインループを常駐の while (true) にし,毎回のモデル呼び出しを上層チャネルに再試行させないのか?核心的な理由は 3 つあります。

第一に,再試行状態はラウンドをまたいで累積しなければなりません。同じ 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;

これら 3 つの addition はそれぞれ再試行シナリオに対応:reasoningOnlyRetryInstruction はモデルが思考だけ出力して可視応答をくれないときの続き,emptyResponseRetryInstruction は 0 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 で清理する 3 つに注意:clearAttemptTimeoutRelease は lane タイムアウト解放タイマー,stopLaneProgressHeartbeat はハートビート停止,parentAbortSignal はアンバインド——これらは次のループイテレーションのためにきれいな状態を残し,前回 attempt のウォッチドッグ残留を回避します。

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 対応——圧縮完了後にモデルがすぐツールループ無限ループに入った場合,守衛が現在の attempt を abort し,タイムアウトを待ちません。
  • lane タイムアウト解放の grace タイマー:armAttemptTimeoutRelease(run.ts:2034)はネイティブ transport が abort シグナルを無視するとき,lane に EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS の猶予を与えてから解放し,1 回の悪い transport が lane キュー全体を hang させるのを避けます。
  • 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 は 3 回再試行だがゲートウェイは 1 回だけ」のような分裂はありません。

まとめ

embedded-runner は状態機械を持つ while (true) です:単回のモデル呼び出しが成功するとは仮定せず,「再試行、圧縮、failover、コストヒューズ」をすべて内化します。すべてのカウンタは attempt をまたいで累積し,すべての再試行には独立予算があり,すべての失敗には出口があります。

さらに下へ進むと,単発 (attempt) 内部でどうモデルを呼び,tool_use を解析し,tool_result を埋め戻すかは 単発 attempt:モデル呼び出しとツールペアリング,セッション (session) ファイルがどう管理され,ツールがどう SessionManager に登録されるかは セッション管理:SessionManager,ツール自体の定義とポリシーは ツールシステム,各 provider のストリーミング接続は Provider 接入 を参照。

公式資料:Agent runtime ドキュメント · README