O JSON não chega inteiro
Sem streaming, poderíamos receber diretamente {"key":"Objective"}. Com streaming, o provedor começa a responder enquanto o modelo ainda está gerando. O mesmo JSON pode chegar em vários eventos:
{"key":"Objective"}1 · {"key":"Obj2 · ective"}O tamanho de uma leitura de rede não é uma fronteira útil. Um chunk pode conter metade de um evento SSE, vários eventos completos ou JSON cortado no meio de uma palavra. As fronteiras úteis são as do protocolo: uma linha em branco encerra uma mensagem SSE e um evento terminal encerra o turno do provedor.
acumule texto durante o stream; interprete os argumentos apenas quando o provedor confirmar que a chamada e o turno terminaram. Mostrar um rascunho é aceitável. Executá-lo não é.
Uma sequência real de deltas
Esta é uma versão pequena do que o parser da OpenAI pode receber. O modelo quer chamar lookup_demo_value, mas os argumentos são completados em dois deltas:
- A chamada é anunciada
O evento inicial traz a identidade e o nome, mas ainda não há um JSON completo para interpretar.
response.output_item.added item.id = "fc_objective" item.name = "lookup_demo_value" - Chega o primeiro delta
O acumulador guarda o texto como está, mesmo sem poder analisá-lo ainda.
response.function_call_arguments.delta arguments = "{\"key\":\"Obj" - Chega o segundo delta
A string já parece um JSON completo, mas a camada de tools ainda espera a confirmação do turno.
response.function_call_arguments.delta arguments = "ective\"}" - O provedor encerra o turno
response.completedé um evento do stream SSE que vem do provedor. Ele informa ao parser que a OpenAI terminou de enviar este turno. A chamada aexecuteToolacontece depois e é criada localmente pelo harness; não é enviada pelo provedor.// Provider SSE event response.completed// Local harness action executeTool("lookup_demo_value", { key: "Objective" })
O exemplo usa a Responses API da OpenAI, mas a ideia não depende desse protocolo. No Anthropic Messages, os fragmentos chegam dentro de um bloco tool_use e message_stop encerra a mensagem. No Google Interactions, interaction.created abre a interação, step.start anuncia o function_call, step.delta leva arguments_delta, step.stop encerra o passo e interaction.completed encerra a interação. Quando o Google termina com status: "requires_action", o harness também precisa executar as tools; os nomes dos eventos mudam, mas a fronteira de segurança é a mesma.
O que significa acumular uma chamada
Uma tool call em streaming não aparece como um objeto completo. Durante alguns instantes ela é um registro incompleto: sabemos qual tool o modelo pediu, mas ainda estamos reunindo seus argumentos. Cada delta acrescenta uma pequena parte desse registro.
Por isso o parser mantém um estado pendente para cada chamada. “Acumular” significa apenas conservar esse registro e acrescentar cada fragmento ao texto dos argumentos até chegar o sinal final. O acumulador não executa a tool nem decide que o JSON é válido: ele reconstrói o que o provedor enviou para que outra camada tome essa decisão depois.
A qual chamada pertence?
O parser precisa de uma chave de correlação. A OpenAI usa item_id e output_index; a Anthropic usa o índice do bloco tool_use; o Google usa o índice do step para correlacionar os fragmentos de arguments_delta. O ID do function_call é conservado para o resultado final e exposto como call_id.
Qual fragmento chegou?
O delta carrega texto dos argumentos. Ele é acrescentado ao registro correto sem tentar interpretar cada fragmento sozinho.
A chamada está completa?
O registro permanece aberto até o evento terminal. Ver um texto que parece JSON completo não é suficiente.
for (const delta of event.arguments) {
const call = pendingCalls.get(delta.callId);
call.argumentText += delta.text;
}
// Não execute aqui: ainda falta o evento terminal.
const completeArgs = JSON.parse(pendingCalls.get(callId).argumentText);Este pseudocódigo não depende de um SDK específico. Ele mostra a ideia: o mapa mantém as chamadas separadas, argumentText guarda o JSON ainda incompleto e JSON.parse fica depois da fronteira terminal. O loop de tools não deve receber esse texto antes de verificar que o turno terminou.
Quando o harness pode agir
Até aqui falamos apenas de receber e reconstruir dados. Executar uma tool é uma fase separada que acontece no harness, não dentro do stream. O fluxo completo é: o provedor envia eventos, o parser os transforma em um resultado — ou em um erro — e o harness decide o que fazer com esse resultado. Na OpenAI e na Anthropic, truncated: false significa que o turno terminou normalmente e truncated: true significa que terminou cedo demais. No Google, um corte antes de interaction.completed é informado como um erro truncated, então o harness nunca recebe um turno executável.
Leia a tabela da esquerda para a direita: primeiro o evento do provedor, depois o que o parser entrega ao harness e, por fim, o que o harness pode fazer. O provedor nunca envia executeTool.
| Evento do provedor | O que o harness recebe | O que pode fazer depois |
|---|---|---|
A OpenAI enviou response.completed. | truncated: falseTurno completo. | Analisar os argumentos acumulados e executar as chamadas completas. |
A Anthropic enviou message_stop. | truncated: falseTurno completo. | Analisar os blocos tool_use encerrados e executar as chamadas completas. |
O Google enviou interaction.completed depois de encerrar seus steps. | Interação completa com status: "requires_action". | Analisar e validar os function_call completos antes de executar as tools. |
| A conexão fechou antes do evento terminal. | OpenAI/Anthropic: truncated: true. Google: erro truncated. | Rejeitar o turno. Não chamar executeTool com argumentos parciais. |
Um fragmento que pode ser analisado só prova que o texto atual tem formato de JSON. Não prova que o provedor terminou. O harness só pode agir depois que o parser confirmar que o turno está completo.
O que mostrar enquanto chega
Esperar não significa deixar a interface congelada. A UI pode mostrar que o agente está preparando uma ação sem apresentar o rascunho como fato nem prometer que a tool será executada:
Enquanto chega
"Preparando uma consulta…" ou um cartão de progresso. Os argumentos parciais ficam no diagnóstico, não na conversa normal.
Quando termina
Se o turno estiver completo, execute a chamada e mostre o resultado devolvido pela tool.
Se parar
Explique que a resposta foi interrompida e permita tentar novamente. Nunca execute uma ação com dados incompletos.
E se o stream parar?
Um corte no meio de {"key":"Obj não é uma chamada com argumentos vazios nem uma chamada com JSON “quase válido”. É um turno incompleto. Na OpenAI e na Anthropic, o parser conserva informação suficiente para o diagnóstico e devolve truncated: true; no Google, informa um erro truncated. Nos três casos, a camada de tools para antes da execução.
«A resposta do provedor foi interrompida antes de terminar. Tente novamente.» O harness pode adaptar o nome do provedor, mas nunca transformar esse erro em uma chamada executável.
Estas são duas verificações diferentes feitas pelo harness depois que ele recebe o resultado do parser. Primeiro pergunta recebemos o turno inteiro?: verifica truncated: false na OpenAI e na Anthropic, ou verifica que o Google não devolveu um erro truncated. Só então pergunta os argumentos têm o formato permitido?: analisa-os quando necessário e valida-os contra o schema da tool. Substituir JSON inválido por {} apenas evita que uma camada antiga de compatibilidade falhe ao ler o valor; não prova que o stream terminou nem que os argumentos são aceitáveis. A sequência segura é: resultado completo do parser, validação do harness e só então executeTool.
Testes
O teste-base deste problema não chama um provedor real. Ele reproduz um SSE gravado e verifica a propriedade que queremos proteger: com os mesmos eventos, o parser e o harness devem esperar pelo evento terminal antes de executar, mesmo quando a rede entrega chunks arbitrários. Uma chamada real acrescenta variabilidade do modelo e da rede, então não prova sozinha esse comportamento local; este teste complementa os testes de contrato e E2E do provedor.
Acumulação
Vários deltas formam {"key":"Objective"}.
Intercaladas
Duas chamadas recebem cada fragmento pelo seu item_id/output_index, índice do bloco tool_use ou índice de step do Google.
Truncamento
Um stream sem evento terminal marca o turno como truncado e não chama a tool.
Fronteiras de rede
Divisões arbitrárias não alteram o resultado reconstruído.
Construindo um agente completo
Este artigo faz parte de uma série sobre como desenvolver um agente ou harness do início ao fim. Depois de reconstruir os argumentos, o próximo passo é verificar se eles cumprem o schema declarado antes de executar a tool.