Ir para o conteúdo

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

Idiomas

O seletor entende turnos escritos em inglês. O português é a sua próxima gramática e ainda não foi lançado; até lá, um turno escrito em português não é lido como português.

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.

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 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 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. 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 (os turnos são lidos em inglês):

# The shop's desk, grown: an order is charged, marked paid in the CRM, and its account provisioned.
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                               # a failed run is undone, last write first
desk = Agent.from_file("agent.yaml")


def order(turn, user):
    reply = desk.chat(turn, user=user)
    while reply.waiting and reply.waiting[0].kind == "confirm":
        reply = desk.confirm(user, "approve")       # the person approves each write as it comes
    return reply


reply = order("charge order 4411 40.00, then update the crm for order 4411, "
              "then provision the account for order 4411", "u-17")
print(reply.text)
print("The tools ran in this order:")
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("charge order 4411 40.00, then send the receipt for order 4411, "
              "then provision the account for order 4411", "u-18")
print(reply.text)
This run stopped: provision_account failed (RuntimeError). Undone, latest first: update_crm (by restore_crm), charge_card (by refund_card).
The tools ran in this order:
1. charge_card
2. update_crm
3. provision_account
4. restore_crm
5. refund_card
This run stopped: provision_account failed (RuntimeError). Undone, latest first: charge_card (by refund_card). Not undoable, and left as they are: 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.