Um agente de IA com tool calling quebra menos por causa do modelo e mais por causa do loop em volta dele. Para ele ser robusto em Laravel, você precisa de quatro coisas: tratar cada stop_reason da API do Claude de forma explícita, devolver falhas de ferramenta como tool_result com is_error: true em vez de lançar exceção no job, separar erro que o modelo consegue corrigir de erro que ele não deve nem tentar, e limitar iterações e tokens com uma saída de emergência que sempre responde o cliente.

Esse é o código que ninguém mostra nos tutoriais. Os exemplos oficiais fazem while (stop_reason === 'tool_use') e param aí. Em produção, num agente de agendamento no WhatsApp como o do Horalis ou num agente que consulta pagamentos na Granpag, o caminho feliz é a minoria dos problemas.

O que é o loop do agente e onde ele costuma quebrar?

Tool calling é o recurso em que o LLM, em vez de responder texto, devolve um pedido estruturado para executar uma função sua (nome da ferramenta + argumentos em JSON). O loop do agente é o ciclo: chama o modelo, executa as ferramentas pedidas, devolve os resultados, chama o modelo de novo, até ele dar uma resposta final.

Os pontos de quebra mais comuns que vejo nesse tipo de sistema:

  1. A ferramenta lança exceção (registro não encontrado, gateway fora do ar) e o job morre, deixando o cliente no vácuo.
  2. O modelo manda argumentos que passam no input_schema mas não fazem sentido no domínio (data no passado, ID de outro cliente).
  3. O modelo chama a mesma ferramenta com os mesmos argumentos várias vezes, esperando um resultado diferente.
  4. A resposta é cortada por max_tokens no meio de um bloco tool_use, e o código executa uma ferramenta com input incompleto.
  5. O modelo chama duas ferramentas em paralelo, você devolve resultado de uma só e a próxima chamada volta 400.
  6. O loop não tem teto e uma conversa consome dezenas de chamadas.

Nenhum desses é exótico. Todos aparecem na primeira semana de tráfego real.

Quais valores de stop_reason o Claude devolve e o que fazer com cada um?

O stop_reason é o campo da resposta da Messages API que diz por que o modelo parou de gerar. Tratar só tool_use e assumir que o resto é resposta final é o bug mais comum.

stop_reason O que significa O que eu faço
end_turn O modelo terminou naturalmente Extrai o texto e responde o cliente
tool_use Pediu uma ou mais ferramentas Executa todas e devolve um tool_result para cada
max_tokens Bateu o limite de saída Se o último bloco é tool_use, não executa: refaz com limite maior
stop_sequence Encontrou uma stop sequence sua Trata como resposta final
pause_turn Turno longo pausado (server tools) Reenvia o conteúdo do assistente para continuar
refusal O modelo se recusou a continuar Não insiste: transfere para humano
qualquer outro Valor novo da API Loga e cai na saída de emergência

O default importa. A API ganha valores novos com o tempo, e um switch sem default vira loop infinito ou resposta vazia no dia em que isso acontece.

Como montar o loop do agente com Http:: no Laravel?

Uso o Http:: do Laravel direto na API da Anthropic, sem SDK. A requisição é esta:

{
  "model": "<modelo configurado>",
  "max_tokens": 1024,
  "system": "Você é o assistente de agendamento...",
  "tools": [
    {
      "name": "buscar_horarios",
      "description": "Lista horários livres de um profissional numa data.",
      "input_schema": {
        "type": "object",
        "properties": {
          "profissional_id": {"type": "integer"},
          "data": {"type": "string", "description": "YYYY-MM-DD"}
        },
        "required": ["profissional_id", "data"]
      }
    }
  ],
  "messages": [{"role": "user", "content": "tem horário amanhã com a Ana?"}]
}

E o loop, com os limites já embutidos:

final class AgentLoop
{
    private const MAX_ITERATIONS = 8;
    private const MAX_INPUT_TOKENS = 60_000;

    public function __construct(private ToolRegistry $tools) {}

    public function run(string $system, array $messages): AgentResult
    {
        $inputTokens = 0;
        $seen = [];

        for ($i = 0; $i < self::MAX_ITERATIONS; $i++) {
            $response = $this->call($system, $messages);

            if ($this->truncatedToolUse($response)) {
                $response = $this->call($system, $messages, maxTokens: 4096);
                if ($this->truncatedToolUse($response)) {
                    return AgentResult::handoff('tool_use_truncado');
                }
            }

            $inputTokens += $response['usage']['input_tokens'];
            $messages[] = ['role' => 'assistant', 'content' => $response['content']];

            switch ($response['stop_reason']) {
                case 'end_turn':
                case 'stop_sequence':
                case 'max_tokens':
                    return AgentResult::answer($this->text($response));
                case 'tool_use':
                    $messages[] = [
                        'role' => 'user',
                        'content' => $this->runTools($response['content'], $seen),
                    ];
                    break;
                case 'refusal':
                    return AgentResult::handoff('refusal');
                default:
                    logger()->warning('stop_reason inesperado', ['value' => $response['stop_reason']]);
                    return AgentResult::handoff('stop_reason_desconhecido');
            }

            if ($inputTokens > self::MAX_INPUT_TOKENS) {
                break;
            }
        }

        return $this->forceFinalAnswer($system, $messages);
    }

    private function truncatedToolUse(array $response): bool
    {
        $last = end($response['content']);

        return $response['stop_reason'] === 'max_tokens'
            && is_array($last)
            && $last['type'] === 'tool_use';
    }
}

Duas decisões aqui merecem explicação. Primeiro, o max_tokens com texto vira resposta final (cortada, mas útil), enquanto o max_tokens com tool_use no fim nunca é executado: o JSON dos argumentos pode estar incompleto e você não quer agendar um horário com metade do input. Segundo, eu não guardo a resposta truncada no histórico; refaço a mesma chamada com limite maior.

O call() em si é uma chamada HTTP com timeout e retry de transporte. Não repito aqui porque já detalhei em como chamar LLMs no Laravel com filas, retries e timeouts sem duplicar custo. O ponto é: retry de rede fica dentro do call(), retry de raciocínio fica no loop. Misturar os dois é como você acaba reexecutando ferramentas.

Como devolver um erro de tool para o Claude sem derrubar o agente?

A regra que mais mudou a estabilidade dos agentes que construí: ferramenta nunca lança exceção para fora do loop. Toda falha vira um tool_result com is_error: true e uma mensagem que o modelo consegue usar.

{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_use_id": "toolu_01...",
      "is_error": true,
      "content": "Não há expediente da profissional 12 em 2025-03-09 (domingo). Ofereça o próximo dia útil."
    }
  ]
}

O is_error sinaliza ao modelo que a ferramenta falhou, e ele costuma se corrigir sozinho: pede outra data, pergunta o dado que falta, ou explica ao cliente. Se em vez disso o job explode, o cliente fica sem resposta e o retry do job reprocessa a conversa inteira.

Outra regra da API que derruba muita gente: se a resposta do assistente tem três blocos tool_use, a próxima mensagem user precisa ter três tool_result, um para cada tool_use_id, antes de qualquer texto. Se uma das três falhar e você simplesmente não devolver nada para ela, a chamada seguinte volta erro 400. Por isso o runTools itera todos os blocos e garante um resultado para cada um, mesmo que seja de erro:

private function runTools(array $content, array &$seen): array
{
    $results = [];

    foreach ($content as $block) {
        if ($block['type'] === 'tool_use') {
            $results[] = $this->executeOne($block, $seen);
        }
    }

    return $results;
}

Qual erro vai para o modelo e qual fica só no log?

Nem toda falha deve ser contada ao modelo do mesmo jeito. O que eu faço é classificar por "o modelo consegue fazer algo útil com isso?".

Tipo de erro Exemplo Mensagem para o modelo Log/alerta
Input inválido data em formato errado Detalhada, com o que corrigir Não
Regra de domínio horário ocupado, pedido de outro cliente Clara, com sugestão de próximo passo Não
Ferramenta inexistente nome alucinado "Essa ferramenta não existe" Sim, baixo
Infraestrutura timeout no gateway Genérica, com "não tente de novo" Sim
Bug exceção não prevista Genérica, sem stack trace Sim, alto

A diferença entre "tente de novo" e "não tente de novo" na mensagem é deliberada. Se o gateway de pagamento está fora, o modelo vai insistir na mesma tool se você não disser explicitamente que não adianta. E nunca mando a mensagem da exceção crua: ela pode ter SQL, caminho de arquivo ou dado de outro cliente, e o que entra no contexto do modelo pode sair na resposta para o usuário.

private function executeOne(array $block, array &$seen): array
{
    $key = $block['name'] . ':' . md5(json_encode($block['input']));
    $seen[$key] = ($seen[$key] ?? 0) + 1;

    if ($seen[$key] > 2) {
        return $this->error($block['id'], 'Você já chamou esta ferramenta com os mesmos argumentos e o resultado não muda. Responda com o que já tem ou peça ao cliente a informação que falta.');
    }

    try {
        $tool = $this->tools->get($block['name']); // lança ToolDomainException se não existir
        $input = Validator::make($block['input'], $tool->rules())->validate();

        return [
            'type' => 'tool_result',
            'tool_use_id' => $block['id'],
            'content' => json_encode($tool->handle($input), JSON_UNESCAPED_UNICODE),
        ];
    } catch (ValidationException $e) {
        return $this->error($block['id'], 'Argumentos inválidos: ' . implode(' ', $e->validator->errors()->all()) . ' Corrija e chame de novo.');
    } catch (ToolDomainException $e) {
        return $this->error($block['id'], $e->getMessage());
    } catch (ConnectionException|RequestException $e) {
        report($e);
        return $this->error($block['id'], 'Serviço indisponível no momento. Não tente de novo nesta conversa; avise o cliente que a informação será verificada.');
    } catch (Throwable $e) {
        report($e);
        return $this->error($block['id'], 'Erro interno ao executar a ferramenta. Não tente de novo.');
    }
}

private function error(string $id, string $message): array
{
    return ['type' => 'tool_result', 'tool_use_id' => $id, 'is_error' => true, 'content' => $message];
}

Repare na validação com o Validator do Laravel antes do handle(). O input_schema ajuda o modelo a montar o JSON, mas não garante regra de negócio: data pode vir como string válida e estar no passado, profissional_id pode existir e ser de outro estabelecimento. A escopagem por tenant tem que estar na ferramenta, nunca na boa vontade do modelo. A ToolDomainException é uma exceção minha, cujas mensagens eu escrevo pensando que o modelo vai lê-las: curtas, com o fato e o próximo passo.

Para ferramentas que escrevem (agendar, estornar, transferir), o erro de infraestrutura tem uma armadilha extra: o timeout não diz se a ação aconteceu. Aí entra a chave de idempotência por tool call, que expliquei em idempotência em tool calls para o agente não executar a mesma ação duas vezes.

Como limitar o número de iterações do agente de IA?

Uso três travas independentes, porque cada uma pega um tipo diferente de descontrole:

  1. Teto de iterações (MAX_ITERATIONS): num agente de atendimento, uma pergunta legítima raramente precisa de mais que três ou quatro rodadas de ferramenta. Oito dá folga sem permitir espiral.
  2. Orçamento de tokens de entrada por execução: cada iteração reenvia o histórico inteiro, então o custo cresce mais rápido que o número de chamadas. Somar usage.input_tokens e cortar num teto protege contra conversas com resultados de ferramenta enormes. Para traduzir esse teto em dinheiro por modelo, a calculadora de custo de IA ajuda a escolher um valor que caiba na margem.
  3. Detecção de repetição: a impressão digital nome + hash dos argumentos no $seen. Na terceira chamada idêntica, em vez de executar, devolvo um erro dizendo que o resultado não vai mudar. Isso quebra o padrão clássico do modelo que consulta o mesmo saldo esperando outro número.

Os valores exatos dependem do seu agente. O que não pode é não ter nenhum.

O que fazer quando o agente estoura o limite?

O erro aqui é devolver "desculpe, ocorreu um erro". O agente já tem bastante contexto coletado; o certo é forçar uma última resposta sem ferramentas. O parâmetro tool_choice com {"type": "none"} impede o modelo de pedir novas tools, mas mantém as definições no request, o que é necessário porque o histórico tem blocos tool_use.

private function forceFinalAnswer(string $system, array $messages): AgentResult
{
    $response = $this->call(
        $system . "\n\nLimite de etapas atingido. Responda ao cliente com o que já foi apurado, sem prometer nada que não foi confirmado, e diga que um atendente vai complementar.",
        $messages,
        toolChoice: ['type' => 'none'],
    );

    $text = $this->text($response);

    return $text !== ''
        ? AgentResult::answerAndHandoff($text, 'limite_iteracoes')
        : AgentResult::handoff('limite_iteracoes');
}

Repare que o resultado é answerAndHandoff: o cliente recebe uma resposta, e a conversa entra na fila humana. Bater no limite é sinal de que algo saiu do previsto, e insistir com o agente só piora. A mecânica de passar a conversa para uma pessoa no WhatsApp está em como transferir o atendimento do agente de IA para um humano.

E todo handoff carrega um motivo (refusal, tool_use_truncado, limite_iteracoes). Esse campo vira métrica. Se limite_iteracoes sobe depois de uma mudança de prompt, você descobre antes do cliente reclamar.

Como testar os caminhos de erro do loop?

O caminho feliz quase sempre funciona. Os testes que valem são os de erro, e o Http::sequence() do Laravel deixa simular a conversa rodada por rodada:

it('devolve is_error quando a ferramenta falha e o modelo se recupera', function () {
    Http::fake([
        'api.anthropic.com/*' => Http::sequence()
            ->push(claudeToolUse('buscar_horarios', ['profissional_id' => 12, 'data' => 'amanhã']))
            ->push(claudeText('Para qual data você quer? Me diga no formato dia/mês.')),
    ]);

    $result = app(AgentLoop::class)->run('...', [['role' => 'user', 'content' => 'tem horário?']]);

    expect($result->text)->toContain('data');

    Http::assertSent(function ($request) {
        $last = collect($request['messages'])->last();

        return $last['role'] === 'user'
            && ($last['content'][0]['is_error'] ?? false) === true;
    });
});

Os helpers claudeToolUse e claudeText só montam o JSON de resposta com o stop_reason certo. Faço um teste para cada linha da tabela de stop_reason e um para cada tipo de erro de ferramenta: input inválido, ferramenta inexistente, timeout, repetição e limite de iterações. O resto da estratégia de teste, com respostas gravadas e evals, está no post sobre testar código que usa LLM.

Por onde começar se o seu agente já está em produção

Se você já tem um agente rodando com o loop ingênuo, eu faria nesta ordem, porque cada passo fecha um modo de falha que hoje deixa o cliente sem resposta:

  1. Envolva toda execução de ferramenta num try/catch que devolve tool_result com is_error: true, garantindo um resultado para cada tool_use_id.
  2. Troque o while por um for com teto e adicione o default no tratamento do stop_reason.
  3. Bloqueie a execução de tool_use truncado por max_tokens.
  4. Implemente a resposta final forçada com tool_choice: none e o handoff com motivo.
  5. Só depois refine as mensagens de erro das ferramentas, escrevendo cada uma como uma instrução para o modelo.

Os quatro primeiros são mecânicos e cabem num PR pequeno. O quinto é onde está o ganho contínuo: a qualidade da mensagem de erro decide se o modelo se recupera sozinho ou se a conversa vai parar na fila humana.