mozak.tech Engenharia de IA Corporativa 26%

Parte II — Sistemas que Raciocinam e Agem de Forma Autônoma

2.1 — O que Define um Agente Autônomo: Percepção, Decisão e Ação (AI Agents)

Objetivo da Aula

Ao concluir esta aula, você será capaz de:

Descrever os 4 componentes de um agente autônomo (memória, planner, executor, toolbox) e suas responsabilidades

Implementar um agent loop completo em Python com Anthropic SDK

Executar tools baseadas em tool_use blocks do response do Claude

Controlar o loop com stop_reason e limite de iterações

Distinguir quando usar agente vs chatbot vs workflow fixo

Por que isso importa

Agentes autônomos são fundamentalmente diferentes de chatbots.

Um chatbot responde perguntas. Um agente decompõe tarefas, decide quais ações tomar, executa essas ações e itera até atingir o objetivo — sem que o humano precise supervisionar cada passo.

A diferença não é filosófica, é arquitetural. Um chatbot tem o loop:

user_input → LLM → resposta

Um agente tem o loop:

task → LLM → tool_use → execute → LLM → tool_use → execute → ... → resposta

Esse loop transforma um LLM de “respondedor passivo” para “executor ativo”. E é exatamente isso que o mercado está comprando hoje: não modelos mais inteligentes, mas sistemas que fazem coisas de verdade.

O custo de uma chamada de agente com 5 iterações é ~5x o custo de uma resposta simples. Isso é real e precisa entrar no cálculo de produto. Mas o valor entregue também é ~5-50x maior.

Conceitos Fundamentais

Os 4 Componentes de um Agente

1. Memória O que o agente sabe e lembra. No starter, a memória de curta duração é a lista mensagens — a conversa acumulada. A memória de longa duração é a lista notas — persiste além do loop atual.

2. Planner (LLM) O cérebro do agente. Decide o que fazer a seguir baseado no estado atual (mensagens). No starter, é a chamada a client.messages.create(). O Claude analisa a tarefa, consulta as tools disponíveis via TOOLS_SCHEMA, e decide: responder diretamente (end_turn) ou usar uma tool (tool_use).

3. Executor Executa as decisões do Planner. No starter, é executar_tool(nome, argumentos). Recebe o nome da tool e os argumentos que o Claude escolheu, e chama a função Python correspondente.

4. Toolbox O repertório de ações disponíveis. No starter, são 3 tools: calcular, buscar_web, salvar_nota. O TOOLS_SCHEMA é a interface entre o LLM (que precisa de JSON Schema) e as funções Python.

O Agent Loop: Linha a Linha

def agent_loop(tarefa: str, max_iteracoes: int = 10) -> str:

    mensagens = [{'role': 'user', 'content': tarefa}]

    iteracao = 0



    while iteracao < max_iteracoes:

        iteracao += 1



        # 1. Planner decide

        response = client.messages.create(

            model='claude-haiku-4-5-20251001',

            max_tokens=1000,

            tools=TOOLS_SCHEMA,

            messages=mensagens,

        )



        # 2. Verificar parada

        if response.stop_reason == 'end_turn':

            # Tarefa completa — extrair texto final

            texto = next((b.text for b in response.content if hasattr(b, 'text')), '')

            return texto



        # 3. Processar tool calls

        mensagens.append({'role': 'assistant', 'content': response.content})

        tool_results = []



        for block in response.content:

            if block.type != 'tool_use':

                continue

            resultado = executar_tool(block.name, block.input)

            tool_results.append({

                'type': 'tool_result',

                'tool_use_id': block.id,

                'content': resultado,

            })



        # 4. Adicionar resultados e iterar

        mensagens.append({'role': 'user', 'content': tool_results})



    return 'Limite de iterações atingido.'

4 etapas que se repetem: 1. Planner decide: client.messages.create() com tools=TOOLS_SCHEMA 2. Verificar parada: stop_reason == 'end_turn' → tarefa completa 3. Executar tools: para cada tool_use block, executar a função e coletar resultado 4. Iterar: adicionar os tool_result blocks ao histórico e chamar o LLM novamente

stop_reason: A Bifurcação Central

response.stop_reason  # 'end_turn' ou 'tool_use'

'end_turn': o Claude decidiu que tem informação suficiente para responder. Loop termina.

'tool_use': o Claude quer usar uma ou mais tools antes de responder. Loop continua.

Quando stop_reason == 'tool_use', response.content contém blocos tool_use como:

[

  TextBlock(text="Vou calcular isso.", type='text'),

  ToolUseBlock(id='toolu_abc', name='calcular', input={'expressao': '12 * 365'}, type='tool_use'),

]

Note que pode haver um TextBlock antes do ToolUseBlock — o “raciocínio” do Claude. Você deve incluir todo response.content no histórico (não só os tool_use blocks).

Tool Results: Formato Correto

tool_results.append({

    'type': 'tool_result',

    'tool_use_id': block.id,   # ← DEVE corresponder ao id do tool_use

    'content': resultado,       # string com o resultado

})



mensagens.append({'role': 'user', 'content': tool_results})

O tool_use_id é crítico — o Claude usa para correlacionar qual resultado corresponde a qual tool call. Erro comum: passar block.name no lugar de block.id.

Segurança em calcular()

def calcular(expressao: str) -> str:

    permitidos = set('0123456789+-*/.() ')

    if not all(c in permitidos for c in expressao):

        return 'Erro: expressão contém caracteres não permitidos'

    resultado = eval(expressao)

    return str(resultado)

eval() em código de produção é normalmente perigoso (RCE). Aqui é seguro por dois motivos: 1. Whitelist de caracteres — só permite dígitos e operadores matemáticos 2. Sem import, sem acesso a builtins com strings maliciosas

Em produção, use sympy.sympify() ou ast.literal_eval() para parsing mais seguro.

Aprofundamento Técnico

Por que response.content (não só o texto) vai para o histórico

# ERRADO: perde os tool_use blocks

mensagens.append({'role': 'assistant', 'content': response.content[0].text})



# CORRETO: preserva todos os blocos

mensagens.append({'role': 'assistant', 'content': response.content})

Quando o Claude chama uma tool, response.content contém uma lista com TextBlock + ToolUseBlock. Se você só adicionar o texto, na próxima iteração o Claude não vai ter o contexto dos tool_use blocks, e a correlação com os tool_result vai quebrar.

max_iteracoes como Guardrail de Custo

while iteracao < max_iteracoes:  # default: 10

Sem esse limite, um agente mal projetado pode entrar em loop infinito. Cada iteração custa tokens. 10 iterações com claude-haiku (~$0.00003/iteração) = ~$0.0003 por task. 1000 tasks/dia = $0.30/dia. Escalável.

Com claude-sonnet (~$0.003/iteração): 1000 tasks = $30/dia. O limite de iterações é também um safety valve financeiro.

Tipos de Agentes: Quando Usar Cada Um

Tipo

Descrição

Quando usar

Reactive

percepção → ação sem plano

alertas simples, roteamento de mensagens

Deliberative

planeja antes de agir

tarefas complexas, múltiplos passos

Hybrid

reactive para urgências + deliberative para planejamento

sistemas de produção

Single-agent

1 LLM + toolbox

maioria dos casos

Multi-agent

múltiplos LLMs colaborando

decomposição de tarefas muito complexas

Exemplos Anotados

Exemplo 1: Agent Loop Completo (solução dos TODOs)

def agent_loop(tarefa: str, max_iteracoes: int = 10) -> str:

    print(f'\n🤖 AGENTE INICIADO')

    print(f'Tarefa: {tarefa}\n')

    

    mensagens = [{'role': 'user', 'content': tarefa}]

    iteracao = 0



    while iteracao < max_iteracoes:

        iteracao += 1

        print(f'--- Iteração {iteracao}/{max_iteracoes} ---')



        # TODO 1: chamada de API

        response = client.messages.create(

            model='claude-haiku-4-5-20251001',

            max_tokens=1000,

            tools=TOOLS_SCHEMA,

            messages=mensagens,

        )

        

        # Mostrar raciocínio textual se presente

        for block in response.content:

            if hasattr(block, 'text') and block.text:

                print(f'💭 {block.text[:200]}')



        # TODO 2: verificar stop_reason

        if response.stop_reason == 'end_turn':

            # Tarefa completa

            texto = next((b.text for b in response.content if hasattr(b, 'text')), '')

            print(f'\n✅ Concluído em {iteracao} iteração(ões)')

            return texto



        if response.stop_reason == 'tool_use':

            # Adicionar resposta completa (inclui text + tool_use blocks)

            mensagens.append({'role': 'assistant', 'content': response.content})

            tool_results = []



            for block in response.content:

                if block.type != 'tool_use':

                    continue

                print(f'🔧 Chamando: {block.name}({block.input})')

                resultado = executar_tool(block.name, block.input)

                print(f'   → {resultado[:100]}')

                tool_results.append({

                    'type': 'tool_result',

                    'tool_use_id': block.id,    # ← id, não name

                    'content': resultado,

                })



            mensagens.append({'role': 'user', 'content': tool_results})



    print(f'⚠️ Limite de {max_iteracoes} iterações atingido.')

    return 'Tarefa não concluída dentro do limite de iterações.'

Exemplo 2: Trace Completo de Execução

Para a tarefa “Pesquise o que é RAG, calcule 12 * 365 e salve uma nota”:

Iteração 1:

  💭 Vou pesquisar RAG primeiro.

  🔧 buscar_web({'query': 'RAG'})

  → RAG (Retrieval-Augmented Generation) combina busca semântica com LLMs...



Iteração 2:

  💭 Agora o cálculo.

  🔧 calcular({'expressao': '12 * 365'})

  → 4380



Iteração 3:

  💭 Salvando a nota com os resultados.

  🔧 salvar_nota({'titulo': 'RAG e Cálculo', 'conteudo': 'RAG é... 12*365=4380'})

  → Nota #1 salva: RAG e Cálculo



Iteração 4 (end_turn):

  ✅ Concluído. RAG é um padrão que combina busca com LLMs.

     12*365=4380 dias. Nota salva com sucesso.

Padrões e Armadilhas

Padrões

Padrão 1: executar_tool() com dict de funções

TOOL_FUNCTIONS = {

    'calcular': calcular,

    'buscar_web': buscar_web,

    'salvar_nota': salvar_nota,

}



def executar_tool(nome: str, argumentos: dict) -> str:

    if nome not in TOOL_FUNCTIONS:

        return f'Erro: tool "{nome}" não encontrada'

    return TOOL_FUNCTIONS[nome](**argumentos)

Evita if/elif aninhados. Adicionar nova tool = adicionar ao dict.

Padrão 2: Tools retornam str, sempre O content de um tool_result deve ser string. Se sua função retorna dict ou list, serialize:

return json.dumps(resultado, ensure_ascii=False)

Padrão 3: Incluir todo response.content no histórico

mensagens.append({'role': 'assistant', 'content': response.content})

Não filtre — o Claude precisa ver os tool_use blocks que ele mesmo gerou.

Armadilhas

⚠️ Armadilha 1: tool_use_id vs tool_name

# ERRADO: quebra correlação

tool_results.append({'type': 'tool_result', 'tool_use_id': block.name, ...})



# CORRETO: usar block.id

tool_results.append({'type': 'tool_result', 'tool_use_id': block.id, ...})

⚠️ Armadilha 2: Sem limite de iterações

# PERIGO: pode rodar para sempre (e consumir budget)

while True:

    response = client.messages.create(...)



# SEGURO: sempre limite

while iteracao < max_iteracoes:

    iteracao += 1

    ...

⚠️ Armadilha 3: Tool que lança exceção quebra o loop

# RUIM: exceção propaga para cima e mata o agente

def buscar_web(query):

    return requests.get(url).json()['result']



# BOM: capturar e retornar erro como string

def buscar_web(query):

    try:

        return requests.get(url, timeout=5).json()['result']

    except Exception as e:

        return f'Erro ao buscar: {e}'

O Claude pode se recuperar de erros em tools se receber a mensagem de erro como tool_result. Exceções não tratadas matam o processo.

⚗ Laboratório prático — mozak.tech
Se não for realizar o laboratório, pule para o próximo capítulo.

Ponte para o Lab

O starter tem 2 TODOs:

TODO 1 (linha response = None): substitua por:

response = client.messages.create(

    model='claude-haiku-4-5-20251001',

    max_tokens=1000,

    tools=TOOLS_SCHEMA,

    messages=mensagens,

)

TODO 2 (verificar stop_reason): após a chamada de API:

if response.stop_reason == 'end_turn':

    texto = next((b.text for b in response.content if hasattr(b, 'text')), '')

    return texto



mensagens.append({'role': 'assistant', 'content': response.content})

tool_results = []

for block in response.content:

    if block.type != 'tool_use':

        continue

    resultado = executar_tool(block.name, block.input)

    tool_results.append({'type': 'tool_result', 'tool_use_id': block.id, 'content': resultado})

mensagens.append({'role': 'user', 'content': tool_results})

A seção “Conceitos Fundamentais” explica cada linha. O Exemplo 1 mostra a solução completa com prints de debug.

Agora você está pronto para o lab.