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:
- A ferramenta lança exceção (registro não encontrado, gateway fora do ar) e o job morre, deixando o cliente no vácuo.
- O modelo manda argumentos que passam no
input_schemamas não fazem sentido no domínio (data no passado, ID de outro cliente). - O modelo chama a mesma ferramenta com os mesmos argumentos várias vezes, esperando um resultado diferente.
- A resposta é cortada por
max_tokensno meio de um blocotool_use, e o código executa uma ferramenta com input incompleto. - O modelo chama duas ferramentas em paralelo, você devolve resultado de uma só e a próxima chamada volta 400.
- 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:
- 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. - 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_tokense 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. - Detecção de repetição: a impressão digital
nome + hash dos argumentosno$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:
- Envolva toda execução de ferramenta num
try/catchque devolvetool_resultcomis_error: true, garantindo um resultado para cadatool_use_id. - Troque o
whilepor umforcom teto e adicione odefaultno tratamento dostop_reason. - Bloqueie a execução de
tool_usetruncado pormax_tokens. - Implemente a resposta final forçada com
tool_choice: nonee o handoff com motivo. - 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.