Skip to content

Repository files navigation

Byte — Agente de IA local (self-hosted)

Un agente de IA propio que corre un modelo open source localmente, capaz de programar, buscar en internet y usar herramientas vía MCP — sin depender de APIs pagas.

Estado: las fases 0 a 6 andan de punta a punta en local — agente con herramientas, sandbox WASM, RAG híbrido, MCP, endpoint compatible con OpenAI, multiusuario con JWT y un CLI interactivo. 480 tests. El despliegue (Fase 7) está pausado a propósito: correr Ollama 24/7 en la nube cuesta ~$180/mes de RAM y el proyecto todavía no lo necesita.

Stack

Python · FastAPI · LangGraph · Ollama · PostgreSQL + pgvector · MCP · AG-UI · Pyodide/WASM · Docker

Arquitectura en una línea

web / CLIFastAPI (runs + SSE con eventos AG-UI)agente LangGraph (checkpointer en Postgres) → herramientas: búsqueda web (Tavily), sandbox WASM (Pyodide), RAG híbrido (pgvector), MCP (n8n y otros).

Documentación

Estructura

Un módulo por carpeta; cada carpeta tiene su README explicando qué va ahí.

api/  agent/  tools/  sandbox/  mcp_client/  rag/  models/  db/  web/  cli/  n8n/  docker/  tests/  evals/  perfil/  docs/

Cómo correrlo

Hace falta uv y Ollama (local o en Docker).

# 1. Dependencias (crea el venv con Python 3.12 desde uv.lock)
uv sync

# 2. Configuración
cp .env.example .env
# Editar .env: como mínimo BYTE_API_KEY y BYTE_SECRET_KEY
#   openssl rand -hex 32   (una para cada una)

# 3. El modelo (gratis, en tu PC)
ollama pull qwen2.5-coder:7b

# 4. El sandbox de ejecución de código (en otra terminal)
cd sandbox && npm install
SANDBOX_TOKEN=$(openssl rand -hex 32) npm start   # el mismo token va en .env
cd ..

# 5. Postgres con pgvector (opcional; sin él todo queda en memoria)
# El --env-file es necesario: con -f, Compose busca el .env junto al compose
# (en docker/), no en la raíz, y la interpolación de SANDBOX_TOKEN falla.
docker compose --env-file .env -f docker/docker-compose.yml up -d postgres ollama

# 6. Levantar la API
uv run uvicorn api.main:app --reload

En CPU, un 7B hace unos 5-15 tokens por segundo y un run son varias llamadas al modelo: por eso BYTE_RUN_TIMEOUT_S viene en 600. Si ves runs que se cortan solos, ese es el primer lugar donde mirar.

Sin DATABASE_URL, Byte arranca en memoria: sirve para probar, pero las conversaciones se pierden al reiniciar (lo avisa en el log). Con BYTE_ENV=prod directamente no arranca sin Postgres. Sin TAVILY_API_KEY el agente funciona igual pero sin búsqueda web, y sin SANDBOX_URL/SANDBOX_TOKEN, sin ejecutar código.

Probar

Lo más rápido es el CLI, que abre una ventana de chat y se queda:

uv run python -m cli.byte_cli          # o `byte`, si se instaló el alias
❯ ¿qué hace el archivo agent/runner.py?
✻ Worked for 12s  ·  read_file

❯ calculá el Sharpe anualizado de [0.01, 0.012, -0.003, 0.008, 0.005]
✻ Worked for 9s  ·  code_exec

❯ /model            # cambiar de modelo sin perder la conversación
❯ /safe             # pedir aprobación antes de ejecutar código

Y por HTTP, sin navegador:

KEY=tu-api-key
CONV=$(curl -s -X POST localhost:8000/api/v1/conversations \
  -H "X-API-Key: $KEY" -H 'Content-Type: application/json' -d '{}' | jq -r .id)

# ?wait=true espera la respuesta completa en un solo JSON
curl -s -X POST "localhost:8000/api/v1/conversations/$CONV/messages?wait=true" \
  -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{"content":"buscá la última versión de FastAPI"}' | jq

Tests y calidad

uv run pytest -q          # no necesita Ollama, Tavily ni Postgres

# Con Postgres levantado, se suman los tests del repositorio y el checkpointer
# contra la base real (en CI esto corre siempre)
BYTE_TEST_DATABASE_URL=postgresql://byte:byte@localhost:5432/byte uv run pytest -q

uv run ruff check .
uv run ruff format .
uv run pre-commit install # ruff + gitleaks antes de cada commit

cd sandbox && npm test    # incluye la suite de escape del sandbox

Qué hay hoy

Usuarios con JWT

  • POST /auth/register, POST /auth/login, GET /me: contraseñas con argon2id y un JWT de 30 minutos. El token va en Authorization: Bearer, el mismo header que la API key —se prueba primero el JWT y después la clave—, así que los clientes de OpenAI y n8n siguen funcionando sin cambios

  • El login no dice si un email existe: mismo mensaje y mismo tiempo ante un email desconocido y una contraseña incorrecta, para que no se pueda enumerar quién está registrado

  • El filtro por dueño está en las tres capas (documentos, conversaciones y runs), con tests de aislamiento que corren también contra Postgres

  • Cada usuario ve lo suyo: ver, borrar, renombrar o escribir en una conversación ajena responde 404 —no 403, que confirmaría que ese id existe— y los listados no se mezclan. Con API key el dueño es None, así que lo que había antes del multi-usuario sigue siendo accesible con ella

  • Refresh token de 14 días, rotado en cada uso: el que se manda deja de valer y vuelve otro. Si aparece uno ya canjeado se revoca la sesión entera — hay dos copias dando vueltas y no se puede saber cuál es la del dueño

Navegar un repositorio

list_files, read_file y grep: el agente recorre un proyecto, abre el archivo que importa y cita la línea. Es la diferencia entre un asistente que habla de código y uno que lo mira.

Opt-in con BYTE_PROJECT_ROOT, y confinado a esa raíz. No es una precaución de manual: el modelo elige las rutas a partir de lo que leyó, y lo que leyó puede ser un README con instrucciones metidas adentro. Las rutas se comparan después de resolve(), que sigue los symlinks — el caso que una comparación de texto deja pasar, porque la ruta parece inocente y el destino real está afuera.

Elegir el modelo sin reiniciar

/model en el chat lista los configurados y cambia entre ellos. Sirve porque ningún modelo local gana en todo: evals/COMPARACION.md mide cinco contra las tareas que el agente hace de verdad, y granite4.1:8b saca 19/21 contra 16/21 de qwen3:8b —acierta el Sharpe que el otro erra— pero tarda 60% más.

El cambio es explícito y no automático por una razón medida: en 16 GB no entran dos modelos a la vez, así que Ollama desaloja uno para cargar el otro y alternar cuesta ~27 s. Un clasificador que dudara pagaría ese precio cada vez que cambiara de opinión.

El criterio para evaluar cualquier modelo nuevo está ahí también: primero verificar el tool calling, después la inteligencia. qwen2.5-coder:7b saca 2/21 no por tonto sino porque escribe la llamada como texto JSON en vez de emitirla por el canal de herramientas — y el catálogo de Ollama lo declara tools igual.

El CLI

byte ask, byte chat, byte search, byte run, byte docs, y byte login / logout / whoami para entrar como usuario — sin login usa la API key, que identifica a la instancia. El token se guarda en ~/.config/byte con permisos 0600 y se renueva solo. Detalles en cli/.

Es el CLI definitivo: la reescritura en Go que el plan preveía quedó fuera de alcance, porque cambiaría código probado por código nuevo sin ganar nada para quien lo usa.

Observabilidad (opcional, apagada por defecto)

  • Langfuse para las trazas del agente: por qué decidió usar una herramienta, cuántas vueltas dio, qué devolvió cada una. El langfuse_trace_id queda en el mensaje, así que desde una respuesta se salta a su traza
  • Bugsink (self-hosted, un contenedor) para los errores

Las dos están apagadas y hay que encenderlas a mano. Byte corre local, y Langfuse Cloud recibiría tus prompts y respuestas: sin LANGFUSE_PUBLIC_KEY no sale nada de tu máquina. Bugsink es self-hosted, así que sus datos no salen de tu red — y va con PHONEHOME=false, porque lo que se instala local no tiene por qué avisarle a nadie.

Lo que sí sale, sale redactado: claves de API, JWT, URLs con credenciales, emails y tarjetas se reemplazan por una marca ([API_KEY], [EMAIL]) antes de enviarse. La redacción va enganchada en el cliente, no en cada punto de instrumentación, para que no dependa de acordarse.

uv sync --extra observabilidad          # los SDK no vienen por defecto
cd docker && docker compose --profile observabilidad up -d bugsink

Fase 3 — herramientas externas por MCP

  • Cliente MCP (mcp_client/): los servidores se declaran en BYTE_MCP_SERVERS como nombre=url y sus herramientas entran al registro del agente junto a las nativas, con source: "mcp:<nombre>". Un servidor caído no impide arrancar, igual que Byte arranca sin Tavily o sin sandbox

  • Lista blanca: el modelo no elige a qué host se conecta Byte. Solo http(s): stdio implicaría lanzar procesos, que es otra superficie de ataque

  • Contra el tool poisoning: los argumentos se validan contra el esquema que declara el servidor antes de ejecutar; el resultado entra al prompt marcado como contenido no confiable; la descripción se sanea y se atribuye ([servidor MCP 'x'] …); y una herramienta externa no puede tapar a una nativa —code_exec sigue siendo el sandbox de Byte

  • La descripción no se envuelve en los delimitadores, a diferencia del resultado: medido contra qwen3:8b, envolverla rompe el tool calling (0 de 3 llamadas contra 3 de 3). El porqué está en mcp_client/README.md

  • Compatible con OpenAI: POST /v1/chat/completions (con y sin streaming) y GET /v1/models. Open WebUI, Continue.dev o cualquier SDK de OpenAI usan a Byte como backend apuntando a http://localhost:8000/v1 con la BYTE_API_KEY como clave. El model se valida contra una lista blanca: un nombre arbitrario nunca llega a Ollama

    from openai import OpenAI
    
    c = OpenAI(base_url="http://localhost:8000/v1", api_key="<BYTE_API_KEY>")
    c.chat.completions.create(model="byte", messages=[{"role": "user", "content": "hola"}])

    Lo que entra por ahí es el agente completo, con sus herramientas: una pregunta que necesite buscar en la web la busca. Las conversaciones quedan guardadas y se ven en la web y en byte conversations. El modo seguro es la excepción — no hay forma de pedir una aprobación humana en ese formato, así que un run que la necesite se corta y lo dice

  • n8n (opcional, n8n/): tres workflows listos para importar — ingesta automática de documentos al RAG, un canal de email para preguntarle a Byte desde el correo, y n8n como servidor MCP para que Byte use sus herramientas. Va en un perfil aparte del compose (docker compose --profile n8n up -d n8n), así que no pesa si no se usa. Los JSON no llevan credenciales: cada nodo dice en sus notas cuál necesita

Fase 2 — memoria y RAG

  • Documentos: POST /documents sube PDF, TXT o Markdown (máx. 20 MB, tipo validado por magic bytes), responde 202 e indexa en segundo plano. El estado se sigue con GET /documents/{id}: processingindexed | error
  • Búsqueda híbrida sobre pgvector: similitud de vector (embeddings de nomic-embed-text, índice HNSW) combinada con coincidencia léxica (tsvector + GIN). Expuesta en POST /search y como herramienta doc_search del agente
  • Citas: los fragmentos que usó el agente quedan en MESSAGES.metadata con su document_id y chunk_id. Los chunks vienen de archivos de terceros, así que entran al prompt marcados como contenido no confiable, y leer un documento y querer ejecutar código en el mismo run dispara el modo seguro
  • Compactación: cuando el historial pasa el ~60% del contexto, los mensajes viejos se resumen en CONVERSATIONS.summary en vez de descartarse, y el resumen se inyecta en cada turno. Los originales no se borran: la UI los sigue mostrando. POST /conversations/{id}/compact la fuerza a mano

Fase 1 — ejecución de código

  • Servicio sandbox/ (Node + Pyodide): corre el código que escribe el agente dentro de WebAssembly, con un intérprete nuevo por ejecución. Cada capa de aislamiento se verificó contra un Pyodide sin endurecer, donde el vector funcionaba — el detalle y la tabla completa están en sandbox/README.md
  • POST /execute para ejecutar directo (lo que va a usar byte run), y code_exec como segunda herramienta del agente
  • Modo seguro (HITL): si en la conversación entró contenido externo —una búsqueda web o un documento del RAG— y el agente quiere ejecutar código, el run se detiene y espera confirmación humana, aunque nadie lo haya pedido. La combinación "contenido de terceros + ejecutar código" es justo la que permite que una inyección indirecta llegue a correr algo. Cuenta la conversación entera, no el run: si mirara solo el run, partir el ataque en dos mensajes lo evadiría. Se modela como estado AG-UI (awaiting_approval) y se retoma con POST /runs/{id}/resume, en el mismo run, desde el checkpoint
  • evals/ con 10 tareas para detectar regresiones del agente

Fase 0 — el MVP

Implementado:

  • API según el contrato: /health, /health/details, conversaciones (CRUD con paginación por cursor), POST /conversations/{id}/messagesGET /runs/{id}/events, POST /runs/{id}/cancel, GET /runs/{id}, GET /messages/{id}, GET /tools
  • Streaming SSE con eventos AG-UI (no nombres propios): RUN_STARTED, STEP_*, TOOL_CALL_*, TEXT_MESSAGE_*, STATE_SNAPSHOT/STATE_DELTA, RUN_FINISHED, RUN_ERROR; cada evento con id: para reconectar con Last-Event-ID
  • Agente LangGraph con StateGraph propio: retrieve_contextagentshould_continuetoolsfinalize, checkpointer con thread_id = conversation_id
  • Búsqueda web (Tavily) como única herramienta, con argumentos validados por Pydantic
  • Logging estructurado con structlog y request_id en logs y errores
  • Seguridad de Fase 0: API key hasheada comparada en tiempo constante, cookie httpOnly + SameSite=Strict para la web, events_token firmado de 60 s de un solo uso ligado al run, rate limiting por credencial, tope de runs concurrentes, timeout y tope de iteraciones por run, resultados de herramientas delimitados como datos no confiables, CSP/HSTS/nosniff, CORS restringido, gitleaks y pip-audit en CI
  • Un run por conversación (409): dos a la vez compartirían el hilo del checkpointer y se pisarían el estado. Borrar una conversación corta sus runs en vuelo
  • BYTE_ENV=prod no arranca sin Postgres: el checkpointer nunca queda en memoria en producción, como pide el plan
  • Página HTML mínima que consume el SSE (sin diseño: la identidad Byte llega en la Fase 5)
  • 158 tests de Python + 25 del sandbox, con dobles de Ollama, Tavily y el sandbox. Los del repositorio y el checkpointer corren contra las dos implementaciones: en memoria siempre, y contra Postgres cuando hay uno (se saltean si no)

Pendiente de Fase 0:

  • Correr el agente contra un Ollama real. El código y los tests están, pero los tests usan un modelo falso: todavía no se ejecutó una conversación contra Ollama. Primer paso: qwen2.5-coder:7b, medir RAM con num_ctx explícito; después el 30B-A3B
  • Deploy de prueba en Railway con la RAM mínima y medir el costo real
  • Límite de gasto y alertas en Railway (se configura en el dashboard)

Decisiones que se corrieron de fase, a propósito:

  • POST /runs/{id}/resume y el modo seguro (HITL) van con el sandbox (Fase 1): hoy safe_mode se acepta y se reporta, pero no hay herramienta peligrosa que aprobar
  • Idempotency-Key queda para la Fase 4, junto con el resto del endurecimiento de la API
  • Markdown sanitizado con nh3: la página mínima pinta con textContent, así que no hay HTML que sanear hasta la Fase 5

Licencia

MIT

About

Self-hosted AI agent — local LLM with tool calling, WASM sandbox, hybrid RAG on pgvector, MCP, and prompt-injection defenses

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages