El JSON no llega entero
En una respuesta sin streaming podríamos recibir directamente {"key":"Objective"}. Con streaming, el proveedor empieza a contestar mientras el modelo todavía está generando. El mismo JSON puede llegar dividido en varios eventos:
{"key":"Objective"}1 · {"key":"Obj2 · ective"}El tamaño de cada lectura de red no sirve como frontera. Un chunk puede contener medio evento SSE, varios eventos completos o un JSON cortado en mitad de una palabra. La frontera útil es la que define el protocolo: una línea en blanco termina un mensaje SSE y un evento terminal termina el turno del proveedor.
acumula texto durante el stream; interpreta los argumentos solo cuando el proveedor confirma que la llamada y el turno han terminado. Mostrar un borrador es válido. Ejecutarlo no.
Una secuencia real de deltas
Esta es una versión pequeña de lo que puede recibir el parser de OpenAI. El modelo quiere llamar a lookup_demo_value, pero los argumentos se completan en dos deltas:
- Se anuncia la llamada
El evento inicial trae el identificador y el nombre, pero todavía no hay un JSON completo que interpretar.
response.output_item.added item.id = "fc_objective" item.name = "lookup_demo_value" - Llega el primer delta
El acumulador guarda el texto tal cual, aunque todavía no se pueda parsear.
response.function_call_arguments.delta arguments = "{\"key\":\"Obj" - Llega el segundo delta
La cadena ya representa el JSON completo, pero la capa de tools sigue esperando la confirmación del turno.
response.function_call_arguments.delta arguments = "ective\"}" - El proveedor termina su turno
response.completedsí es un evento del stream SSE que llega del proveedor. Le dice al parser que OpenAI terminó de enviar este turno. La llamada aexecuteToolocurre después y la genera localmente el harness; no la envía el proveedor.// Provider SSE event response.completed// Local harness action executeTool("lookup_demo_value", { key: "Objective" })
El ejemplo usa el Responses API de OpenAI, pero la idea no depende de ese protocolo. En Anthropic Messages, los fragmentos llegan dentro del bloque tool_use y message_stop cierra el mensaje. En Google Interactions, interaction.created abre la interacción, step.start anuncia el function_call, los step.delta llevan arguments_delta, step.stop cierra el paso y interaction.completed cierra la interacción. Cuando Google termina con estado requires_action, el harness también tiene que ejecutar las tools; el nombre de los eventos cambia, pero la frontera de seguridad es la misma.
Qué significa acumular una llamada
Una tool call en streaming no aparece como un objeto completo. Durante unos instantes es un registro incompleto: sabemos qué tool ha pedido el modelo, pero todavía estamos reuniendo sus argumentos. Cada delta añade una pequeña parte de ese registro.
Por eso el parser mantiene un estado pendiente para cada llamada. “Acumular” solo significa conservar ese registro y añadir cada fragmento al texto de argumentos hasta que llega la señal final. El acumulador no ejecuta la tool ni decide que el JSON ya es válido: reconstruye lo que el proveedor ha enviado para que otra capa pueda decidir después.
¿A qué llamada pertenece?
El parser necesita una clave de correlación. OpenAI usa item_id y output_index; Anthropic usa el índice del bloque tool_use; Google usa el índice del step para correlacionar los arguments_delta. El id del function_call se conserva para el resultado final y se expone como call_id.
¿Qué fragmento acaba de llegar?
El delta aporta texto de argumentos. Se añade al registro correcto sin intentar interpretar cada fragmento por separado.
¿Ya se puede cerrar?
El registro permanece abierto hasta el evento terminal. Que el texto parezca un JSON completo no basta.
for (const delta of event.arguments) {
const call = pendingCalls.get(delta.callId);
call.argumentText += delta.text;
}
// No se ejecuta aquí: todavía falta el evento terminal.
const completeArgs = JSON.parse(pendingCalls.get(callId).argumentText);Este pseudocódigo no describe un SDK concreto. Muestra la idea: el mapa separa las llamadas, argumentText guarda el JSON todavía incompleto y JSON.parse queda después de la frontera terminal. El loop de tools no debe recibir ese texto antes de comprobar que el turno terminó.
Cuándo puede actuar el harness
Hasta aquí solo hemos hablado de recibir y reconstruir datos. Ejecutar una tool es otra fase y ocurre en el harness, no dentro del stream. El recorrido completo es: el proveedor envía eventos, el parser los convierte en un resultado —o en un error— y el harness decide qué hacer con ese resultado. En OpenAI y Anthropic, truncated: false significa que el turno terminó con normalidad y truncated: true significa que terminó demasiado pronto. En Google, un corte antes de interaction.completed se comunica como un error truncated, por lo que el harness no recibe un turno ejecutable.
La tabla se lee de izquierda a derecha: primero el evento que llegó del proveedor, después lo que el parser entrega al harness y, por último, la acción que el harness puede tomar. El proveedor nunca envía executeTool.
| Evento del proveedor | Qué recibe el harness | Qué puede hacer después |
|---|---|---|
OpenAI envió response.completed. | truncated: falseTurno completo. | Parsear los argumentos acumulados y ejecutar las llamadas completas. |
Anthropic envió message_stop. | truncated: falseTurno completo. | Parsear los bloques tool_use cerrados y ejecutar las llamadas completas. |
Google envió interaction.completed después de cerrar sus step. | Interacción completa con status: "requires_action". | Parsear y validar los function_call completos antes de ejecutar las tools. |
| La conexión se cerró antes del evento terminal. | OpenAI/Anthropic: truncated: true. Google: error truncated. | Rechazar el turno. No llamar a executeTool con argumentos parciales. |
Un fragmento que se puede parsear solo demuestra que el texto actual tiene forma de JSON. No demuestra que el proveedor haya terminado. El harness puede actuar únicamente después de que el parser confirme que el turno está completo.
Qué enseñar mientras llega
Esperar no obliga a dejar la interfaz congelada. La UI puede enseñar que el agente está preparando una acción sin presentar el borrador como un hecho ni prometer que la tool se ejecutará:
Mientras llega
«Preparando una consulta…» o una tarjeta de progreso. El texto parcial de los argumentos se queda en diagnóstico, no en la conversación normal.
Cuando termina
Si el turno es completo, se ejecuta la llamada y se muestra el resultado que devuelva la tool.
Si se corta
Se informa de que la respuesta se interrumpió y se permite reintentar. No se ejecuta una acción con datos incompletos.
Qué pasa si el stream se corta
Un corte a mitad de {"key":"Obj no es una llamada con argumentos vacíos ni una llamada con JSON “casi válido”. Es un turno incompleto. En OpenAI y Anthropic, el parser conserva la información suficiente para diagnosticarlo y devuelve truncated: true; en Google comunica un error truncated. En los tres casos, la capa de tools detiene el flujo antes de ejecutar.
«La respuesta del proveedor se cortó antes de completarse. Vuelve a intentarlo.» El harness puede adaptar el nombre del proveedor, pero nunca convertir este error en una llamada ejecutable.
Estas son dos comprobaciones distintas que hace el harness después de recibir el resultado del parser. Primero pregunta ¿recibimos todo el turno?: comprueba truncated: false en OpenAI y Anthropic, o comprueba que Google no haya devuelto el error truncated. Solo entonces pregunta ¿los argumentos tienen la forma permitida?: los parsea cuando hace falta y los valida contra el schema de la tool. Reemplazar un JSON inválido por {} solo evita que una capa antigua falle al intentar leerlo; no demuestra que el stream terminara ni que los argumentos sean aceptables. La secuencia segura es: resultado completo del parser, validación del harness y, únicamente si todo es correcto, executeTool.
Tests
La prueba base para este problema no llama a un proveedor real. Reproduce un SSE grabado y comprueba la propiedad que queremos proteger: con los mismos eventos, el parser y el harness deben esperar al evento terminal antes de ejecutar, aunque la red entregue los datos en fronteras arbitrarias. Una llamada real añade variabilidad de red y del modelo, así que no demuestra por sí sola este comportamiento local; por eso esta prueba se complementa con tests de contrato o E2E del proveedor.
Acumulación
Varios deltas terminan en {"key":"Objective"}.
Intercalado
Dos llamadas reciben cada fragmento por su item_id/output_index, índice de bloque tool_use o índice de step de Google.
Truncamiento
Un stream sin evento terminal marca el turno como truncado y no llama a la tool.
Fronteras de red
El mismo stream se corta en posiciones arbitrarias sin cambiar el resultado final.
Aprender a construir un agente completo
Este artículo pertenece a una serie sobre cómo desarrollar un agente o harness de principio a fin. El siguiente paso después de reconstruir los argumentos es validar que cumplen el esquema declarado antes de ejecutar la tool.
Ver el índice completo de la serie sobre el agente de Gymnasia.