Streaming (SSE)#
O endpoint .../messages/stream responde com Content-Type: text/event-stream
(Cache-Control: no-cache, X-Accel-Buffering: no), status 200 imediato. O
formato de cada evento é:
event: <nome>
data: <json>
O agente responde token a token; os chips de ferramenta acendem ao vivo; o resumo de raciocínio aparece antes da resposta. Há duas famílias de evento.
POSTConsumir um stream#
curl -N -X POST \
https://agents-api.catcher.one/v1/agent-runtime/AGENT_ID/sessions/sess_a1b2c3/messages/stream \
-H "X-Agent-Token: prt_SEU_RUNTIME_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "content": "Resuma os 3 últimos pedidos." }'
Sequência real de eventos num turno com ferramentas:
event: start
data: {"run_id":"9f2c…","user_message":{"id":"…","role":"user","content":"Resuma os 3 últimos pedidos.","…":"…"}}
event: tool_call_start
data: {"type":"tool_call_start","tool_call":{"index":0,"id":"tc_1","name":"brain_search","arguments":"{\"q\":\"últimos pedidos\"}"}}
event: tool_call_result
data: {"type":"tool_call_result","tool_result":{"id":"tc_1","name":"brain_search","is_error":false,"output":"…","duration_ms":142}}
event: text_delta
data: {"type":"text_delta","text":"Os três últimos"}
event: text_delta
data: {"type":"text_delta","text":" pedidos foram…"}
event: round_usage
data: {"type":"round_usage","round_usage":{"round":2,"model":"codex/gpt-5.6-sol","usage":{"input_tokens":812,"output_tokens":143,"total_tokens":955,"cached_tokens":700,"reasoning_tokens":90},"latency_ms":4210,"first_token_ms":2900,"tool_calls":1,"prompt":{"system_chars":9800,"tools_chars":4100,"history_chars":2600,"message_count":3}}}
event: done
data: {"type":"done","rounds":2,"stop_reason":"end_turn","cost":{"total_cost_usd":0.0144},"usage":{"input_tokens":812,"output_tokens":143,"total_tokens":955}}
event: complete
data: {"run_id":"9f2c…","assistant_message":{"id":"…","role":"assistant","content":"Os três últimos pedidos foram…","run_id":"9f2c…","…":"…"},"usage":{"input_tokens":812,"output_tokens":143,"total_tokens":955},"tool_calls":[…]}
GETCatálogo de eventos#
| Evento | Origem | data |
|---|---|---|
start |
API (sempre 1º) | { run_id, user_message } |
thinking |
engine / single-shot | { type: "thinking", text } — resumo de raciocínio |
text_delta |
engine / single-shot | { type: "text_delta", text } — fragmento da resposta |
warning |
API (início do run) | { type, kind: "tools_unavailable", tools: [...], details: [...], tools_mounted: [...] } — só quando falta ferramenta; ver "O catálogo de ferramentas do run é reportado" |
tool_call_start |
engine | { type, tool_call: { index, id, name } } — o ANÚNCIO; ainda sem arguments |
tool_call_delta |
engine | { type, tool_call: { index, arguments } } — fragmento dos argumentos |
tool_call_end |
engine | { type, tool_call: { index, id, name, arguments } } — os argumentos COMPLETOS (desde 2026-08-19) |
tool_call_result |
engine | { type, tool_result: { id, name, is_error, output, duration_ms } } |
round_usage |
engine | { type, round_usage: { round, provider, model, usage: { input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens }, latency_ms, first_token_ms, tool_calls, prompt: { system_chars, tools_chars, history_chars, message_count }, …custo } } — uma por rodada do loop. Onde foi o relógio (desde 2026-09-02): latency_ms é a chamada ao provider (request → último evento); first_token_ms a espera até o primeiro evento (prefill + raciocínio oculto; só no streaming); usage.reasoning_tokens o raciocínio oculto como o provider reporta (OpenAI: subconjunto de output_tokens; Gemini: thoughtsTokenCount, aditivo; Anthropic não separa); usage.cached_tokens a fatia de input_tokens servida do cache (subconjunto); prompt.* o que a rodada enviou em caracteres por origem (o provider não reporta tokens por seção). latency_ms − first_token_ms ≈ tempo escrevendo; first_token_ms alto com cached_tokens ≪ input_tokens = prefill sem cache. done.rounds_usage[] carrega os mesmos campos |
compaction |
engine | { type, compaction: { round, layer, trigger, tokens_before, tokens_after, summarized } } — o engine compactou a conversa para caber na janela do modelo; ver "Compaction automática de contexto" |
leg |
engine | { type: "leg", leg: { leg, rounds } } — não-terminal: o run continuou numa nova requisição api→runtime (a cada ~40 min); ignore ou mostre "continuando…". round/round_usage.round seguem absolutos e round.max continua sendo o teto do run (não o restante da leg); done chega uma única vez, no fim |
done |
engine (verbatim) | { type, rounds, stop_reason, cost: {…}, usage: {…}, rounds_usage: […], context_tokens, compactions } — no stream compat (/v1/compat/streams/{id}) o done é o enriquecido da API e traz também model (o configurado), model_used ("<provider>/<modelo>", quem realmente respondeu) e, quando o reserva atendeu, fell_back: true + model_failures[] dizendo qual cérebro recusou e de quê; ver "Cérebro reserva" |
| — | — | stop_reason terminal: completed (resposta final), max_rounds (bateu o teto de rounds), timeout (esgotou o wall clock do turno), tool_failure_limit (as chamadas de ferramenta pararam de funcionar — ver abaixo), error (falha real), user_aborted (parado via POST …/stop). Os três primeiros significam que o trabalho feito até ali é real — reagir com retry/aprofundar, não escalar. |
complete |
API (sempre último no sucesso) | { run_id, assistant_message, usage, tool_calls, tools_mounted, tools_unavailable? } |
error |
API / engine | { error } (ou { type: "error", error }); quando a falha é de provedor, também error_code + provider, e quando a cadeia de fallback rodou inteira, model_failures[] — ver "Falha de provedor: código estruturado" |
Onde ler os argumentos de uma chamada de ferramenta. Todo provedor anuncia a chamada antes de os argumentos existirem — eles chegam como um fluxo de fragmentos. Por isso
tool_call_starttraz sóindex/id/name: carregá-los ali exigiria segurar o anúncio até a chamada estar completa, destruindo o sinal ao vivo ("chamando X…") que a UI usa. Desde 2026-08-19,tool_call_endtraz os argumentos completos (maisidenamepara correlacionar) — se você não quer concatenar ostool_call_deltaporindex, leia esse frame. Os argumentos saem exatamente como o modelo os emitiu: se os fragmentos não formam JSON válido, é isso que você recebe — trocá-los por{}reportaria uma chamada vazia bem-formada que o modelo nunca fez.
Corrigido em 2026-08-11 —
rounds,costestop_reasonno RESULTADO do dispatch. Os EVENTOS SSE ao vivo sempre trouxeram esses campos. O que chegava zerado era o resultado consolidado — o corpo que o dispatch devolve e o histórico de runs — nos DOIS caminhos (com e sem streaming):roundssempre1,cost0,stop_reasonvazio — um defeito de leitura nosso, não do seu código. Se você tratavarounds: 1como "o agente respondeu de primeira", passe a ler o valor real: um mesmo dispatch pode legitimamente fazer dezenas de rodadas. Pelo mesmo motivo, uma falha originada no engine agora aparece como falha em vez de virar um runcompletedcom a resposta parcial.
stop_reason: "tool_failure_limit" merece tratamento próprio. Ele diz que o
agente parou porque as chamadas de ferramenta pararam de funcionar — não porque
faltou profundidade. O engine para de despachar uma ferramenta depois de 5 falhas
consecutivas e encerra o run depois de 15 falhas seguidas sem nenhum sucesso no
meio, para não queimar cota repetindo uma chamada que não vai passar. Qualquer
sucesso de ferramenta zera as duas contagens. Ao receber esse terminal, investigue
o que está recusando as chamadas (auth, quota, fence/permite do seu lado, endpoint
fora do ar) — aumentar max_rounds_per_run só aumenta o custo. As ferramentas que
falharam vêm em tool_calls[] com is_error: true e o corpo da resposta do seu
endpoint, que é onde está o motivo real.
Guarda de repetição (tool_loop_guard) — o que ela mede desde 2026-08-19. O
engine recusa uma chamada repetida, mas o critério mudou: antes bastavam 6
chamadas com argumentos idênticos, e isso era errado para ferramentas com efeito
externo. Um compile_project não recebe argumentos e é chamado depois de CADA
correção — o projeto mudou, os argumentos não podiam mudar. O guard lia isso como
loop e o agente parava com o trabalho pela metade.
Agora só é loop quando os argumentos E o resultado são idênticos: um compile cujos erros mudaram é progresso; um que devolve os mesmos bytes seis vezes seguidas é um loop de verdade. Você não precisa declarar nada na ferramenta.
Quando o guard dispara, duas coisas acontecem:
event: warning
data: {"type":"warning","warning":{"kind":"tool_loop_guard","tool":"…","count":7,
"message":"the platform refused a repeated call because neither the arguments nor the result changed — this is not a limit on the number of tool calls"}}
e o texto entregue ao modelo diz explicitamente que é uma guarda da plataforma, não uma cota — a redação anterior ("Stop calling this tool") era lida pelo modelo como "acabaram minhas chamadas" e ele encerrava a tarefa.
O nome do evento de texto é text_delta (não "token"), e o de raciocínio é
thinking. No fim de um turno com sucesso o cliente vê dois eventos
terminais — o done do engine (repassado verbatim) e o complete da API — e pode
usar qualquer um dos dois como sinal de fim. Um turno não-agêntico (single-shot,
sem ferramentas) emite start, então thinking (se houver), text_delta com a
resposta inteira, e complete.
Sem janela de conexão; silêncio não é morte; erro com causa#
Nenhum stream tem timeout de requisição (desde 2026-08-24). Nem o stream de
chat (…/messages/stream, Console e runtime), nem o stream de run
(/v1/compat/streams/{id}), nem os de Document AI, imagens e memória. A conexão
vive enquanto a run viver — 8, 20, 40 minutos com texto chegando terminam em
complete/done, nunca em error. Um run não é encerrado por relógio em
nenhuma camada nossa: o engine não impõe wall clock, e os clientes de streaming
dos provedores não carregam timeout de leitura. O único teto de tempo que
resta é o da infraestrutura: a requisição HTTP vive no máximo 3600 s
(Cloud Run — o máximo da plataforma). Um run que chega lá recebe
error_code: "RUN_TIMEOUT"; é o valor publicado em
GET …/limits como infrastructure_request_timeout_seconds.
Heartbeat. Um pensamento longo ou uma ferramenta lenta deixam o stream em
silêncio por minutos; para que nenhum proxy leia silêncio como conexão morta,
todo stream emite o comentário SSE : ping a cada 15 s. Comentários são
ignorados por qualquer cliente SSE conforme a spec — não chegam ao seu handler
de eventos.
reconnect continua no contrato ({"type":"reconnect","reason": "connection_window_elapsed","last_event_id":"…"}), mas só aparece se a conexão
ao stream de run terminar sem um terminal da run por outro motivo (rotação
de instância, o corte de 3600 s da infraestrutura). Reconecte com
Last-Event-ID como antes; a run autônoma não é afetada pela sua conexão.
Erro com causa. O event: error dos streams de chat diz por quê:
event: error
data: {"error":"agent run failed","error_code":"RUN_TIMEOUT","code":"RUN_TIMEOUT",
"detail":"turn safety timeout exceeded","provider":"kimi"}
error mantém o texto que os clientes já casam; error_code e code são o
mesmo código estável (PROVIDER_*, MAX_TOOL_ROUNDS, CONTEXT_WINDOW_EXCEEDED,
RUN_TIMEOUT, RUN_CANCELLED, AGENT_RUN_FAILED); detail é a razão literal do
runtime; provider e model_failures só quando existem. Aditivo — nada do
envelope anterior mudou.