Skip to content

Agent main loop: embedded-runner

源码版本v2026.6.11

Responsibilities

embedded-agent-runner is the core layer where OpenClaw turns "model + tools + session history" into a complete agent interaction. It takes an already-prepared user prompt and, inside a while (true) loop, repeatedly calls the model, parses tool calls, backfills tool results, and triggers context compaction as needed — until the model produces a terminal reply or the budget is exhausted. This layer doesn't talk to channels directly; by the time a channel message reaches here through the gateway, only "sessionId + prompt + config" remains.

run.ts is the carrier of this loop. It manages single-attempt dispatch, but it also decides on single-attempt failure whether to retry with the same model, switch auth profile, switch to a fallback model, or trigger compaction and continue. In other words, embedded-runner is not a thin shell that "calls the model once and returns" — it's a retry orchestrator with a state machine.

Design motivation

Why make the agent main loop a resident while (true) instead of letting each model call be retried by the upper-layer channel? Three core reasons:

First, retry state must accumulate across turns. The same prompt may need retries for rate limits, idle timeouts, empty replies, incomplete reasoning, and each retry class has its own limit (such as MAX_SAME_MODEL_RATE_LIMIT_RETRIES, MAX_EMPTY_ERROR_RETRIES, MAX_MISSING_ASSISTANT_RETRIES). If retries were left to upper layers, every channel would have to re-implement this state machine, and profile rotation / fallback chains couldn't be shared.

Second, context compaction must coordinate with retries. When the model context overflows, embedded-runner doesn't simply error — it first attempts in-place compaction (overflowCompactionAttempts, up to 3 times) and continues with the same prompt. This "compact-retry" coupling can't live in a stateless upper layer.

Third, runaway-cost protection belongs at the loop layer. idleTimeoutBreakerState is a cost circuit breaker designed for #76293: when consecutive idle timeouts occur with no model output, it forcibly halts further attempts to avoid burning money for nothing. This breaker state naturally belongs to the loop itself.

Putting the loop in the embedded layer has one side benefit: the CLI runner and the gateway runner share the same retry semantics, just with different entry points.

Key files

Data flow

The core loop of embedded-runner (run.ts:1885) is a while (true) that starts with a budget check:

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;

Here MAX_RUN_LOOP_ITERATIONS is computed by resolveMaxRunRetryIterations(profileCandidates.length, config, agentId) — more profile candidates and more aggressive agent config mean a higher allowed iteration count. Hitting the limit doesn't just error; it asks resolveRunFailoverDecision whether a fallback model can take over. Only when fallback is also exhausted does it end with livenessState: "blocked".

After passing the budget, the loop assembles this attempt's prompt. The prompt isn't passed through as-is — it's appended with a few "continuation instructions":

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;

Each addition corresponds to a retry scenario: reasoningOnlyRetryInstruction continues when the model only emits thinking and gives no visible answer; emptyResponseRetryInstruction asks for a visible reply on zero-token empty responses; compactionContinuationRetryInstruction tells the model after compaction to "continue from the compacted transcript, don't start over." This design makes retries context-aware "directed continuations" rather than simple re-sends of the original prompt.

With the prompt ready, the loop dispatches to runEmbeddedAttemptWithBackend (run.ts:2044), the actual entry into a single 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;
  }
});

Note the three cleanups in .finally: clearAttemptTimeoutRelease is the lane timeout release timer; stopLaneProgressHeartbeat stops the heartbeat; parentAbortSignal detaches — this leaves a clean state for the next loop iteration and avoids watchdog residue from the previous attempt.

After the attempt returns, the loop first runs the cost breaker (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, ... });
}

The breaker is a pure function stepIdleTimeoutBreaker; the state idleTimeoutBreakerState is created outside the loop and accumulates across attempts — so profile rotation or same-model retries don't reset the count, truly acting as a "cost gate across attempts."

Then come the retry branches (run.ts:3746), each with its own counter:

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;
}

The benefit of this structure: exhausting one retry class doesn't affect the others — when reasoning-only is exhausted, empty-response retries can still fire, and vice versa. All continues return control to the loop head, so budget checks and prompt reassembly take effect again rather than skipping checks in place.

Boundaries and failures

  • loop cap isn't a hardcoded constant: MAX_RUN_LOOP_ITERATIONS is resolved by resolveMaxRunRetryIterations(profileCandidates.length, params.config, sessionAgentId) (run.ts:1562). The same agent has different retry budgets under different profile candidate counts — adding a backup profile widens the cap automatically.
  • overflow compaction has its own counter: MAX_OVERFLOW_COMPACTION_ATTEMPTS=3 (run.ts:1561) is dedicated to context overflow, separate from MAX_TIMEOUT_COMPACTION_ATTEMPTS=2 for timeout compaction, so one failure class doesn't eat the other's budget.
  • post-compaction loop guard: createPostCompactionLoopGuard (run.ts:1599) targets #77474 — if the model immediately enters a tool-call loop after compaction, the guard aborts the current attempt instead of waiting for timeout.
  • lane timeout release grace timer: armAttemptTimeoutRelease (run.ts:2034) gives the lane an EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS grace before releasing when a native transport ignores the abort signal, so a single bad transport can't stall the entire lane queue.
  • before_agent_run hook can block: runPreparedCliAgent (cli-runner.ts:508) runs the before_agent_run hook before entering the loop. If the hook chooses to block, the loop never starts — this is the plugin-layer "refuse to execute" exit.
  • CLI entry and gateway entry share the loop: runCliAgent (cli-runner.ts:392) only does lifecycle generation binding and the before_agent_reply cron hook; the real work goes back through runPreparedCliAgentexecutePreparedCliRun → embedded runner. So retry semantics are identical in CLI mode and gateway mode — no "CLI retries three times but gateway only once" split.

Summary

embedded-runner is a while (true) with a state machine: it doesn't assume a single model call succeeds. It internalizes retry, compaction, failover, and cost-breaking. All counters accumulate across attempts, every retry has its own budget, every failure has an exit.

To keep going deeper — how a single attempt internally calls the model, parses tool_use, and backfills tool_result is in Single attempt: model calls and tool pairing; how session files are managed and how tools are registered onto SessionManager is in Session management: SessionManager; tool definitions and policies are in Tool system; and per-provider streaming integration is in Provider integration.

Official references: Agent runtime docs · README.