Skip to content

Boucle while double

源码版本v0.73.1

runLoop est le moteur de pi-agent-core. La boucle while (true) externe vide la file followUp, la boucle interne while (hasMoreToolCalls || pendingMessages.length > 0) traite la chaîne d'appels d'outils et les intercalaires steering ; à chaque tour, on appelle streamAssistantResponse pour obtenir un message assistant, on passe dans executeToolCalls s'il y a des toolCalls puis on reboucle à l'interne, sinon on sort de la boucle interne pour interroger la file followUp. La boucle elle-même ne détient aucun état : tout est fourni par l'appelant (Agent), et les événements sont émis via AgentEventSink.

Responsabilités

  1. Orchestration des événements : à chaque turn on émet turn_start, pour les messages assistant on émet message_start/message_update/message_end, pour l'exécution des outils tool_execution_*, et turn_end à la fin du tour, puis agent_end à la fin de toute la boucle. Voir packages/agent/src/agent-loop.ts:155-246.
  2. Tirage streaming de la réponse assistant : streamAssistantResponse appelle convertToLlm pour convertir en Message[], utilise streamFn (streamSimple par défaut) pour envoyer la requête, puis consomme les événements via for await en réécrivant le partial message en fin de context.messages. Voir packages/agent/src/agent-loop.ts:252-345.
  3. Injection steering : au début de la boucle interne, on vérifie pendingMessages, on pousse les messages steering dans currentContext.messages avant le prochain appel LLM. Voir packages/agent/src/agent-loop.ts:180-188.
  4. Relais followUp : après sortie de la boucle interne, l'externe appelle getFollowUpMessages ; s'il y a des messages, on les remet dans pendingMessages et on rentre dans l'interne, sinon break. Voir packages/agent/src/agent-loop.ts:233-243.
  5. Arrêt précoce : shouldStopAfterTurn est appelé après turn_end ; s'il renvoie true, on émet agent_end et on sort directement, en court-circuitant la rotation des files. Voir packages/agent/src/agent-loop.ts:218-228.

Motivation de design

Pourquoi deux while plutôt qu'un ? Parce qu'il existe deux sémantiques de « continuation ». Les appels d'outils sont une continuation « intra-tour » — l'assistant a émis des toolCalls, on les exécute puis renvoie les résultats au LLM pour qu'il continue : c'est la boucle interne. followUp est une continuation « après la fin naturelle du tour » — l'utilisateur a mis en file des tâches que l'agent doit traiter une fois le tour courant terminé : c'est la boucle externe. Mélanger les deux brouillerait le timing d'injection entre steering (intercalaire dans le tour) et followUp (relais entre tours).

Pourquoi steering à l'intérieur et followUp à l'extérieur ? La sémantique de steering est « intercaler avant le prochain appel LLM », il doit donc être consommé en haut de la boucle while interne, avant streamAssistantResponse. followUp, c'est « l'agent allait s'arrêter, on lui refait passer une tâche », donc il faut attendre que la boucle interne sorte naturellement (hasMoreToolCalls=false et steering vide) avant de vérifier.

Pourquoi streamAssistantResponse modifie-t-il directement context.messages ? Parce que le partial message doit être visible par l'UI pendant le streaming, alors que l'appel LLM doit s'appuyer sur un context où « le précédent message assistant est déjà écrit ». Pousser le partial dans le context est le plus simple ; l'événement done remplace ensuite le partial par le finalMessage. Le coût : le context est en état « semi-fini » pendant le streaming, mais la boucle ne fait que le lire, pas l'écrire, donc pas de race.

Fichiers clés

Le squelette : while externe tire les followUp, while interne gère les appels d'outils :

typescript
// packages/agent/src/agent-loop.ts:168-243
while (true) {
  let hasMoreToolCalls = true;
  while (hasMoreToolCalls || pendingMessages.length > 0) {
    if (!firstTurn) await emit({ type: "turn_start" }); else firstTurn = false;
    // push steering into context ...
    const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);
    if (message.stopReason === "error" || message.stopReason === "aborted") { /* emit turn_end/agent_end return */ }
    const toolCalls = message.content.filter((c) => c.type === "toolCall");
    hasMoreToolCalls = false;
    if (toolCalls.length > 0) {
      const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);
      toolResults.push(...executedToolBatch.messages);
      hasMoreToolCalls = !executedToolBatch.terminate;
      // push toolResults into context ...
    }
    await emit({ type: "turn_end", message, toolResults });
    if (await config.shouldStopAfterTurn?.(...)) { await emit({ type: "agent_end", ... }); return; }
    pendingMessages = (await config.getSteeringMessages?.()) || [];
  }
  const followUpMessages = (await config.getFollowUpMessages?.()) || [];
  if (followUpMessages.length > 0) { pendingMessages = followUpMessages; continue; }
  break;
}

streamAssistantResponse modifie directement le partial message en fin de context.messages :

typescript
// packages/agent/src/agent-loop.ts:290-316
for await (const event of response) {
  switch (event.type) {
    case "start":
      partialMessage = event.partial;
      context.messages.push(partialMessage);
      addedPartial = true;
      await emit({ type: "message_start", message: { ...partialMessage } });
      break;
    case "text_start": case "text_delta": case "text_end":
    case "thinking_start": case "thinking_delta": case "thinking_end":
    case "toolcall_start": case "toolcall_delta": case "toolcall_end":
      if (partialMessage) {
        partialMessage = event.partial;
        context.messages[context.messages.length - 1] = partialMessage;
        await emit({ type: "message_update", assistantMessageEvent: event, message: { ...partialMessage } });
      }
      break;
    case "done": case "error": { /* replace with finalMessage, emit message_end, return */ }
  }
}

Flux de données

Le chemin complet après que agent.prompt(text) est appelé :

Limites et échecs

  • Court-circuit d'erreur : quand stopReason vaut "error" ou "aborted", on émet turn_end+agent_end et on retourne directement, sans exécuter d'outils ni interroger steering/followUp. Voir packages/agent/src/agent-loop.ts:194-198.
  • Contrat de transformContext : si transformContext throw, la boucle est interrompue sans produire de séquence d'événements normale ; le contrat de types exige donc qu'il ne throw pas, qu'il renvoie une fallback. Voir packages/agent/src/agent-loop.ts:260-263 et Contrat de types.
  • Steering ignoré au premier tour : runPromptMessages peut passer skipInitialSteeringPoll à runAgentLoop pour éviter que le prompt juste émis soit préempté par un steering. Voir packages/agent/src/agent.ts:374-388.
  • Validation du rôle en continue : runAgentLoopContinue/agentLoopContinue vérifient avant d'appeler runLoop que le dernier message n'est pas un assistant, sinon throw. Voir packages/agent/src/agent-loop.ts:127-133.
  • Le partial n'entre pas dans newMessages : le partial message est bien écrit dans currentContext.messages pour le context du prochain appel LLM, mais newMessages (ce qui est renvoyé à l'appelant pour ce tour) ne pousse le finalMessage complet qu'après message_end. Voir packages/agent/src/agent-loop.ts:191-192.

Résumé

runLoop utilise une double boucle while pour séparer « la chaîne d'appels d'outils intra-tour » et « le relais followUp inter-tours » : steering est injecté en haut de l'interne, followUp est relayé à la fin de l'externe, les erreurs et abort court-circuitent la sortie. La boucle est sans état, toute information inter-tours vient du context passé par Agent et des deux callbacks getSteeringMessages/getFollowUpMessages. Pour le détail de l'exécution des outils, voir Exécution des outils sequential/parallel ; pour l'enveloppe d'entrée et la coquille d'état, voir Classe Agent et cycle de vie.