Skip to content

Bucle principal del agent: embedded-runner

源码版本v2026.6.11

Responsabilidad

embedded-agent-runner es la capa central con la que OpenClaw convierte «modelo + herramientas + historial de sesión» en una interacción completa de agent. Recibe un prompt de usuario ya preparado y, en un bucle while (true) (loop), invoca repetidamente al modelo, parsea las llamadas a herramientas, rellena los resultados de las herramientas, dispara bajo demanda la compactación de contexto (compaction), hasta que el modelo da una respuesta terminal o se agota el presupuesto (budget). Esta capa no se conecta directamente a los canales — cuando un mensaje de canal atraviesa el gateway y llega aquí, solo queda «sessionId + prompt + configuración».

run.ts es el soporte de este bucle. Gestiona tanto el despacho de un turno único (attempt) como la decisión, ante un intento anómalo, de reintentar (retry) con el mismo modelo, cambiar de perfil de autenticación, cambiar a un modelo de fallback o disparar una compactación y seguir. Es decir, embedded-runner no es una envoltura fina que «llama al modelo una vez y se va», sino un orquestador de reintentos con máquina de estados.

Motivación de diseño

¿Por qué convertir el bucle principal del agent en un while (true) residente, en vez de confiar cada llamada al modelo al reintento del canal superior? Tres razones centrales:

Primera, el estado de reintento debe acumular entre turnos. Un mismo prompt puede necesitar reintento por rate limit, idle timeout, respuesta vacía, razonamiento incompleto, etc., y cada tipo tiene su propio máximo (MAX_SAME_MODEL_RATE_LIMIT_RETRIES, MAX_EMPTY_ERROR_RETRIES, MAX_MISSING_ASSISTANT_RETRIES). Si el reintento se delega arriba, cada canal reimplementaría la máquina de estados y no podría compartir la rotación de perfiles ni la cadena de fallback.

Segunda, la compactación de contexto debe coordinarse con el reintento. Cuando el contexto del modelo se desborda, embedded-runner no reporta error sin más: primero intenta compactar in situ (overflowCompactionAttempts, máximo 3 veces) y luego continúa con el mismo prompt. Este acoplamiento «compactar—reintentar» no puede vivir en una capa superior sin estado.

Tercera, la protección contra la fuga de coste debe aterrizar en la capa del bucle. idleTimeoutBreakerState es un cortacircuitos de coste diseñado para #76293: ante varios idle timeout consecutivos sin producción del modelo, detiene los intentos siguientes, evitando quemar dinero en vano. Este estado de cortacircuitos pertenece naturalmente al bucle.

Poner el bucle en la capa embedded tiene además un beneficio lateral: CLI runner y gateway runner comparten la misma semántica de reintento, solo cambia la entrada.

Archivos clave

Flujo de datos

El bucle central de embedded-runner (run.ts:1885) es un while (true) que empieza con la comprobación de presupuesto:

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_ITERATIONS se calcula con resolveMaxRunRetryIterations(profileCandidates.length, config, agentId) — cuantos más perfiles candidatos y más agresiva la configuración del agent, más iteraciones se permiten. Al alcanzar el límite, no se reporta error sin más: se pregunta a resolveRunFailoverDecision si hay un modelo de fallback que pueda tomar el relevo; solo cuando el fallback también se agota se cierra con livenessState: "blocked".

Superado el presupuesto, el bucle monta el prompt del intento. El prompt no se pasa transparente: se le añaden unas «instrucciones de continuación»:

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;

Cada una de las tres adiciones corresponde a un escenario de reintento: reasoningOnlyRetryInstruction continúa cuando el modelo solo emite razonamiento sin respuesta visible; emptyResponseRetryInstruction exige una respuesta visible cuando se recibe una respuesta vacía sin tokens; compactionContinuationRetryInstruction tras compactar le dice al modelo «continúa desde el transcript compactado, no empieces de cero». Este diseño convierte el reintento en una «continuación dirigida con conciencia de contexto», no en un simple reenvío del prompt original.

Preparado el prompt, el bucle despacha a runEmbeddedAttemptWithBackend (run.ts:2044), la entrada real a un turno único (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;
  }
});

Nótese que el .finally limpia tres cosas: clearAttemptTimeoutRelease es el timer de liberación de lane; stopLaneProgressHeartbeat detiene el heartbeat; parentAbortSignal se desvincula — deja estado limpio para la iteración siguiente y evita residuos del watchdog del intento anterior.

Tras el retorno del attempt, el bucle primero pasa por el cortacircuitos de coste (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, ... });
}

El cortacircuitos es una función pura stepIdleTimeoutBreaker; el estado idleTimeoutBreakerState se crea fuera del bucle y acumula entre intentos — así la rotación de perfil o el reintento con el mismo modelo no reinician el contador, de modo que actúa como una «compuerta de coste entre intentos».

Luego vienen las ramas de reintento (run.ts:3746), cada una con su propio contador:

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

La ventaja de esta estructura: cada tipo de reintento se agota sin afectar a los demás — cuando reasoning-only se agota, el reintento por respuesta vacía sigue pudiendo dispararse; y viceversa. Todos los continue devuelven el control a la cabecera del bucle, de modo que la comprobación de presupuesto y el reensamblaje del prompt se vuelven a aplicar, en vez de saltárselos.

Límites y fallos

  • El límite del bucle no es una constante hard-coded: MAX_RUN_LOOP_ITERATIONS se resuelve con resolveMaxRunRetryIterations(profileCandidates.length, params.config, sessionAgentId) (run.ts:1562). Eso significa que un mismo agent tiene presupuesto de reintento distinto según el número de perfiles candidatos — añadir un perfil de respaldo relaja automáticamente el límite del bucle.
  • La compactación por overflow tiene contador propio: MAX_OVERFLOW_COMPACTION_ATTEMPTS=3 (run.ts:1561) solo cubre el escenario de desbordamiento de contexto, separado de MAX_TIMEOUT_COMPACTION_ATTEMPTS=2 para timeout — evita que un tipo de fallo devore el presupuesto del otro.
  • Guardián post-compaction: createPostCompactionLoopGuard (run.ts:1599) está pensado para #77474 — si tras compactar el modelo entra de inmediato en un bucle infinito de herramientas, el guardián aborta el intento en curso en lugar de esperar al timeout.
  • Timer de grace para liberación de lane: armAttemptTimeoutRelease (run.ts:2034) da al lane un grace de EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS antes de liberarlo cuando el transport nativo ignora la señal de abort — evita que un transport defectuoso bloquee toda la cola de lanes.
  • El hook before_agent_run puede bloquear: runPreparedCliAgent (cli-runner.ts:508) ejecuta el hook before_agent_run antes de entrar en el bucle; si el hook elige bloquear, el bucle ni siquiera arranca — es la salida de «rechazo de ejecución» de la capa de plugin.
  • Entrada CLI y entrada gateway comparten bucle: runCliAgent (cli-runner.ts:392) solo vincula la generación de lifecycle y el hook cron before_agent_reply; el trabajo real vuelve a runPreparedCliAgentexecutePreparedCliRun → embedded runner. Por eso la semántica de reintento en modo CLI y en modo gateway es consistente; no hay una división tipo «CLI reintenta tres veces pero gateway solo una».

Resumen

embedded-runner es un while (true) con máquina de estados: no asume que una sola llamada al modelo vaya a tener éxito, sino que integra «reintento, compactación, failover, cortacircuitos de coste». Todos los contadores acumulan entre intentos; todos los reintentos tienen presupuesto independiente; todos los fallos tienen una salida.

Para seguir bajando: qué hace un turno único (attempt) internamente al invocar al modelo, parsear tool_use y rellenar tool_result se ve en Turno único attempt: invocación al modelo y emparejamiento de herramientas; cómo se gestiona el archivo de sesión (session) y cómo se registran las herramientas en SessionManager se ve en Gestión de sesión: SessionManager; la definición y política de las herramientas en sí se ve en Sistema de herramientas; la integración de streaming de cada provider se ve en Integración de providers.

Referencias oficiales: Documentación del agent runtime · README.