Bucle while doble principal
runLoop es el motor de pi-agent-core. El bucle externo while (true) vacía la cola followUp; el bucle interno while (hasMoreToolCalls || pendingMessages.length > 0) procesa las llamadas a herramientas y las interrupciones steering; en cada turno llama a streamAssistantResponse para obtener un mensaje del asistente, y si trae toolCalls, ejecuta executeToolCalls y vuelve al bucle interno; si no, sale del interno y pregunta a la cola followUp. Todo el bucle no guarda estado: lo pasa el llamador (Agent), y los eventos se emiten vía AgentEventSink.
Responsabilidades
- Orquestación de eventos: cada turn emite
turn_start; el mensaje del asistente emitemessage_start/message_update/message_end; la ejecución de herramientas emitetool_execution_*; al final del turn se emiteturn_end; al terminar todo el bucle,agent_end. Verpackages/agent/src/agent-loop.ts:155-246. - Pull streaming de la respuesta del asistente:
streamAssistantResponsellama aconvertToLlmpara convertir aMessage[], invoca constreamFn(por defectostreamSimple), y consume eventos confor awaitreescribiendo el partial message al final decontext.messages. Verpackages/agent/src/agent-loop.ts:252-345. - Inyección steering: al inicio del bucle interno se comprueba
pendingMessagesy se empujan los mensajes de steering acurrentContext.messagesantes de la siguiente llamada al LLM. Verpackages/agent/src/agent-loop.ts:180-188. - Relé followUp: al salir del interno, el externo llama a
getFollowUpMessages; si hay mensajes, se los vuelve a pasar apendingMessagesy reentra en el interno; si no,break. Verpackages/agent/src/agent-loop.ts:233-243. - Parada temprana:
shouldStopAfterTurnse invoca después deturn_end; si devuelve true, emiteagent_endy sale sin pasar por las colas. Verpackages/agent/src/agent-loop.ts:218-228.
Motivación de diseño
¿Por qué dos while y no uno? Porque hay dos semánticas de "continuar". Las llamadas a herramientas son "continuación dentro del turno": el asistente emitió toolCalls, hay que ejecutarlas y devolver el resultado al LLM para que siga; eso es el bucle interno. followUp es "continuación después de que el turno debería haber terminado": el usuario encoló tareas esperando a que el agent terminara; eso es el bucle externo. Mezclarlos difuminaría los momentos de inyección de steering (interrupción del turno) y followUp (relé entre turnos).
¿Por qué steering en el interno y followUp en el externo? La semántica de steering es "interrumpir antes de la siguiente llamada al LLM", así que debe consumirse en la parte superior del interno, antes de streamAssistantResponse. followUp es "el agent iba a parar y le metemos otra tarea", así que se comprueba cuando el interno sale de forma natural (hasMoreToolCalls=false y steering vacío).
¿Por qué streamAssistantResponse modifica context.messages directamente? Porque el partial message tiene que verse en la UI durante el streaming, y la llamada al LLM tiene que partir del context "el último assistant ya está escrito". Empujar el partial al context es lo más simple; el evento done luego reemplaza el partial por el finalMessage. El coste es que el context está "a medias" durante el streaming, pero el bucle sólo lo lee, no lo escribe, así que no hay carreras.
Archivos clave
packages/agent/src/agent-loop.ts:25-26— TipoAgentEventSink, entrada síncrona para todos los eventos.packages/agent/src/agent-loop.ts:31-54— Entrada síncronaagentLoop, internamentevoid runAgentLoop(...).then(stream.end), devuelveEventStream.packages/agent/src/agent-loop.ts:64-93—agentLoopContinueno añade prompt, retoma desde el context existente, para reintentos.packages/agent/src/agent-loop.ts:95-118—runAgentLoopemiteagent_start/turn_start, emite los mensajes del prompt conmessage_start/message_endy luego llama arunLoop.packages/agent/src/agent-loop.ts:120-143—runAgentLoopContinueno emite eventos de prompt, entra directamente arunLoop.packages/agent/src/agent-loop.ts:155-246— Cuerpo derunLoop: bucle externo, bucle interno, cortocircuito de errores,shouldStopAfterTurn, relé followUp.packages/agent/src/agent-loop.ts:252-345—streamAssistantResponse:transformContext→convertToLlm→streamFunction→ buclefor await.packages/agent/src/agent-loop.ts:281-285— La llamada real al stream;apiKeyse vuelve a resolver congetApiKeyantes de invocar, para evitar que un OAuth token caduque en medio de una tarea larga.
Esqueleto: el externo vacía followUp, el interno procesa tool calls:
// 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;
// empujar steering al context ...
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);
if (message.stopReason === "error" || message.stopReason === "aborted") { /* emit turn_end/agent_end y salir */ }
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;
// empuzar toolResults al 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 reescribe el partial message al final de context.messages según llegan eventos:
// 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": { /* reemplaza por finalMessage, emite message_end, devuelve */ }
}
}Flujo de datos
Camino completo desde agent.prompt(text):
Límites y fallos
- Cortocircuito de error: cuando
stopReasones"error"o"aborted", emiteturn_end+agent_endy sale; no ejecuta herramientas ni pregunta steering/followUp. Verpackages/agent/src/agent-loop.ts:194-198. - Contrato de transformContext: si
transformContextlanza, interrumpe el bucle sin producir la secuencia normal de eventos; por eso el contrato de tipos exige que no lance y devuelva un fallback. Verpackages/agent/src/agent-loop.ts:260-263y contrato de tipos. - Skip primera encuesta steering:
runPromptMessagespuede pasarskipInitialSteeringPollarunAgentLoop, para evitar que el prompt recién emitido sea interrumpido por steering. Verpackages/agent/src/agent.ts:374-388. - Validación de rol en continue:
runAgentLoopContinue/agentLoopContinuecomprueban antes de llamar arunLoopque el último mensaje no sea assistant; si no, lanzan. Verpackages/agent/src/agent-loop.ts:127-133. - Partial no entra en newMessages: el partial message se escribe en
currentContext.messagespara la siguiente llamada al LLM, peronewMessages(lo devuelto al llamador para este turno) sólo empuja el finalMessage completo después demessage_end. Verpackages/agent/src/agent-loop.ts:191-192.
Resumen
runLoop usa el bucle while doble para separar "el bucle de herramientas dentro del turno" y "el relé followUp entre turnos"; steering se inyecta arriba en el interno, followUp se relanza al final del externo, y errores y abort cortocircuitan la salida. El bucle no tiene estado; la información entre turnos viene del context pasado por Agent y de los callbacks getSteeringMessages/getFollowUpMessages. Cómo se ejecutan las herramientas en ejecución de herramientas sequential/parallel; la envoltura de entrada y la caja de estado en clase Agent y ciclo de vida.