# Erros no loop do agente de IA no Laravel: is_error, stop_reason e limite de iterações

> Como tratar falhas de tool, cada stop_reason do Claude e loops infinitos num agente de IA em Laravel, sem derrubar o job nem deixar o cliente sem resposta.

Por Lucas Roberto · IA · publicado em 2026-10-26 · 10 min de leitura
Versão web: https://lucasroberto.com/blog/erros-no-loop-do-agente-de-ia-no-laravel-is-error-stop-reason-e-limite-de-iteracoes

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:

```json
{
  "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:

```php
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](/blog/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.

```json
{
  "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:

```php
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.

```php
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](/blog/idempotencia-em-tool-calls-como-evitar-que-o-agente-de-ia-execute-a-mesma-acao-duas-vezes-no-laravel).

## 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](/ferramentas/calculadora-custo-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`.

```php
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](/blog/como-transferir-o-atendimento-do-agente-de-ia-para-um-humano-no-whatsapp-com-laravel).

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:

```php
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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
