Skip to content

Bucle while doble principal

源码版本v0.73.1

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

  1. Orquestación de eventos: cada turn emite turn_start; el mensaje del asistente emite message_start/message_update/message_end; la ejecución de herramientas emite tool_execution_*; al final del turn se emite turn_end; al terminar todo el bucle, agent_end. Ver packages/agent/src/agent-loop.ts:155-246.
  2. Pull streaming de la respuesta del asistente: streamAssistantResponse llama a convertToLlm para convertir a Message[], invoca con streamFn (por defecto streamSimple), y consume eventos con for await reescribiendo el partial message al final de context.messages. Ver packages/agent/src/agent-loop.ts:252-345.
  3. Inyección steering: al inicio del bucle interno se comprueba pendingMessages y se empujan los mensajes de steering a currentContext.messages antes de la siguiente llamada al LLM. Ver packages/agent/src/agent-loop.ts:180-188.
  4. Relé followUp: al salir del interno, el externo llama a getFollowUpMessages; si hay mensajes, se los vuelve a pasar a pendingMessages y reentra en el interno; si no, break. Ver packages/agent/src/agent-loop.ts:233-243.
  5. Parada temprana: shouldStopAfterTurn se invoca después de turn_end; si devuelve true, emite agent_end y sale sin pasar por las colas. Ver packages/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

Esqueleto: el externo vacía followUp, el interno procesa tool calls:

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;
    // 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:

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": { /* reemplaza por finalMessage, emite message_end, devuelve */ }
  }
}

Flujo de datos

Camino completo desde agent.prompt(text):

Límites y fallos

  • Cortocircuito de error: cuando stopReason es "error" o "aborted", emite turn_end+agent_end y sale; no ejecuta herramientas ni pregunta steering/followUp. Ver packages/agent/src/agent-loop.ts:194-198.
  • Contrato de transformContext: si transformContext lanza, interrumpe el bucle sin producir la secuencia normal de eventos; por eso el contrato de tipos exige que no lance y devuelva un fallback. Ver packages/agent/src/agent-loop.ts:260-263 y contrato de tipos.
  • Skip primera encuesta steering: runPromptMessages puede pasar skipInitialSteeringPoll a runAgentLoop, para evitar que el prompt recién emitido sea interrumpido por steering. Ver packages/agent/src/agent.ts:374-388.
  • Validación de rol en continue: runAgentLoopContinue/agentLoopContinue comprueban antes de llamar a runLoop que el último mensaje no sea assistant; si no, lanzan. Ver packages/agent/src/agent-loop.ts:127-133.
  • Partial no entra en newMessages: el partial message se escribe en currentContext.messages para la siguiente llamada al LLM, pero newMessages (lo devuelto al llamador para este turno) sólo empuja el finalMessage completo después de message_end. Ver packages/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.