Boucle while double
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
- Orchestration des événements : à chaque turn on émet
turn_start, pour les messages assistant on émetmessage_start/message_update/message_end, pour l'exécution des outilstool_execution_*, etturn_endà la fin du tour, puisagent_endà la fin de toute la boucle. Voirpackages/agent/src/agent-loop.ts:155-246. - Tirage streaming de la réponse assistant :
streamAssistantResponseappelleconvertToLlmpour convertir enMessage[], utilisestreamFn(streamSimplepar défaut) pour envoyer la requête, puis consomme les événements viafor awaiten réécrivant le partial message en fin decontext.messages. Voirpackages/agent/src/agent-loop.ts:252-345. - Injection steering : au début de la boucle interne, on vérifie
pendingMessages, on pousse les messages steering danscurrentContext.messagesavant le prochain appel LLM. Voirpackages/agent/src/agent-loop.ts:180-188. - Relais followUp : après sortie de la boucle interne, l'externe appelle
getFollowUpMessages; s'il y a des messages, on les remet danspendingMessageset on rentre dans l'interne, sinonbreak. Voirpackages/agent/src/agent-loop.ts:233-243. - Arrêt précoce :
shouldStopAfterTurnest appelé aprèsturn_end; s'il renvoie true, on émetagent_endet on sort directement, en court-circuitant la rotation des files. Voirpackages/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
packages/agent/src/agent-loop.ts:25-26— typeAgentEventSink, point d'entrée synchrone pour tous les événements.packages/agent/src/agent-loop.ts:31-54— entrée synchroneagentLoop, avecvoid runAgentLoop(...).then(stream.end)à l'intérieur, renvoieEventStream.packages/agent/src/agent-loop.ts:64-93—agentLoopContinuen'ajoute pas de nouveau prompt, reprend depuis le context existant ; sert aux retries.packages/agent/src/agent-loop.ts:95-118—runAgentLoopémetagent_start/turn_start, émet les messages de prompt un par un viamessage_start/message_end, puis appellerunLoop.packages/agent/src/agent-loop.ts:120-143—runAgentLoopContinuen'émet pas d'événements de message de prompt, entre directement dansrunLoop.packages/agent/src/agent-loop.ts:155-246— corps derunLoop: while externe, while interne, court-circuit d'erreur,shouldStopAfterTurn, relais followUp.packages/agent/src/agent-loop.ts:252-345—streamAssistantResponse:transformContext→convertToLlm→streamFunction→ boucle d'événementsfor await.packages/agent/src/agent-loop.ts:281-285— appel réel au stream ;apiKeyest ré-résolu viagetApiKeyjuste avant l'appel, pour éviter l'expiration du token OAuth sur les longues tâches.
Le squelette : while externe tire les followUp, while interne gère les appels d'outils :
// 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 :
// 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
stopReasonvaut"error"ou"aborted", on émetturn_end+agent_endet on retourne directement, sans exécuter d'outils ni interroger steering/followUp. Voirpackages/agent/src/agent-loop.ts:194-198. - Contrat de transformContext : si
transformContextthrow, 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. Voirpackages/agent/src/agent-loop.ts:260-263et Contrat de types. - Steering ignoré au premier tour :
runPromptMessagespeut passerskipInitialSteeringPollàrunAgentLooppour éviter que le prompt juste émis soit préempté par un steering. Voirpackages/agent/src/agent.ts:374-388. - Validation du rôle en continue :
runAgentLoopContinue/agentLoopContinuevérifient avant d'appelerrunLoopque le dernier message n'est pas un assistant, sinon throw. Voirpackages/agent/src/agent-loop.ts:127-133. - Le partial n'entre pas dans newMessages : le partial message est bien écrit dans
currentContext.messagespour le context du prochain appel LLM, maisnewMessages(ce qui est renvoyé à l'appelant pour ce tour) ne pousse le finalMessage complet qu'aprèsmessage_end. Voirpackages/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.