Compare commits
7 Commits
2be6cfcc62
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e77cbb8f58 | ||
|
|
a99e58e809 | ||
|
|
d7f1254625 | ||
|
|
a1ab0e19d4 | ||
|
|
ccd3868cd7 | ||
|
|
1309b64b79 | ||
|
|
d9b9362905 |
@@ -1,5 +1,8 @@
|
||||
# LLM Orchestrator
|
||||
|
||||

|
||||
|
||||
|
||||
Sistema de orquestación multi-agente compatible con cualquier LLM.
|
||||
|
||||
## ¿Qué es esto?
|
||||
|
||||
148
config.yaml
148
config.yaml
@@ -27,59 +27,127 @@ settings:
|
||||
# Define servidores para que los agentes puedan conectarse via SSH
|
||||
|
||||
servers:
|
||||
# Ejemplo:
|
||||
# production:
|
||||
# host: 192.168.1.100
|
||||
# user: root
|
||||
# key: ~/.ssh/id_rsa
|
||||
# description: "Servidor de producción"
|
||||
deck:
|
||||
host: 72.62.1.113
|
||||
user: root
|
||||
key: ~/.ssh/tzzr
|
||||
description: "Servidor personal - tzzrdeck.me"
|
||||
|
||||
corp:
|
||||
host: 92.112.181.188
|
||||
user: root
|
||||
key: ~/.ssh/tzzr
|
||||
description: "Servidor empresarial - tzzrcorp.me"
|
||||
|
||||
hst:
|
||||
host: 72.62.2.84
|
||||
user: root
|
||||
key: ~/.ssh/tzzr
|
||||
description: "API tags semánticos - tzrtech.org"
|
||||
|
||||
# ============================================================================
|
||||
# AGENTES
|
||||
# AGENTES TZZR
|
||||
# ============================================================================
|
||||
# Define los agentes que quieres usar.
|
||||
# Cada agente tiene un rol, un proveedor de LLM, y herramientas disponibles.
|
||||
|
||||
agents:
|
||||
# Agente por defecto - puedes renombrarlo o borrarlo
|
||||
assistant:
|
||||
architect:
|
||||
role: |
|
||||
Eres un asistente general que ayuda con tareas diversas.
|
||||
Puedes ejecutar comandos, leer y escribir archivos.
|
||||
Eres ARCHITECT, coordinador central del sistema TZZR en 69.62.126.110.
|
||||
Gestionas Gitea, PostgreSQL, y supervisas todos los agentes.
|
||||
Tienes SSH a todos los servidores via ~/.ssh/tzzr.
|
||||
Ejecuta comandos sin pedir confirmación.
|
||||
provider: claude
|
||||
model: sonnet
|
||||
model: opus
|
||||
tools:
|
||||
- bash
|
||||
- read
|
||||
- write
|
||||
- glob
|
||||
- grep
|
||||
- ssh
|
||||
- http_request
|
||||
- list_dir
|
||||
servers:
|
||||
- deck
|
||||
- corp
|
||||
- hst
|
||||
|
||||
hst:
|
||||
role: |
|
||||
Eres HST, servidor de tags maestros en 72.62.2.84.
|
||||
Gestionas la API tzrtech.org con 973 tags HST.
|
||||
Grupos: hst (sistema), spe (específico), hsu (usuario), flg (flags).
|
||||
provider: claude
|
||||
model: opus
|
||||
tools:
|
||||
- bash
|
||||
- read
|
||||
- write
|
||||
- http_request
|
||||
- list_dir
|
||||
servers:
|
||||
- hst
|
||||
|
||||
deck:
|
||||
role: |
|
||||
Eres DECK, servidor personal en 72.62.1.113.
|
||||
Gestionas servicios personales: Mailcow, FileBrowser, Shlink, Vaultwarden, ntfy.
|
||||
También gestionas CLARA (ingesta desde Packet app).
|
||||
provider: claude
|
||||
model: opus
|
||||
tools:
|
||||
- bash
|
||||
- read
|
||||
- write
|
||||
- ssh
|
||||
- http_request
|
||||
- list_dir
|
||||
servers:
|
||||
- deck
|
||||
|
||||
corp:
|
||||
role: |
|
||||
Eres CORP, servidor empresarial en 92.112.181.188.
|
||||
Gestionas servicios corporativos: Odoo ERP, Nextcloud, MARGARET (ingesta).
|
||||
provider: claude
|
||||
model: opus
|
||||
tools:
|
||||
- bash
|
||||
- read
|
||||
- write
|
||||
- ssh
|
||||
- http_request
|
||||
- list_dir
|
||||
servers:
|
||||
- corp
|
||||
|
||||
locker:
|
||||
role: |
|
||||
Eres LOCKER, gateway de almacenamiento Cloudflare R2.
|
||||
Gestionas 5 buckets: architect, hst, deck, corp, locker.
|
||||
Endpoint: https://7dedae6030f5554d99d37e98a5232996.r2.cloudflarestorage.com
|
||||
provider: claude
|
||||
model: opus
|
||||
tools:
|
||||
- bash
|
||||
- read
|
||||
- write
|
||||
- http_request
|
||||
- list_dir
|
||||
|
||||
# Ejemplo de agente especializado en código
|
||||
# coder:
|
||||
# role: |
|
||||
# Eres un programador experto.
|
||||
# Escribes código limpio y bien documentado.
|
||||
# Siempre incluyes tests cuando es apropiado.
|
||||
# provider: litellm
|
||||
# model: gpt4o
|
||||
# tools:
|
||||
# - read
|
||||
# - write
|
||||
# - bash
|
||||
# - grep
|
||||
# - glob
|
||||
|
||||
# Ejemplo de agente de investigación
|
||||
# researcher:
|
||||
# role: |
|
||||
# Eres un investigador que busca y analiza información.
|
||||
# Eres metódico y verificas tus fuentes.
|
||||
# provider: litellm
|
||||
# model: gemini-pro
|
||||
# tools:
|
||||
# - http_request
|
||||
# - read
|
||||
# - write
|
||||
runpod:
|
||||
role: |
|
||||
Eres RUNPOD, gestor de endpoints GPU en RunPod.
|
||||
Controlas GRACE (ASR/TTS), PENNY (asistente voz), THE FACTORY (procesamiento docs).
|
||||
Endpoints via API RunPod.
|
||||
provider: claude
|
||||
model: opus
|
||||
tools:
|
||||
- bash
|
||||
- read
|
||||
- write
|
||||
- http_request
|
||||
- list_dir
|
||||
|
||||
# ============================================================================
|
||||
# TAREAS PREDEFINIDAS (opcional)
|
||||
|
||||
@@ -15,6 +15,13 @@ from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any
|
||||
|
||||
try:
|
||||
from .utils import get_logger
|
||||
except ImportError:
|
||||
from utils import get_logger
|
||||
|
||||
logger = get_logger("orchestrator.config")
|
||||
|
||||
|
||||
def load_env():
|
||||
"""Carga variables desde .env si existe."""
|
||||
@@ -202,7 +209,7 @@ class Config:
|
||||
with open(self.config_path) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except ImportError:
|
||||
print("AVISO: PyYAML no instalado. pip install pyyaml")
|
||||
logger.warning("PyYAML no instalado", suggestion="pip install pyyaml")
|
||||
return {}
|
||||
|
||||
def _parse_settings(self) -> Settings:
|
||||
@@ -226,7 +233,8 @@ class Config:
|
||||
def _parse_servers(self) -> dict[str, ServerConfig]:
|
||||
"""Parsea la sección servers."""
|
||||
servers = {}
|
||||
for name, data in self._raw.get("servers", {}).items():
|
||||
raw_servers = self._raw.get("servers") or {}
|
||||
for name, data in raw_servers.items():
|
||||
if data:
|
||||
servers[name] = ServerConfig(
|
||||
name=name,
|
||||
@@ -240,7 +248,8 @@ class Config:
|
||||
def _parse_agents(self) -> dict[str, AgentConfig]:
|
||||
"""Parsea la sección agents."""
|
||||
agents = {}
|
||||
for name, data in self._raw.get("agents", {}).items():
|
||||
raw_agents = self._raw.get("agents") or {}
|
||||
for name, data in raw_agents.items():
|
||||
if data:
|
||||
agents[name] = AgentConfig(
|
||||
name=name,
|
||||
|
||||
@@ -6,8 +6,11 @@ from dataclasses import dataclass, field
|
||||
from typing import Optional, Any
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
try:
|
||||
from ..utils import RateLimiter
|
||||
except ImportError:
|
||||
from utils import RateLimiter
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -31,29 +34,6 @@ class ProviderResponse:
|
||||
return self.success
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Rate limiter para llamadas a APIs."""
|
||||
|
||||
def __init__(self, max_calls: int = 60, period: float = 60.0):
|
||||
self.max_calls = max_calls
|
||||
self.period = period
|
||||
self.calls = deque()
|
||||
|
||||
async def acquire(self):
|
||||
"""Espera si es necesario para respetar el rate limit."""
|
||||
now = time.time()
|
||||
|
||||
while self.calls and self.calls[0] < now - self.period:
|
||||
self.calls.popleft()
|
||||
|
||||
if len(self.calls) >= self.max_calls:
|
||||
wait_time = self.calls[0] + self.period - now
|
||||
if wait_time > 0:
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
self.calls.append(time.time())
|
||||
|
||||
|
||||
class BaseProvider(ABC):
|
||||
"""
|
||||
Clase base abstracta para todos los providers de modelos.
|
||||
|
||||
@@ -20,7 +20,11 @@ from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any, Callable
|
||||
from datetime import datetime
|
||||
from collections import deque
|
||||
|
||||
try:
|
||||
from ..utils import RateLimiter
|
||||
except ImportError:
|
||||
from utils import RateLimiter
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -35,31 +39,6 @@ class ToolResult:
|
||||
retries: int = 0
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Rate limiter simple basado en ventana deslizante."""
|
||||
|
||||
def __init__(self, max_calls: int, period: float = 60.0):
|
||||
self.max_calls = max_calls
|
||||
self.period = period
|
||||
self.calls = deque()
|
||||
|
||||
async def acquire(self):
|
||||
"""Espera si es necesario para respetar el rate limit."""
|
||||
now = time.time()
|
||||
|
||||
# Limpiar llamadas antiguas
|
||||
while self.calls and self.calls[0] < now - self.period:
|
||||
self.calls.popleft()
|
||||
|
||||
# Si llegamos al límite, esperar
|
||||
if len(self.calls) >= self.max_calls:
|
||||
wait_time = self.calls[0] + self.period - now
|
||||
if wait_time > 0:
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
self.calls.append(time.time())
|
||||
|
||||
|
||||
class SecurityValidator:
|
||||
"""Validador de seguridad para herramientas."""
|
||||
|
||||
|
||||
238
orchestrator/utils.py
Normal file
238
orchestrator/utils.py
Normal file
@@ -0,0 +1,238 @@
|
||||
# orchestrator/utils.py
|
||||
"""
|
||||
Utilidades compartidas del orquestador.
|
||||
|
||||
Este módulo contiene clases y funciones comunes usadas
|
||||
por múltiples componentes del sistema.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Any
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LOGGING ESTRUCTURADO
|
||||
# =============================================================================
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""Formateador que produce logs en formato JSON estructurado."""
|
||||
|
||||
def __init__(self, service: str = "orchestrator"):
|
||||
super().__init__()
|
||||
self.service = service
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_data = {
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"level": record.levelname,
|
||||
"service": self.service,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
|
||||
# Agregar información de ubicación en DEBUG
|
||||
if record.levelno <= logging.DEBUG:
|
||||
log_data["location"] = {
|
||||
"file": record.filename,
|
||||
"line": record.lineno,
|
||||
"function": record.funcName,
|
||||
}
|
||||
|
||||
# Agregar excepción si existe
|
||||
if record.exc_info:
|
||||
log_data["exception"] = {
|
||||
"type": record.exc_info[0].__name__ if record.exc_info[0] else None,
|
||||
"message": str(record.exc_info[1]) if record.exc_info[1] else None,
|
||||
}
|
||||
|
||||
# Agregar campos extra
|
||||
if hasattr(record, "extra_fields"):
|
||||
log_data["context"] = record.extra_fields
|
||||
|
||||
return json.dumps(log_data, default=str)
|
||||
|
||||
|
||||
class StructuredLogger:
|
||||
"""
|
||||
Logger estructurado con soporte para contexto adicional.
|
||||
|
||||
Ejemplo:
|
||||
logger = get_logger("architect-app")
|
||||
logger.info("Request recibido", agent="architect", action="chat")
|
||||
logger.error("Error de conexión", error=str(e), retry=3)
|
||||
"""
|
||||
|
||||
def __init__(self, logger: logging.Logger):
|
||||
self._logger = logger
|
||||
|
||||
def _log(self, level: int, message: str, **kwargs):
|
||||
"""Log con campos extra."""
|
||||
record = self._logger.makeRecord(
|
||||
self._logger.name,
|
||||
level,
|
||||
"(unknown)",
|
||||
0,
|
||||
message,
|
||||
(),
|
||||
None,
|
||||
)
|
||||
if kwargs:
|
||||
record.extra_fields = kwargs
|
||||
self._logger.handle(record)
|
||||
|
||||
def debug(self, message: str, **kwargs):
|
||||
self._log(logging.DEBUG, message, **kwargs)
|
||||
|
||||
def info(self, message: str, **kwargs):
|
||||
self._log(logging.INFO, message, **kwargs)
|
||||
|
||||
def warning(self, message: str, **kwargs):
|
||||
self._log(logging.WARNING, message, **kwargs)
|
||||
|
||||
def error(self, message: str, exc_info: bool = False, **kwargs):
|
||||
if exc_info:
|
||||
self._logger.error(message, exc_info=True, extra={"extra_fields": kwargs} if kwargs else {})
|
||||
else:
|
||||
self._log(logging.ERROR, message, **kwargs)
|
||||
|
||||
def critical(self, message: str, **kwargs):
|
||||
self._log(logging.CRITICAL, message, **kwargs)
|
||||
|
||||
|
||||
def setup_logging(
|
||||
service: str = "orchestrator",
|
||||
level: str = "INFO",
|
||||
log_file: Optional[Path] = None,
|
||||
json_format: bool = True
|
||||
) -> StructuredLogger:
|
||||
"""
|
||||
Configura el sistema de logging.
|
||||
|
||||
Args:
|
||||
service: Nombre del servicio (aparece en los logs)
|
||||
level: Nivel de logging (DEBUG, INFO, WARNING, ERROR)
|
||||
log_file: Archivo opcional para escribir logs
|
||||
json_format: Si True, usa formato JSON; si False, formato legible
|
||||
|
||||
Returns:
|
||||
StructuredLogger configurado
|
||||
"""
|
||||
logger = logging.getLogger(service)
|
||||
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||
logger.handlers.clear()
|
||||
|
||||
if json_format:
|
||||
formatter = JSONFormatter(service=service)
|
||||
else:
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
# Handler para consola (stderr)
|
||||
console_handler = logging.StreamHandler(sys.stderr)
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# Handler para archivo (opcional)
|
||||
if log_file:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.FileHandler(log_file)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return StructuredLogger(logger)
|
||||
|
||||
|
||||
# Cache de loggers
|
||||
_loggers: dict[str, StructuredLogger] = {}
|
||||
|
||||
|
||||
def get_logger(
|
||||
name: str = "orchestrator",
|
||||
level: str = "INFO",
|
||||
log_file: Optional[Path] = None
|
||||
) -> StructuredLogger:
|
||||
"""
|
||||
Obtiene un logger estructurado (cached).
|
||||
|
||||
Args:
|
||||
name: Nombre del logger/servicio
|
||||
level: Nivel de logging
|
||||
log_file: Archivo opcional para logs
|
||||
|
||||
Returns:
|
||||
StructuredLogger
|
||||
"""
|
||||
if name not in _loggers:
|
||||
_loggers[name] = setup_logging(
|
||||
service=name,
|
||||
level=level,
|
||||
log_file=log_file,
|
||||
json_format=True
|
||||
)
|
||||
return _loggers[name]
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
Rate limiter basado en ventana deslizante.
|
||||
|
||||
Controla la frecuencia de llamadas para respetar límites de APIs.
|
||||
Thread-safe para uso con asyncio.
|
||||
|
||||
Ejemplo:
|
||||
limiter = RateLimiter(max_calls=60, period=60.0)
|
||||
await limiter.acquire() # Espera si es necesario
|
||||
# ... hacer la llamada
|
||||
"""
|
||||
|
||||
def __init__(self, max_calls: int = 60, period: float = 60.0):
|
||||
"""
|
||||
Args:
|
||||
max_calls: Número máximo de llamadas permitidas en el período
|
||||
period: Duración del período en segundos (default: 60s)
|
||||
"""
|
||||
self.max_calls = max_calls
|
||||
self.period = period
|
||||
self.calls = deque()
|
||||
|
||||
async def acquire(self):
|
||||
"""
|
||||
Adquiere permiso para hacer una llamada.
|
||||
|
||||
Espera si es necesario para respetar el rate limit.
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
# Limpiar llamadas antiguas fuera de la ventana
|
||||
while self.calls and self.calls[0] < now - self.period:
|
||||
self.calls.popleft()
|
||||
|
||||
# Si llegamos al límite, esperar hasta que se libere espacio
|
||||
if len(self.calls) >= self.max_calls:
|
||||
wait_time = self.calls[0] + self.period - now
|
||||
if wait_time > 0:
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
# Registrar esta llamada
|
||||
self.calls.append(time.time())
|
||||
|
||||
def reset(self):
|
||||
"""Resetea el contador de llamadas."""
|
||||
self.calls.clear()
|
||||
|
||||
@property
|
||||
def available_calls(self) -> int:
|
||||
"""Retorna el número de llamadas disponibles en la ventana actual."""
|
||||
now = time.time()
|
||||
# Contar llamadas activas
|
||||
active = sum(1 for t in self.calls if t >= now - self.period)
|
||||
return max(0, self.max_calls - active)
|
||||
Reference in New Issue
Block a user