Volver al Blog
AI & Development25 de febrero de 202510 min de lectura

Una estructura de monorepo para agentes de IA que no se volverá caos en 6 meses

DR

Doug Ribeiro

10 min de lectura

El primer agente que entregas siempre es un script. El segundo agente es una copia del primero con herramientas distintas. El tercer agente es donde las cosas se tuercen: alguien hace fork de uno de los scripts anteriores, las dependencias divergen, el logging queda inconsistente, y un trimestre después tienes cinco agentes en cuatro repos sin código compartido.

Si sabes que vas a construir más de un agente, monta el monorepo antes de construir el segundo. No antes del primero. Hay un costo real en la estructura prematura, y no voy a fingir lo contrario. Pero la ventana entre el agente uno y el agente dos es cuando pagas menos y ahorras más.

La premisa del stack

Este post asume Angular en el frontend, Python para el runtime del agente, Docker para desarrollo local. Ese es el stack por defecto que uso para mis propios proyectos. Si vas Node/TypeScript de punta a punta, la mayor parte se traduce directamente. Si vas Go o Rust en el backend, la estructura es la misma, las opciones de tooling difieren.

Por qué Python para el agente en sí. Los SDKs de IA (Anthropic, OpenAI) son más maduros en Python, el ecosistema de herramientas alrededor de vector stores y pipelines de embedding es Python-first, y un loop ReAct se lee mejor en un lenguaje donde una herramienta es solo una función y un dict es solo un dict. Mantengo el frontend en Angular porque ahí es donde construyo cada producto, y el dashboard para observar las ejecuciones de agentes es una app Angular de verdad.

La estructura

text
my-agents/
├── apps/
│   ├── dashboard/              # Angular app, agent run viewer
│   └── api/                    # Python FastAPI, dashboard backend
├── agents/
│   ├── sonar_bridge/           # First agent
│   │   ├── agent.py
│   │   ├── tools.py
│   │   ├── playbook.py
│   │   └── tests/
│   ├── docs_drift/             # Second agent
│   └── bug_triage/             # Third agent
├── libs/
│   ├── base_agent/             # The shared ReAct loop
│   ├── memory/                 # pgvector client, Postgres schema, Redis client
│   ├── tools/                  # Reusable tools: github, slack, sonar, jira
│   └── telemetry/              # Logging, cost tracking, evaluation
├── infra/
│   ├── docker-compose.yml      # postgres, redis, sonarqube for local dev
│   ├── migrations/             # Alembic for Postgres schema
│   └── k8s/                    # deployment manifests if you need them
├── knowledge/                  # Markdown knowledge base (Obsidian-compatible)
│   ├── conventions/
│   ├── runbooks/
│   └── decisions/
├── pyproject.toml              # Workspace root, points to libs and agents
├── package.json                # For the dashboard
└── docker-compose.yml

Tres zonas. apps/ es para código orientado al usuario: el dashboard y su API. agents/ es para agentes individuales, cada uno un paquete pequeño. libs/ es código compartido del que depende cada agente. Nada más pertenece a este nivel.

El agente base

libs/base_agent/ es la pieza más importante de toda la estructura. Contiene el loop ReAct, los patrones de acceso a memoria, el seguimiento de costos y la telemetría. Cada agente en agents/ importa desde aquí y define solo lo específico de su dominio: el playbook (system prompt), la lista de herramientas, las reglas de escalamiento.

python
# libs/base_agent/agent.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Callable
from libs.memory import MemoryStack
from libs.telemetry import Telemetry

@dataclass
class AgentConfig:
    name: str
    version: str
    playbook: str
    tools: list[Callable]
    confidence_threshold: float = 0.7
    max_iterations: int = 20
    scratchpad_window: int = 6

class BaseAgent(ABC):
    """Owns the loop. The three hooks below are what each agent supplies."""

    def __init__(self, config: AgentConfig, memory: MemoryStack, telemetry: Telemetry):
        self.config = config
        self.memory = memory
        self.telemetry = telemetry

    @abstractmethod
    def _plan(self, trigger: dict, context: str | None,
              scratchpad: list) -> tuple[str, dict | None]:
        """One model call. Returns (thought, action), or action=None when done."""

    @abstractmethod
    def _execute(self, action: dict) -> dict:
        """Run one tool from config.tools and return the observation."""

    @abstractmethod
    def _decide(self, scratchpad: list) -> dict:
        """Turn the finished scratchpad into the run outcome."""

    def run(self, trigger: dict) -> dict:
        run_id = self.telemetry.start_run(self.config, trigger)

        similar = self.memory.vector_search(trigger, top_k=5)
        context = self.memory.summarize(similar) if similar else None

        scratchpad = []
        for iteration in range(self.config.max_iterations):
            thought, action = self._plan(trigger, context, scratchpad)
            if action is None:
                break
            observation = self._execute(action)
            scratchpad.append((thought, action, observation))
            # The scratchpad is what blows the context budget on a long
            # run. Compress the older entries instead of carrying 20
            # raw observations into the next plan call.
            if len(scratchpad) > self.config.scratchpad_window:
                scratchpad = self.memory.compress(
                    scratchpad, keep_last=self.config.scratchpad_window
                )

        outcome = self._decide(scratchpad)
        self.telemetry.end_run(run_id, outcome)
        self.memory.write(run_id, outcome)
        return outcome

BaseAgent es dueño del loop y de nada más. _plan, _execute y _decide son abstractos a propósito: _plan es la llamada al modelo, _execute es el dispatch de herramientas, _decide convierte el scratchpad terminado en un resultado. La implementación concreta de esos tres vive una sola vez en libs/base_agent/, así que un agente nuevo es un objeto de configuración y un playbook, unas 30 líneas. Fíjate en el paso de compresión dentro del loop. Veinte iteraciones de thought, action y observation en crudo es como terminas con una factura de tokens que no habías planeado.

python
# agents/sonar_bridge/agent.py
from libs.base_agent import ReActAgent, AgentConfig
from libs.memory import MemoryStack
from libs.telemetry import Telemetry
from libs.tools import sonar, github
from .playbook import PLAYBOOK

def build() -> ReActAgent:
    return ReActAgent(
        config=AgentConfig(
            name="sonar_bridge",
            version="0.1.0",
            playbook=PLAYBOOK,
            tools=[
                sonar.get_issues_for_pr,
                github.read_file_range,
                github.comment_on_pr,
            ],
            confidence_threshold=0.85,
        ),
        memory=MemoryStack.default(),
        telemetry=Telemetry.default(),
    )

El dashboard sí importa

Angular está haciendo trabajo real aquí. Necesitas un lugar para ver cada ejecución de agente, filtrar por resultado, comparar decisiones entre versiones y reproducir fallas. Eso no es un nice-to-have, es cómo depuras un agente que decidió escalar tres veces seguidas anoche.

La API Python bajo apps/api/ es un wrapper FastAPI delgado que lee del mismo Postgres al que los agentes escriben. Sin magia, sin duplicación. El dashboard es un cliente tonto de los mismos datos que tus agentes ya producen.

Dev local en un solo comando

yaml
# docker-compose.yml at the root
services:
  postgres:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_PASSWORD: local
      POSTGRES_DB: agents
    ports: ["5432:5432"]

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]

  sonarqube:
    image: sonarqube:community
    ports: ["9000:9000"]

  api:
    build: ./apps/api
    depends_on: [postgres, redis]
    environment:
      DATABASE_URL: postgres://postgres:local@postgres/agents
      REDIS_URL: redis://redis:6379
    ports: ["8000:8000"]

  dashboard:
    build: ./apps/dashboard
    depends_on: [api]
    ports: ["4200:4200"]

docker-compose up le da a cualquiera que clone el repo un entorno de trabajo completo: Postgres con pgvector, Redis, SonarQube, la API y el dashboard Angular. Sin trastear instalaciones locales de Postgres, sin 'funciona en mi máquina' el primer día.

El directorio de conocimiento

knowledge/ es una carpeta de archivos markdown. No una funcionalidad. No un plugin. Solo markdown. El equipo la edita directamente, o apunta un vault de Obsidian hacia ella. Un indexador en libs/memory/ sincroniza el contenido en pgvector con cada cambio, y cada agente que necesita un playbook o una convención lee desde la misma fuente.

Separo la base de conocimiento del código deliberadamente. El conocimiento de negocio cambia de manera independiente del código del agente. Quieres que no-ingenieros editen knowledge/ sin tocar agents/. Git sigue versionando. Las revisiones siguen ocurriendo. Pero la frontera es clara.

Lo que esto te da

  • Agente nuevo = carpeta nueva en agents/, 30 líneas de Python. Sin debate de infraestructura.
  • Cada agente produce la misma forma de telemetría. El dashboard no necesita código por agente.
  • La memoria, las herramientas y la lógica de evaluación son compartidas. Un bug fix en libs/ arregla cada agente a la vez.
  • El dev local es un solo comando. Del clone al stack corriendo, sin documento de setup que seguir.
  • El deploy a producción es el mismo patrón de imagen de contenedor para cada agente. Un pipeline, muchos agentes.

Dónde trazo la línea

No todo pertenece al monorepo. Si un agente tiene un radio de impacto distinto, digamos que puede escribir en un sistema de producción mientras el resto solo comenta pull requests, obtiene su propio repo y su propia cadencia de release. Si un agente es experimental y no estoy seguro de que sobreviva al mes, lo prototipo fuera y lo muevo adentro una vez que ha probado su valor.

El monorepo es para los agentes de producción. Los estables. De los que depende tu equipo. Todo lo demás puede empezar como un script y ser promovido una vez que se gane su lugar.

Un agente base compartido y una capa de telemetría consistente valen más que cualquier agente individual. Ese es el código que hace que el quinto agente sea fácil de entregar.

Referencias

MonorepoArchitectureAngularPythonAI Agents