Na primeira parte da nossa jornada, desenhamos como nossa CLI se comportará, desde o pedido do usuário até a conclusão da tarefa. Agora, vamos desenhar o coração do Xabironelson: a Command Line Interface (CLI).
O que é uma CLI
A galera de Ciência da Computação que me perdoe, mas uma CLI é, de forma simples, um sistema baseado em texto que permite a você interagir com o computador por meio de comandos, em vez de uma interface gráfica.
Codificando nossa CLI
Seria um tanto chato se a nossa CLI apenas recebesse uma entrada do usuário e terminasse. Se você já interagiu com um shell (como Bash ou Zsh), já percebeu que ele funciona em um loop, mais conhecido como Read-Eval-Print-Loop (REPL).
Já adianto, não vamos construir o Warp (você pode fazer uma versão mais simples no Build your own Shell - Codecrafters), vamos utilizar, apenas, o REPL para os casos em que precisamos de um Human-in-the-Loop (HITL).
O que é o HITL?
Ah, irmão. Sem Spoiler, preciso te segurar em pelo menos quatro desses textos antes de
vender um curso sobre issote explicar isso.
Criando a configuração da nossa CLI
Uma das premissas que vamos adotar é nos livrarmos do famoso Vendor Lock-In ]. Para isso, temos de tornar a escolha do nosso modelo e dos parâmetros algo fácil. Para isso, adicionaremos uma nova dependência
uv add pyyaml
Criando nosso primeiro comando - Init
Poderíamos utilizar Argparse, Hug, Plac, Click ou qualquer outro nome que pareça uma onomatopeia. Iremos usar o Typer, até porque foi ele que adicionamos na primeira parte do tutorial.
Vamos criar o constants.py
. Este arquivo guardará todas as constantes do projeto.
from typing import Dict, Any
CONFIG_FILE_NAME: str = "xabiro.yaml"
DEFAULT_CONFIG: Dict[str, Any] = {
"max_steps": 10,
"tools": {
"planner": True,
},
"prompt": "Você é um agente de código pessoal chamado Xabironelson Codex. Sua tarefa é ajudar o usuário a escrever, revisar e depurar código. Sempre que possível, forneça exemplos de código e explique conceitos técnicos de maneira clara e concisa.",
"llm": {
"model": "gpt-4",
"temperature": 0.7,
"max_tokens": 1500,
}
}
Agora vamos criar o cli.py
, o qual será responsável por centralizar toda a configuração da nossa CLI:
import typer
import yaml
import os
from pathlib import Path
from constants import CONFIG_FILE_NAME, DEFAULT_CONFIG
from dotenv import load_dotenv
app = typer.Typer(help="Xabironelson Codex")
@app.callback()
def main_callback():
pass
@app.command()
def init():
config_path = Path.cwd() / CONFIG_FILE_NAME
if config_path.exists():
typer.confirm(
f"O arquivo {CONFIG_FILE_NAME} já existe. Deseja sobrescrevê-lo?",
abort=True
)
with open(config_path, "w") as f:
yaml.dump(DEFAULT_CONFIG, f, sort_keys=False)
typer.secho(f"Arquivo de configuração '{CONFIG_FILE_NAME}' criado com sucesso!", fg=typer.colors.GREEN)
Criando nosso segundo Solve - solve
Agora que temos uma forma de configurar os parâmetros da nossa aplicação, ele se parecerá com algo do tipo:
sequenceDiagram participant U as Usuário participant CLI as cli.py (solve) participant DOTENV as python-dotenv participant OS as Ambiente (os.getenv) participant CFG as Config. (xabiro.yaml) U->>CLI: xabiro-codex solve "Tarefa" activate CLI CLI->>CLI: typer.secho("Carregando variáveis...") CLI->>DOTENV: load_dotenv() Note right of DOTENV: Lê o arquivo .env DOTENV-->>CLI: Variáveis injetadas no Ambiente CLI->>CLI: typer.secho("Carregando configurações...") CLI->>CFG: Tenta Abrir(config.yaml) alt Carregamento Falha (YAML ou File Not Found) CFG-->>CLI: Exceção (YAMLError/FileNotFound) CLI->>U: Exibe Erro CLI->>CLI: raise typer.Exit(1) end CFG-->>CLI: Retorna o Conteúdo YAML (config) CLI->>CLI: Extrai llm_configuration e verbose CLI->>CLI: Obtém 'api_key_env' do config CLI->>OS: os.getenv(api_key_env) OS-->>CLI: Retorna o Valor (api_key) alt API Key Ausente CLI->>U: Exibe ERRO CLI->>CLI: raise typer.Exit(1) end CLI->>CLI: Exibe [CONFIGURAÇÕES CARREGADAS] CLI->>U: Exibe Tarefa, LLM Params e Status da Chave API CLI->>CLI: (TODO) Chama Lógica do Agente deactivate CLI
Note que ainda não temos o nosso agente sendo chamado, mas já conseguimos ver onde ele se encaixará. Porém, isso será feito na próxima aula. Transformando esse mermaid em código teríamos:
@app.command()
def solve(
task: str = typer.Argument(..., help="Descrição da tarefa que o agente deve realizar."),
config_file: Path = typer.Option(CONFIG_FILE_NAME, "--config", "-c", exists=True, file_okay=True, dir_okay=False, writable=False, help="Caminho para o arquivo de configuração."),
):
typer.secho("Carregando variáveis de ambiente...", fg=typer.colors.BLUE)
load_dotenv()
typer.secho(f"Carregando configurações de: {config_file}", fg=typer.colors.CYAN)
try:
with open(config_file, "r") as f:
config = yaml.safe_load(f)
except yaml.YAMLError as e:
typer.secho(f"Erro ao carregar o arquivo de configuração: {e}", fg=typer.colors.RED)
raise typer.Exit(code=1)
except FileNotFoundError:
typer.secho(f"Erro: Arquivo de configuração '{config_file}' não encontrado.", fg=typer.colors.RED)
raise typer.Exit(code=1)
llm_configuration = config.get("llm", {})
verbose = config.get("verbose", False)
env_var_name = llm_configuration.get("api_key_env", "LLM_API_KEY")
api_key = os.getenv(env_var_name)
if not api_key:
typer.secho("\nERRO: Chave API não encontrada!", fg=typer.colors.RED)
typer.echo(f"A variável de ambiente '{env_var_name}' não foi definida.")
typer.echo("Certifique-se de criar um arquivo .env ou exportar a variável.")
raise typer.Exit(code=1)
typer.echo("\n[CONFIGURAÇÕES CARREGADAS]")
typer.secho(f" Tarefa: {task}", fg=typer.colors.YELLOW)
if llm_configuration:
typer.echo(f" Modelo: {llm_configuration.get('model', 'N/A')}")
typer.echo(f" Temperatura: {llm_configuration.get('temperature', 'N/A')}")
typer.echo(f" Max Tokens: {llm_configuration.get('max_tokens', 'N/A')}")
typer.secho(f" Chave API carregada de: {env_var_name} (OK)", fg=typer.colors.GREEN)
typer.echo(f" Modo Verbose: {verbose}")
E agora?
O nosso próximo passo é criarmos a nossa camada de abstração para interagirmos com a LLM. Por agora, vamos imaginar que a LLM é apenas uma API.
Não me mate
Pô, eu entendo o quão complexo é uma LLM, mas, do ponto de vista de uma aplicação consumidora, ela é, apenas, um provedor terceiro de dados. E por estar fora do nosso domínio, temos de pensar em como aplicar todas as boas práticas para termos uma boa interação.
Má pera lá! Será que compensa criar toda essa camada de abstração?
O Trade-off make vs. buy
Ao criarmos essa camada de abstração, nos deparamos com a decisão fundamental:
- Make: Construir classes e wrappers do zero para garantir 100% de controle sobre o agnosticismo de LLM.
- Buy: Adotar uma biblioteca pronta como o LiteLLM, que já resolve a compatibilidade entre APIs (OpenAI, Gemini, Anthropic, etc.), nos fazendo ganhar tempo e focando na lógica do agente.
Ambas as abordagens têm seus méritos, e teremos de tomar essa decisão, além de registrá-la numa ADR (Lembra que o seu eu do futuro pode não saber o porquê você fez o que fez).
Mas isso é papo para outra hora!