Agent メインループ:embedded-runner
責務
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_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— 3 つの核心上限: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 の 3 種再試行分岐。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;これら 3 つの addition はそれぞれ再試行シナリオに対応:reasoningOnlyRetryInstruction はモデルが思考だけ出力して可視応答をくれないときの続き,emptyResponseRetryInstruction は 0 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 で清理する 3 つに注意:clearAttemptTimeoutRelease は lane タイムアウト解放タイマー,stopLaneProgressHeartbeat はハートビート停止,parentAbortSignal はアンバインド——これらは次のループイテレーションのためにきれいな状態を残し,前回 attempt のウォッチドッグ残留を回避します。
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 対応——圧縮完了後にモデルがすぐツールループ無限ループに入った場合,守衛が現在の 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_replycron フックだけで,本当の仕事はrunPreparedCliAgent→executePreparedCliRun→ 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。