Pular para o conteúdo

Seu primeiro Agent com smolagents

Nota

Agora vamos construir o mesmo Alfred judiciário — mesmas Tools, mesma base, mesmo teto de sigilo — usando o smolagents. E vamos comparar linha a linha com a versão do capítulo anterior.

O curso original da Hugging Face faz este capítulo em um Space no Hub, com HF_TOKEN e modelo servido pela API deles. Aqui não: tudo roda na sua máquina, contra o Ollama em 127.0.0.1. Nenhum dado sai do computador — o que, no nosso contexto, não é preferência, é requisito.

Instalação

Com o ambiente virtual ativado (veja a Unidade 0):

pip install "smolagents[litellm]"

O extra [litellm] traz o LiteLLM, que é a camada que fala com o Ollama. O smolagents sozinho assume os modelos da Hugging Face.

Confira a versão — o curso foi escrito e testado com a 1.26.0:

python -c "import smolagents; print(smolagents.__version__)"

O arquivo inteiro

Está em unidade1/exemplos/alfred_smolagents.py. Vamos pelas partes que mudaram.

As Tools ganham um decorador

from smolagents import CodeAgent, LiteLLMModel, tool

@tool
def consultar_assunto(termo: str) -> int:
    """Converte o nome de um assunto judicial no código numérico correspondente.

    Args:
        termo: nome (ou parte do nome) do assunto, por exemplo "violência doméstica".
    """

É o mesmo @tool que você escreveu do zero com o módulo inspect, no capítulo de Tools. O smolagents lê a assinatura e a docstring e monta o bloco de texto que vai para a system message. Nada de novo — só automatizado.

Duas exigências que o framework impõe, e que valem a pena:

  1. Anotações de tipo em tudo — parâmetros e retorno.
  2. Docstring com seção Args:, uma linha por parâmetro. Sem isso o smolagents levanta erro na hora de registrar a Tool.

Isso é mais rígido que a versão manual, e é rigidez bem colocada: uma Tool mal descrita é uma Tool que o modelo vai usar errado.

O corpo das Tools não mudou nada

WHERE a.codigo = ?
  AND p.id_nivel_sigilo <= ?
(int(codigo_assunto), TETO_SIGILO, limite),

Ponto que merece ser dito em voz alta: trocar de framework não mexeu no controle de acesso. O TETO_SIGILO continua sendo uma constante do seu programa, fora do alcance do modelo, aplicada dentro da consulta. Se a proteção dependesse de algo que o framework oferece, ela dependeria do framework — e a decisão certa é ela não depender de nada além do banco.

O modelo

modelo = LiteLLMModel(
    model_id="ollama_chat/qwen2:7b",
    api_base="http://127.0.0.1:11434",
    num_ctx=8192,
    temperature=0,
)

O prefixo ollama_chat/ diz ao LiteLLM para usar o endpoint de chat do Ollama. O api_base aponta para a sua máquina.

Atenção

Confira o api_base sempre. É a linha que decide se o texto da consulta — que pode conter nome de assunto, número de processo, o que você mandar — fica no seu computador ou vai para um servidor de terceiro.

Trocar LiteLLMModel por InferenceClientModel, como faz o curso original, muda exatamente isso. Não faça essa troca com dados do Tribunal.

O Agent

agente = CodeAgent(
    tools=[consultar_assunto, buscar_processos],
    model=modelo,
    max_steps=6,
    additional_authorized_imports=[],  # nenhum import além do básico
)

Compare com o capítulo anterior. Desapareceram: o system prompt do ciclo, a lista de stop sequences, o extrair_action, o laço for, a montagem das mensagens, o tratamento de Tool inexistente. Tudo isso o CodeAgent faz.

Ficaram: as Tools, o modelo, e o max_steps — que é o mesmo MAX_PASSOS = 6.

O additional_authorized_imports=[] é a lista de módulos que o código gerado pode importar. Vazia, por padrão. Deixe vazia enquanto não houver motivo concreto para o contrário: é o que impede o código gerado pelo modelo de abrir arquivos, rede ou processos.

Rodando

resposta = agente.run(
    "Traga os 3 processos mais recentes sobre violência doméstica. "
    "Use somente os dados devolvidos pelas Tools."
)
python exemplos\alfred_smolagents.py

A primeira execução — que falhou

Vale mostrar como foi de verdade, porque a falha ensina mais que o acerto.

Na primeira versão do arquivo, consultar_assunto devolvia uma string legível para humano — "codigo=1001 (Violência Doméstica Contra a Mulher)" — igual à versão sem framework. Resultado:

━━━━━━━━━━━━━━━━━━ Step 1 ━━━━━━━━━━━━━━━━━━
 ─ Executing parsed code: ────────────────────
  codigo_assunto = consultar_assunto("violência doméstica")
  print(f"Código do assunto violência doméstica: {codigo_assunto}")

  processos_recentes = buscar_processos(codigo_assunto, limite=3)
  print(processos_recentes)
 ─────────────────────────────────────────────
Execution logs:
Código do assunto violência doméstica: codigo=1001 (Violência Doméstica Contra a Mulher)

Code execution failed at line 'processos_recentes = buscar_processos(codigo_assunto, limite=3)'
due to: ValueError: invalid literal for int() with base 10:
'codigo=1001 (Violência Doméstica Contra a Mulher)'
[Step 1: Duration 186.68 seconds| Input tokens: 2,197 | Output tokens: 106]

Repare em duas coisas de uma vez.

Primeira: o Code Agent encadeou as duas Tools em um bloco só. No capítulo anterior isso levou duas voltas do ciclo — Action, Observation, Action, Observation. Aqui, uma. É a vantagem que o capítulo de Actions prometia, aparecendo na prática.

Segunda: o erro voltou ao modelo, e ele se corrigiu. No passo seguinte:

━━━━━━━━━━━━━━━━━━ Step 2 ━━━━━━━━━━━━━━━━━━
 ─ Executing parsed code: ────────────────────
  codigo_assunto = consultar_assunto("violência doméstica")

  # Extracting the code from the string
  codigo = int(codigo_assunto.split('=')[1].strip().split(' ')[0])
  print(f"Código extraído: {codigo}")

  processos_recentes = buscar_processos(codigo, limite=3)
  print(processos_recentes)
 ─────────────────────────────────────────────
Execution logs:
Código extraído: 1001
[{"numero_processo": "0000184-40.2026.8.19.0001", ...}]

Ele leu o ValueError, entendeu que o retorno era uma string com o código embutido, escreveu o split para extrair e seguiu. É o ReAct funcionando como anunciado — a Observation de erro produziu correção de rumo, sem intervenção humana.

Custou 186 segundos de passo desperdiçado. Nada mal para uma recuperação automática, e caro demais para deixar como está.

O que a falha ensinou

O problema não foi do modelo. Foi da Tool.

Em um Code Agent, o tipo de retorno da Tool é contrato de API. O modelo vai escrever código que consome esse retorno, e uma string formatada para leitura humana quebra na primeira composição. Na versão sem framework isso não aparecia, porque lá o retorno ia direto para o texto da Observation e o próprio modelo lia o 1001 de dentro dela.

A correção:

@tool
def consultar_assunto(termo: str) -> int:
    ...
    if linha is None:
        con = sqlite3.connect(BASE)
        nomes = [r[0] for r in con.execute("SELECT nome FROM dim_assunto")]
        con.close()
        # A exceção vira Observation e volta ao modelo, sem sujar o tipo de retorno.
        raise ValueError(
            "Assunto nao encontrado. Assuntos disponiveis: " + "; ".join(nomes)
        )

    # Devolve o código puro: é isso que buscar_processos espera receber.
    return linha[0]

Repare que a mensagem de erro informativa não se perdeu. Ela virou exceção — e o smolagents entrega a exceção ao modelo como Observation, exatamente como fazíamos à mão. O que se ganhou foi o tipo de retorno limpo.

Duas regras que valem para todas as Tools que você vier a escrever:

  1. Devolva o dado, não a frase. 1001, não "codigo=1001 (...)".
  2. Erros pelo mecanismo de erro. Levante exceção com mensagem útil; não devolva sentinelas como -1 nem texto de erro misturado com o tipo normal.

A execução depois da correção

┌───────────────────────── New run ─────────────────────────┐
│ Traga os 3 processos mais recentes sobre violência        │
│ doméstica. Use somente os dados devolvidos pelas Tools.   │
└─ LiteLLMModel - ollama_chat/qwen2:7b ─────────────────────┘
━━━━━━━━━━━━━━━━━━ Step 1 ━━━━━━━━━━━━━━━━━━
 ─ Executing parsed code: ────────────────────
  codigo_assunto = consultar_assunto('violência doméstica')
  print(f"Código do assunto violência doméstica: {codigo_assunto}")

  processos_recentes = buscar_processos(codigo_assunto, limite=3)
  print(f"Processos mais recentes sobre violência doméstica: {processos_recentes}")
 ─────────────────────────────────────────────
Execution logs:
Código do assunto violência doméstica: 1001
Processos mais recentes sobre violência doméstica: [{"numero_processo":
"0000184-40.2026.8.19.0001", "data_distribuicao": "2026-01-10", "situacao":
"Arquivado", "orgao_julgador": "2ª Vara de Violência Doméstica e Familiar contra
a Mulher", "classe": "Medida Protetiva de Urgência"}, {"numero_processo":
"0000031-41.2025.8.19.0001", ...}, {"numero_processo":
"0000116-27.2025.8.19.0001", ...}]

[Step 1: Duration 91.62 seconds| Input tokens: 2,197 | Output tokens: 121]
━━━━━━━━━━━━━━━━━━ Step 2 ━━━━━━━━━━━━━━━━━━
 ─ Executing parsed code: ────────────────────
  final_answer(processos_recentes)
 ─────────────────────────────────────────────
Final answer: [{"numero_processo": "0000184-40.2026.8.19.0001", ...}]
[Step 2: Duration 151.16 seconds| Input tokens: 4,983 | Output tokens: 161]

Dois passos. Um para consultar, um para responder — contra os três da versão sem framework, porque as duas Tools couberam no mesmo bloco de código.

E os processos são os mesmos três: 0000184-40.2026.8.19.0001, 0000031-41.2025.8.19.0001 e 0000116-27.2025.8.19.0001. Com a mesma ausência, pelo mesmo motivo: o 0000121-49.2025.8.19.0001, de 27/07/2025, continua fora porque é Sigilo Absoluto e o teto da sessão é 1.

O framework mudou o encanamento. Não mudou nem podia mudar quem decide o que pode ser lido.

Comparando as duas versões

Sem framework Com smolagents
Linhas de código 244 128
Formato da Action JSON, parser escrito à mão Código Python, parser do framework
Passos para responder 3 2
System prompt do ciclo Escrito por você Gerado pelo framework
Parada "stop": ["Observation:"] explícito Cuidada pelo framework
Tratamento de erro try/except no laço Exceção vira Observation
Execução de código Não existe Interpretador restrito
max_steps MAX_PASSOS = 6 max_steps=6
Teto de sigilo Constante no WHERE Constante no WHERE

A última linha é a que resume o capítulo.

O final_answer

Você viu no Step 2:

final_answer(processos_recentes)

O smolagents injeta essa função no ambiente de execução. Chamá-la é como o Agent encerra o laço — o equivalente ao if "Final Answer:" in saida do capítulo anterior.

Nota

Repare que o modelo chamou final_answer passando a variável, não um texto reescrito por ele. É o melhor caso possível: a resposta é literalmente o que a consulta devolveu, sem intermediação.

Na versão sem framework, o modelo reescrevia a lista em português — e foi justamente aí que, no experimento dos 500 processos, ele afirmou uma contagem que não existia. Passar o dado adiante em vez de narrá-lo é menos bonito e mais seguro.

Exercícios

  1. Mude a pergunta. agente.run("Quantos processos de tráfico de drogas existem na base?") — repare que não existe Tool de contagem. Acompanhe o que ele faz com isso.
  2. Peça um assunto inexistente e veja a exceção virar Observation, com a lista de assuntos disponíveis chegando ao modelo.
  3. Mude TETO_SIGILO para 0 e confirme que o resultado é vazio, igual à versão sem framework.
  4. Autorize um importadditional_authorized_imports=["datetime"] — e peça algo que dependa da data de hoje. Depois volte a lista para vazia e entenda por que o padrão é esse.

Fixando o capítulo

Q1: Por que consultar_assunto devolvendo \"codigo=1001 (Violência Doméstica Contra a Mulher)\" quebrou o Code Agent?


Q2: A correção trocou o retorno para int e passou a levantar ValueError quando o assunto não existe. O que se ganhou?


Q3: Ao trocar o laço manual pelo CodeAgent, o que não mudou?


Você tem dois Agents funcionando, e sabe o que cada linha do framework está escondendo. Hora do quiz final da unidade.