Ir al contenido principal
Laravel, thinking fast.
Capitulo 8 · El bucle del agente

El truncamiento no puede mostrarse como una respuesta

Julian Beaujardin

if ($stopReason === 'max_tokens') {
    report(new \RuntimeException('Response truncated on max_tokens'));

    $state['messages'][] = [
        'role' => 'bot',
        'content' => __('messages.errors.ai_truncated'),
        'is_error' => true,
    ];
    $state['hasError'] = true;
    break;
}

El capítulo 4 cubrió la causa. Lo que importa aquí es la forma del arreglo: el truncamiento tiene su propia rama, antes de la salida general. Si se deja caer, una respuesta cortada se muestra igual que una completa: segura de sí misma, bien formada y sin su conclusión.

El report() importa tanto como el mensaje. Un truncamiento es una señal de que un tope está mal o de que un prompt pide demasiado, y si sólo se le muestra a un usuario, no se arregla nunca.

Negarse no es un error

// Safety classifiers declined. Not an error — say so and stop.
if ($stopReason === 'refusal') {
    $state['messages'][] = [
        'role' => 'bot',
        'content' => __('messages.errors.ai_refused'),
        'is_error' => true,
    ];
    break;
}

No ha fallado nada. Tu código está bien, el proveedor está bien, la petición estaba bien formada. El modelo declinó, y lo correcto es decirlo con claridad y parar.

No lo reintentes. No lo reformules y lo reenvíes. Un bucle de reintentos alrededor de una negativa es una máquina intentando colarse por un sistema de seguridad, lo cual es a la vez inútil y exactamente el patrón que busca la detección de abuso.

El historial es el estado

No hay sesión del lado del proveedor. Todo el estado de la conversación es el array que guardas y reenvías:

$history[] = [
    'role' => 'assistant',
    'content' => $content,
];

$content es el array de contenido en crudo, añadido literalmente: no el texto que extrajiste, no un resumen. Contiene bloques tipados: texto, llamadas a herramientas, resultados de herramientas y posiblemente razonamiento. Si lo aplanas a una cadena habrás tirado las llamadas a herramientas, con lo que la siguiente petición no tendrá sentido.

La regla: añade lo que te dieron, exactamente como te lo dieron. Extrae para mostrar; almacena el original.

Y como el historial se reenvía entero en cada iteración, crece de forma monótona durante el turno —la consecuencia en tokens de entrada del capítulo 4.

Lee los bloques por tipo

Una respuesta es una lista de bloques tipados, y el bucle los quiere separados:

$textBlocks = array_filter($content, fn ($b) => $b['type'] === 'text');
$toolUseBlocks = array_filter($content, fn ($b) => $b['type'] === 'mcp_tool_use');
$toolResultBlocks = array_filter($content, fn ($b) => $b['type'] === 'mcp_tool_result');

Tres tipos, tres propósitos. El texto se le muestra al usuario. Los usos de herramientas te dicen a qué recurrió el modelo —que es lo que alimenta el indicador de espera. Los resultados llevan las cargas estructuradas que la interfaz renderiza, que es el capítulo 9.

De filtrar por tipo en lugar de indexar por posición se derivan dos cosas.

Una respuesta puede tener varios bloques de texto, así que se unen en vez de escoger uno:

$fullText = implode("\n", array_map(fn ($b) => $b['text'], $textBlocks));

if (! empty(trim($fullText))) {
    // … append as a message …
}

Una respuesta puede no tener texto alguno. Un turno que sólo llama a herramientas es normal, y añadir un mensaje vacío por él llena la transcripción de burbujas en blanco. De ahí la comprobación de vacío: una guarda pequeña que evita una interfaz descuidada.