Hay tres relojes y deben estar anidados, o el más externo te mata antes de que los internos puedan hacer su trabajo.
El tiempo de espera HTTP de cada llamada individual al proveedor. El tiempo de espera del job, tras el cual el worker lo mata sin más. Un plazo de reloj que impones tú, fijado dentro del tiempo de espera del job:
// Stop starting new round-trips once we're close to the job timeout, so
// handle() always reaches the Cache::put below instead of being killed
// mid-turn and leaving the widget polling a key that never appears.
$deadline = now()->addSeconds($this->timeout - 20);
El margen de veinte segundos es la idea entera. Cuando el worker mata un job al llegar a su tiempo de espera, lo mata donde esté: a mitad del bucle, antes de haber escrito nada. El frontend se queda consultando una clave de caché que no aparecerá jamás, y la conversación simplemente deja de hablar. Sin error, sin mensaje, nada.
Fijando tu propio plazo por debajo del real, garantizas que el job siempre llega a su escritura final. El margen no es para el modelo: es para ti, para terminar de recoger.
Y el tiempo de espera por llamada se deriva de lo que queda, nunca se fija como constante:
$remaining = (int) max(0, now()->diffInSeconds($deadline, false));
if ($remaining < 5) {
report(new \RuntimeException('Skipped call — job budget exhausted'));
return null;
}
$response = Http::connectTimeout(10)
->timeout(min(60, $remaining))
// ...
min(60, $remaining) —una llamada normal recibe sesenta segundos, y una llamada hecha tarde en el turno recibe sólo lo que queda. Sin eso, tres intentos de sesenta segundos más las esperas superan holgadamente un job de ciento veinte y consiguen que lo maten antes de poder guardar nada.
Nunca empieces un trabajo que no puedes terminar. Si quedan menos de cinco segundos, no hagas la llamada. Pagar por una respuesta que llega después de que te hayan matado es el peor de los dos resultados.
Publica sobre la marcha
El bucle ingenuo acumula mensajes y los escribe al final. Funciona, y hace que un turno con herramientas parezca roto, porque la interfaz se queda muda un minuto mientras corren tres herramientas.
Publica después de cada iteración en su lugar:
// A tool-using turn can take several round-trips, and holding every message
// back until the loop ends made the widget sit silent for the whole turn.
$this->publish($state, $history, done: false);
private function publish(array $state, array $history, bool $done): void
{
$state['conversationHistory'] = $history;
$state['done'] = $done;
Cache::put($this->cacheKey, $state, now()->addMinutes(10));
}
La bandera done es el contrato con el frontend: sigue consultando, o para. Sin ella el cliente tiene que deducir la finalización a partir del contenido, lo que significa adivinar, lo que significa o una consulta que no para nunca o una que para antes de tiempo.
Esto no es streaming. Es grueso —una escritura por ida y vuelta, no por token— y en una función movida por herramientas entrega casi todo el beneficio por una fracción de la complejidad. El usuario ve cada paso aterrizar cuando ocurre.