Lição 3: Retomando de um checkpoint: reiniciando o loop
Objetivos de aprendizado:
- Dizer por que uma “chamada órfã” fatalmente aparece em um cenário de recuperação de queda, e como ela é coisa diferente de uma falha comum de execução de ferramenta
- Escrever o caminho completo de retomada, do
loadCheckpoint()de volta ao loop, incluindo checagem de versão e reconstrução do estado- Reconciliar uma chamada órfã pela natureza da ferramenta — ferramentas somente leitura reexecutam direto, ferramentas de alto impacto recorrem antes ao fallback — em vez de reexecutar às cegas ou apagar às cegas
Pré-requisitos: Conclua a Lição 2 e entenda os campos do
checkpoint.jsone os dois pontos de gravação | Anterior: Lição 2 << | Próxima: Lição 4 >>
Retome, não recomece
Um agente cai na metade do caminho, e o primeiro instinto costuma ser rodá-lo de novo. Mas para uma tarefa longa que já avançou uma dúzia de turnos e chamou ferramentas várias vezes, recomeçar é uma péssima troca: "restarts are expensive and frustrating for users"1 (reinícios são caros e frustrantes para os usuários). A Lição 2 gravou a cena de execução em checkpoint.json — version, task, turns, tokensUsed, messages, pendingToolUse — salvando uma vez depois que o modelo responde (ponto de gravação A) e uma vez depois que o resultado da ferramenta é registrado (ponto de gravação B). O que esta lição faz é transformar essa cena salva de volta em um loop que consegue avançar: construir um sistema capaz de "resume from where the agent was when the errors occurred"1 (retomar de onde o agente estava quando os erros aconteceram) em vez de recomeçar do topo toda vez.
A espinha da retomada: uma metade é fácil
Comece pela metade fácil. A espinha da retomada são quatro etapas: ler o arquivo, passá-lo por JSON.parse, checar version e espalhar os campos de volta no estado de execução. Cumpridas essas quatro, o runAgent não precisa reconstruir um array messages inicial — o checkpoint já guarda um completo, então ele pula a inicialização e cai direto no loop.
Com essas duas funções no lugar, o topo do runAgent vira um desvio simples:
Depois de retomar, a primeira coisa que o loop faz é exatamente o que ele sempre faz: pegar state.messages e disparar o próximo client.messages.create(). As messages que o modelo vê são idênticas ao que ele via antes da queda — ele não faz ideia de que houve um reinício de processo no meio. É por isso que a Lição 2 insistiu que messages entrasse no checkpoint intocado: enquanto esse array for restaurado fielmente, a retomada é invisível para o modelo.
A metade difícil: reconciliar uma chamada órfã
O problema de verdade é o checkpoint em que state.pendingToolUse não é null. Lembre onde ficam os dois pontos de gravação: o ponto A vem depois da resposta do modelo, e nesse momento pendingToolUse guarda o {id, name, input} desta resposta; o ponto B vem depois que o resultado da ferramenta é registrado, e pendingToolUse é zerado de volta para null. Se o processo morre bem entre A e B — a ferramenta ainda não rodou, ou ela terminou mas o resultado nunca chegou a messages — o que o checkpoint guarda é um pendingToolUse que não é null.
Agora a cauda de messages é uma mensagem assistant carregando um bloco tool_use, sem nenhum tool_result correspondente. Este não é um estado no qual dê para ir empurrando: o protocolo exige que você "return one tool_result for each tool_use block, all together in the next user message"2 (retorne um tool_result para cada bloco tool_use, todos juntos na próxima mensagem do usuário). Sem aquele único resultado, a retomada não consegue nem fazer a chamada seguinte — o que o modelo vê é uma troca pela metade, na qual ele disparou uma chamada de ferramenta e nunca vai receber resposta. Essa chamada órfã tem que ser resolvida antes de reentrar no loop.
Três formas de lidar com isso, só uma se sustenta
Diante dessa mensagem assistant órfã, há três jogadas óbvias, mas só uma de fato se sustenta.
Jogada um: apagar a mensagem assistant de messages e fingir que ela nunca aconteceu. Parece a mais limpa — a conversa retomada não tem mais nenhuma lacuna. Mas o custo vem em duas camadas. Primeira: o modelo esquece uma decisão que já tinha tomado, então pode refazer a mesma exploração inteira e queimar um turno à toa. Segunda, e mais perigosa: se aquela chamada de ferramenta de fato já tinha rodado, e o processo simplesmente morreu antes de registrar o resultado, apagar a mensagem não desfaz o efeito colateral que já aconteceu — só faz o modelo, e toda entrada de log posterior, deixarem de saber que ele aconteceu. Apagar esconde o fato, não o risco.
Jogada dois: simplesmente reexecutar a ferramenta e preencher o resultado em um tool_result. Para uma ferramenta somente leitura (read_file, grep e afins) isso é exatamente o certo — ler duas vezes não é diferente de ler uma, o efeito colateral é zero. Para uma ferramenta de alto impacto (enviar e-mail, gravar em banco de dados) é perigoso: a ferramenta muito provavelmente já rodou uma vez, e reexecutá-la incondicionalmente significa rodá-la uma segunda vez. Este é justamente o problema de idempotência que a Lição 4 assume por inteiro; por ora esta lição fixa uma regra sobre a qual você já pode agir: ferramentas somente leitura reexecutam direto; ferramentas de alto impacto precisam antes confirmar se já rodaram, para só então decidir se reexecutam.
Jogada três: acrescentar um tool_result com is_error: true dizendo “estado de execução desconhecido, por favor reavalie”, e devolver a decisão ao modelo. Este é o fallback conservador para quando você não consegue dizer se ela rodou — o campo is_error existe justamente para "Set to true if the tool execution resulted in an error"2 (defina como true se a execução da ferramenta resultou em erro). E acontece que "letting the agent know when a tool is failing and letting it adapt works surprisingly well"1 (deixar o agente saber quando uma ferramenta está falhando e deixá-lo se adaptar funciona surpreendentemente bem): o modelo relê o contexto e decide se confirma o resultado por outro meio, em vez de se queimar com uma ação silenciosa e possivelmente repetida.
Enfileire as três e a jogada um está fora; as jogadas dois e três cobrem, respectivamente, os casos “dá para dizer” e “não dá para dizer”, e só juntas elas formam a regra completa de reconciliação.
reconcile(cp): transformando a reconciliação em código
Transforme essa regra em uma função: decida pelo nome da ferramenta se ela é somente leitura e, se for, reexecute-a; se não for, vá consultar o “livro-razão de efeitos” para confirmar se esta chamada já rodou — ainda não existe livro-razão de efeitos nesta lição, então um comentário faz as vezes dele, e a Lição 4 dá a implementação de verdade. Quando não dá para dizer, caia no fallback do is_error.
Depois que reconcile() termina, a cauda de cp.messages tem o tool_result correspondente preenchido e cp.pendingToolUse está de volta em null. Este cp é agora indistinguível de um checkpoint que caiu normalmente no ponto de gravação B, e pode ir direto para o laço while para continuar.
Depois de retomar: contando turns e tokensUsed
Dois contadores são fáceis de o caminho de retomada bagunçar, e vale explicitá-los à parte.
turns não zera na retomada. Ele conta o total de turnos da tarefa desde o começo até agora, não “quantos turnos esta instância do processo rodou” — o turns do checkpoint deve continuar sendo incrementado de onde parou, que é a única forma de o teto MAX_TURNS definido na Lição 2 seguir fazendo seu trabalho. Zere turns na retomada e uma tarefa que vive caindo e se recuperando pode driblar o teto de turnos e rodar para sempre.
tokensUsed funciona da mesma forma — carregado adiante a partir do checkpoint, não recalculado. Quando "Context Engineering: Spending Finite Attention Where It Counts" cobre compactação de contexto, tokensUsed significa “o uso da janela atual”, e o que o checkpoint armazenou é exatamente o uso dessa janela no instante da queda. Os dois carregam o mesmo sentido, então na retomada você o pega e segue, sem conversão extra nenhuma.
Recapitulação
A espinha da retomada não é difícil: ler o checkpoint, checar a versão, espalhar os campos de volta no estado de execução, pular a inicialização e cair direto no loop — o modelo não consegue nem sentir que houve uma queda no meio. O que de fato exige projeto é reconciliar a chamada órfã: apagar perde uma decisão e mascara um efeito colateral que já aconteceu; uma ferramenta somente leitura pode ser reexecutada sem preocupação; e para uma ferramenta de alto impacto cujo estado de já-executada você não consegue determinar, um fallback is_error é uma escolha mais segura que uma reexecução às cegas. Mas essa regra ainda deixa um problema sem solução: como você de fato verifica se uma ferramenta de alto impacto já rodou? Esta lição só recorreu ao “não dá para dizer”; conseguir dizer de verdade exige um livro-razão de efeitos — e é exatamente isso que a próxima lição resolve.
Footnotes
-
How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system ↩ ↩2 ↩3
-
Handle tool calls — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls ↩ ↩2
Exercícios
Um relatório de incidente de operação: “O processo foi morto e reiniciado pelo OOM killer depois da chamada da ferramenta send_email mas antes de o resultado ser registrado. No reinício, o harness fez --resume automaticamente e, alguns minutos depois, um usuário relatou ter recebido dois e-mails idênticos.”
Nível 2: Encontre a causa raiz do e-mail duplicadoO reconcile() que rodava em produção na época era assim:
Determine a causa raiz e então reescreva este reconcile() em uma versão que roteia pela natureza da ferramenta (dica: a regra que esta lição fixou é “somente leitura reexecuta direto; ferramenta de alto impacto sem registro recorre ao is_error”). Depois de reescrever, rode-o sob o node e verifique que uma ferramenta de alto impacto como send_email não dispara mais o executeTool().