Skip to content

attemptApiRequest: llamada streaming al LLM y reintentos

源码版本v4.0.10

Responsabilidades

attemptApiRequest es el método generador (generator) dentro de Task encargado de «comunicarse con el provider del LLM». Es una función async * que va haciendo yield de cada chunk del stream para que lo consuma el recursivelyMakeClineRequests superior. Antes de hacer yield hace una cantidad considerable de preparación: esperar a que los servidores MCP conecten, leer los archivos de reglas, ensamblar el system prompt y hacer el truncado de contexto (context truncation); si el primer chunk falla, decide si reintentar automáticamente, si preguntar al usuario o si rendirse directamente.

Su posición es el nivel intermedio del agent loop. Cada recursión de recursivelyMakeClineRequests llama una vez a attemptApiRequest, y una vez obtenido el stream, es el while externo el que consume los chunks, los parsea y llama a presentAssistantMessage. Por eso este método no consume el stream: solo se encarga de «prepararlo y entregarlo de forma segura».

Su tipo de salida es ApiStream, en esencia un iterador asíncrono. El nivel superior usa primero iterator.next() para tantear el primer chunk (first chunk probe:2389). Si el primer chunk sale bien, hace yield* iterator para trasferir todo lo restante; si falla, entra en la lógica de clasificación de errores y de reintento. Este diseño de «tantear con un chunk» separa «fallo antes del stream» de «fallo en mitad del stream»: el primero deja el estado limpio y se puede reintentar sin efectos secundarios; el segundo puede haber ejecutado ya algunas herramientas y no se puede reintentar de forma simple.

Motivación de diseño

  • La espera de MCP tiene límite: se usa pWaitFor para esperar a que mcpHub.isConnecting pase a false; si pasa de 10 segundos, se registra un error y se sigue adelante (mcp wait:2177). Así MCP no bloquea toda la solicitud.
  • SystemPromptContext todo en un sitio: cwd, IDE, info del provider, archivos de reglas, clineignore, skills, pestañas del editor, parallel tool calling, etc. se meten en un único objeto de contexto (promptContext:2300). getSystemPrompt lo consume de una vez, evitando que la lógica de ensamblado del prompt quede esparcida.
  • Truncado de contexto antes de la llamada: contextManager.getNewContextMessagesAndMetadata se encarga de recortar el historial de conversación a la ventana de contexto del modelo (context truncate:2354). Si durante el recorte se detecta que hay que actualizar el rango de borrado, se escribe enseguida a clineMessages para persistir.
  • Tanteo con el primer chunk: con el flag isWaitingForFirstChunk y un try/catch aparte para iterator.next(), si el primer chunk falla el estado sigue limpio y se puede reintentar de forma segura (first chunk try:2388).
  • La clasificación de errores decide el reintento: auth, spend limit, quota, entitlement, límite de ClinePass, insufficient credits y otros errores en los que «reintentar no sirve de nada» se saltan el reintento automático (shouldRetry gate:2495); el resto se reintenta como máximo 3 veces con backoff exponencial de 2s/4s/8s (backoff:2509).

Archivos clave

Flujo de datos

La ruta principal desde la preparación hasta el yield es «espera MCP → ensambla prompt → trunca historial → crea stream → tantea → transfiere». El paso de truncar el historial es clave: decide qué ve realmente el modelo:

typescript
// apps/vscode/src/core/task/index.ts
const contextManagementMetadata =
    await this.contextManager.getNewContextMessagesAndMetadata(
        this.messageStateHandler.getApiConversationHistory(),
        this.messageStateHandler.getClineMessages(),
        this.api,
        this.taskState.conversationHistoryDeletedRange,
        previousApiReqIndex,
        await ensureTaskDirectoryExists(this.taskId),
        this.stateManager.getGlobalSettingsKey("useAutoCondense") &&
            isNextGenModelFamily(this.api.getModel().id),
    );

if (contextManagementMetadata.updatedConversationHistoryDeletedRange) {
    this.taskState.conversationHistoryDeletedRange =
        contextManagementMetadata.conversationHistoryDeletedRange;
    await this.messageStateHandler.saveClineMessagesAndUpdateHistory();
    // saves task history item which we use to keep track of conversation history deleted range
}

ContextManager toma el historial completo de la conversación + el último rango de borrado registrado + el uso de tokens de la última solicitud y calcula el nuevo rango de truncado. Si el rango cambia, escribe clineMessages a disco en el acto, porque la siguiente solicitud va a depender de ese nuevo rango. useAutoCondense es un switch de compresión automática exclusivo de los modelos nuevos (next-gen): si está activo, usa al propio modelo para generar un resumen que sustituye a los primeros mensajes (autocondense flag:2362).

Una vez truncado, se crea el stream y se hace el tanteo:

typescript
// apps/vscode/src/core/task/index.ts
const stream = this.api.createMessage(
    systemPrompt,
    truncatedConversationHistory,
    tools,
);

const iterator = stream[Symbol.asyncIterator]();

try {
    // awaiting first chunk to see if it will throw an error
    this.taskState.isWaitingForFirstChunk = true;
    const firstChunk = await iterator.next();
    yield firstChunk.value;
    this.taskState.isWaitingForFirstChunk = false;
} catch (error) {
    // ... clasificación de errores, reintento automático, o pregunta al usuario
    yield* this.attemptApiRequest(previousApiReqIndex);
    return;
}

El flag isWaitingForFirstChunk le dice al nivel superior «estoy esperando el primer chunk, no me trates como en mitad del stream». El catch del primer chunk se ramifica según el tipo de error: si se excedió la ventana de contexto y no se ha reintentado todavía, se llama a handleContextWindowExceededError para truncar y reintentar; en el resto, shouldRetry decide entre reintento con backoff automático o ask("api_req_failed"), dejando la decisión al usuario.

Límites y fallos

  • El timeout de MCP no es letal: si MCP no conecta en 10 segundos solo se registra un error y se sigue adelante (mcp timeout catch:2179). Las herramientas MCP pueden faltar después en el system prompt, pero la solicitud no falla por eso.
  • El contexto excedido se reintenta solo una vez: el flag didAutomaticallyRetryFailedApiRequest asegura que handleContextWindowExceededError se llame una sola vez (auto retry flag:2407). Si la segunda vez sigue excedido, se va a ask("api_req_failed") y la decisión queda en el usuario.
  • Conversación corta y aún excedida: si tras truncar el número de mensajes es ≤ 3, se le dice al usuario «context window exceeded, retry to truncate» pero ya no se trunca automáticamente (conversation bricked:2423). La conversación está prácticamente muerta y hace falta intervención del usuario.
  • Actualización de estado api_req_started: cada reintento localiza el último mensaje api_req_started y actualiza su streamingFailedMessage y retryStatus (update api_req_started:2433). La UI se apoya en ese campo para mostrar «reintentando» o «reintentos agotados».
  • Doble visualización de error_retry deduplicada: al reintentar automáticamente se hace primero say("error_retry", ...) con la info completa y luego se borra streamingFailedMessage del api_req_started, para que el mismo error no se muestre a la vez en ErrorRow y en error_retry (dedupe error display:2548).
  • Reset manual del contador: cuando el usuario dice sí a reintentar, se pone autoRetryAttempts a cero (reset counter:2586), dejando hueco para otros 3 reintentos automáticos en los fallos posteriores.
  • Fallos tras el primer chunk no se tratan aquí: los fallos del stream posteriores al primer chunk los asume el try/catch externo de recursivelyMakeClineRequests (stream-mid failure:3935). Aquí solo se cubre el «fallo antes de que el stream empiece».

Resumen

attemptApiRequest reúne tres cosas: preparar, tantear y reintentar. La preparación pasa por reglas, skills, MCP y el truncado de contexto; el tanteo usa el primer chunk para decidir si reintenta; el reintento apoya en clasificación de errores + backoff exponencial + decisión del usuario, tres capas de respaldo. Una vez el stream arranca, se transfiere con yield* y ya no es cosa suya. Para ver cómo se consume el stream al salir y cómo se corta en bloques que avanzan, salta a /agent-loop/present-assistant-message; para ver el driver recursivo superior, salta a /agent-loop/task-class.

Véase la documentación oficial: Documentación de Cline · README