Skip to content

Boucle principale de l'agent: embedded-runner

源码版本v2026.6.11

Responsabilités

embedded-agent-runner est la couche centrale où OpenClaw tricote « modèle + outils + historique de session » en une interaction agent complète. Elle reçoit un prompt utilisateur déjà prêt, et dans une boucle while (true) (loop) appelle itérativement le modèle, parse les tool calls, remplit les tool results, déclenche à la demande une compaction de contexte (compaction), jusqu'à ce que le modèle produise une réponse terminale ou que le budget (budget) soit épuisé. Cette couche n'interagit pas directement avec les canaux — quand le message d'un canal atteint cette couche via la passerelle, il ne reste que « sessionId + prompt + config ».

run.ts est le support de cette boucle. Il pilote le dispatch d'une tentative (attempt), et en cas d'exception décide si on réessaie (retry) avec le même modèle, si on change de profile d'auth, si on bascule sur un modèle de repli (fallback), ou si on déclenche une compaction puis on continue. Autrement dit, embedded-runner n'est pas un wrapper fin « on appelle le modèle une fois et c'est tout », c'est un orchestrateur de retry avec machine à états.

Motivation de conception

Pourquoi faire de la boucle principale de l'agent un while (true) persistant, plutôt que de laisser chaque couche supérieure de canal réessayer elle-même? Trois raisons principales:

Premièrement, l'état de retry doit s'accumuler au-delà d'un tour. Un même prompt peut nécessiter un retry pour rate limit, idle timeout, réponse vide, raisonnement incomplet, etc.; et chaque retry a son propre plafond (MAX_SAME_MODEL_RATE_LIMIT_RETRIES, MAX_EMPTY_ERROR_RETRIES, MAX_MISSING_ASSISTANT_RETRIES). Si on remontait le retry à la couche supérieure, chaque canal devrait réécrire cette machine à états, sans pouvoir partager la rotation de profile ni la chaîne de fallback.

Deuxièmement, la compaction du contexte doit coordonner avec le retry. Quand le contexte modèle déborde, embedded-runner ne lève pas simplement une erreur; il tente d'abord une compaction sur place (overflowCompactionAttempts, au plus 3 fois), puis reprend avec le même prompt. Ce couplage « compaction + retry » ne peut pas vivre dans une couche supérieure stateless.

Troisièmement, la protection contre l'emballement de coût doit atterrir dans la couche boucle. idleTimeoutBreakerState est un disjoncteur de coût conçu pour #76293: quand plusieurs idle timeout consécutifs se produisent sans aucune production du modèle, on stoppe les attempts suivants pour éviter de brûler de l'argent inutilement. Cet état de disjoncteur appartient naturellement à la boucle elle-même.

Placer la boucle dans la couche embedded a un effet bénéfique: CLI runner et gateway runner partagent la même sémantique de retry, seul le point d'entrée diffère.

Fichiers clés

Flux de données

La boucle centrale de embedded-runner (run.ts:1885) est un while (true) qui commence par vérifier le budget:

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;

Ici MAX_RUN_LOOP_ITERATIONS est calculé par resolveMaxRunRetryIterations(profileCandidates.length, config, agentId) — plus il y a de candidats profile, plus la config de l'agent est agressive, plus le plafond d'itérations est élevé. À l'atteinte du plafond, on ne lève pas simplement une erreur; on demande à resolveRunFailoverDecision s'il y a un modèle de fallback pour reprendre; seulement quand le fallback est épuisé, on termine avec livenessState: "blocked".

Budget passé, la boucle assemble le prompt de cet attempt. Le prompt n'est pas forwardé tel quel; on y concatène plusieurs « instructions de continuation »:

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;

Ces trois additions correspondent chacune à un scénario de retry: reasoningOnlyRetryInstruction poursuit quand le modèle n'a émis que du raisonnement sans réponse visible; emptyResponseRetryInstruction demande une réponse visible en cas de réponse vide à zéro token; compactionContinuationRetryInstruction indique au modèle, après compaction, de « continuer depuis le transcript compacté, ne pas recommencer à zéro ». Cette design fait du retry une « continuation dirigée avec conscience du contexte » plutôt qu'un simple renvoi du prompt initial.

Prompt prêt, la boucle dispatch sur runEmbeddedAttemptWithBackend (run.ts:2044) qui est l'entrée réelle d'une tentative (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;
  }
});

Notez les trois nettoyages dans .finally: clearAttemptTimeoutRelease est le timer de libération du timeout de lane, stopLaneProgressHeartbeat arrête le heartbeat, parentAbortSignal est détaché — c'est pour laisser un état propre à la prochaine itération et éviter qu'un watchdog de l'attempt précédent ne survive.

Au retour de l'attempt, la boucle fait d'abord le disjoncteur de coût (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, ... });
}

Le disjoncteur est une fonction pure stepIdleTimeoutBreaker; l'état idleTimeoutBreakerState est créé hors boucle et accumulé à travers les attempts — ainsi la rotation de profile ou le retry même modèle ne remet pas le compteur à zéro, ce qui en fait un vrai « portail de coût inter-attempts ».

Ensuite les branches de retry (run.ts:3746); chaque retry a son propre compteur:

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

L'intérêt de cette structure: épuiser un type de retry n'affecte pas les autres — quand reasoning-only est épuisé, le retry empty-response peut encore se déclencher; et inversement. Tous les continue repassent le contrôle en tête de boucle, pour que le budget check et la réorganisation du prompt s'appliquent, plutôt que de sauter ces contrôles localement.

Limites et modes d'échec

  • Plafond de boucle non codé en dur: MAX_RUN_LOOP_ITERATIONS est résolu par resolveMaxRunRetryIterations(profileCandidates.length, params.config, sessionAgentId) (run.ts:1562). Le même agent a un budget de retry différent selon le nombre de candidats profile — ajouter un profile de secours élargit automatiquement le plafond.
  • Compteur de compaction overflow séparé: MAX_OVERFLOW_COMPACTION_ATTEMPTS=3 (run.ts:1561) est dédié au scénario de débordement de contexte, et est compté séparément de MAX_TIMEOUT_COMPACTION_ATTEMPTS=2 pour éviter qu'un type d'échec n'épuise le budget de l'autre.
  • Garde-boucle post-compaction: createPostCompactionLoopGuard (run.ts:1599) cible #77474 — si après compaction le modèle entre tout de suite dans une boucle infinie d'outils, le garde abort l'attempt courant au lieu d'attendre le timeout.
  • Timer de grâce pour libération du timeout de lane: armAttemptTimeoutRelease (run.ts:2034) donne au lane un EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS de grâce avant de libérer, quand le transport natif ignore le signal abort — pour éviter qu'un seul mauvais transport ne fige toute la file de lane.
  • Le hook before_agent_run peut bloquer: runPreparedCliAgent (cli-runner.ts:508) lance le hook before_agent_run avant d'entrer dans la boucle; si le hook choisit block, la boucle ne démarre pas — c'est l'échappatoire « refuser d'exécuter » côté plugin.
  • Entrée CLI et entrée passerelle partagent la boucle: runCliAgent (cli-runner.ts:392) ne fait que le bind de lifecycle generation et le hook cron before_agent_reply; le vrai travail repart sur runPreparedCliAgentexecutePreparedCliRun → embedded runner. Donc la sémantique de retry CLI et passerelle est cohérente; pas de split « CLI réessaie 3 fois mais passerelle 1 fois ».

Résumé

embedded-runner est un while (true) avec machine à états: il ne suppose pas qu'un seul appel modèle réussit, il internalise « retry, compaction, failover, disjoncteur de coût ». Tous les compteurs s'accumulent à travers les attempts, tous les retries ont un budget séparé, toutes les failures ont une sortie.

Pour aller plus loin — comment l'attempt interne appelle le modèle, parse tool_use, remplit tool_result, voir Tentative attempt: appel modèle et appariement d'outils; comment le fichier de session (session) est géré et comment les outils sont enregistrés sur le SessionManager, voir Gestion de session: SessionManager; la définition et la politique des outils, voir Système d'outils; le branchement streaming de chaque provider, voir Branchement Provider.

Pour comparer avec la documentation officielle: Agent runtime docs · README.