Ir para o conteúdo

34 · Quando uma execução falha: desfazer, tentar de novo e voltar atrás

Idiomas

O seletor lê turnos escritos em inglês e em português do Brasil (pt-BR). O idioma é escolhido, nunca adivinhado a partir do turno: na página Settings do console (Language the selector reads), em language numa requisição de plano, ou no settings.language do seu arquivo de vocabulário; sem nenhum deles, inglês. Um turno em português pode ser digitado com os acentos ou sem eles. O português europeu não é uma gramática que o seletor tenha, e um turno em qualquer outra língua não é lido.

Uma execução que escreve pode falhar no meio do caminho: o cartão é cobrado, o CRM atualizado, e então o serviço de provisionamento está fora do ar. Com runs: atomic, o padrão, a execução desfaz o que fez, da última escrita para a primeira, e dá uma única resposta que diz o que falhou, o que foi desfeito e o que não pôde ser. Este capítulo mostra como declarar um desfazer, o que uma execução que falha faz e em que ordem, e o que acontece quando ninguém sabe se uma escrita teve efeito, ou quando um desfazer falha.

Declarando um desfazer

Uma escrita nomeia a ferramenta que a desfaz:

@tool(writes=True, undo="refund_card")

No agent.yaml, undo: refund_card sob o nome da ferramenta diz o mesmo. O desfazer é uma ferramenta como qualquer outra: ela precisa ser uma das ferramentas do mesmo agente, e é verificada antes de rodar.

Cada argumento do desfazer é ligado à escrita que ele desfaz: $args.<nome> é um argumento da escrita, e $result.<campo> um campo do que a escrita devolveu. Antes de um desfazer rodar, a verificação confere que cada argumento ligado veio daquele passo, que o passo rodou, e que ele ainda não foi desfeito (capítulo 32). Assim, um desfazer reembolsa a cobrança que esta execução fez, com o seu valor, e nunca um valor que o modelo escreveu.

Um desfazer pode ser a própria ferramenta com os argumentos trocados: um rótulo adicionado é removido pela mesma ferramenta, undo: {tool: label, arguments: {add: $args.remove, remove: $args.add}}. E onde uma execução que falha pode deixar uma escrita feita -- um rascunho, um rótulo -- undo: none diz isso: nada é desfeito, e nada pede um desfazer a você.

Passos irreversíveis

Uma escrita que nada consegue desfazer diz isso: @tool(writes=True, irreversible=True), como enviar um recibo. Toda escrita é confirmada por uma pessoa antes de rodar (capítulo 32), e uma irreversível é confirmada com um aviso:

  • a interface mostra Nothing can undo this step once it runs. abaixo dela (capítulo 35);
  • numa execução cujas escritas anteriores seriam desfeitas se um passo posterior falhasse, a pergunta também termina com: Nothing undoes this step, and the steps after it cannot undo it.

A pessoa a aprova sabendo que, se a execução falhar depois, este passo continua feito. Um rollback o deixa no lugar e o lista como não desfeito. Antes do lançamento, o QG013 encontra uma rota em que um passo irreversível pode vir antes de passos que seriam desfeitos, sem que a sua confirmação diga isso.

Os lints

O registro lê cada escrita em busca do que a recuperação precisa, e diz o que falta. Nenhum faz nada falhar (o registro os imprime em inglês):

Lint O que o registro diz (como impresso)
write_without_compensation a write neither compensated nor marked irreversible
write_not_retry_safe a write with no idempotency, so an ambiguous failure goes to a person
irreversible_without_confirmation an irreversible operation that asks for no confirmation
compensation_unknown_tool the compensation names '<undo>', which the catalogue lacks
compensation_not_retry_safe '<undo>' undoes it and is not retry-safe
compensation_unbound <argument> <- <from>
compensation_cycle its compensations return to '<at>'
compensation_out_of_reach agent '<agent_name>' can call it but not '<undo>', which undoes it, so a run of that agent cannot undo it

Uma escrita sem desfazer, sem undo: none e sem irreversible também é QG105 no stalwart verify, numa rota com outra escrita: uma execução que falha não consegue desfazê-la, e nada diz isso; o achado diz isso uma vez, então o lint não é impresso ao lado. Nada é pedido que só outra pessoa poderia mudar: uma ferramenta que um servidor MCP serve não recebe write_not_retry_safe nem write_without_result_schema, porque declarar isso cabe ao servidor, e uma escrita irreversível não recebe write_not_retry_safe, porque é confirmada e nunca repetida.

O que faz uma execução que falha

Uma execução falha quando uma ferramenta levanta um erro depois das suas novas tentativas, quando a verificação recusa um passo posterior de uma execução que já escreveu, quando uma pessoa responde cancelar tudo, ou quando uma pessoa diz que uma escrita de resultado desconhecido não aconteceu. Então, nesta ordem:

  1. O modelo não é chamado de novo. A execução vai para o seu rollback; nada mais é pedido a nenhum modelo.
  2. As escritas são desfeitas, da última para a primeira. Cada desfazer é verificado como seria verificado o agente cuja escrita ele desfaz, com o passo que desfaz nomeado, e não é confirmado de novo: a pessoa aprovou a escrita. Nem cabe ao rulebook recusá-lo (capítulo 32): ele desfaz uma escrita que a verificação permitiu. A verificação e o envio são passos separados, então o que a verificação permitiu fica registrado antes de qualquer coisa ser enviada.
  3. Escritas irreversíveis são puladas, e listadas.
  4. A execução volta atrás quando nada mais resta a desfazer (abaixo), e a pessoa recebe uma única resposta.

O pedido que falhou

O balcão da loja, crescido com uma atualização do CRM e um passo de provisionamento, cada escrita com o seu desfazer, e um serviço de provisionamento fora do ar:

# A central da loja, crescida: um pedido é cobrado, marcado como pago no CRM, e a sua conta provisionada.
agents:
  desk:
    model: "anthropic:claude-sonnet-5-5"
    tools: [get_order, charge_card, refund_card, update_crm, restore_crm, provision_account, send_receipt]
    on_unread: refuse
tools:
  - python: shop.tools
rulebook: ./policy.co
runs: atomic                               # uma execução que falha é desfeita, da última escrita para trás
desk = Agent.from_file("agent.yaml", context={"card_on_file": True})   # o fato do rulebook, como a loja sabe


def order(turn, user):
    reply = desk.chat(turn, user=user)
    while reply.waiting and reply.waiting[0].kind == "confirm":
        reply = desk.confirm(user, "approve")       # a pessoa aprova cada escrita conforme ela chega
    return reply


reply = order("cobre 40,00 do pedido 4411, depois atualize o crm do pedido 4411, "
              "depois provisione a conta do pedido 4411", "u-17")
print(reply.text)
print("As ferramentas rodaram nesta ordem:")
for number, name in enumerate(CALLS, 1):
    print(f"{number}. {name}")

Depois, o mesmo pedido com um recibo enviado antes do provisionamento:

CALLS.clear()
reply = order("cobre 40,00 do pedido 4411, depois envie o recibo do pedido 4411, "
              "depois provisione a conta do pedido 4411", "u-18")
print(reply.text)
Esta execução parou: provision_account failed (RuntimeError). Desfeito, do mais recente: update_crm (por restore_crm), charge_card (por refund_card).
As ferramentas rodaram nesta ordem:
1. charge_card
2. update_crm
3. provision_account
4. restore_crm
5. refund_card
Esta execução parou: provision_account failed (RuntimeError). Desfeito, do mais recente: charge_card (por refund_card). Não dá para desfazer, e ficaram como estão: send_receipt.

Na primeira execução as ferramentas rodaram nesta ordem, como o exemplo a imprimiu e como ele a afirma:

  1. charge_card
  2. update_crm
  3. provision_account
  4. restore_crm
  5. refund_card

O CRM é restaurado antes de o cartão ser reembolsado: a última escrita primeiro. Os três passos da execução vieram do único turno da pessoa, lido pelo seletor; o modelo nunca foi chamado, e a pessoa recebeu uma única resposta. Na segunda execução a cobrança é reembolsada e o recibo é listado como não desfeito: ele foi confirmado como irreversível, e continua enviado.

Pular este passo e cancelar tudo

Uma confirmação aceita três respostas (capítulo 35):

  • approve (aprovar): a chamada roda, exatamente com os argumentos mostrados;
  • skip (pular): a chamada não roda, o seu resultado diz que ela foi recusada pela pessoa, e a execução segue sem ela;
  • cancel all (cancelar tudo): a execução falha, como uma pessoa que a cancela, e o que ela já fez é desfeito como acima.

Novas tentativas e as suas chaves

Toda escrita carrega uma chave de idempotência: SHA-256 sobre a sessão, o passo, a ferramenta e os argumentos, então a mesma chamada no mesmo passo da mesma sessão sempre tem a mesma chave. Uma nova tentativa, uma execução retomada e um desfazer reenviado carregam todos a chave que a primeira tentativa carregou, então uma ferramenta que respeita chaves faz a coisa uma única vez.

Uma chamada é registrada em andamento antes de ser enviada. Uma leitura, ou uma escrita marcada retry_safe=True, é tentada de novo sob a mesma chave -- no máximo mais duas vezes -- quando falha de um jeito que deixa o seu efeito em dúvida, como um timeout ou uma conexão perdida. Qualquer outra escrita é enviada uma única vez.

Um resultado que ninguém conhece

Uma escrita que não é segura para repetir pode falhar de um jeito que deixa o seu efeito em dúvida: a chamada foi enviada e nenhuma resposta voltou. Enviá-la de novo poderia cobrar duas vezes, e supor que ela falhou poderia deixar uma cobrança que ninguém desfaz, então a execução não faz nenhum dos dois. Ela pergunta a uma pessoa: Did charge_card take effect? The call was sent and no answer came back. As respostas são happened (aconteceu), e a execução segue como se a chamada tivesse rodado, ou did not happen (não aconteceu), e a execução falha e é desfeita.

Um processo que para no meio de uma chamada encontra o registro de chamada em andamento quando a execução é retomada, em qualquer worker. Uma chamada segura para repetir é enviada de novo sob a sua chave; qualquer outra é a mesma pergunta: Did charge_card take effect? It was being sent when the run stopped.

Um desfazer que falha

Um desfazer que levanta um erro é tentado de novo sob a sua própria chave, mais três vezes, esperando cada vez mais entre as tentativas. Então o rollback para: desfazer fora de ordem as escritas abaixo dele poderia deixar as coisas piores do que qualquer dos dois estados. A execução fica travada, e uma pessoa é consultada: Undoing <tool> failed after <tries> tries (<error>). <n> step(s) still need undoing. As respostas:

  • retry (tentar de novo): o desfazer é enviado de novo, sob a sua chave;
  • done by hand (feito à mão): alguém o desfez fora do agente; o rollback segue com a escrita seguinte;
  • abandon (deixar): o rollback para aqui, e a resposta lista o que ainda falta desfazer.

Voltar atrás

Quando nada mais resta a desfazer, as próprias mensagens da execução -- tudo depois do turno da pessoa que a começou -- são tiradas do que o modelo lê em seguida, e uma única resposta de resumo é acrescentada. O turno seguinte não lê um rastro de erro, nem um e-mail marcado que a execução que falhou leu.

  • Voltar atrás espera por todo desfazer. Enquanto resta algum desfazer, ou a execução está travada, ele é recusado (rewind_with_pending_compensation): esquecer o que ainda falta desfazer é o único erro que voltar atrás não pode cometer.
  • O registro nunca volta atrás. O histórico da sessão guarda toda chamada, todo desfazer e todo resultado, então uma regra de ordem ou um limite ainda vê que uma cobrança rodou e foi reembolsada. A memória volta atrás; o registro, não.

Um operador pode desfazer a execução de uma conversa de fora dela, com o token de operador (capítulo 35).

Os limites

  • Um passo irreversível não é desfeito. Ele é confirmado com um aviso, e listado como não desfeito.
  • Uma execução compensa; ela não trava nada. Entre uma escrita e o seu desfazer, a cobrança é real: quem olhar a vê, e o que agir sobre ela age. Um desfazer é uma segunda escrita, não um passo para trás no tempo.
  • Voltar atrás espera por todo desfazer, e o registro nunca volta atrás.
  • O desfazer é a sua função, e faz o que faz. O produto decide quando ela é chamada e com quais argumentos, e verifica a chamada; se ela de fato reembolsa o cartão é com o seu código.
  • Uma escrita de resultado desconhecido é decidida por uma pessoa; o produto não adivinha.